package main import ( "context" "fmt" "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.Load(".env") for i, letter := range []string{"A", "B", "C", "D"} { a.slots[i] = SlotState{Slot: letter, Status: "idle"} } } // 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 }