Compare commits
29 Commits
6954d7cc06
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| ba377a4e4a | |||
| 19efbe6dd7 | |||
| 9bf66f6d90 | |||
| 3f0f13147b | |||
| 2b2eb87f45 | |||
| 3ffbb550ec | |||
| 98b5187bfc | |||
| 18fe9d7186 | |||
| d8f3a29f8e | |||
| 55663e3a19 | |||
| db57cfacaf | |||
| 037f211d9d | |||
| da8fc74a68 | |||
| 30ece54758 | |||
| d03ff595ed | |||
| 8c5257e96f | |||
| af439a8e69 | |||
| 50c8ad9823 | |||
| 959298fc2d | |||
| fa17301842 | |||
| 8718b04a7d | |||
| 1a51d6b5fa | |||
| 997d82bb96 | |||
| 7d77f4d901 | |||
| 3c9a6e70eb | |||
| 62d6c7f89f | |||
| 415a09a12d | |||
| b460502626 | |||
| ead08293fa |
@@ -11,7 +11,10 @@
|
||||
"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(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
2
.gitignore
vendored
@@ -1,7 +1,7 @@
|
||||
log/
|
||||
dist/
|
||||
build/
|
||||
__pycache__/
|
||||
rsrc.syso
|
||||
*.spec
|
||||
*.exe.old
|
||||
_update.bat
|
||||
|
||||
107
CLAUDE.md
107
CLAUDE.md
@@ -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.
|
||||
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)
|
||||
├── version.txt # Fichier contenant le numéro de version (ex: "0.5.1")
|
||||
├── config.ini # Configuration multi-repo
|
||||
├── build.bat # Script de compilation en .exe via PyInstaller
|
||||
├── log/ # Dossier de logs (créé automatiquement, 1 fichier par jour)
|
||||
├── 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
|
||||
├── 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 `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)
|
||||
- Format : **semver simplifié** `MAJEUR.MINEUR.PATCH` (ex: `0.5.1`)
|
||||
- **Les deux doivent toujours être synchronisés** : quand on change la version, mettre à jour `VERSION` dans `git_updater.py` ET `version.txt`
|
||||
- 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 `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
|
||||
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 :
|
||||
|
||||
```
|
||||
@@ -50,18 +66,18 @@ Changements :
|
||||
|
||||
Exemple :
|
||||
```
|
||||
v0.5.2 - Detection depot hors ligne
|
||||
v0.7.6 - Clone dossier non-vide et verification rapide
|
||||
|
||||
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
|
||||
- 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 `_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
|
||||
- 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
|
||||
- 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
|
||||
@@ -71,64 +87,77 @@ Changements :
|
||||
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, demande un redémarrage
|
||||
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
32
app.manifest
Normal 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>
|
||||
39
build.bat
39
build.bat
@@ -1,41 +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 [*] Conversion icon.png -> icon.ico...
|
||||
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)])"
|
||||
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 --onedir --noconsole --name "GitUpdateChecker" --icon=icon.ico git_updater.py
|
||||
go build -ldflags "-H windowsgui -s -w" -o GitUpdateChecker.exe .
|
||||
|
||||
echo.
|
||||
if exist "dist\GitUpdateChecker\GitUpdateChecker.exe" (
|
||||
echo [OK] Executable cree : dist\GitUpdateChecker\GitUpdateChecker.exe
|
||||
echo.
|
||||
echo Pour deployer, copier le contenu de dist\GitUpdateChecker\ :
|
||||
echo - GitUpdateChecker.exe
|
||||
echo - _internal\
|
||||
echo - config.ini ^(a copier manuellement a cote de l'exe^)
|
||||
echo - icon.png ^(a copier manuellement 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.
|
||||
)
|
||||
|
||||
82
config.go
Normal file
82
config.go
Normal file
@@ -0,0 +1,82 @@
|
||||
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"] != "" {
|
||||
repos = append(repos, RepoConfig{
|
||||
Name: name,
|
||||
URL: kv["url"],
|
||||
Path: kv["path"],
|
||||
Branch: kv["branch"], // vide = auto-détection
|
||||
})
|
||||
}
|
||||
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()
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
[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
|
||||
|
||||
473
git.go
Normal file
473
git.go
Normal file
@@ -0,0 +1,473 @@
|
||||
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
|
||||
}
|
||||
|
||||
// detectDefaultBranch détecte la branche par défaut d'un remote via ls-remote --symref HEAD.
|
||||
// Retourne "main", "master", etc. ou "" si indétectable.
|
||||
func detectDefaultBranch(urlOrRemote string, cwd string) string {
|
||||
_, out, _ := runGit([]string{"ls-remote", "--symref", urlOrRemote, "HEAD"}, cwd, 15*time.Second)
|
||||
// Format attendu : "ref: refs/heads/main\tHEAD"
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if strings.HasPrefix(line, "ref: refs/heads/") {
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) >= 2 {
|
||||
return strings.TrimPrefix(parts[0], "ref: refs/heads/")
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func checkRepo(cfg RepoConfig) RepoResult {
|
||||
res := RepoResult{Name: cfg.Name, URL: cfg.URL, Branch: cfg.Branch}
|
||||
local := absRepoPath(cfg.Path)
|
||||
res.Path = local
|
||||
|
||||
// Détecter la branche si non spécifiée dans la config
|
||||
branch := cfg.Branch
|
||||
if branch == "" {
|
||||
detected := detectDefaultBranch(cfg.URL, "")
|
||||
if detected != "" {
|
||||
branch = detected
|
||||
} else {
|
||||
branch = "master"
|
||||
}
|
||||
res.Branch = branch
|
||||
}
|
||||
|
||||
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)
|
||||
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]
|
||||
}
|
||||
}
|
||||
// Si la branche n'existe pas sur le remote, détecter la branche par défaut
|
||||
if remoteHash == "" {
|
||||
detected := detectDefaultBranch("origin", local)
|
||||
if detected == "" || detected == branch {
|
||||
res.Error = fmt.Sprintf("Branche '%s' introuvable sur le remote", branch)
|
||||
return res
|
||||
}
|
||||
logInfo(fmt.Sprintf("[%s] Branche '%s' introuvable, utilisation de '%s'", cfg.Name, branch, detected))
|
||||
branch = detected
|
||||
res.Branch = detected
|
||||
_, lsOut, _ = runGit([]string{"ls-remote", "origin", "refs/heads/" + branch}, local, 15*time.Second)
|
||||
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(parent context.Context, args []string, cwd string, timeout time.Duration, cb ProgressCallback) (int, string, string) {
|
||||
ctx, cancel := context.WithTimeout(parent, 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.Canceled {
|
||||
return 1, stdout, "Annulé"
|
||||
}
|
||||
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.
|
||||
// branch est la branche à checkout (détectée ou configurée).
|
||||
func doCloneWithProgress(ctx context.Context, cfg RepoConfig, branch string, 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 {
|
||||
args := []string{"clone", "--progress"}
|
||||
if branch != "" {
|
||||
args = append(args, "-b", branch)
|
||||
}
|
||||
args = append(args, cfg.URL, local)
|
||||
code, _, stderr := runGitWithProgress(
|
||||
ctx, args, "", 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(
|
||||
ctx, []string{"fetch", "--progress", "origin"},
|
||||
local, 2*time.Hour, cb,
|
||||
)
|
||||
if code != 0 {
|
||||
return fmt.Errorf("fetch: %s", stderr)
|
||||
}
|
||||
|
||||
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(ctx context.Context, 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(
|
||||
ctx, []string{"pull", "--progress", "origin", branch},
|
||||
res.Path, 2*time.Hour, cb,
|
||||
)
|
||||
if code != 0 {
|
||||
return fmt.Errorf("%s", stderr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
1029
git_updater.py
1029
git_updater.py
File diff suppressed because it is too large
Load Diff
14
go.mod
Normal file
14
go.mod
Normal 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
11
go.sum
Normal 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=
|
||||
751
gui.go
Normal file
751
gui.go
Normal file
@@ -0,0 +1,751 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
_ "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
|
||||
btnStop *walk.PushButton
|
||||
|
||||
reposConfig []RepoConfig
|
||||
suConfig SelfUpdateConfig
|
||||
checking atomic.Bool
|
||||
cancelMu sync.Mutex
|
||||
cancelFunc context.CancelFunc
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
PushButton{
|
||||
AssignTo: &a.btnStop,
|
||||
Text: "Arrêter",
|
||||
Enabled: false,
|
||||
OnClicked: a.stopOperations,
|
||||
},
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── Annulation ────────────────────────────────────────────────────────────────
|
||||
|
||||
// newCancelCtx crée un nouveau contexte annulable et stocke la fonction cancel.
|
||||
func (a *App) newCancelCtx() context.Context {
|
||||
a.cancelMu.Lock()
|
||||
defer a.cancelMu.Unlock()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
a.cancelFunc = cancel
|
||||
return ctx
|
||||
}
|
||||
|
||||
// stopOperations annule toutes les opérations git en cours.
|
||||
func (a *App) stopOperations() {
|
||||
a.cancelMu.Lock()
|
||||
fn := a.cancelFunc
|
||||
a.cancelFunc = nil
|
||||
a.cancelMu.Unlock()
|
||||
if fn != nil {
|
||||
fn()
|
||||
a.appendLog("Opérations annulées par l'utilisateur")
|
||||
logInfo("Opérations annulées par l'utilisateur")
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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.btnStop.SetEnabled(true)
|
||||
a.appendLog(fmt.Sprintf("[%s] Mise à jour en cours...", res.Name))
|
||||
|
||||
ctx := a.newCancelCtx()
|
||||
cb := a.makeProgressCB(idx)
|
||||
go func() {
|
||||
var err error
|
||||
if res.NeedsClone {
|
||||
err = doCloneWithProgress(ctx, cfg, res.Branch, cb)
|
||||
} else {
|
||||
if res.LocalChanges > 0 {
|
||||
err = doCheckout(res)
|
||||
}
|
||||
if err == nil && res.HasUpdate {
|
||||
err = doPullWithProgress(ctx, res, cb)
|
||||
}
|
||||
}
|
||||
a.mw.Synchronize(func() {
|
||||
a.btnStop.SetEnabled(false)
|
||||
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)
|
||||
a.btnStop.SetEnabled(true)
|
||||
pending := atomic.Int32{}
|
||||
ctx := a.newCancelCtx()
|
||||
|
||||
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(ctx, cfg, res.Branch, cb)
|
||||
} else {
|
||||
if res.LocalChanges > 0 {
|
||||
err = doCheckout(res)
|
||||
}
|
||||
if err == nil && res.HasUpdate {
|
||||
err = doPullWithProgress(ctx, 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.btnStop.SetEnabled(false)
|
||||
a.startCheck()
|
||||
}
|
||||
})
|
||||
}()
|
||||
}
|
||||
if pending.Load() == 0 {
|
||||
a.btnStop.SetEnabled(false)
|
||||
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
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
44
logger.go
Normal 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
36
main.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"github.com/lxn/walk"
|
||||
)
|
||||
|
||||
const VERSION = "0.7.9"
|
||||
|
||||
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
40
platform_windows.go
Normal 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
95
selfupdate.go
Normal 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 +1 @@
|
||||
0.6.0
|
||||
0.7.9
|
||||
|
||||
Reference in New Issue
Block a user