Compare commits

...

21 Commits

Author SHA1 Message Date
9bf66f6d90 Nettoyage: suppression fichiers inutiles
Changements :
- Suppression icon_small.png (non utilise)
- Suppression rsrc.syso du tracking (fichier genere par build.bat)
- Suppression dossier Scripts vide
- Nettoyage .gitignore (ajout rsrc.syso, retrait entrees Python)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 12:58:54 +01:00
3f0f13147b Suppression gitchecker.exe (ancien build inutilise)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 12:57:35 +01:00
2b2eb87f45 Fix layout icone en-tete: redimensionnement 24x24 avant affichage
Changements :
- Redimensionnement de l'icone PNG a 24x24 via CatmullRom (golang.org/x/image/draw)
- Corrige le decalage de l'interface cause par l'image 912x1164

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 12:51:04 +01:00
3ffbb550ec Fix png dans le titre 2026-03-25 12:46:53 +01:00
98b5187bfc v0.7.7 - Icone PNG embarquee dans l'en-tete GUI
Changements :
- Icone icon.png embarquee dans l'exe via go:embed
- Affichage de l'icone a gauche du titre dans l'interface
- Mise a jour CLAUDE.md pour refleter la migration Go

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 12:42:33 +01:00
18fe9d7186 maj 2026-03-25 11:41:51 +01:00
d8f3a29f8e v0.7.6 - Clone dossier non-vide et verification rapide
Changements :
- Clone dans dossier non-vide (git init + remote add + fetch + checkout)
- Verification rapide via git ls-remote au lieu de git fetch (timeout 15s)
- Support branche par repo dans config.ini (champ branch)
- Suppression fichiers Python et artefacts PyInstaller (_internal/)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 11:41:11 +01:00
55663e3a19 v0.7.5 - Miroir depot git et detection fichiers non suivis
Changements :
- Detection des fichiers non suivis (untracked) dans chaque depot
- Affichage "X fichier(s) en trop" dans le statut
- Popup de confirmation listant les fichiers avant suppression (git clean -fd)
- Suppression auto des fichiers en trop via "Tout mettre a jour"
- Verification du depot distant via git ls-remote avant de proposer le clone
- Affichage "Depot introuvable" si l'URL pointe vers un repo inexistant

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 10:34:40 +01:00
db57cfacaf v0.7.4 - Verification depot distant et suppression popups erreur
Changements :
- Verification du depot distant via git ls-remote avant de proposer le clone
- Affichage "Depot introuvable" si l'URL pointe vers un repo inexistant
- Remplacement des popups d'erreur par des messages dans le journal

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 10:26:33 +01:00
037f211d9d v0.7.3 - Fix re-check unique apres action depot
Changements :
- Ajout barre de progression Unicode dans la colonne Progression
- Capture temps reel de la sortie git (clone/pull --progress)
- Timeouts augmentes (2h clone/pull, 5min fetch) pour gros depots 10+ Go
- Apres mise a jour d'un depot, seul ce depot est re-verifie (plus de re-fetch global)
- Config self-update : ajout branch = feature/go-rewrite

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 09:59:28 +01:00
da8fc74a68 maj exe 2026-03-25 09:21:41 +01:00
30ece54758 Maj branch 2026-03-25 09:18:49 +01:00
d03ff595ed v0.7.1 - Test auto-update Go
Changements :
- Version 0.7.1 pour tester le mecanisme d auto-update
- GitUpdateChecker.exe compile en Go (exe unique, sans _internal/)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 07:50:40 +01:00
8c5257e96f Fix TTM_ADDTOOL : manifeste Windows Common Controls 6.0
- app.manifest : active comctl32 v6 (requis par walk) + DPI awareness
- rsrc.syso : manifeste + icone exe embarques dans le binaire via rsrc
- build.bat : genere rsrc.syso automatiquement avant go build

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 07:47:50 +01:00
af439a8e69 Migration vers go 2026-03-25 07:34:53 +01:00
50c8ad9823 feature/go-rewrite : base Go avec walk GUI
- Rewrite complet en Go : exe unique sans _internal/, sans extraction temp
- GUI Windows-native via github.com/lxn/walk (TableView, TextEdit, PushButton)
- Meme fonctionnalites : check repos, pull, checkout, auto-update, logs
- build.bat : go build -ldflags "-H windowsgui -s -w" -> 9.6 Mo, zero dependance

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 07:33:26 +01:00
959298fc2d v0.6.6 - Fix icone header : icon_small.png pre-generee
Changements :
- icon_small.png (25x32) generee au build via Pillow LANCZOS, plus de subsample au runtime
- Chargement direct de icon_small.png dans le header, sans calcul de redimensionnement
- build.bat genere automatiquement icon_small.png avant la compilation
- _find_icon() generalisee pour chercher n'importe quel fichier icone

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 22:32:49 +01:00
fa17301842 Bug icon fix 2026-03-24 22:24:59 +01:00
8718b04a7d v0.6.5 - Fix affichage icone dans le header
Changements :
- Methode _find_icon() : cherche icon.png a cote de l'exe puis dans _internal comme fallback
- Redimensionnement corrige : diviseur commun pour conserver le ratio (image 912x1164)
- tk.Label au lieu de ttk.Label pour l'image (meilleur rendu sur fond sombre)
- icon.png bundle dans l'exe via --add-data pour fonctionner sans le fichier externe
- Logs d'erreur si l'icone ne charge pas

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 22:24:24 +01:00
1a51d6b5fa Fix _internal imbriquee
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 22:07:35 +01:00
997d82bb96 v0.6.4 - Retour onedir (configuration stable)
Changements :
- Retour a --onedir : seule configuration sans erreur DLL confirmee
- _internal/ recommite dans le repo
- build.bat restaure avec copie automatique de _internal/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 22:06:46 +01:00
19 changed files with 1580 additions and 1089 deletions

View File

@@ -13,7 +13,8 @@
"Bash(pyinstaller --onefile --noconsole --name \"GitUpdateChecker\" --icon=icon.ico git_updater.py)", "Bash(pyinstaller --onefile --noconsole --name \"GitUpdateChecker\" --icon=icon.ico git_updater.py)",
"Bash(pyinstaller --onedir --noconsole --name \"GitUpdateChecker\" --icon=icon.ico git_updater.py)", "Bash(pyinstaller --onedir --noconsole --name \"GitUpdateChecker\" --icon=icon.ico git_updater.py)",
"Bash(\"j:/Documents/- PROJET -/Code/Lanceur-Geco/Lanceur-Geco/.gitignore\")", "Bash(\"j:/Documents/- PROJET -/Code/Lanceur-Geco/Lanceur-Geco/.gitignore\")",
"Bash(pyinstaller --onefile --noconsole --runtime-tmpdir . --name \"GitUpdateChecker\" --icon=icon.ico git_updater.py)" "Bash(pyinstaller --onefile --noconsole --runtime-tmpdir . --name \"GitUpdateChecker\" --icon=icon.ico git_updater.py)",
"Bash(python -c \"from PIL import Image; img = Image.open\\(''j:/Documents/- PROJET -/Code/Lanceur-Geco/Lanceur-Geco/icon.png''\\); print\\(f''{img.width}x{img.height}''\\)\")"
] ]
} }
} }

3
.gitignore vendored
View File

@@ -1,8 +1,7 @@
log/ log/
dist/ dist/
build/ build/
__pycache__/ rsrc.syso
*.spec *.spec
*.exe.old *.exe.old
_update.bat _update.bat
_internal/

107
CLAUDE.md
View File

@@ -6,37 +6,53 @@ Outil Windows (.exe) avec interface graphique qui vérifie les mises à jour de
Conçu pour être placé sur une **clé USB** dont la lettre de lecteur peut changer. Conçu pour être placé sur une **clé USB** dont la lettre de lecteur peut changer.
Le programme peut **s'auto-mettre à jour** car il est lui-même dans un dépôt git. Le programme peut **s'auto-mettre à jour** car il est lui-même dans un dépôt git.
## Langage
**Go** (anciennement Python, migré en Go depuis v0.7.x).
- GUI : `github.com/lxn/walk` (contrôles natifs Windows)
- Exe unique, aucune dépendance externe à l'exécution
- Build : `go build` via `build.bat`
## Structure du projet ## Structure du projet
``` ```
Lanceur-geco/ Lanceur-geco/
├── git_updater.py # Script principal Python (GUI tkinter) ├── main.go # Point d'entrée, constante VERSION
├── version.txt # Fichier contenant le numéro de version (ex: "0.5.1") ├── config.go # Chargement config.ini (repos + self-update)
├── config.ini # Configuration multi-repo ├── git.go # Opérations git (check, clone, pull, checkout, clean)
├── build.bat # Script de compilation en .exe via PyInstaller ├── gui.go # Interface graphique (walk/TableView)
├── log/ # Dossier de logs (créé automatiquement, 1 fichier par jour) ├── logger.go # Logging fichier (1 fichier/jour, rotation 30j)
├── selfupdate.go # Auto-mise à jour du programme
├── platform_windows.go # Code spécifique Windows (création processus)
├── version.txt # Numéro de version (utilisé par l'auto-update distant)
├── config.ini # Configuration multi-repo
├── build.bat # Script de compilation en .exe
├── app.manifest # Manifeste Windows (DPI, elevation)
├── icon.ico # Icône application
├── go.mod / go.sum # Dépendances Go
├── log/ # Dossier de logs (créé automatiquement)
└── CLAUDE.md └── CLAUDE.md
``` ```
## Règles importantes ## Règles importantes
- **Tous les chemins doivent être relatifs** à l'emplacement de l'exe. Jamais de chemin absolu (pas de `C:\`, `G:\`, etc.). Utiliser `..` et des chemins relatifs pour référencer les dossiers. - **Tous les chemins doivent être relatifs** à l'emplacement de l'exe. Jamais de chemin absolu (pas de `C:\`, `G:\`, etc.). Utiliser `..` et des chemins relatifs pour référencer les dossiers.
- **Accès lecture seule** : le programme ne fait que `git fetch`, `git pull` et `git checkout`. Jamais de `git push`, `git commit`, `git add`, ou toute opération d'écriture vers le remote. - **Accès lecture seule** : le programme ne fait que `git ls-remote`, `git fetch`, `git pull`, `git checkout` et `git clean`. Jamais de `git push`, `git commit`, `git add`, ou toute opération d'écriture vers le remote.
- **Multi-repo** : le programme peut surveiller plusieurs dépôts Git configurés dans `config.ini`. - **Multi-repo** : le programme peut surveiller plusieurs dépôts Git configurés dans `config.ini`.
## Versioning ## Versioning
- La version est définie en dur dans `git_updater.py` via la constante `VERSION` (ex: `VERSION = "0.5.1"`) - La version est définie en dur dans `main.go` via la constante `VERSION` (ex: `const VERSION = "0.7.6"`)
- Le fichier `version.txt` à la racine du projet contient le même numéro de version (utilisé par le mécanisme d'auto-update distant) - Le fichier `version.txt` à la racine du projet contient le même numéro de version (utilisé par le mécanisme d'auto-update distant)
- Format : **semver simplifié** `MAJEUR.MINEUR.PATCH` (ex: `0.5.1`) - Format : **semver simplifié** `MAJEUR.MINEUR.PATCH` (ex: `0.7.6`)
- **Les deux doivent toujours être synchronisés** : quand on change la version, mettre à jour `VERSION` dans `git_updater.py` ET `version.txt` - **Les deux doivent toujours être synchronisés** : quand on change la version, mettre à jour `VERSION` dans `main.go` ET `version.txt`
### Mise à jour de la version ### Mise à jour de la version
A chaque changement de version, il faut mettre à jour **4 éléments** : A chaque changement de version, il faut mettre à jour **4 éléments** :
1. `VERSION` dans `git_updater.py` (constante en haut du fichier) 1. `VERSION` dans `main.go` (constante en haut du fichier)
2. `version.txt` à la racine du projet 2. `version.txt` à la racine du projet
3. **Recompiler l'exe** via `build.bat` et copier `dist/GitUpdateChecker.exe` à la racine du projet 3. **Recompiler l'exe** via `build.bat` (produit `GitUpdateChecker.exe` à la racine)
4. **Créer un commit** avec le message suivant : 4. **Créer un commit** avec le message suivant :
``` ```
@@ -50,18 +66,18 @@ Changements :
Exemple : Exemple :
``` ```
v0.5.2 - Detection depot hors ligne v0.7.6 - Clone dossier non-vide et verification rapide
Changements : Changements :
- Ajout verification connectivite remote avant fetch (git ls-remote) - Clone dans dossier non-vide (git init + remote add + fetch + checkout)
- Affichage "HORS LIGNE" si le serveur est inaccessible - Verification rapide via git ls-remote au lieu de git fetch
- Synchronisation auto de l'URL origin depuis config.ini - Support branche par repo dans config.ini
``` ```
### Mécanisme de comparaison ### Mécanisme de comparaison
- La fonction `_version_tuple(v)` convertit la chaîne version en tuple d'entiers (ex: `"0.5.1"` -> `(0, 5, 1)`) pour permettre la comparaison numérique - La fonction `parseVersion(v)` convertit la chaîne version en `[3]int` (ex: `"0.7.6"` -> `[0, 7, 6]`) pour permettre la comparaison numérique
- L'auto-update télécharge `version.txt` depuis le serveur Gitea via HTTP (`{repo_url}/raw/branch/master/version.txt`) et compare avec la `VERSION` locale - L'auto-update télécharge `version.txt` depuis le serveur Gitea via HTTP (`{repo_url}/raw/branch/{branch}/version.txt`) et compare avec la `VERSION` locale
- Si la version distante est supérieure, une mise à jour est proposée - Si la version distante est supérieure, une mise à jour est proposée
## Fonctionnement ## Fonctionnement
@@ -71,64 +87,77 @@ Changements :
2. Compare la version distante avec la constante `VERSION` locale (comparaison par tuple numérique) 2. Compare la version distante avec la constante `VERSION` locale (comparaison par tuple numérique)
3. Si la version distante est supérieure, propose de télécharger le nouvel exe 3. Si la version distante est supérieure, propose de télécharger le nouvel exe
4. Stratégie de remplacement : télécharge dans `.new`, renomme l'exe actuel en `.old`, place le nouveau 4. Stratégie de remplacement : télécharge dans `.new`, renomme l'exe actuel en `.old`, place le nouveau
5. Après mise à jour, demande un redémarrage 5. Après mise à jour, lance un script batch de redémarrage
### Vérification des dépôts ### Vérification des dépôts
1. Lit la liste des dépôts depuis `config.ini` (chemins relatifs à l'exe) 1. Lit la liste des dépôts depuis `config.ini` (chemins relatifs à l'exe)
2. Pour chaque dépôt : 2. Pour chaque dépôt (en parallèle via goroutines) :
- `git fetch` pour récupérer l'état distant - `git ls-remote` pour vérifier la disponibilité et comparer les hashs (rapide, timeout 15s)
- Compare commits locaux vs distants - `git status --porcelain` pour détecter les fichiers modifiés/non suivis localement
- Détecte les fichiers supprimés/modifiés localement 3. Affiche le résultat dans une interface graphique (walk/TableView)
3. Affiche le résultat dans une interface graphique (tkinter)
4. Propose pour chaque dépôt : 4. Propose pour chaque dépôt :
- `git pull` si nouveaux commits distants - `git pull` si MAJ disponible (hash distant différent)
- `git checkout -- .` si fichiers locaux manquants/modifiés - `git checkout -- .` si fichiers locaux modifiés
- `git clean -fd` si fichiers non suivis en trop
### Clone dans dossier non-vide
Si le dossier cible existe déjà mais n'a pas de `.git` (ex: repos imbriqués), le programme fait un clone "in-place" :
`git init` + `git remote add` + `git fetch` + `git checkout -b <branch>`
## Configuration (config.ini) ## Configuration (config.ini)
Supporte plusieurs sections `[repo:NomDuRepo]` : Supporte plusieurs sections `[repo:NomDuRepo]` :
```ini ```ini
[repo:Batch] [self-update]
url = http://192.168.1.235:3125/zogzog/Batch url = http://192.168.1.235:3125/zogzog/Lanceur-geco
path = ../Batch exe_name = GitUpdateChecker.exe
branch = master
[repo:Powershell] [repo:Scripts]
url = http://192.168.1.235:3125/zogzog/Powershell url = http://192.168.1.235:3125/zogzog/Scripts
path = ../Powershell path = ../SOFT/Batch/Scripts
[repo:Soft]
url = http://192.168.1.235:3125/zogzog/Soft.git
path = ../SOFT/
branch = master
``` ```
- `url` : URL du dépôt Git distant - `url` : URL du dépôt Git distant
- `path` : Chemin **relatif** vers le dossier local du dépôt (relatif à l'exe) - `path` : Chemin **relatif** vers le dossier local du dépôt (relatif à l'exe)
- `branch` : Branche à suivre (optionnel, défaut: `master`)
## Logging ## Logging
- Les logs sont écrits dans `log/` à côté de l'exe (1 fichier par jour, format `YYYY-MM-DD.log`) - Les logs sont écrits dans `log/` à côté de l'exe (1 fichier par jour, format `YYYY-MM-DD.log`)
- Les vieux logs sont nettoyés automatiquement (30 jours de rétention) - Les vieux logs sont nettoyés automatiquement (30 jours de rétention)
- Chaque action git, erreur, et résultat est loggé avec timestamp - Chaque action git, erreur, et résultat est loggé avec timestamp
- Bouton "Ouvrir les logs" dans la GUI pour accéder au dossier - Bouton "Logs" dans la GUI pour ouvrir le dossier
## Build ## Build
```bat ```bat
build.bat build.bat
``` ```
Requiert Python + pip. Installe PyInstaller automatiquement si absent. Requiert Go installé et dans le PATH. Installe `rsrc` automatiquement si absent.
Produit `dist/GitUpdateChecker.exe`. Copier `config.ini` à côté de l'exe. Produit `GitUpdateChecker.exe` à la racine (exe unique, pas de dépendances).
Flags de build : `-H windowsgui -s -w` (pas de console, symboles strippés).
## Contraintes techniques ## Contraintes techniques
- **Chemins relatifs** : Tout est relatif à l'exe, jamais de chemin absolu - **Chemins relatifs** : Tout est relatif à l'exe, jamais de chemin absolu
- **Encodage** : Force UTF-8 pour les caractères Unicode
- **Clé USB** : Fonctionne sur n'importe quelle lettre de lecteur - **Clé USB** : Fonctionne sur n'importe quelle lettre de lecteur
- **Git requis** : Git doit être installé et dans le PATH de la machine - **Git requis** : Git doit être installé et dans le PATH de la machine
- **Serveur Gitea** : Le remote origin pointe vers une instance Gitea locale (192.168.1.235:3125) - **Serveur Gitea** : Le remote origin pointe vers une instance Gitea locale (192.168.1.235:3125)
- **Lecture seule** : Aucune opération d'écriture vers le remote (pas de push/commit) - **Lecture seule** : Aucune opération d'écriture vers le remote (pas de push/commit)
- **Interface** : GUI tkinter (inclus dans Python, pas de dépendance externe) - **Interface** : GUI native Windows via walk (pas de console)
- **Logs** : Dossier `log/` à côté de l'exe, rotation automatique 30 jours - **Logs** : Dossier `log/` à côté de l'exe, rotation automatique 30 jours
- **Repos imbriqués** : Supporte les dépôts git imbriqués (ex: parent/enfant) via clone in-place
## Conventions ## Conventions
- Langage : Python 3, pas de dépendances externes (seulement stdlib + tkinter) - Langage : Go 1.22+
- Interface : GUI tkinter en français - GUI : github.com/lxn/walk (contrôles natifs Windows)
- Langue : Français pour l'interface utilisateur - Interface en français
- Pas de console (flag `-H windowsgui`)

Binary file not shown.

32
app.manifest Normal file
View File

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<assemblyIdentity
version="1.0.0.0"
processorArchitecture="*"
name="GitUpdateChecker"
type="win32"/>
<description>Git Update Checker</description>
<dependency>
<dependentAssembly>
<assemblyIdentity
type="win32"
name="Microsoft.Windows.Common-Controls"
version="6.0.0.0"
processorArchitecture="*"
publicKeyToken="6595b64144ccf1df"
language="*"/>
</dependentAssembly>
</dependency>
<asmv3:application>
<asmv3:windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/PM</dpiAware>
</asmv3:windowsSettings>
</asmv3:application>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>
<requestedExecutionLevel level="asInvoker" uiAccess="false"/>
</requestedPrivileges>
</security>
</trustInfo>
</assembly>

View File

@@ -1,37 +1,38 @@
@echo off @echo off
echo ======================================== echo ========================================
echo Build Git Update Checker (.exe) echo Build Git Update Checker (.exe) - Go
echo ======================================== echo ========================================
echo. echo.
:: Vérifier que Python est installé where go >nul 2>&1
python --version >nul 2>&1
if errorlevel 1 ( if errorlevel 1 (
echo [ERREUR] Python n'est pas installe ou pas dans le PATH. echo [ERREUR] Go n'est pas installe ou pas dans le PATH.
pause pause
exit /b 1 exit /b 1
) )
:: Installer PyInstaller si nécessaire echo [*] Telechargement des dependances...
pip show pyinstaller >nul 2>&1 go mod tidy
if errorlevel 1 ( if errorlevel 1 (
echo [*] Installation de PyInstaller... echo [ERREUR] go mod tidy a echoue.
pip install pyinstaller pause
exit /b 1
) )
echo [*] Conversion icon.png -> icon.ico... echo [*] Generation du manifeste Windows (rsrc.syso)...
python -c "from PIL import Image; img = Image.open('icon.png'); img.save('icon.ico', format='ICO', sizes=[(256,256),(128,128),(64,64),(32,32),(16,16)])" where rsrc >nul 2>&1
if errorlevel 1 (
echo [*] Installation de rsrc...
go install github.com/akavel/rsrc@latest
)
rsrc -manifest app.manifest -ico icon.ico -o rsrc.syso
echo [*] Compilation en cours... echo [*] Compilation en cours...
echo. go build -ldflags "-H windowsgui -s -w" -o GitUpdateChecker.exe .
pyinstaller --onefile --noconsole --runtime-tmpdir . --name "GitUpdateChecker" --icon=icon.ico git_updater.py
echo. echo.
if exist "dist\GitUpdateChecker.exe" ( if exist "GitUpdateChecker.exe" (
echo [*] Copie de GitUpdateChecker.exe a la racine... echo [OK] GitUpdateChecker.exe cree - exe unique, aucune dependance.
copy /Y "dist\GitUpdateChecker.exe" "GitUpdateChecker.exe" >nul
echo [OK] Deploiement pret. Committer GitUpdateChecker.exe
) else ( ) else (
echo [ERREUR] La compilation a echoue. echo [ERREUR] La compilation a echoue.
) )

86
config.go Normal file
View File

@@ -0,0 +1,86 @@
package main
import (
"bufio"
"os"
"path/filepath"
"strings"
)
type RepoConfig struct {
Name string
URL string
Path string
Branch string // branche à suivre (défaut: "master")
}
type SelfUpdateConfig struct {
URL string
ExeName string
Branch string
}
func loadConfig() ([]RepoConfig, SelfUpdateConfig, error) {
cfgPath := filepath.Join(exeDir(), "config.ini")
f, err := os.Open(cfgPath)
if err != nil {
return nil, SelfUpdateConfig{}, err
}
defer f.Close()
var repos []RepoConfig
var su SelfUpdateConfig
section := ""
kv := map[string]string{}
flush := func() {
switch {
case strings.HasPrefix(section, "repo:"):
name := strings.TrimPrefix(section, "repo:")
if kv["url"] != "" && kv["path"] != "" {
branch := kv["branch"]
if branch == "" {
branch = "master"
}
repos = append(repos, RepoConfig{
Name: name,
URL: kv["url"],
Path: kv["path"],
Branch: branch,
})
}
case section == "self-update":
su.URL = strings.TrimRight(kv["url"], "/")
su.ExeName = kv["exe_name"]
if su.ExeName == "" {
su.ExeName = "GitUpdateChecker.exe"
}
su.Branch = kv["branch"]
if su.Branch == "" {
su.Branch = "master"
}
}
kv = map[string]string{}
}
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, ";") || strings.HasPrefix(line, "#") {
continue
}
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
flush()
section = strings.ToLower(strings.TrimSpace(line[1 : len(line)-1]))
continue
}
if idx := strings.IndexByte(line, '='); idx > 0 {
k := strings.TrimSpace(strings.ToLower(line[:idx]))
v := strings.TrimSpace(line[idx+1:])
kv[k] = v
}
}
flush()
return repos, su, scanner.Err()
}

View File

@@ -5,6 +5,7 @@
[self-update] [self-update]
url = http://192.168.1.235:3125/zogzog/Lanceur-geco url = http://192.168.1.235:3125/zogzog/Lanceur-geco
exe_name = GitUpdateChecker.exe exe_name = GitUpdateChecker.exe
branch = feature/go-rewrite
[repo:Scripts] [repo:Scripts]
url = http://192.168.1.235:3125/zogzog/Scripts url = http://192.168.1.235:3125/zogzog/Scripts

421
git.go Normal file
View File

@@ -0,0 +1,421 @@
package main
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
)
type RepoResult struct {
Name string
Path string
URL string
Branch string // branche configurée
Pending bool
UpToDate bool
Offline bool
NeedsClone bool
HasUpdate bool // MAJ disponible (hash local != distant)
Error string
LocalChanges int
UntrackedFiles int
UntrackedList []string // liste des fichiers non suivis
}
func runGit(args []string, cwd string, timeout time.Duration) (code int, stdout string, stderr string) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
fullArgs := append([]string{"-c", "safe.directory=*"}, args...)
cmd := newGitCmd(ctx, fullArgs, cwd)
var outBuf, errBuf strings.Builder
cmd.Stdout = &outBuf
cmd.Stderr = &errBuf
err := cmd.Run()
stdout = strings.TrimSpace(outBuf.String())
stderr = strings.TrimSpace(errBuf.String())
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
return 1, stdout, "Timeout"
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return exitErr.ExitCode(), stdout, stderr
}
return -1, stdout, err.Error()
}
return 0, stdout, stderr
}
func absRepoPath(rel string) string {
if filepath.IsAbs(rel) {
return rel
}
return filepath.Join(exeDir(), rel)
}
func checkRemoteOffline(stderr string) bool {
for _, kw := range []string{"could not resolve", "connection refused", "unable to connect", "timed out", "the remote end hung up", "timeout"} {
if strings.Contains(strings.ToLower(stderr), kw) {
return true
}
}
return false
}
func checkRepo(cfg RepoConfig) RepoResult {
res := RepoResult{Name: cfg.Name, URL: cfg.URL, Branch: cfg.Branch}
local := absRepoPath(cfg.Path)
res.Path = local
if _, err := os.Stat(filepath.Join(local, ".git")); os.IsNotExist(err) {
// Vérifier que le dépôt distant existe avant de proposer le clone
code, _, stderr := runGit([]string{"ls-remote", "--exit-code", cfg.URL}, "", 15*time.Second)
if code != 0 {
if checkRemoteOffline(stderr) {
res.Offline = true
res.Error = "Hors ligne"
return res
}
if strings.Contains(strings.ToLower(stderr), "not found") || strings.Contains(strings.ToLower(stderr), "repository not found") {
res.Error = "Dépôt introuvable : " + cfg.URL
return res
}
res.Error = "Erreur remote : " + stderr
return res
}
res.NeedsClone = true
return res
}
runGit([]string{"remote", "set-url", "origin", cfg.URL}, local, 10*time.Second)
// Hash local
_, localHash, _ := runGit([]string{"rev-parse", "HEAD"}, local, 5*time.Second)
if localHash == "" {
res.Error = "Impossible de lire le commit local"
return res
}
// Vérification rapide du remote via ls-remote (timeout court)
branch := cfg.Branch
code, lsOut, stderr := runGit([]string{"ls-remote", "origin", "refs/heads/" + branch}, local, 15*time.Second)
if code != 0 {
if checkRemoteOffline(stderr) {
res.Offline = true
res.Error = "Hors ligne"
return res
}
res.Error = "ls-remote: " + stderr
return res
}
// Extraire le hash distant
remoteHash := ""
if lsOut != "" {
parts := strings.Fields(lsOut)
if len(parts) > 0 {
remoteHash = parts[0]
}
}
if remoteHash == "" {
res.Error = fmt.Sprintf("Branche '%s' introuvable sur le remote", branch)
return res
}
// Comparer les hashs
if localHash != remoteHash {
res.HasUpdate = true
}
// Modifications locales
_, status, _ := runGit([]string{"status", "--porcelain"}, local, 5*time.Second)
if status != "" {
for _, line := range strings.Split(strings.TrimSpace(status), "\n") {
if strings.HasPrefix(line, "?? ") {
res.UntrackedFiles++
res.UntrackedList = append(res.UntrackedList, strings.TrimPrefix(line, "?? "))
} else {
res.LocalChanges++
}
}
}
res.UpToDate = !res.HasUpdate && res.LocalChanges == 0 && res.UntrackedFiles == 0
return res
}
func doClone(cfg RepoConfig) error {
local := absRepoPath(cfg.Path)
if err := os.MkdirAll(filepath.Dir(local), 0755); err != nil {
return err
}
// Si le dossier n'existe pas ou est vide, clone classique
entries, _ := os.ReadDir(local)
if len(entries) == 0 {
code, _, stderr := runGit([]string{"clone", cfg.URL, local}, "", 300*time.Second)
if code != 0 {
return fmt.Errorf("%s", stderr)
}
return nil
}
// Dossier non-vide sans .git : init + remote + fetch + checkout
return doCloneInPlace(cfg, local)
}
func doCloneInPlace(cfg RepoConfig, local string) error {
code, _, stderr := runGit([]string{"init"}, local, 30*time.Second)
if code != 0 {
return fmt.Errorf("git init: %s", stderr)
}
code, _, stderr = runGit([]string{"remote", "add", "origin", cfg.URL}, local, 10*time.Second)
if code != 0 {
// remote existe déjà, mettre à jour l'URL
runGit([]string{"remote", "set-url", "origin", cfg.URL}, local, 10*time.Second)
}
code, _, stderr = runGit([]string{"fetch", "origin"}, local, 5*time.Minute)
if code != 0 {
return fmt.Errorf("fetch: %s", stderr)
}
branch := cfg.Branch
code, _, stderr = runGit([]string{"checkout", "origin/" + branch, "-b", branch}, local, 30*time.Second)
if code != 0 {
// Branche locale existe déjà
code, _, stderr = runGit([]string{"checkout", branch}, local, 30*time.Second)
if code == 0 {
code, _, stderr = runGit([]string{"reset", "--hard", "origin/" + branch}, local, 30*time.Second)
}
}
if code != 0 {
return fmt.Errorf("checkout: %s", stderr)
}
return nil
}
func doPull(res RepoResult) error {
_, branch, _ := runGit([]string{"rev-parse", "--abbrev-ref", "HEAD"}, res.Path, 5*time.Second)
if branch == "" {
branch = "master"
}
code, _, stderr := runGit([]string{"pull", "origin", branch}, res.Path, 120*time.Second)
if code != 0 {
return fmt.Errorf("%s", stderr)
}
return nil
}
func doCheckout(res RepoResult) error {
code, _, stderr := runGit([]string{"checkout", "--", "."}, res.Path, 30*time.Second)
if code != 0 {
return fmt.Errorf("%s", stderr)
}
return nil
}
func doClean(res RepoResult) error {
code, _, stderr := runGit([]string{"clean", "-fd"}, res.Path, 60*time.Second)
if code != 0 {
return fmt.Errorf("%s", stderr)
}
return nil
}
// ── Progression Git ───────────────────────────────────────────────────────────
// ProgressInfo contient l'état de progression d'une opération git.
type ProgressInfo struct {
Phase string // ex: "Receiving objects", "Resolving deltas"
Percent float64 // 0.0 à 1.0
Current int64
Total int64
Speed string // ex: "1.2 MiB/s"
}
// ProgressCallback est appelé à chaque mise à jour de la progression.
type ProgressCallback func(ProgressInfo)
// reGitProgress capture les lignes de progression git :
//
// "Receiving objects: 45% (123/456), 1.20 MiB | 500.00 KiB/s"
// "Resolving deltas: 100% (89/89), done."
var reGitProgress = regexp.MustCompile(
`(?i)([\w\s]+):\s+(\d+)%\s+\((\d+)/(\d+)\)(?:.*\|\s*(.+/s))?`,
)
// parseGitProgress analyse une ligne de sortie git et renvoie un ProgressInfo.
func parseGitProgress(line string) (ProgressInfo, bool) {
m := reGitProgress.FindStringSubmatch(line)
if m == nil {
return ProgressInfo{}, false
}
pct, _ := strconv.Atoi(m[2])
cur, _ := strconv.ParseInt(m[3], 10, 64)
tot, _ := strconv.ParseInt(m[4], 10, 64)
speed := strings.TrimSpace(m[5])
return ProgressInfo{
Phase: strings.TrimSpace(m[1]),
Percent: float64(pct) / 100.0,
Current: cur,
Total: tot,
Speed: speed,
}, true
}
// runGitWithProgress exécute une commande git et capture la progression en temps réel.
// Le timeout est désactivé (0) ou très long pour les gros dépôts.
func runGitWithProgress(args []string, cwd string, timeout time.Duration, cb ProgressCallback) (int, string, string) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
fullArgs := append([]string{"-c", "safe.directory=*"}, args...)
cmd := newGitCmd(ctx, fullArgs, cwd)
var outBuf strings.Builder
cmd.Stdout = &outBuf
// Pipe stderr pour lire la progression en temps réel
stderrPipe, err := cmd.StderrPipe()
if err != nil {
return -1, "", err.Error()
}
if err := cmd.Start(); err != nil {
return -1, "", err.Error()
}
// Lire stderr byte par byte pour détecter les \r (git écrase la ligne)
var stderrBuf strings.Builder
reader := bufio.NewReader(stderrPipe)
var lineBuf strings.Builder
for {
b, err := reader.ReadByte()
if err != nil {
if err != io.EOF {
stderrBuf.WriteString(err.Error())
}
break
}
stderrBuf.WriteByte(b)
if b == '\r' || b == '\n' {
line := lineBuf.String()
lineBuf.Reset()
if cb != nil && line != "" {
if info, ok := parseGitProgress(line); ok {
cb(info)
}
}
} else {
lineBuf.WriteByte(b)
}
}
// Dernière ligne sans \r\n
if lineBuf.Len() > 0 && cb != nil {
if info, ok := parseGitProgress(lineBuf.String()); ok {
cb(info)
}
}
err = cmd.Wait()
stdout := strings.TrimSpace(outBuf.String())
stderr := strings.TrimSpace(stderrBuf.String())
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
return 1, stdout, "Timeout"
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return exitErr.ExitCode(), stdout, stderr
}
return -1, stdout, err.Error()
}
return 0, stdout, stderr
}
// doCloneWithProgress clone un dépôt avec suivi de progression.
func doCloneWithProgress(cfg RepoConfig, cb ProgressCallback) error {
local := absRepoPath(cfg.Path)
if err := os.MkdirAll(filepath.Dir(local), 0755); err != nil {
return err
}
// Si le dossier n'existe pas ou est vide, clone classique avec progression
entries, _ := os.ReadDir(local)
if len(entries) == 0 {
code, _, stderr := runGitWithProgress(
[]string{"clone", "--progress", cfg.URL, local},
"", 2*time.Hour, cb,
)
if code != 0 {
return fmt.Errorf("%s", stderr)
}
return nil
}
// Dossier non-vide sans .git : init + remote + fetch avec progression + checkout
code, _, stderr := runGit([]string{"init"}, local, 30*time.Second)
if code != 0 {
return fmt.Errorf("git init: %s", stderr)
}
code, _, stderr = runGit([]string{"remote", "add", "origin", cfg.URL}, local, 10*time.Second)
if code != 0 {
runGit([]string{"remote", "set-url", "origin", cfg.URL}, local, 10*time.Second)
}
code, _, stderr = runGitWithProgress(
[]string{"fetch", "--progress", "origin"},
local, 2*time.Hour, cb,
)
if code != 0 {
return fmt.Errorf("fetch: %s", stderr)
}
branch := cfg.Branch
code, _, stderr = runGit([]string{"checkout", "origin/" + branch, "-b", branch}, local, 30*time.Second)
if code != 0 {
code, _, stderr = runGit([]string{"checkout", branch}, local, 30*time.Second)
if code == 0 {
code, _, stderr = runGit([]string{"reset", "--hard", "origin/" + branch}, local, 30*time.Second)
}
}
if code != 0 {
return fmt.Errorf("checkout: %s", stderr)
}
return nil
}
// doPullWithProgress fait un pull avec suivi de progression.
func doPullWithProgress(res RepoResult, cb ProgressCallback) error {
_, branch, _ := runGit([]string{"rev-parse", "--abbrev-ref", "HEAD"}, res.Path, 5*time.Second)
if branch == "" {
branch = "master"
}
code, _, stderr := runGitWithProgress(
[]string{"pull", "--progress", "origin", branch},
res.Path, 2*time.Hour, cb,
)
if code != 0 {
return fmt.Errorf("%s", stderr)
}
return nil
}

File diff suppressed because it is too large Load Diff

14
go.mod Normal file
View File

@@ -0,0 +1,14 @@
module gitchecker
go 1.25.0
require (
github.com/lxn/walk v0.0.0-20210112085537-c389da54e794
golang.org/x/image v0.38.0
golang.org/x/sys v0.18.0
)
require (
github.com/lxn/win v0.0.0-20210218163916-a377121e959e // indirect
gopkg.in/Knetic/govaluate.v3 v3.0.0 // indirect
)

11
go.sum Normal file
View File

@@ -0,0 +1,11 @@
github.com/lxn/walk v0.0.0-20210112085537-c389da54e794 h1:NVRJ0Uy0SOFcXSKLsS65OmI1sgCCfiDUPj+cwnH7GZw=
github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ=
github.com/lxn/win v0.0.0-20210218163916-a377121e959e h1:H+t6A/QJMbhCSEH5rAuRxh+CtW96g0Or0Fxa9IKr4uc=
github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=
golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
gopkg.in/Knetic/govaluate.v3 v3.0.0 h1:18mUyIt4ZlRlFZAAfVetz4/rzlJs9yhN+U02F4u1AOc=
gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E=

710
gui.go Normal file
View File

@@ -0,0 +1,710 @@
package main
import (
"bytes"
_ "embed"
"fmt"
"image"
"image/png"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/lxn/walk"
. "github.com/lxn/walk/declarative"
xdraw "golang.org/x/image/draw"
)
//go:embed icon.png
var iconPNG []byte
// ── Modèle TableView ──────────────────────────────────────────────────────────
type RepoItem struct {
result RepoResult
progress float64 // 0.0 à 1.0
progressText string // ex: "Réception 45% (1.2 Go/2.5 Go)"
}
func (it *RepoItem) statusText() string {
r := it.result
if r.Pending {
return "Vérification..."
}
if r.Error != "" {
return r.Error
}
if r.NeedsClone {
return "À cloner"
}
if r.UpToDate {
return "À jour"
}
var parts []string
if r.HasUpdate {
parts = append(parts, "MAJ disponible")
}
if r.LocalChanges > 0 {
parts = append(parts, fmt.Sprintf("%d modif. locale(s)", r.LocalChanges))
}
if r.UntrackedFiles > 0 {
parts = append(parts, fmt.Sprintf("%d fichier(s) en trop", r.UntrackedFiles))
}
if len(parts) == 0 {
return "À jour"
}
return strings.Join(parts, ", ")
}
// progressBarText génère une barre de progression visuelle en Unicode.
// Ex: "████████░░░░ 67% Réception objets"
func progressBarText(pct float64, width int, label string) string {
if width <= 0 {
width = 20
}
filled := int(pct * float64(width))
if filled > width {
filled = width
}
bar := strings.Repeat("█", filled) + strings.Repeat("░", width-filled)
pctInt := int(pct * 100)
if pctInt > 100 {
pctInt = 100
}
if label != "" {
return fmt.Sprintf("%s %3d%% %s", bar, pctInt, label)
}
return fmt.Sprintf("%s %3d%%", bar, pctInt)
}
func (it *RepoItem) textColor() walk.Color {
r := it.result
if r.Pending {
return walk.RGB(120, 120, 120)
}
if r.Error != "" {
return walk.RGB(200, 50, 50)
}
if r.NeedsClone {
return walk.RGB(180, 120, 0)
}
if r.UpToDate {
return walk.RGB(0, 150, 0)
}
return walk.RGB(180, 120, 0)
}
type RepoModel struct {
walk.TableModelBase
mu sync.RWMutex
items []*RepoItem
}
func newRepoModel(cfgs []RepoConfig) *RepoModel {
m := &RepoModel{}
m.items = make([]*RepoItem, len(cfgs))
for i, c := range cfgs {
m.items[i] = &RepoItem{result: RepoResult{Name: c.Name, Pending: true}}
}
return m
}
func (m *RepoModel) RowCount() int {
m.mu.RLock()
defer m.mu.RUnlock()
return len(m.items)
}
func (m *RepoModel) Value(row, col int) interface{} {
m.mu.RLock()
defer m.mu.RUnlock()
if row >= len(m.items) {
return ""
}
it := m.items[row]
switch col {
case 0:
return it.result.Name
case 1:
return it.statusText()
case 2:
return it.progressText
}
return ""
}
func (m *RepoModel) StyleCell(style *walk.CellStyle) {
m.mu.RLock()
defer m.mu.RUnlock()
row := style.Row()
if row >= len(m.items) {
return
}
col := style.Col()
if col == 1 {
style.TextColor = m.items[row].textColor()
}
if col == 2 {
it := m.items[row]
if it.progress > 0 && it.progress < 1.0 {
style.TextColor = walk.RGB(0, 100, 180)
} else if it.progress >= 1.0 {
style.TextColor = walk.RGB(0, 150, 0)
}
}
}
func (m *RepoModel) setResult(row int, res RepoResult) {
m.mu.Lock()
if row < len(m.items) {
m.items[row].result = res
m.items[row].progress = 0
m.items[row].progressText = ""
}
m.mu.Unlock()
m.PublishRowChanged(row)
}
func (m *RepoModel) setProgress(row int, pct float64, text string) {
m.mu.Lock()
if row < len(m.items) {
m.items[row].progress = pct
m.items[row].progressText = text
}
m.mu.Unlock()
m.PublishRowChanged(row)
}
func (m *RepoModel) reset(cfgs []RepoConfig) {
m.mu.Lock()
m.items = make([]*RepoItem, len(cfgs))
for i, c := range cfgs {
m.items[i] = &RepoItem{result: RepoResult{Name: c.Name, Pending: true}}
}
m.mu.Unlock()
m.PublishRowsReset()
}
func (m *RepoModel) getResult(row int) (RepoResult, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
if row < 0 || row >= len(m.items) {
return RepoResult{}, false
}
return m.items[row].result, true
}
func (m *RepoModel) hasUpdates() bool {
m.mu.RLock()
defer m.mu.RUnlock()
for _, it := range m.items {
r := it.result
if !r.Pending && r.Error == "" && !r.Offline && (r.HasUpdate || r.LocalChanges > 0 || r.UntrackedFiles > 0 || r.NeedsClone) {
return true
}
}
return false
}
// ── Application ───────────────────────────────────────────────────────────────
type App struct {
mw *walk.MainWindow
iconView *walk.ImageView
statusLabel *walk.Label
tv *walk.TableView
model *RepoModel
logEdit *walk.TextEdit
btnRefresh *walk.PushButton
btnUpdateAll *walk.PushButton
btnAction *walk.PushButton
reposConfig []RepoConfig
suConfig SelfUpdateConfig
checking atomic.Bool
}
func runApp() error {
app := &App{}
var err error
app.reposConfig, app.suConfig, err = loadConfig()
if err != nil {
logWarn("Config: " + err.Error())
}
app.model = newRepoModel(app.reposConfig)
return app.buildAndRun()
}
func (a *App) buildAndRun() error {
if err := (MainWindow{
AssignTo: &a.mw,
Title: "Git Update Checker v" + VERSION,
MinSize: Size{Width: 750, Height: 400},
Size: Size{Width: 950, Height: 600},
Layout: VBox{Margins: Margins{Left: 10, Top: 10, Right: 10, Bottom: 10}},
Children: []Widget{
// En-tête
Composite{
Layout: HBox{MarginsZero: true},
Children: []Widget{
ImageView{
AssignTo: &a.iconView,
MinSize: Size{Width: 24, Height: 24},
MaxSize: Size{Width: 24, Height: 24},
Mode: ImageViewModeIdeal,
},
Label{
Text: "Git Update Checker v" + VERSION,
Font: Font{Bold: true, PointSize: 12},
},
HSpacer{},
Label{AssignTo: &a.statusLabel, Text: "Démarrage..."},
},
},
// Zone principale : table + log
VSplitter{
Children: []Widget{
TableView{
AssignTo: &a.tv,
AlternatingRowBG: true,
ColumnsOrderable: false,
Columns: []TableViewColumn{
{Title: "Dépôt", Width: 150},
{Title: "Statut", Width: 200},
{Title: "Progression", Width: 350},
},
Model: a.model,
OnCurrentIndexChanged: a.onSelectionChanged,
OnItemActivated: a.doAction,
},
Composite{
Layout: VBox{MarginsZero: true},
Children: []Widget{
Composite{
Layout: HBox{MarginsZero: true},
Children: []Widget{
Label{Text: "Journal", Font: Font{Bold: true}},
HSpacer{},
PushButton{
Text: "Effacer",
MaxSize: Size{Width: 65},
OnClicked: func() { a.logEdit.SetText("") },
},
},
},
TextEdit{
AssignTo: &a.logEdit,
ReadOnly: true,
VScroll: true,
Font: Font{Family: "Consolas", PointSize: 9},
},
},
},
},
},
// Boutons
Composite{
Layout: HBox{MarginsZero: true},
Children: []Widget{
PushButton{
AssignTo: &a.btnRefresh,
Text: "Rafraîchir",
OnClicked: a.startCheck,
},
PushButton{
AssignTo: &a.btnUpdateAll,
Text: "Tout mettre à jour",
Enabled: false,
OnClicked: a.updateAll,
},
PushButton{
AssignTo: &a.btnAction,
Text: "Mettre à jour",
Enabled: false,
OnClicked: a.doAction,
},
HSpacer{},
PushButton{Text: "config.ini", OnClicked: a.openConfig},
PushButton{Text: "Logs", OnClicked: a.openLogs},
},
},
},
}.Create()); err != nil {
return err
}
// Icône fenêtre (depuis fichier .ico externe)
if icoPath := filepath.Join(exeDir(), "icon.ico"); fileExists(icoPath) {
if icon, err := walk.NewIconFromFile(icoPath); err == nil {
a.mw.SetIcon(icon)
}
}
// Icône dans l'en-tête (depuis PNG embarqué dans l'exe, redimensionné 24x24)
if img, err := png.Decode(bytes.NewReader(iconPNG)); err != nil {
logWarn("Icône PNG: décodage échoué: " + err.Error())
} else {
const iconSize = 24
dst := image.NewRGBA(image.Rect(0, 0, iconSize, iconSize))
xdraw.CatmullRom.Scale(dst, dst.Bounds(), img, img.Bounds(), xdraw.Over, nil)
bmp, err := walk.NewBitmapFromImageForDPI(dst, 96)
if err != nil {
logWarn("Icône PNG: bitmap échoué: " + err.Error())
} else {
a.iconView.SetImage(bmp)
}
}
// Lancer la vérification au démarrage
go func() {
time.Sleep(150 * time.Millisecond)
a.mw.Synchronize(a.checkSelfUpdateThenRepos)
}()
a.mw.Run()
return nil
}
// ── Vérifications ─────────────────────────────────────────────────────────────
func (a *App) checkSelfUpdateThenRepos() {
a.setStatus("Vérification auto-update...")
go func() {
needs, info, err := checkSelfUpdate(a.suConfig)
a.mw.Synchronize(func() {
if err != nil {
logWarn("Auto-update: " + err.Error())
}
if needs {
ans := walk.MsgBox(a.mw,
"Mise à jour disponible",
info+"\n\nTélécharger maintenant ?",
walk.MsgBoxYesNo|walk.MsgBoxIconQuestion,
)
if ans == walk.DlgCmdYes {
a.doSelfUpdate()
return
}
}
a.startCheck()
})
}()
}
func (a *App) startCheck() {
if !a.checking.CompareAndSwap(false, true) {
return
}
a.btnRefresh.SetEnabled(false)
a.btnUpdateAll.SetEnabled(false)
a.btnAction.SetEnabled(false)
a.model.reset(a.reposConfig)
a.setStatus(fmt.Sprintf("Vérification 0/%d...", len(a.reposConfig)))
logInfo("Vérification des dépôts...")
done := atomic.Int32{}
total := int32(len(a.reposConfig))
for i, cfg := range a.reposConfig {
i, cfg := i, cfg
go func() {
res := checkRepo(cfg)
a.mw.Synchronize(func() {
a.model.setResult(i, res)
a.appendLog(logLineForResult(res))
logInfo(fmt.Sprintf("[%s] %s", res.Name, logLineForResult(res)))
n := done.Add(1)
if int32(n) == total {
a.onCheckDone()
} else {
a.setStatus(fmt.Sprintf("Vérification %d/%d...", n, total))
}
})
}()
}
if total == 0 {
a.onCheckDone()
}
}
func (a *App) onCheckDone() {
a.checking.Store(false)
a.btnRefresh.SetEnabled(true)
a.btnUpdateAll.SetEnabled(a.model.hasUpdates())
a.setStatus(fmt.Sprintf("Dernière vérification : %s", time.Now().Format("15:04:05")))
}
func logLineForResult(r RepoResult) string {
if r.Error != "" {
return r.Error
}
if r.NeedsClone {
return "À cloner"
}
if r.UpToDate {
return "À jour"
}
var parts []string
if r.HasUpdate {
parts = append(parts, "MAJ disponible")
}
if r.LocalChanges > 0 {
parts = append(parts, fmt.Sprintf("%d modif. locale(s)", r.LocalChanges))
}
if r.UntrackedFiles > 0 {
parts = append(parts, fmt.Sprintf("%d fichier(s) en trop", r.UntrackedFiles))
}
return strings.Join(parts, ", ")
}
// recheckOne re-vérifie un seul dépôt sans toucher aux autres.
func (a *App) recheckOne(idx int) {
if idx < 0 || idx >= len(a.reposConfig) {
return
}
cfg := a.reposConfig[idx]
a.model.setResult(idx, RepoResult{Name: cfg.Name, Pending: true})
go func() {
res := checkRepo(cfg)
a.mw.Synchronize(func() {
a.model.setResult(idx, res)
a.btnUpdateAll.SetEnabled(a.model.hasUpdates())
a.onSelectionChanged()
})
}()
}
// proposeClean affiche un popup listant les fichiers non suivis et propose de les supprimer.
func (a *App) proposeClean(idx int, res RepoResult) {
// Construire la liste des fichiers (max 30 affichés)
list := ""
for i, f := range res.UntrackedList {
if i >= 30 {
list += fmt.Sprintf("\n... et %d autre(s)", len(res.UntrackedList)-30)
break
}
list += "\n - " + f
}
msg := fmt.Sprintf("[%s] %d fichier(s) non suivi(s) détecté(s) :%s\n\nSupprimer ces fichiers ?",
res.Name, res.UntrackedFiles, list)
ans := walk.MsgBox(a.mw, "Fichiers en trop", msg, walk.MsgBoxYesNo|walk.MsgBoxIconQuestion)
if ans == walk.DlgCmdYes {
a.appendLog(fmt.Sprintf("[%s] Nettoyage de %d fichier(s)...", res.Name, res.UntrackedFiles))
go func() {
err := doClean(res)
a.mw.Synchronize(func() {
if err != nil {
a.appendLog(fmt.Sprintf("[%s] Erreur nettoyage: %v", res.Name, err))
logError(fmt.Sprintf("[%s] clean: %v", res.Name, err))
} else {
a.appendLog(fmt.Sprintf("[%s] %d fichier(s) supprimé(s)", res.Name, res.UntrackedFiles))
logInfo(fmt.Sprintf("[%s] %d fichier(s) supprimé(s)", res.Name, res.UntrackedFiles))
}
a.recheckOne(idx)
})
}()
} else {
a.recheckOne(idx)
}
}
// ── Progression ───────────────────────────────────────────────────────────────
// makeProgressCB crée un callback de progression pour la ligne row du tableau.
// Le callback est appelé depuis un goroutine git et synchronise l'UI via mw.Synchronize.
func (a *App) makeProgressCB(row int) ProgressCallback {
// Limiter les mises à jour UI (max ~10/s) pour ne pas surcharger
var lastUpdate time.Time
return func(info ProgressInfo) {
now := time.Now()
if now.Sub(lastUpdate) < 100*time.Millisecond && info.Percent < 1.0 {
return
}
lastUpdate = now
label := info.Phase
if info.Speed != "" {
label += " " + info.Speed
}
text := progressBarText(info.Percent, 20, label)
a.mw.Synchronize(func() {
a.model.setProgress(row, info.Percent, text)
})
}
}
// ── Actions dépôt ─────────────────────────────────────────────────────────────
func (a *App) onSelectionChanged() {
idx := a.tv.CurrentIndex()
res, ok := a.model.getResult(idx)
if !ok || res.Pending || res.Offline || res.Error != "" {
a.btnAction.SetEnabled(false)
return
}
if res.NeedsClone {
a.btnAction.SetText("Cloner")
a.btnAction.SetEnabled(true)
} else if res.HasUpdate || res.LocalChanges > 0 || res.UntrackedFiles > 0 {
a.btnAction.SetText("Mettre à jour")
a.btnAction.SetEnabled(true)
} else {
a.btnAction.SetEnabled(false)
}
}
func (a *App) doAction() {
idx := a.tv.CurrentIndex()
res, ok := a.model.getResult(idx)
if !ok {
return
}
cfg := a.reposConfig[idx]
// Si uniquement des fichiers en trop, proposer directement le nettoyage
if res.UntrackedFiles > 0 && !res.HasUpdate && res.LocalChanges == 0 && !res.NeedsClone {
a.proposeClean(idx, res)
return
}
a.btnAction.SetEnabled(false)
a.appendLog(fmt.Sprintf("[%s] Mise à jour en cours...", res.Name))
cb := a.makeProgressCB(idx)
go func() {
var err error
if res.NeedsClone {
err = doCloneWithProgress(cfg, cb)
} else {
if res.LocalChanges > 0 {
err = doCheckout(res)
}
if err == nil && res.HasUpdate {
err = doPullWithProgress(res, cb)
}
}
a.mw.Synchronize(func() {
if err != nil {
a.model.setProgress(idx, 0, "Erreur")
a.appendLog(fmt.Sprintf("[%s] Erreur: %v", res.Name, err))
logError(fmt.Sprintf("[%s] %v", res.Name, err))
} else {
a.model.setProgress(idx, 1.0, progressBarText(1.0, 20, "Terminé"))
a.appendLog(fmt.Sprintf("[%s] Mise à jour OK", res.Name))
logInfo(fmt.Sprintf("[%s] Mise à jour OK", res.Name))
}
// Si des fichiers en trop après la mise à jour, proposer le nettoyage
if res.UntrackedFiles > 0 {
a.proposeClean(idx, res)
return
}
// Re-vérifier uniquement ce dépôt, pas tous
a.recheckOne(idx)
})
}()
}
func (a *App) updateAll() {
a.btnUpdateAll.SetEnabled(false)
a.btnRefresh.SetEnabled(false)
pending := atomic.Int32{}
for i, cfg := range a.reposConfig {
res, ok := a.model.getResult(i)
if !ok || res.Pending || res.UpToDate || res.Offline || res.Error != "" {
continue
}
pending.Add(1)
i, cfg, res := i, cfg, res
cb := a.makeProgressCB(i)
go func() {
var err error
if res.NeedsClone {
err = doCloneWithProgress(cfg, cb)
} else {
if res.LocalChanges > 0 {
err = doCheckout(res)
}
if err == nil && res.HasUpdate {
err = doPullWithProgress(res, cb)
}
if err == nil && res.UntrackedFiles > 0 {
err = doClean(res)
}
}
a.mw.Synchronize(func() {
if err != nil {
a.model.setProgress(i, 0, "Erreur")
logError(fmt.Sprintf("[%s] %v", res.Name, err))
a.appendLog(fmt.Sprintf("[%s] Erreur: %v", res.Name, err))
} else {
a.model.setProgress(i, 1.0, progressBarText(1.0, 20, "Terminé"))
logInfo(fmt.Sprintf("[%s] Mise à jour OK", res.Name))
a.appendLog(fmt.Sprintf("[%s] Mise à jour OK", res.Name))
}
if pending.Add(-1) == 0 {
a.startCheck()
}
})
}()
}
if pending.Load() == 0 {
a.startCheck()
}
}
// ── Auto-update programme ─────────────────────────────────────────────────────
func (a *App) doSelfUpdate() {
a.setStatus("Téléchargement de la mise à jour...")
go func() {
err := doSelfUpdate(a.suConfig)
a.mw.Synchronize(func() {
if err != nil {
walk.MsgBox(a.mw, "Erreur", "Mise à jour échouée :\n"+err.Error(), walk.MsgBoxIconError)
logError("Auto-update: " + err.Error())
a.startCheck()
return
}
logInfo("Auto-update: mise à jour appliquée, redémarrage...")
walk.MsgBox(a.mw, "Mise à jour", "Mise à jour installée.\nLe programme va redémarrer.", walk.MsgBoxIconInformation)
exePath, _ := os.Executable()
relaunchAfterUpdate(exePath)
a.mw.Close()
})
}()
}
// ── Utilitaires GUI ───────────────────────────────────────────────────────────
func (a *App) setStatus(text string) {
a.statusLabel.SetText(text)
}
func (a *App) appendLog(line string) {
ts := time.Now().Format("15:04:05")
current := a.logEdit.Text()
if current != "" {
current += "\r\n"
}
a.logEdit.SetText(current + "[" + ts + "] " + line)
// Scroller en bas
a.logEdit.SendMessage(0x0115 /*WM_VSCROLL*/, 7 /*SB_BOTTOM*/, 0)
}
func (a *App) openConfig() {
p := filepath.Join(exeDir(), "config.ini")
exec.Command("notepad", p).Start()
}
func (a *App) openLogs() {
p := filepath.Join(exeDir(), "log")
exec.Command("explorer", p).Start()
}

BIN
icon.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 793 KiB

44
logger.go Normal file
View File

@@ -0,0 +1,44 @@
package main
import (
"fmt"
"os"
"path/filepath"
"time"
)
var logFile *os.File
func initLogger() {
dir := filepath.Join(exeDir(), "log")
os.MkdirAll(dir, 0755)
// Nettoyage logs > 30 jours
entries, _ := os.ReadDir(dir)
cutoff := time.Now().AddDate(0, 0, -30)
for _, e := range entries {
if !e.IsDir() {
if info, err := e.Info(); err == nil && info.ModTime().Before(cutoff) {
os.Remove(filepath.Join(dir, e.Name()))
}
}
}
logPath := filepath.Join(dir, time.Now().Format("2006-01-02")+".log")
f, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
return
}
logFile = f
}
func logMsg(level, msg string) {
line := fmt.Sprintf("[%s] %-5s %s\n", time.Now().Format("15:04:05"), level, msg)
if logFile != nil {
logFile.WriteString(line)
}
}
func logInfo(msg string) { logMsg("INFO", msg) }
func logWarn(msg string) { logMsg("WARN", msg) }
func logError(msg string) { logMsg("ERROR", msg) }

36
main.go Normal file
View File

@@ -0,0 +1,36 @@
package main
import (
"fmt"
"os"
"path/filepath"
"runtime"
"github.com/lxn/walk"
)
const VERSION = "0.7.7"
func exeDir() string {
exe, err := os.Executable()
if err != nil {
return "."
}
return filepath.Dir(exe)
}
func fileExists(p string) bool {
_, err := os.Stat(p)
return err == nil
}
func main() {
runtime.LockOSThread()
initLogger()
logInfo(fmt.Sprintf("=== Demarrage Git Update Checker v%s ===", VERSION))
if err := runApp(); err != nil {
walk.MsgBox(nil, "Erreur fatale", err.Error(), walk.MsgBoxIconError)
os.Exit(1)
}
}

40
platform_windows.go Normal file
View File

@@ -0,0 +1,40 @@
//go:build windows
package main
import (
"context"
"fmt"
"os"
"os/exec"
"golang.org/x/sys/windows"
)
const createNoWindow = 0x08000000
// newGitCmd crée une commande git sans fenêtre console.
func newGitCmd(ctx context.Context, args []string, cwd string) *exec.Cmd {
cmd := exec.CommandContext(ctx, "git", args...)
if cwd != "" {
cmd.Dir = cwd
}
cmd.SysProcAttr = &windows.SysProcAttr{
CreationFlags: createNoWindow,
}
return cmd
}
// relaunchAfterUpdate crée un batch qui attend 1s, relance l'exe et nettoie le .old.
func relaunchAfterUpdate(exePath string) {
oldPath := exePath + ".old"
batPath := exePath + "_update.bat"
content := fmt.Sprintf(
"@echo off\r\ntimeout /t 1 /nobreak >nul\r\nstart \"\" \"%s\"\r\ndel \"%s\" 2>nul\r\ndel \"%%~f0\"\r\n",
exePath, oldPath,
)
os.WriteFile(batPath, []byte(content), 0644)
cmd := exec.Command("cmd", "/c", batPath)
cmd.SysProcAttr = &windows.SysProcAttr{CreationFlags: createNoWindow}
cmd.Start()
}

95
selfupdate.go Normal file
View File

@@ -0,0 +1,95 @@
package main
import (
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
func parseVersion(v string) [3]int {
var t [3]int
for i, p := range strings.SplitN(strings.TrimSpace(v), ".", 3) {
fmt.Sscanf(p, "%d", &t[i])
}
return t
}
func versionGreater(remote, local string) bool {
r, l := parseVersion(remote), parseVersion(local)
for i := 0; i < 3; i++ {
if r[i] > l[i] {
return true
}
if r[i] < l[i] {
return false
}
}
return false
}
func checkSelfUpdate(cfg SelfUpdateConfig) (needsUpdate bool, info string, err error) {
if cfg.URL == "" {
return false, "", nil
}
url := fmt.Sprintf("%s/raw/branch/%s/version.txt", cfg.URL, cfg.Branch)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(url)
if err != nil {
return false, "", err
}
defer resp.Body.Close()
data, _ := io.ReadAll(resp.Body)
remote := strings.TrimSpace(string(data))
if !versionGreater(remote, VERSION) {
return false, "", nil
}
return true, fmt.Sprintf("Version actuelle : %s\nVersion disponible : %s", VERSION, remote), nil
}
func doSelfUpdate(cfg SelfUpdateConfig) error {
exePath, err := os.Executable()
if err != nil {
return err
}
newPath := exePath + ".new"
oldPath := exePath + ".old"
url := fmt.Sprintf("%s/raw/branch/%s/%s", cfg.URL, cfg.Branch, cfg.ExeName)
client := &http.Client{Timeout: 120 * time.Second}
resp, err := client.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
f, err := os.Create(newPath)
if err != nil {
return err
}
n, copyErr := io.Copy(f, resp.Body)
f.Close()
if copyErr != nil {
os.Remove(newPath)
return copyErr
}
if n < 1000 {
os.Remove(newPath)
return fmt.Errorf("fichier telecharge invalide (%d octets)", n)
}
// Supprimer le Mark of the Web (Zone.Identifier)
os.Remove(newPath + ":Zone.Identifier")
if err := os.Rename(exePath, oldPath); err != nil {
os.Remove(newPath)
return err
}
if err := os.Rename(newPath, exePath); err != nil {
os.Rename(oldPath, exePath) // restaurer
return err
}
return nil
}

View File

@@ -1 +1 @@
0.6.3 0.7.7