- 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>
532 lines
12 KiB
Go
532 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/lxn/walk"
|
|
. "github.com/lxn/walk/declarative"
|
|
)
|
|
|
|
// ── Modèle TableView ──────────────────────────────────────────────────────────
|
|
|
|
type RepoItem struct {
|
|
result RepoResult
|
|
}
|
|
|
|
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"
|
|
}
|
|
msg := ""
|
|
if r.NewCommits > 0 {
|
|
msg = fmt.Sprintf("%d commit(s)", r.NewCommits)
|
|
}
|
|
if r.LocalChanges > 0 {
|
|
if msg != "" {
|
|
msg += ", "
|
|
}
|
|
msg += fmt.Sprintf("%d modif. locale(s)", r.LocalChanges)
|
|
}
|
|
if msg == "" {
|
|
return "À jour"
|
|
}
|
|
return msg
|
|
}
|
|
|
|
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]
|
|
if col == 0 {
|
|
return it.result.Name
|
|
}
|
|
return it.statusText()
|
|
}
|
|
|
|
func (m *RepoModel) StyleCell(style *walk.CellStyle) {
|
|
if style.Col() != 1 {
|
|
return
|
|
}
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
if style.Row() >= len(m.items) {
|
|
return
|
|
}
|
|
style.TextColor = m.items[style.Row()].textColor()
|
|
}
|
|
|
|
func (m *RepoModel) setResult(row int, res RepoResult) {
|
|
m.mu.Lock()
|
|
if row < len(m.items) {
|
|
m.items[row].result = res
|
|
}
|
|
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.NewCommits > 0 || r.LocalChanges > 0 || r.NeedsClone) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ── Application ───────────────────────────────────────────────────────────────
|
|
|
|
type App struct {
|
|
mw *walk.MainWindow
|
|
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: 650, Height: 400},
|
|
Size: Size{Width: 820, 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{
|
|
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: 180},
|
|
{Title: "Statut", 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
|
|
if icoPath := filepath.Join(exeDir(), "icon.ico"); fileExists(icoPath) {
|
|
if icon, err := walk.NewIconFromFile(icoPath); err == nil {
|
|
a.mw.SetIcon(icon)
|
|
}
|
|
}
|
|
|
|
// 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"
|
|
}
|
|
msg := ""
|
|
if r.NewCommits > 0 {
|
|
msg += fmt.Sprintf("%d commit(s) disponible(s)", r.NewCommits)
|
|
}
|
|
if r.LocalChanges > 0 {
|
|
if msg != "" {
|
|
msg += ", "
|
|
}
|
|
msg += fmt.Sprintf("%d modif. locale(s)", r.LocalChanges)
|
|
}
|
|
return msg
|
|
}
|
|
|
|
// ── 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.NewCommits > 0 || res.LocalChanges > 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]
|
|
|
|
a.btnAction.SetEnabled(false)
|
|
a.appendLog(fmt.Sprintf("[%s] Mise à jour en cours...", res.Name))
|
|
|
|
go func() {
|
|
var err error
|
|
if res.NeedsClone {
|
|
err = doClone(cfg)
|
|
} else {
|
|
if res.LocalChanges > 0 {
|
|
err = doCheckout(res)
|
|
}
|
|
if err == nil && res.NewCommits > 0 {
|
|
err = doPull(res)
|
|
}
|
|
}
|
|
a.mw.Synchronize(func() {
|
|
if err != nil {
|
|
walk.MsgBox(a.mw, "Erreur", res.Name+"\n\n"+err.Error(), walk.MsgBoxIconError)
|
|
logError(fmt.Sprintf("[%s] %v", res.Name, err))
|
|
} else {
|
|
a.appendLog(fmt.Sprintf("[%s] Mise à jour OK", res.Name))
|
|
logInfo(fmt.Sprintf("[%s] Mise à jour OK", res.Name))
|
|
}
|
|
a.startCheck()
|
|
})
|
|
}()
|
|
}
|
|
|
|
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)
|
|
cfg, res := cfg, res
|
|
go func() {
|
|
var err error
|
|
if res.NeedsClone {
|
|
err = doClone(cfg)
|
|
} else {
|
|
if res.LocalChanges > 0 {
|
|
err = doCheckout(res)
|
|
}
|
|
if err == nil && res.NewCommits > 0 {
|
|
err = doPull(res)
|
|
}
|
|
}
|
|
a.mw.Synchronize(func() {
|
|
if err != nil {
|
|
logError(fmt.Sprintf("[%s] %v", res.Name, err))
|
|
a.appendLog(fmt.Sprintf("[%s] Erreur: %v", res.Name, err))
|
|
} else {
|
|
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()
|
|
}
|