feat: resumable transfer UX — surface partial downloads to UI on reconnect
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user