- Panel Reglages dans l'UI (modal) - Folder picker natif Wails pour dossier local - ListS3Buckets via aws-cli (profile-aware) - TestS3Access pour verifier acces bucket - Config persistee user config dir (cross-OS): %APPDATA%/field-sync ou ~/.config/field-sync - Header affiche bucket + dest visibles - Auto-open modal si bucket vide au premier run Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
371 lines
8.9 KiB
Go
371 lines
8.9 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os/exec"
|
|
"strings"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"time"
|
|
|
|
"field-sync-gui/internal/config"
|
|
"field-sync-gui/internal/copier"
|
|
"field-sync-gui/internal/detect"
|
|
"field-sync-gui/internal/manifest"
|
|
|
|
"github.com/wailsapp/wails/v2/pkg/runtime"
|
|
)
|
|
|
|
// SlotState holds per-slot progress
|
|
type SlotState struct {
|
|
Slot string `json:"slot"`
|
|
Mountpoint string `json:"mountpoint"`
|
|
Label string `json:"label"`
|
|
Status string `json:"status"` // idle|scanning|copying|uploading|done|error
|
|
FilesTotal int `json:"filesTotal"`
|
|
FilesDone int `json:"filesDone"`
|
|
BytesTotal int64 `json:"bytesTotal"`
|
|
BytesDone int64 `json:"bytesDone"`
|
|
SpeedMBs float64 `json:"speedMBs"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// Snapshot is the full UI state
|
|
type Snapshot struct {
|
|
SessionID string `json:"sessionID"`
|
|
Slots [4]SlotState `json:"slots"`
|
|
Running bool `json:"running"`
|
|
DryRun bool `json:"dryRun"`
|
|
Config config.Config `json:"config"`
|
|
}
|
|
|
|
// SlotPayload is the per-slot input from the frontend
|
|
type SlotPayload struct {
|
|
Slot string `json:"slot"`
|
|
Mountpoint string `json:"mountpoint"`
|
|
}
|
|
|
|
// App is the Wails application struct
|
|
type App struct {
|
|
ctx context.Context
|
|
cfg config.Config
|
|
sessionID string
|
|
slots [4]SlotState
|
|
mu sync.RWMutex
|
|
cancel context.CancelFunc
|
|
dryRun bool
|
|
manifest *manifest.Manifest
|
|
running bool
|
|
}
|
|
|
|
// NewApp creates a new App
|
|
func NewApp() *App {
|
|
return &App{}
|
|
}
|
|
|
|
// startup is called by Wails when the app starts
|
|
func (a *App) startup(ctx context.Context) {
|
|
a.ctx = ctx
|
|
a.cfg = config.LoadFromFile()
|
|
for i, letter := range []string{"A", "B", "C", "D"} {
|
|
a.slots[i] = SlotState{Slot: letter, Status: "idle"}
|
|
}
|
|
}
|
|
|
|
// SaveConfig persists config to user config dir and updates in-memory config
|
|
func (a *App) SaveConfig(cfg config.Config) error {
|
|
if err := config.Save(cfg); err != nil {
|
|
return err
|
|
}
|
|
a.mu.Lock()
|
|
a.cfg = cfg
|
|
a.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
// GetConfigPath returns where the config is stored (for display)
|
|
func (a *App) GetConfigPath() string {
|
|
return config.Path()
|
|
}
|
|
|
|
// PickLocalDest opens native folder picker, returns selected path or "" if cancelled
|
|
func (a *App) PickLocalDest() (string, error) {
|
|
path, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{
|
|
Title: "Dossier de destination locale",
|
|
})
|
|
return path, err
|
|
}
|
|
|
|
// ListS3Buckets runs `aws s3api list-buckets --profile <p>` returns []string
|
|
func (a *App) ListS3Buckets(profile string) ([]string, error) {
|
|
args := []string{"s3api", "list-buckets", "--query", "Buckets[].Name", "--output", "text"}
|
|
if profile != "" {
|
|
args = append(args, "--profile", profile)
|
|
}
|
|
out, err := exec.Command("aws", args...).CombinedOutput()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("aws error: %s", strings.TrimSpace(string(out)))
|
|
}
|
|
raw := strings.TrimSpace(string(out))
|
|
if raw == "" || raw == "None" {
|
|
return []string{}, nil
|
|
}
|
|
parts := strings.Fields(raw)
|
|
return parts, nil
|
|
}
|
|
|
|
// TestS3Access checks bucket reachable: `aws s3 ls s3://<bucket>/ --profile <p>`
|
|
func (a *App) TestS3Access(bucket, profile string) error {
|
|
args := []string{"s3", "ls", "s3://" + bucket + "/"}
|
|
if profile != "" {
|
|
args = append(args, "--profile", profile)
|
|
}
|
|
out, err := exec.Command("aws", args...).CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("accès refusé: %s", strings.TrimSpace(string(out)))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ScanCards returns up to 4 detected SD cards
|
|
func (a *App) ScanCards() []detect.SDCard {
|
|
cards := detect.Detect()
|
|
if len(cards) > 4 {
|
|
cards = cards[:4]
|
|
}
|
|
return cards
|
|
}
|
|
|
|
// LoadConfig returns current config
|
|
func (a *App) LoadConfig() config.Config {
|
|
return a.cfg
|
|
}
|
|
|
|
// SetDryRun toggles dry-run mode
|
|
func (a *App) SetDryRun(b bool) {
|
|
a.mu.Lock()
|
|
a.dryRun = b
|
|
a.mu.Unlock()
|
|
}
|
|
|
|
// GetSnapshot returns current state
|
|
func (a *App) GetSnapshot() Snapshot {
|
|
a.mu.RLock()
|
|
defer a.mu.RUnlock()
|
|
return Snapshot{
|
|
SessionID: a.sessionID,
|
|
Slots: a.slots,
|
|
Running: a.running,
|
|
DryRun: a.dryRun,
|
|
Config: a.cfg,
|
|
}
|
|
}
|
|
|
|
// CancelIngest cancels a running ingest
|
|
func (a *App) CancelIngest() error {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
if a.cancel != nil {
|
|
a.cancel()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// StartIngest launches the ingest pipeline in a goroutine
|
|
func (a *App) StartIngest(slotConfigs []SlotPayload) error {
|
|
a.mu.Lock()
|
|
if a.running {
|
|
a.mu.Unlock()
|
|
return fmt.Errorf("ingest already running")
|
|
}
|
|
|
|
// Generate session ID
|
|
now := time.Now()
|
|
a.sessionID = fmt.Sprintf("cosma-%s", now.Format("20060102-1504"))
|
|
a.manifest = manifest.New(a.sessionID)
|
|
a.running = true
|
|
|
|
ctx, cancel := context.WithCancel(a.ctx)
|
|
a.cancel = cancel
|
|
|
|
dryRun := a.dryRun
|
|
cfg := a.cfg
|
|
sessionID := a.sessionID
|
|
|
|
// Reset slots
|
|
for i := range a.slots {
|
|
a.slots[i].Status = "idle"
|
|
a.slots[i].FilesTotal = 0
|
|
a.slots[i].FilesDone = 0
|
|
a.slots[i].BytesTotal = 0
|
|
a.slots[i].BytesDone = 0
|
|
a.slots[i].SpeedMBs = 0
|
|
a.slots[i].Error = ""
|
|
}
|
|
a.mu.Unlock()
|
|
|
|
go func() {
|
|
defer func() {
|
|
a.mu.Lock()
|
|
a.running = false
|
|
a.cancel = nil
|
|
a.mu.Unlock()
|
|
runtime.EventsEmit(a.ctx, "session-done", a.GetSnapshot())
|
|
}()
|
|
|
|
// Map slot letter → index
|
|
slotIndex := map[string]int{"A": 0, "B": 1, "C": 2, "D": 3}
|
|
|
|
// Build all jobs per slot
|
|
type slotJobs struct {
|
|
idx int
|
|
cfg SlotPayload
|
|
jobs []copier.FileJob
|
|
}
|
|
|
|
var allSlots []slotJobs
|
|
for _, sc := range slotConfigs {
|
|
idx, ok := slotIndex[sc.Slot]
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
a.mu.Lock()
|
|
a.slots[idx].Slot = sc.Slot
|
|
a.slots[idx].Mountpoint = sc.Mountpoint
|
|
a.slots[idx].Label = filepath.Base(sc.Mountpoint)
|
|
a.slots[idx].Status = "scanning"
|
|
a.mu.Unlock()
|
|
runtime.EventsEmit(a.ctx, "slot-update", a.slots[idx])
|
|
|
|
var jobs []copier.FileJob
|
|
var bytesTotal int64
|
|
_ = filepath.Walk(sc.Mountpoint, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil || info.IsDir() {
|
|
return nil
|
|
}
|
|
if !detect.HasMediaExtension(path) {
|
|
return nil
|
|
}
|
|
rel, _ := filepath.Rel(sc.Mountpoint, path)
|
|
label := filepath.Base(sc.Mountpoint)
|
|
localDest := filepath.Join(cfg.LocalDest, sessionID, label, rel)
|
|
s3Key := fmt.Sprintf("%s/%s/%s", sessionID, label, rel)
|
|
jobs = append(jobs, copier.FileJob{
|
|
Source: path,
|
|
LocalDest: localDest,
|
|
S3Bucket: cfg.S3Bucket,
|
|
S3Key: s3Key,
|
|
AWSProfile: cfg.AWSProfile,
|
|
})
|
|
bytesTotal += info.Size()
|
|
return nil
|
|
})
|
|
|
|
a.mu.Lock()
|
|
a.slots[idx].FilesTotal = len(jobs)
|
|
a.slots[idx].BytesTotal = bytesTotal
|
|
a.slots[idx].Status = "copying"
|
|
a.mu.Unlock()
|
|
runtime.EventsEmit(a.ctx, "slot-update", a.slots[idx])
|
|
|
|
allSlots = append(allSlots, slotJobs{idx: idx, cfg: sc, jobs: jobs})
|
|
}
|
|
|
|
// Dry-run: mark all done immediately
|
|
if dryRun {
|
|
for _, sl := range allSlots {
|
|
for _, job := range sl.jobs {
|
|
a.manifest.Append(manifest.Entry{
|
|
Source: job.Source,
|
|
LocalDest: job.LocalDest,
|
|
S3Path: fmt.Sprintf("s3://%s/%s", job.S3Bucket, job.S3Key),
|
|
Status: "dry-run",
|
|
})
|
|
}
|
|
a.mu.Lock()
|
|
a.slots[sl.idx].FilesDone = a.slots[sl.idx].FilesTotal
|
|
a.slots[sl.idx].BytesDone = a.slots[sl.idx].BytesTotal
|
|
a.slots[sl.idx].Status = "done"
|
|
snap := a.slots[sl.idx]
|
|
a.mu.Unlock()
|
|
runtime.EventsEmit(a.ctx, "slot-update", snap)
|
|
}
|
|
_ = a.manifest.Save(filepath.Join(cfg.LocalDest, sessionID, "manifest.json"))
|
|
return
|
|
}
|
|
|
|
// Real copy: flatten all jobs into channel, track per-slot
|
|
jobCh := make(chan copier.FileJob, 64)
|
|
|
|
// Build slot index from job source
|
|
jobSlot := make(map[string]int)
|
|
for _, sl := range allSlots {
|
|
for _, j := range sl.jobs {
|
|
jobSlot[j.Source] = sl.idx
|
|
}
|
|
}
|
|
|
|
mani := a.manifest
|
|
|
|
pool := &copier.Pool{
|
|
Workers: cfg.Concurrency,
|
|
OnResult: func(res copier.FileResult) {
|
|
idx, ok := jobSlot[res.Job.Source]
|
|
if !ok {
|
|
return
|
|
}
|
|
status := "done"
|
|
errStr := ""
|
|
if res.Error != nil {
|
|
status = "error"
|
|
errStr = res.Error.Error()
|
|
}
|
|
mani.Append(manifest.Entry{
|
|
Source: res.Job.Source,
|
|
LocalDest: res.Job.LocalDest,
|
|
S3Path: fmt.Sprintf("s3://%s/%s", res.Job.S3Bucket, res.Job.S3Key),
|
|
SHA256: res.SHA256,
|
|
Bytes: res.Bytes,
|
|
Status: status,
|
|
Error: errStr,
|
|
})
|
|
|
|
a.mu.Lock()
|
|
a.slots[idx].FilesDone++
|
|
a.slots[idx].BytesDone += res.Bytes
|
|
if res.Error != nil {
|
|
a.slots[idx].Error = errStr
|
|
a.slots[idx].Status = "error"
|
|
} else if a.slots[idx].FilesDone >= a.slots[idx].FilesTotal {
|
|
a.slots[idx].Status = "done"
|
|
}
|
|
snap := a.slots[idx]
|
|
a.mu.Unlock()
|
|
runtime.EventsEmit(a.ctx, "slot-update", snap)
|
|
},
|
|
}
|
|
|
|
go func() {
|
|
for _, sl := range allSlots {
|
|
for _, j := range sl.jobs {
|
|
select {
|
|
case <-ctx.Done():
|
|
close(jobCh)
|
|
return
|
|
case jobCh <- j:
|
|
}
|
|
}
|
|
}
|
|
close(jobCh)
|
|
}()
|
|
|
|
pool.Run(ctx, jobCh)
|
|
_ = mani.Save(filepath.Join(cfg.LocalDest, sessionID, "manifest.json"))
|
|
}()
|
|
|
|
return nil
|
|
}
|