Compare commits

...

53 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
7d77f4d901 Bug fix 2026-03-24 21:12:05 +01:00
3c9a6e70eb v0.6.3 - Fix _PYI_APPLICATION_HOME_DIR avec --runtime-tmpdir
Changements :
- Ajout de --runtime-tmpdir . dans PyInstaller : extraction a cote de l'exe au lieu de %TEMP%
- Resout l'erreur "_PYI_APPLICATION_HOME_DIR is not defined" de PyInstaller 6.x en onefile

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:11:20 +01:00
62d6c7f89f bug fix 2026-03-24 21:09:00 +01:00
415a09a12d v0.6.2 - Retour en onefile, suppression _internal
Changements :
- Retour a --onefile : exe unique, plus besoin de _internal/
- Le fix Zone.Identifier (v0.5.9) resout le probleme de DLL pour les exe telecharges
- Suppression de _internal/ du repo et ajout au .gitignore
- build.bat simplifie

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:08:26 +01:00
b460502626 v0.6.1 - Bump version
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:04:47 +01:00
ead08293fa v0.6.0 - Ajout _internal pour fix erreur DLL
Changements :
- Ajout du dossier _internal/ (DLL Python generees par PyInstaller --onedir)
- Resout l'erreur "Failed to load Python DLL" : plus d'extraction dans %TEMP%
- build.bat copie automatiquement _internal/ a la racine apres chaque build
- _internal/ a re-committer uniquement si la version de Python change

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:00:22 +01:00
6954d7cc06 bug fix 2026-03-24 20:54:26 +01:00
a2cefb2c4c v0.6.0 - Fix erreur DLL : passage en mode onedir
Changements :
- Remplacement de --onefile par --onedir dans PyInstaller
- --onefile extrayait les DLL dans %TEMP% a chaque lancement, ce que Windows bloquait (securite, antivirus)
- --onedir place les DLL dans _internal/ a cote de l'exe : pas d'extraction, pas de blocage
- L'auto-update continue de ne remplacer que GitUpdateChecker.exe (_internal/ reste en place)
- build.bat mis a jour avec les instructions de deploiement

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 20:52:43 +01:00
dc3ac499d5 v0.5.9 - Fix erreur DLL apres mise a jour
Changements :
- Suppression du flux Zone.Identifier (Mark of the Web) apres le telechargement du nouvel exe
- Windows bloquait le chargement de python313.dll car le fichier etait marque comme telecharge depuis internet

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 20:44:28 +01:00
345171f035 Améliorations 2026-03-24 20:40:28 +01:00
6863fbad98 v0.5.8 - Ajout icone
Changements :
- Icone affichee dans le coin haut gauche du header de la GUI
- Icone de fenetre et taskbar via iconphoto (icon.png)
- Icone de l'exe compilee depuis icon.png -> icon.ico (Pillow, multi-tailles)
- build.bat mis a jour avec la conversion PNG -> ICO automatique

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 20:39:52 +01:00
5f2cd13072 v0.5.7 - Suppression de la fenetre console
Changements :
- Remplacement du flag --console par --noconsole dans PyInstaller (app GUI, pas besoin de console)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 20:34:33 +01:00
97db6a8033 Améliorations 2026-03-24 20:32:05 +01:00
ef3ce2b12b v0.5.6 - Suppression de la fenetre console
Changements :
- Remplacement du flag --console par --noconsole dans PyInstaller (app GUI, pas besoin de console)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 20:31:50 +01:00
83b437cb29 v0.5.6 - Corrections mineures
Changements :
- Fichier _update.bat ecrit en encodage ANSI (mbcs) pour compatibilite avec cmd.exe sur les chemins avec caracteres speciaux
- Condition has_any_updates rendue explicite : exclut desormais clairement offline et needs_clone en plus de error

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 20:23:30 +01:00
0e92b76687 v0.5.5 - Ameliorations ergonomie
Changements :
- Horloge en temps reel : le label de date se met a jour chaque seconde
- Molette de souris ciblee : scroll les cartes ou le journal selon la position du curseur (plus de conflit entre les deux zones)
- Feedback de progression : le journal affiche l'etat de chaque depot au debut et a la fin de la verification (a jour, hors ligne, erreur, changements disponibles)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 19:25:09 +01:00
6438605d7c v0.5.4 - Ameliorations de fond
Changements :
- Verification des depots en parallele (ThreadPoolExecutor, max 4) avec progression en temps reel dans le log GUI
- Branche self-update configurable via cle 'branch' dans [self-update] du config.ini (defaut: master)
- Telechargement du nouvel exe en streaming par blocs de 64 Ko au lieu de tout charger en RAM

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 19:19:53 +01:00
b5068b3a97 . 2026-03-24 19:14:28 +01:00
6d29250fc4 v0.5.3 - Correction de 3 bugs
Changements :
- Fix _update_all : un seul _start_check() a la fin du batch au lieu d'un par depot (evite les refreshs concurrents)
- Fix check_repo : suppression du double appel reseau (ls-remote + fetch), le fetch seul detecte maintenant le mode hors ligne via les mots-cles d'erreur
- Fix timeout : clone passe de 30s a 300s, pull de 30s a 120s pour eviter les faux echecs sur repos volumineux

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 19:13:21 +01:00
ad1ec0c024 v0.5.2 - Detection depot hors ligne
Changements :
- Ajout verification connectivite remote avant fetch (git ls-remote)
- Affichage "HORS LIGNE" si le serveur est inaccessible
- Synchronisation auto de l'URL origin depuis config.ini
- Documentation versioning et convention de commit dans CLAUDE.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 17:46:59 +01:00
ed7885fa29 Maj de l'exe 2026-03-24 17:42:04 +01:00
14898275b4 Maj Claude.md
FIx test offline dépot
2026-03-24 17:39:04 +01:00
c7779b7ce7 Maj du exe 2026-03-24 16:32:07 +01:00
b8776b6594 FIx suppression du exe.old 2026-03-24 16:26:33 +01:00
94af36fccf maj exe pour test 2026-03-24 16:02:49 +01:00
85a2217615 Maj version 2026-03-24 15:59:03 +01:00
8b5b92bb4f FIx Maj exe 2026-03-24 15:57:44 +01:00
4cf30c6110 maj version exe 2026-03-24 15:47:28 +01:00
056ab94a10 Bug fix 2026-03-24 15:40:01 +01:00
e0e70a41b8 maj version 2026-03-24 15:31:47 +01:00
db69b77739 Fix bug maj soft 2026-03-24 15:28:45 +01:00
fe56e563f3 Ajout log dans interface
Passe a la version 0.2
2026-03-24 13:17:28 +01:00
21 changed files with 1633 additions and 834 deletions

7
.claude/settings.json Normal file
View File

@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(pyinstaller --onedir --noconsole --name \"GitUpdateChecker\" --icon=icon.ico -y git_updater.py)"
]
}
}

View File

@@ -4,7 +4,17 @@
"Bash(python git_updater.py)",
"Bash(pip show:*)",
"Bash(pip install:*)",
"Bash(pyinstaller --onefile --windowed --name \"GitUpdateChecker\" git_updater.py)"
"Bash(pyinstaller --onefile --windowed --name \"GitUpdateChecker\" git_updater.py)",
"Bash(pyinstaller --onefile --console --name \"GitUpdateChecker\" --icon=NONE git_updater.py)",
"Bash(python -m PyInstaller --onefile --console --name \"GitUpdateChecker\" --icon=NONE git_updater.py)",
"Bash(cmd /c build.bat)",
"Bash(pyinstaller --onefile --noconsole --name \"GitUpdateChecker\" --icon=NONE git_updater.py)",
"Bash(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\\)]\\)\")",
"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(\"j:/Documents/- PROJET -/Code/Lanceur-Geco/Lanceur-Geco/.gitignore\")",
"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}''\\)\")"
]
}
}

2
.gitignore vendored
View File

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

129
CLAUDE.md
View File

@@ -6,87 +6,158 @@ 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.
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
```
Lanceur-geco/
├── git_updater.py # Script principal Python (GUI tkinter)
├── main.go # Point d'entrée, constante VERSION
├── config.go # Chargement config.ini (repos + self-update)
├── git.go # Opérations git (check, clone, pull, checkout, clean)
├── gui.go # Interface graphique (walk/TableView)
├── 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 via PyInstaller
├── log/ # Dossier de logs (créé automatiquement, 1 fichier par jour)
├── 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
```
## 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.
- **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`.
## Versioning
- 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)
- 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 `main.go` ET `version.txt`
### Mise à jour de la version
A chaque changement de version, il faut mettre à jour **4 éléments** :
1. `VERSION` dans `main.go` (constante en haut du fichier)
2. `version.txt` à 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 :
```
v{VERSION} - {description courte des changements}
Changements :
- {changement 1}
- {changement 2}
- ...
```
Exemple :
```
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
- Support branche par repo dans config.ini
```
### Mécanisme de comparaison
- 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/{branch}/version.txt`) et compare avec la `VERSION` locale
- Si la version distante est supérieure, une mise à jour est proposée
## Fonctionnement
### Auto-update
1. Au démarrage, le programme vérifie si son propre dossier est un dépôt git
2. Si oui, il fait un `git fetch` et compare avec le remote
3. Si une MAJ du programme est dispo, il propose de la télécharger (`git pull`)
4. Après mise à jour, il demande un redémarrage
1. Au démarrage, le programme télécharge `version.txt` depuis le serveur Gitea via HTTP
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
4. Stratégie de remplacement : télécharge dans `.new`, renomme l'exe actuel en `.old`, place le nouveau
5. Après mise à jour, lance un script batch de redémarrage
### Vérification des dépôts
1. Lit la liste des dépôts depuis `config.ini` (chemins relatifs à l'exe)
2. Pour chaque dépôt :
- `git fetch` pour récupérer l'état distant
- Compare commits locaux vs distants
- Détecte les fichiers supprimés/modifiés localement
3. Affiche le résultat dans une interface graphique (tkinter)
2. Pour chaque dépôt (en parallèle via goroutines) :
- `git ls-remote` pour vérifier la disponibilité et comparer les hashs (rapide, timeout 15s)
- `git status --porcelain` pour détecter les fichiers modifiés/non suivis localement
3. Affiche le résultat dans une interface graphique (walk/TableView)
4. Propose pour chaque dépôt :
- `git pull` si nouveaux commits distants
- `git checkout -- .` si fichiers locaux manquants/modifiés
- `git pull` si MAJ disponible (hash distant différent)
- `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)
Supporte plusieurs sections `[repo:NomDuRepo]` :
```ini
[repo:Batch]
url = http://192.168.1.235:3125/zogzog/Batch
path = ../Batch
[self-update]
url = http://192.168.1.235:3125/zogzog/Lanceur-geco
exe_name = GitUpdateChecker.exe
branch = master
[repo:Powershell]
url = http://192.168.1.235:3125/zogzog/Powershell
path = ../Powershell
[repo:Scripts]
url = http://192.168.1.235:3125/zogzog/Scripts
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
- `path` : Chemin **relatif** vers le dossier local du dépôt (relatif à l'exe)
- `branch` : Branche à suivre (optionnel, défaut: `master`)
## Logging
- 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)
- 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
```bat
build.bat
```
Requiert Python + pip. Installe PyInstaller automatiquement si absent.
Produit `dist/GitUpdateChecker.exe`. Copier `config.ini` à côté de l'exe.
Requiert Go installé et dans le PATH. Installe `rsrc` automatiquement si absent.
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
- **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
- **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)
- **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
- **Repos imbriqués** : Supporte les dépôts git imbriqués (ex: parent/enfant) via clone in-place
## Conventions
- Langage : Python 3, pas de dépendances externes (seulement stdlib + tkinter)
- Interface : GUI tkinter en français
- Langue : Français pour l'interface utilisateur
- Langage : Go 1.22+
- GUI : github.com/lxn/walk (contrôles natifs Windows)
- 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,34 +1,38 @@
@echo off
echo ========================================
echo Build Git Update Checker (.exe)
echo Build Git Update Checker (.exe) - Go
echo ========================================
echo.
:: Vérifier que Python est installé
python --version >nul 2>&1
where go >nul 2>&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
exit /b 1
)
:: Installer PyInstaller si nécessaire
pip show pyinstaller >nul 2>&1
echo [*] Telechargement des dependances...
go mod tidy
if errorlevel 1 (
echo [*] Installation de PyInstaller...
pip install pyinstaller
echo [ERREUR] go mod tidy a echoue.
pause
exit /b 1
)
echo [*] Generation du manifeste Windows (rsrc.syso)...
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.
pyinstaller --onefile --console --name "GitUpdateChecker" --icon=NONE git_updater.py
go build -ldflags "-H windowsgui -s -w" -o GitUpdateChecker.exe .
echo.
if exist "dist\GitUpdateChecker.exe" (
echo [OK] Executable cree : dist\GitUpdateChecker.exe
echo.
echo N'oublie pas de copier config.ini a cote de l'exe !
if exist "GitUpdateChecker.exe" (
echo [OK] GitUpdateChecker.exe cree - exe unique, aucune dependance.
) else (
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

@@ -2,6 +2,10 @@
; Les chemins (path) sont relatifs a l'emplacement de l'exe
; Ajouter autant de sections [repo:NomDuRepo] que necessaire
[self-update]
url = http://192.168.1.235:3125/zogzog/Lanceur-geco
exe_name = GitUpdateChecker.exe
branch = feature/go-rewrite
[repo: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
}

View File

@@ -1,787 +0,0 @@
"""
Git Update Checker - GUI multi-repo.
Vérifie les mises à jour de plusieurs dépôts Git et propose de les télécharger.
Accès lecture seule uniquement (fetch/pull/checkout, jamais de push).
Tous les chemins sont relatifs à l'emplacement de l'exécutable.
"""
VERSION = "0.1"
import subprocess
import sys
import os
import configparser
import logging
import threading
import tkinter as tk
from tkinter import ttk, messagebox
from pathlib import Path
from datetime import datetime
# Forcer UTF-8 sur Windows
if sys.platform == "win32":
os.system("chcp 65001 >nul 2>&1")
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
# ── Utilitaires ──────────────────────────────────────────────────────────────
def get_exe_dir():
if getattr(sys, "frozen", False):
return Path(sys.executable).parent
return Path(__file__).parent
def resolve_relative(path_str):
"""Résout un chemin relatif par rapport au dossier de l'exe."""
p = Path(path_str)
if not p.is_absolute():
p = get_exe_dir() / p
return p.resolve()
# ── Logging ──────────────────────────────────────────────────────────────────
def setup_logging():
"""Configure le logging dans un dossier log/ à côté de l'exe."""
log_dir = get_exe_dir() / "log"
log_dir.mkdir(exist_ok=True)
log_file = log_dir / f"{datetime.now().strftime('%Y-%m-%d')}.log"
logger = logging.getLogger("GitUpdateChecker")
logger.setLevel(logging.DEBUG)
# Handler fichier
fh = logging.FileHandler(log_file, encoding="utf-8")
fh.setLevel(logging.DEBUG)
fh.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s", datefmt="%H:%M:%S"))
logger.addHandler(fh)
# Nettoyage des vieux logs (garder 30 jours)
for old_log in sorted(log_dir.glob("*.log"))[:-30]:
try:
old_log.unlink()
except OSError:
pass
return logger
log = setup_logging()
def run_git(args, cwd=None):
# -c safe.directory=* : évite l'erreur "dubious ownership" sur clé USB
cmd = ["git", "-c", "safe.directory=*"] + args
log.debug(f"git {' '.join(args)} (cwd={cwd})")
try:
result = subprocess.run(
cmd, cwd=cwd, capture_output=True, text=True, timeout=30,
creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0,
)
if result.returncode != 0 and result.stderr.strip():
log.warning(f"git {args[0]} erreur: {result.stderr.strip()}")
return result.returncode, result.stdout.strip(), result.stderr.strip()
except FileNotFoundError:
log.error("Git non trouve dans le PATH")
return -1, "", "Git n'est pas installe ou pas dans le PATH."
except subprocess.TimeoutExpired:
log.error(f"Timeout: git {' '.join(args)}")
return 1, "", "Timeout"
# ── Auto-update du programme ─────────────────────────────────────────────────
def check_self_update():
"""
Vérifie si le dossier de l'exe est un dépôt git avec des MAJ disponibles.
Retourne (needs_update: bool, info: str).
"""
exe_dir = str(get_exe_dir())
if not os.path.isdir(os.path.join(exe_dir, ".git")):
log.info("Auto-update: pas de .git dans le dossier de l'exe, skip")
return False, ""
log.info("Auto-update: verification...")
code, branch, _ = run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=exe_dir)
if code != 0:
return False, "Impossible de lire la branche"
code, _, err = run_git(["fetch", "origin"], cwd=exe_dir)
if code != 0:
log.warning(f"Auto-update: fetch echoue: {err}")
return False, f"Fetch echoue: {err}"
code, local_hash, _ = run_git(["rev-parse", "HEAD"], cwd=exe_dir)
code2, remote_hash, _ = run_git(["rev-parse", f"origin/{branch}"], cwd=exe_dir)
if code != 0 or code2 != 0:
return False, "Impossible de comparer les commits"
if local_hash == remote_hash:
log.info("Auto-update: programme a jour")
return False, ""
# Compter les commits en retard
code, log_out, _ = run_git(
["log", "--oneline", f"HEAD..origin/{branch}"],
cwd=exe_dir,
)
count = len(log_out.splitlines()) if code == 0 and log_out else 0
info = f"{count} commit(s) en retard sur origin/{branch}"
log.info(f"Auto-update: MAJ disponible - {info}")
return True, info
def do_self_update():
"""
Met à jour le programme lui-même.
Sur Windows, un .exe en cours d'exécution ne peut pas être écrasé.
Stratégie : renommer l'exe actuel en .old, puis git pull le nouveau.
Retourne (ok, message).
"""
exe_dir = str(get_exe_dir())
is_frozen = getattr(sys, "frozen", False)
code, branch, _ = run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=exe_dir)
if code != 0:
return False, "Impossible de lire la branche"
# Si on tourne en .exe, renommer l'exe actuel pour libérer le fichier
exe_old_path = None
if is_frozen:
exe_path = Path(sys.executable)
exe_old_path = exe_path.with_suffix(".exe.old")
try:
# Supprimer un ancien .old s'il existe
if exe_old_path.exists():
exe_old_path.unlink()
# Windows permet de renommer un exe en cours d'exécution
exe_path.rename(exe_old_path)
log.info(f"Auto-update: exe renomme {exe_path.name} -> {exe_old_path.name}")
except OSError as e:
log.error(f"Auto-update: impossible de renommer l'exe: {e}")
return False, f"Impossible de renommer l'exe: {e}"
# Restaurer les fichiers locaux modifiés puis pull
run_git(["checkout", "--", "."], cwd=exe_dir)
code, _, err = run_git(["pull", "origin", branch], cwd=exe_dir)
if code == 0:
log.info("Auto-update: pull OK")
return True, "Mise a jour reussie !\nLe programme va redemarrer."
else:
# En cas d'échec, restaurer l'ancien exe
if is_frozen and exe_old_path and exe_old_path.exists():
try:
exe_old_path.rename(Path(sys.executable))
log.info("Auto-update: exe restaure apres echec")
except OSError:
pass
log.error(f"Auto-update: pull echoue: {err}")
return False, f"Erreur pull: {err}"
def relaunch_program():
"""Relance le programme (nouvel exe) et quitte le processus actuel."""
if getattr(sys, "frozen", False):
exe_path = str(Path(sys.executable))
log.info(f"Auto-update: relance de {exe_path}")
# Lancer un batch qui attend 1s puis lance le nouvel exe et supprime l'ancien .old
bat_path = str(get_exe_dir() / "_update.bat")
bat_content = (
f'@echo off\n'
f'timeout /t 1 /nobreak >nul\n'
f'start "" "{exe_path}"\n'
f'del "{exe_path}.old" 2>nul\n'
f'del "%~f0"\n'
)
with open(bat_path, "w") as f:
f.write(bat_content)
subprocess.Popen(
["cmd", "/c", bat_path],
creationflags=subprocess.CREATE_NO_WINDOW,
)
else:
# Mode script : relancer python
log.info("Auto-update: relance du script")
subprocess.Popen([sys.executable, __file__])
# ── Configuration ────────────────────────────────────────────────────────────
def get_config_path():
return get_exe_dir() / "config.ini"
def load_repos():
"""Charge la liste des dépôts depuis config.ini."""
config_path = get_config_path()
config = configparser.ConfigParser()
repos = []
if not config_path.exists():
# Créer un config.ini exemple
config["repo:Exemple"] = {
"url": "http://192.168.1.235:3125/user/repo",
"path": "../MonRepo",
}
with open(config_path, "w", encoding="utf-8") as f:
f.write("; Configuration des depots Git a surveiller\n")
f.write("; Les chemins (path) sont relatifs a l'emplacement de l'exe\n")
f.write("; Ajouter autant de sections [repo:NomDuRepo] que necessaire\n\n")
config.write(f)
return repos
config.read(config_path, encoding="utf-8")
for section in config.sections():
if section.startswith("repo:"):
name = section[5:]
url = config.get(section, "url", fallback="").strip()
path = config.get(section, "path", fallback="").strip()
if url and path:
repos.append({"name": name, "url": url, "path": path})
return repos
# ── Logique Git ──────────────────────────────────────────────────────────────
def check_repo(repo):
"""Vérifie un dépôt et retourne son état."""
name = repo["name"]
url = repo["url"]
local_path = str(resolve_relative(repo["path"]))
log.info(f"[{name}] Verification - {url} -> {local_path}")
result = {
"name": name,
"url": url,
"local_path": local_path,
"relative_path": repo["path"],
"exists": False,
"up_to_date": False,
"error": None,
"commits": [],
"files": [],
"local_changes": [],
"local_only": False,
"branch": "",
"local_hash": "",
"remote_hash": "",
"needs_clone": False,
}
# Vérifier si le dossier existe et est un repo git
if not os.path.isdir(os.path.join(local_path, ".git")):
if os.path.isdir(local_path) and os.listdir(local_path):
result["error"] = "Le dossier existe mais n'est pas un depot git."
return result
result["needs_clone"] = True
return result
result["exists"] = True
# Branche courante
code, branch, _ = run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=local_path)
if code != 0:
result["error"] = "Impossible de lire la branche courante."
return result
result["branch"] = branch
# Fetch
code, _, err = run_git(["fetch", "origin"], cwd=local_path)
if code != 0:
result["error"] = f"Erreur fetch : {err}"
return result
# Comparer HEAD local vs origin
code, local_hash, _ = run_git(["rev-parse", "HEAD"], cwd=local_path)
if code != 0:
result["error"] = "Impossible de lire le commit local."
return result
code, remote_hash, _ = run_git(["rev-parse", f"origin/{branch}"], cwd=local_path)
if code != 0:
result["error"] = f"Impossible de trouver origin/{branch}."
return result
result["local_hash"] = local_hash[:8]
result["remote_hash"] = remote_hash[:8]
# Modifications locales
code, local_diff, _ = run_git(["diff", "--name-status", "HEAD"], cwd=local_path)
if code == 0 and local_diff:
for line in local_diff.splitlines():
parts = line.split("\t", 1)
if len(parts) == 2:
sc = parts[0].strip()
fn = parts[1].strip()
status_map = {"D": "Supprime localement", "M": "Modifie localement"}
result["local_changes"].append({
"status": status_map.get(sc[0], sc),
"status_char": sc[0],
"file": fn,
})
if local_hash == remote_hash and not result["local_changes"]:
log.info(f"[{name}] A jour ({local_hash[:8]})")
result["up_to_date"] = True
return result
if local_hash == remote_hash:
log.info(f"[{name}] {len(result['local_changes'])} fichier(s) locaux modifies/supprimes")
result["local_only"] = True
return result
# Nouveaux commits distants
code, log_out, _ = run_git(
["log", "--oneline", "--format=%h|%an|%ar|%s", f"HEAD..origin/{branch}"],
cwd=local_path,
)
if code == 0 and log_out:
for line in log_out.splitlines():
parts = line.split("|", 3)
if len(parts) == 4:
result["commits"].append({
"hash": parts[0], "author": parts[1],
"date": parts[2], "message": parts[3],
})
# Fichiers distants modifiés
code, diff_out, _ = run_git(
["diff", "--name-status", f"HEAD..origin/{branch}"],
cwd=local_path,
)
if code == 0 and diff_out:
for line in diff_out.splitlines():
parts = line.split("\t", 1)
if len(parts) == 2:
sc = parts[0].strip()
fn = parts[1].strip()
status_map = {"A": "Ajoute", "M": "Modifie", "D": "Supprime", "R": "Renomme"}
result["files"].append({
"status": status_map.get(sc[0], sc),
"status_char": sc[0],
"file": fn,
})
return result
def do_clone(repo):
"""Clone un dépôt."""
local_path = str(resolve_relative(repo["path"]))
log.info(f"Clonage: {repo['url']} -> {local_path}")
code, _, err = run_git(["clone", repo["url"], local_path])
if code == 0:
log.info(f"Clonage reussi: {local_path}")
else:
log.error(f"Clonage echoue: {err}")
return code == 0, err
def do_pull(local_path, branch):
"""Pull les mises à jour (lecture seule)."""
log.info(f"Pull: {local_path} (branche {branch})")
code, out, err = run_git(["pull", "origin", branch], cwd=local_path)
if code == 0:
log.info(f"Pull reussi: {local_path}")
else:
log.error(f"Pull echoue: {err}")
return code == 0, out, err
def do_restore(local_path):
"""Restaure les fichiers locaux modifiés/supprimés."""
log.info(f"Restauration: {local_path}")
code, _, err = run_git(["checkout", "--", "."], cwd=local_path)
if code == 0:
log.info(f"Restauration reussie: {local_path}")
else:
log.error(f"Restauration echouee: {err}")
return code == 0, err
# ── Interface graphique ──────────────────────────────────────────────────────
class App(tk.Tk):
def __init__(self):
super().__init__()
self.title(f"Git Update Checker v{VERSION}")
self.geometry("820x600")
self.minsize(700, 450)
self.configure(bg="#1e1e2e")
self.repos_config = load_repos()
self.repo_results = []
log.info(f"=== Demarrage Git Update Checker v{VERSION} ===")
self._build_ui()
self.after(100, self._check_self_update_then_repos)
def _build_ui(self):
style = ttk.Style(self)
style.theme_use("clam")
# Couleurs sombres
bg = "#1e1e2e"
fg = "#cdd6f4"
accent = "#89b4fa"
green = "#a6e3a1"
yellow = "#f9e2af"
red = "#f38ba8"
style.configure("TFrame", background=bg)
style.configure("TLabel", background=bg, foreground=fg, font=("Segoe UI", 10))
style.configure("Title.TLabel", background=bg, foreground=fg, font=("Segoe UI", 14, "bold"))
style.configure("Status.TLabel", background=bg, foreground=accent, font=("Segoe UI", 9))
style.configure("TButton", font=("Segoe UI", 10))
style.configure("Green.TLabel", background=bg, foreground=green, font=("Segoe UI", 10, "bold"))
style.configure("Yellow.TLabel", background=bg, foreground=yellow, font=("Segoe UI", 10, "bold"))
style.configure("Red.TLabel", background=bg, foreground=red, font=("Segoe UI", 10, "bold"))
# Header
header = ttk.Frame(self)
header.pack(fill="x", padx=15, pady=(15, 5))
ttk.Label(header, text=f"Git Update Checker v{VERSION}", style="Title.TLabel").pack(side="left")
self.status_label = ttk.Label(header, text="Verification en cours...", style="Status.TLabel")
self.status_label.pack(side="right")
# Date
date_label = ttk.Label(self, text=datetime.now().strftime(" %d/%m/%Y %H:%M:%S"), style="Status.TLabel")
date_label.pack(anchor="w", padx=15)
# Zone scrollable pour les repos
container = ttk.Frame(self)
container.pack(fill="both", expand=True, padx=15, pady=10)
self.canvas = tk.Canvas(container, bg=bg, highlightthickness=0)
scrollbar = ttk.Scrollbar(container, orient="vertical", command=self.canvas.yview)
self.scroll_frame = ttk.Frame(self.canvas)
self.scroll_frame.bind("<Configure>", lambda e: self.canvas.configure(scrollregion=self.canvas.bbox("all")))
self.canvas.create_window((0, 0), window=self.scroll_frame, anchor="nw")
self.canvas.configure(yscrollcommand=scrollbar.set)
self.canvas.pack(side="left", fill="both", expand=True)
scrollbar.pack(side="right", fill="y")
# Scroll avec la molette
self.canvas.bind_all("<MouseWheel>", lambda e: self.canvas.yview_scroll(int(-1 * (e.delta / 120)), "units"))
# Boutons en bas
btn_frame = ttk.Frame(self)
btn_frame.pack(fill="x", padx=15, pady=(0, 15))
self.btn_refresh = ttk.Button(btn_frame, text="Rafraichir", command=self._start_check)
self.btn_refresh.pack(side="left", padx=(0, 10))
self.btn_update_all = ttk.Button(btn_frame, text="Tout mettre a jour", command=self._update_all)
self.btn_update_all.pack(side="left")
self.btn_update_all.state(["disabled"])
ttk.Button(btn_frame, text="Ouvrir config.ini", command=self._open_config).pack(side="right")
self.btn_open_log = ttk.Button(btn_frame, text="Ouvrir les logs", command=self._open_log)
self.btn_open_log.pack(side="right", padx=(0, 10))
def _check_self_update_then_repos(self):
"""Vérifie d'abord la MAJ du programme, puis les repos."""
self.status_label.configure(text="Verification auto-update...")
def work():
needs, info = check_self_update()
self.after(0, lambda: self._handle_self_update(needs, info))
threading.Thread(target=work, daemon=True).start()
def _handle_self_update(self, needs_update, info):
"""Gère le résultat de l'auto-update."""
if needs_update:
answer = messagebox.askyesno(
"Mise a jour du programme",
f"Une mise a jour du programme est disponible !\n\n{info}\n\nMettre a jour maintenant ?",
)
if answer:
self.status_label.configure(text="Mise a jour du programme...")
def work():
ok, msg = do_self_update()
self.after(0, lambda: self._self_update_done(ok, msg))
threading.Thread(target=work, daemon=True).start()
return
self._start_check()
def _self_update_done(self, ok, msg):
"""Callback après auto-update."""
if ok:
messagebox.showinfo("Auto-update", msg)
log.info("Auto-update appliquee, relance...")
relaunch_program()
self.destroy()
return
else:
messagebox.showwarning("Auto-update", msg)
self._start_check()
def _start_check(self):
"""Lance la vérification dans un thread."""
self.btn_refresh.state(["disabled"])
self.btn_update_all.state(["disabled"])
self.status_label.configure(text="Verification en cours...")
self.repos_config = load_repos()
if not self.repos_config:
self._clear_cards()
self.status_label.configure(text="Aucun depot configure")
self._show_no_repos()
self.btn_refresh.state(["!disabled"])
return
threading.Thread(target=self._check_all, daemon=True).start()
def _check_all(self):
"""Vérifie tous les repos (dans un thread)."""
results = []
for repo in self.repos_config:
results.append(check_repo(repo))
self.repo_results = results
self.after(0, self._display_results)
def _display_results(self):
"""Affiche les résultats dans la GUI."""
self._clear_cards()
has_any_updates = False
for res in self.repo_results:
card = self._create_card(res)
card.pack(fill="x", pady=5)
if not res["up_to_date"] and not res.get("error"):
has_any_updates = True
total = len(self.repo_results)
up = sum(1 for r in self.repo_results if r["up_to_date"])
log.info(f"Resultat: {up}/{total} depots a jour")
self.status_label.configure(text=f"{up}/{total} depots a jour")
self.btn_refresh.state(["!disabled"])
if has_any_updates:
self.btn_update_all.state(["!disabled"])
def _clear_cards(self):
for w in self.scroll_frame.winfo_children():
w.destroy()
def _create_card(self, res):
"""Crée une carte pour un dépôt."""
bg_card = "#313244"
fg = "#cdd6f4"
green = "#a6e3a1"
yellow = "#f9e2af"
red = "#f38ba8"
cyan = "#89dceb"
dim = "#6c7086"
card = tk.Frame(self.scroll_frame, bg=bg_card, bd=0, highlightthickness=1, highlightbackground="#45475a")
# Header de la carte
top = tk.Frame(card, bg=bg_card)
top.pack(fill="x", padx=12, pady=(10, 5))
tk.Label(top, text=res["name"], bg=bg_card, fg=fg, font=("Segoe UI", 11, "bold")).pack(side="left")
if res.get("error"):
tk.Label(top, text="ERREUR", bg=bg_card, fg=red, font=("Segoe UI", 9, "bold")).pack(side="right")
elif res.get("needs_clone"):
tk.Label(top, text="A CLONER", bg=bg_card, fg=yellow, font=("Segoe UI", 9, "bold")).pack(side="right")
elif res["up_to_date"]:
tk.Label(top, text="A JOUR", bg=bg_card, fg=green, font=("Segoe UI", 9, "bold")).pack(side="right")
else:
count = len(res["commits"]) + len(res["local_changes"])
tk.Label(top, text=f"MAJ DISPO ({count})", bg=bg_card, fg=yellow, font=("Segoe UI", 9, "bold")).pack(side="right")
# Infos
info = tk.Frame(card, bg=bg_card)
info.pack(fill="x", padx=12)
tk.Label(info, text=f"Chemin : {res['relative_path']}", bg=bg_card, fg=dim, font=("Segoe UI", 8)).pack(anchor="w")
if res["branch"]:
tk.Label(info, text=f"Branche : {res['branch']} | {res['local_hash']}", bg=bg_card, fg=dim, font=("Segoe UI", 8)).pack(anchor="w")
# Erreur
if res.get("error"):
tk.Label(card, text=f" {res['error']}", bg=bg_card, fg=red, font=("Segoe UI", 9), anchor="w").pack(fill="x", padx=12, pady=(5, 0))
# Commits distants
if res["commits"]:
tk.Label(card, text=f"Nouveaux commits ({len(res['commits'])}) :", bg=bg_card, fg=fg, font=("Segoe UI", 9, "bold"), anchor="w").pack(fill="x", padx=12, pady=(8, 2))
for c in res["commits"][:5]:
line_frame = tk.Frame(card, bg=bg_card)
line_frame.pack(fill="x", padx=20)
tk.Label(line_frame, text=c["hash"], bg=bg_card, fg=cyan, font=("Consolas", 9)).pack(side="left")
tk.Label(line_frame, text=f" {c['message']}", bg=bg_card, fg=fg, font=("Segoe UI", 9), anchor="w").pack(side="left", fill="x")
if len(res["commits"]) > 5:
tk.Label(card, text=f" ... et {len(res['commits']) - 5} autres commits", bg=bg_card, fg=dim, font=("Segoe UI", 8)).pack(anchor="w", padx=20)
# Fichiers distants modifiés
if res["files"]:
tk.Label(card, text=f"Fichiers distants modifies ({len(res['files'])}) :", bg=bg_card, fg=fg, font=("Segoe UI", 9, "bold"), anchor="w").pack(fill="x", padx=12, pady=(5, 2))
color_map = {"A": green, "M": yellow, "D": red, "R": cyan}
for f in res["files"][:8]:
line_frame = tk.Frame(card, bg=bg_card)
line_frame.pack(fill="x", padx=20)
c = color_map.get(f["status_char"], fg)
tk.Label(line_frame, text=f"[{f['status']:>9}]", bg=bg_card, fg=c, font=("Consolas", 9)).pack(side="left")
tk.Label(line_frame, text=f" {f['file']}", bg=bg_card, fg=fg, font=("Segoe UI", 9)).pack(side="left")
if len(res["files"]) > 8:
tk.Label(card, text=f" ... et {len(res['files']) - 8} autres fichiers", bg=bg_card, fg=dim, font=("Segoe UI", 8)).pack(anchor="w", padx=20)
# Fichiers locaux manquants
if res["local_changes"]:
tk.Label(card, text=f"Fichiers locaux a restaurer ({len(res['local_changes'])}) :", bg=bg_card, fg=fg, font=("Segoe UI", 9, "bold"), anchor="w").pack(fill="x", padx=12, pady=(5, 2))
for f in res["local_changes"][:8]:
line_frame = tk.Frame(card, bg=bg_card)
line_frame.pack(fill="x", padx=20)
c = red if f["status_char"] == "D" else yellow
tk.Label(line_frame, text=f"[{f['status']:>20}]", bg=bg_card, fg=c, font=("Consolas", 8)).pack(side="left")
tk.Label(line_frame, text=f" {f['file']}", bg=bg_card, fg=fg, font=("Segoe UI", 9)).pack(side="left")
if len(res["local_changes"]) > 8:
tk.Label(card, text=f" ... et {len(res['local_changes']) - 8} autres fichiers", bg=bg_card, fg=dim, font=("Segoe UI", 8)).pack(anchor="w", padx=20)
# Bouton d'action
if not res["up_to_date"] and not res.get("error"):
btn_frame = tk.Frame(card, bg=bg_card)
btn_frame.pack(fill="x", padx=12, pady=(8, 0))
if res.get("needs_clone"):
btn = ttk.Button(btn_frame, text="Cloner le depot", command=lambda r=res: self._do_clone(r))
else:
btn = ttk.Button(btn_frame, text="Mettre a jour", command=lambda r=res: self._do_update(r))
btn.pack(side="left")
# Stocker ref du bouton pour le désactiver après
res["_btn"] = btn
# Padding bas
tk.Frame(card, bg=bg_card, height=10).pack()
return card
def _do_update(self, res):
"""Met à jour un dépôt (pull + restore)."""
log.info(f"[{res['name']}] MAJ unitaire demandee")
if "_btn" in res:
res["_btn"].state(["disabled"])
def work():
messages = []
success = True
local_path = res["local_path"]
branch = res["branch"]
if res["commits"]:
log.info(f"[{res['name']}] Pull de {len(res['commits'])} commit(s)...")
ok, out, err = do_pull(local_path, branch)
if ok:
msg = f"Pull OK : {len(res['commits'])} commits telecharges."
log.info(f"[{res['name']}] {msg}")
messages.append(msg)
else:
msg = f"Erreur pull : {err}"
log.error(f"[{res['name']}] {msg}")
messages.append(msg)
success = False
if res["local_changes"]:
log.info(f"[{res['name']}] Restauration de {len(res['local_changes'])} fichier(s)...")
ok, err = do_restore(local_path)
if ok:
msg = f"Restauration OK : {len(res['local_changes'])} fichiers restaures."
log.info(f"[{res['name']}] {msg}")
messages.append(msg)
else:
msg = f"Erreur restauration : {err}"
log.error(f"[{res['name']}] {msg}")
messages.append(msg)
success = False
status = "SUCCES" if success else "ECHEC"
log.info(f"[{res['name']}] MAJ unitaire terminee - {status}")
self.after(0, lambda: self._show_update_result(res, messages, success))
threading.Thread(target=work, daemon=True).start()
def _do_clone(self, res):
"""Clone un dépôt."""
log.info(f"[{res['name']}] Clonage demande - {res['url']}")
if "_btn" in res:
res["_btn"].state(["disabled"])
repo = {"url": res["url"], "path": res["relative_path"]}
def work():
ok, err = do_clone(repo)
if ok:
msg = f"Depot '{res['name']}' clone avec succes !"
log.info(f"[{res['name']}] {msg}")
else:
msg = f"Erreur de clonage : {err}"
log.error(f"[{res['name']}] {msg}")
self.after(0, lambda: messagebox.showinfo("Clonage", msg))
self.after(100, self._start_check)
threading.Thread(target=work, daemon=True).start()
def _show_update_result(self, res, messages, success):
title = "Mise a jour" if success else "Erreur"
messagebox.showinfo(title, f"{res['name']}\n\n" + "\n".join(messages))
self._start_check()
def _update_all(self):
"""Met à jour tous les dépôts qui ont des MAJ."""
to_update = [r for r in self.repo_results if not r["up_to_date"] and not r.get("error") and not r.get("needs_clone")]
log.info(f"MAJ globale demandee - {len(to_update)} depot(s) a mettre a jour")
for res in to_update:
self._do_update(res)
def _show_no_repos(self):
bg_card = "#313244"
fg = "#cdd6f4"
dim = "#6c7086"
card = tk.Frame(self.scroll_frame, bg=bg_card, bd=0, highlightthickness=1, highlightbackground="#45475a")
card.pack(fill="x", pady=5)
tk.Label(card, text="Aucun depot configure", bg=bg_card, fg=fg, font=("Segoe UI", 11, "bold")).pack(padx=12, pady=(12, 5))
tk.Label(card, text="Edite config.ini pour ajouter des depots :", bg=bg_card, fg=dim, font=("Segoe UI", 9)).pack(padx=12)
example = (
"[repo:MonRepo]\n"
"url = http://192.168.1.235:3125/user/repo\n"
"path = ../MonRepo"
)
tk.Label(card, text=example, bg="#1e1e2e", fg="#a6e3a1", font=("Consolas", 9), justify="left", padx=10, pady=8).pack(padx=12, pady=8, fill="x")
tk.Frame(card, bg=bg_card, height=10).pack()
def _open_config(self):
config_path = str(get_config_path())
if sys.platform == "win32":
os.startfile(config_path)
def _open_log(self):
log_dir = str(get_exe_dir() / "log")
if sys.platform == "win32":
os.startfile(log_dir)
if __name__ == "__main__":
app = App()
app.mainloop()

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.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

BIN
icon.png Normal file

Binary file not shown.

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
}

1
version.txt Normal file
View File

@@ -0,0 +1 @@
0.7.7