diff --git a/internal/mesh/transfer.go b/internal/mesh/transfer.go index 4544955..4eb80f7 100644 --- a/internal/mesh/transfer.go +++ b/internal/mesh/transfer.go @@ -91,6 +91,47 @@ func writePartialMeta(path string, t *inboundTransfer) { os.WriteFile(path, data, 0o644) //nolint:errcheck } +// ScanResumable scans the download directory for .tmp.meta sidecars left by +// interrupted transfers and emits a resumable_transfers IPC event listing them. +// Called once after a network is joined so the UI can show pending transfers. +func (m *Mesh) ScanResumable() { + if m.DownloadDir == "" { + return + } + metas, _ := filepath.Glob(filepath.Join(m.DownloadDir, "*.tmp.meta")) + var files []proto.ResumableFile + for _, mp := range metas { + data, err := os.ReadFile(mp) + if err != nil { + continue + } + var meta partialMeta + if err := json.Unmarshal(data, &meta); err != nil { + continue + } + tp := strings.TrimSuffix(mp, ".meta") + info, err := os.Stat(tp) + if err != nil { + continue + } + files = append(files, proto.ResumableFile{ + Name: meta.Name, + SHA256: meta.SHA256, + From: meta.From, + Size: meta.Size, + Offset: info.Size(), + }) + } + if len(files) == 0 { + return + } + m.emit(proto.IpcMessage{ + Type: proto.EvtResumableTransfers, + ResumableFiles: files, + }) + log.Printf("transfer: %d resumable transfer(s) found in %s", len(files), m.DownloadDir) +} + // OfferFile reads filename from ShareDir, computes its SHA-256, and sends a // file-offer to peerID over the existing "yaw" DataChannel. func (m *Mesh) OfferFile(peerID proto.PeerID, filename string) error { diff --git a/internal/netmgr/manager.go b/internal/netmgr/manager.go index 5545bc3..abd7354 100644 --- a/internal/netmgr/manager.go +++ b/internal/netmgr/manager.go @@ -162,6 +162,8 @@ func (mgr *Manager) Join(name, shareDir string) (string, error) { }() } + go m.ScanResumable() + mgr.emit(proto.IpcMessage{ Type: proto.EvtNetworkJoined, NetworkID: netID, @@ -249,6 +251,8 @@ func (mgr *Manager) JoinByHash(netHash64, shareDir string) (string, error) { }() } + go m.ScanResumable() + mgr.emit(proto.IpcMessage{ Type: proto.EvtNetworkJoined, NetworkID: netID, diff --git a/internal/proto/proto.go b/internal/proto/proto.go index b8edcb0..418cee2 100644 --- a/internal/proto/proto.go +++ b/internal/proto/proto.go @@ -99,6 +99,15 @@ type PeerMessage struct { HistoryDone bool `json:"history_done,omitempty"` } +// ResumableFile describes a partially-downloaded file found on daemon startup. +type ResumableFile struct { + Name string `json:"name"` + SHA256 string `json:"sha256"` + From string `json:"from"` // peer ID hex + Size int64 `json:"size"` + Offset int64 `json:"offset"` // bytes already received +} + // HistoryEntry is one message in a history_chunk response. type HistoryEntry struct { Mid string `json:"mid"` @@ -291,7 +300,8 @@ const ( EvtIdentityImported IpcMsgType = "identity_imported" EvtSharesList IpcMsgType = "shares_list" EvtRoomCreated IpcMsgType = "room_created" // field: room (name) - EvtHistoryLoaded IpcMsgType = "history_loaded" // fields: room, messages + EvtHistoryLoaded IpcMsgType = "history_loaded" // fields: room, messages + EvtResumableTransfers IpcMsgType = "resumable_transfers" // field: resumable_files ) // NetworkInfo summarises one joined network for state_snapshot and network_joined events. @@ -346,7 +356,8 @@ type IpcMessage struct { ErrorMessage string `json:"error_message,omitempty"` InviteGenerated string `json:"invite,omitempty"` Files []FileEntry `json:"files,omitempty"` - Messages []ChatMessage `json:"messages,omitempty"` // history_loaded + Messages []ChatMessage `json:"messages,omitempty"` // history_loaded + ResumableFiles []ResumableFile `json:"resumable_files,omitempty"` // resumable_transfers Shares []ShareEntry `json:"shares,omitempty"` ShareNetworks []string `json:"network_ids,omitempty"` // for add_share command: scope to specific network IDs, or ["*"] for global // export_identity / import_identity diff --git a/web/src/components/Transfers.tsx b/web/src/components/Transfers.tsx index 7eb0387..cdb0b90 100644 --- a/web/src/components/Transfers.tsx +++ b/web/src/components/Transfers.tsx @@ -7,12 +7,13 @@ function fmt(bytes: number): string { } export function Transfers() { - const { pendingOffers, fileProgress, acceptOffer, rejectOffer, cancelTransfer, connectedPeers } = useWaste() + const { pendingOffers, fileProgress, resumableFiles, acceptOffer, rejectOffer, cancelTransfer, connectedPeers } = useWaste() const hasPending = Object.keys(pendingOffers).length > 0 const hasActive = Object.keys(fileProgress).length > 0 + const hasResumable = Object.keys(resumableFiles).length > 0 - if (!hasPending && !hasActive) return null + if (!hasPending && !hasActive && !hasResumable) return null function alias(peerId: string) { return connectedPeers.find(p => p.id === peerId)?.alias ?? peerId.slice(0, 8) @@ -22,6 +23,24 @@ export function Transfers() {