From f319721e014f1c7e615b8f6cd8dfd035d3335c32 Mon Sep 17 00:00:00 2001 From: Fredrik Johansson Date: Mon, 29 Jun 2026 10:02:49 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20resumable=20transfer=20UX=20=E2=80=94?= =?UTF-8?q?=20surface=20partial=20downloads=20to=20UI=20on=20reconnect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Daemon scans the download directory for .tmp.meta sidecars on network join and emits resumable_transfers IPC event. Web UI shows them in the Transfers panel with a dimmed progress bar and "will resume on reconnect" note. - proto: ResumableFile type, EvtResumableTransfers, resumable_files IpcMessage field - mesh: ScanResumable() scans download dir and emits the event - netmgr: call ScanResumable() after join (both Join and JoinByHash paths) - web: resumableFiles store state, resumable_transfers handler, Transfers UI section Co-Authored-By: Claude Sonnet 4.6 --- internal/mesh/transfer.go | 41 ++++++++++++++++++++++++++++++++ internal/netmgr/manager.go | 4 ++++ internal/proto/proto.go | 15 ++++++++++-- web/src/components/Transfers.tsx | 23 ++++++++++++++++-- web/src/store/index.ts | 11 +++++++++ web/src/types.ts | 3 +++ 6 files changed, 93 insertions(+), 4 deletions(-) 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() {
Transfers + {hasResumable && ( + <> + resumable + {Object.entries(resumableFiles).map(([sha256, f]) => { + const pct = f.size > 0 ? Math.round((f.offset / f.size) * 100) : 0 + return ( +
+ {f.name} + {fmt(f.offset)} / {fmt(f.size)} · {alias(f.from)} · will resume on reconnect +
+
+
+
+ ) + })} + + )} + {Object.entries(pendingOffers).map(([xid, offer]) => (
{offer.name} diff --git a/web/src/store/index.ts b/web/src/store/index.ts index 865026a..571bfd2 100644 --- a/web/src/store/index.ts +++ b/web/src/store/index.ts @@ -55,6 +55,8 @@ interface WasteState { pendingOffers: Record // active in-progress transfers: xid → progress fileProgress: Record + // partial downloads found on daemon startup: sha256 → info + resumableFiles: Record // actions connect: (url: string) => void @@ -96,6 +98,7 @@ export const useWaste = create((set, get) => ({ sharedFilesByNetwork: {}, pendingOffers: {}, fileProgress: {}, + resumableFiles: {}, connect(url: string) { const adapter = new DaemonAdapter(url) @@ -355,6 +358,14 @@ export const useWaste = create((set, get) => ({ } break } + case 'resumable_transfers': { + const files = (msg.resumable_files ?? []) as Array<{ name: string; sha256: string; from: string; size: number; offset: number }> + if (files.length === 0) break + const byHash: Record = {} + for (const f of files) byHash[f.sha256] = { name: f.name, from: f.from, size: f.size, offset: f.offset } + set(s => ({ resumableFiles: { ...s.resumableFiles, ...byHash } })) + break + } case 'history_loaded': { const room = msg.room const incoming = (msg.messages ?? []) as ChatMessage[] diff --git a/web/src/types.ts b/web/src/types.ts index e3d88eb..57ab887 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -85,6 +85,7 @@ export type IpcMsgType = | 'history_loaded' | 'room_created' | 'create_room' + | 'resumable_transfers' export interface IpcMessage { type: IpcMsgType @@ -129,4 +130,6 @@ export interface IpcMessage { remote_address?: string // history_loaded messages?: ChatMessage[] + // resumable_transfers + resumable_files?: Array<{ name: string; sha256: string; from: string; size: number; offset: number }> }