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
` 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:// `
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
}