Compare commits
3 Commits
v0.1.1
...
0e812a2479
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e812a2479 | ||
|
|
f319721e01 | ||
|
|
9de625d617 |
@@ -91,6 +91,47 @@ func writePartialMeta(path string, t *inboundTransfer) {
|
|||||||
os.WriteFile(path, data, 0o644) //nolint:errcheck
|
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
|
// OfferFile reads filename from ShareDir, computes its SHA-256, and sends a
|
||||||
// file-offer to peerID over the existing "yaw" DataChannel.
|
// file-offer to peerID over the existing "yaw" DataChannel.
|
||||||
func (m *Mesh) OfferFile(peerID proto.PeerID, filename string) error {
|
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{
|
mgr.emit(proto.IpcMessage{
|
||||||
Type: proto.EvtNetworkJoined,
|
Type: proto.EvtNetworkJoined,
|
||||||
NetworkID: netID,
|
NetworkID: netID,
|
||||||
@@ -249,6 +251,8 @@ func (mgr *Manager) JoinByHash(netHash64, shareDir string) (string, error) {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
go m.ScanResumable()
|
||||||
|
|
||||||
mgr.emit(proto.IpcMessage{
|
mgr.emit(proto.IpcMessage{
|
||||||
Type: proto.EvtNetworkJoined,
|
Type: proto.EvtNetworkJoined,
|
||||||
NetworkID: netID,
|
NetworkID: netID,
|
||||||
|
|||||||
@@ -99,6 +99,15 @@ type PeerMessage struct {
|
|||||||
HistoryDone bool `json:"history_done,omitempty"`
|
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.
|
// HistoryEntry is one message in a history_chunk response.
|
||||||
type HistoryEntry struct {
|
type HistoryEntry struct {
|
||||||
Mid string `json:"mid"`
|
Mid string `json:"mid"`
|
||||||
@@ -291,7 +300,8 @@ const (
|
|||||||
EvtIdentityImported IpcMsgType = "identity_imported"
|
EvtIdentityImported IpcMsgType = "identity_imported"
|
||||||
EvtSharesList IpcMsgType = "shares_list"
|
EvtSharesList IpcMsgType = "shares_list"
|
||||||
EvtRoomCreated IpcMsgType = "room_created" // field: room (name)
|
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.
|
// 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"`
|
ErrorMessage string `json:"error_message,omitempty"`
|
||||||
InviteGenerated string `json:"invite,omitempty"`
|
InviteGenerated string `json:"invite,omitempty"`
|
||||||
Files []FileEntry `json:"files,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"`
|
Shares []ShareEntry `json:"shares,omitempty"`
|
||||||
ShareNetworks []string `json:"network_ids,omitempty"` // for add_share command: scope to specific network IDs, or ["*"] for global
|
ShareNetworks []string `json:"network_ids,omitempty"` // for add_share command: scope to specific network IDs, or ["*"] for global
|
||||||
// export_identity / import_identity
|
// export_identity / import_identity
|
||||||
|
|||||||
@@ -158,3 +158,5 @@ details summary { color: var(--muted); font-size: 12px; cursor: pointer; }
|
|||||||
.file-entry-dir { cursor: pointer; }
|
.file-entry-dir { cursor: pointer; }
|
||||||
.file-entry-dir:hover { background: rgba(255,255,255,0.04); }
|
.file-entry-dir:hover { background: rgba(255,255,255,0.04); }
|
||||||
.file-entry-icon { font-size: 12px; flex-shrink: 0; }
|
.file-entry-icon { font-size: 12px; flex-shrink: 0; }
|
||||||
|
.history-divider { display: flex; align-items: center; gap: 8px; margin: 10px 0 6px; color: var(--muted); font-size: 11px; }
|
||||||
|
.history-divider::before, .history-divider::after { content: ''; flex: 1; height: 1px; background: var(--border); }
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ import { useEffect, useRef, useState } from 'react'
|
|||||||
import { useWaste } from '../store'
|
import { useWaste } from '../store'
|
||||||
|
|
||||||
export function MessagePane() {
|
export function MessagePane() {
|
||||||
const { messages, activeRoom, activeNetworkId, localPeer, connectedPeers, send } = useWaste()
|
const { messages, historyCutoff, activeRoom, activeNetworkId, localPeer, connectedPeers, send } = useWaste()
|
||||||
const [draft, setDraft] = useState('')
|
const [draft, setDraft] = useState('')
|
||||||
const bottomRef = useRef<HTMLDivElement>(null)
|
const bottomRef = useRef<HTMLDivElement>(null)
|
||||||
const roomMessages = messages[activeRoom] ?? []
|
const roomMessages = messages[activeRoom] ?? []
|
||||||
|
const cutoff = historyCutoff[activeRoom] ?? 0
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||||
@@ -46,11 +47,19 @@ export function MessagePane() {
|
|||||||
const mine = msg.from === localPeer?.id
|
const mine = msg.from === localPeer?.id
|
||||||
const alias = aliasFor(msg.from)
|
const alias = aliasFor(msg.from)
|
||||||
const time = new Date(msg.ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false })
|
const time = new Date(msg.ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||||
|
const showDivider = cutoff > 0 && i > 0 && roomMessages[i - 1].ts <= cutoff && msg.ts > cutoff
|
||||||
return (
|
return (
|
||||||
<div key={msg.mid ?? i} className={`message ${mine ? 'mine' : ''}`}>
|
<div key={msg.mid ?? i}>
|
||||||
<span className="message-ts">{time}</span>
|
{showDivider && (
|
||||||
<span className="message-alias">{alias}</span>
|
<div className="history-divider">
|
||||||
<span className="message-text">{msg.text}</span>
|
<span>earlier messages</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={`message ${mine ? 'mine' : ''}`}>
|
||||||
|
<span className="message-ts">{time}</span>
|
||||||
|
<span className="message-alias">{alias}</span>
|
||||||
|
<span className="message-text">{msg.text}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -7,12 +7,13 @@ function fmt(bytes: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Transfers() {
|
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 hasPending = Object.keys(pendingOffers).length > 0
|
||||||
const hasActive = Object.keys(fileProgress).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) {
|
function alias(peerId: string) {
|
||||||
return connectedPeers.find(p => p.id === peerId)?.alias ?? peerId.slice(0, 8)
|
return connectedPeers.find(p => p.id === peerId)?.alias ?? peerId.slice(0, 8)
|
||||||
@@ -22,6 +23,24 @@ export function Transfers() {
|
|||||||
<div className="sidebar-section">
|
<div className="sidebar-section">
|
||||||
<span className="sidebar-label">Transfers</span>
|
<span className="sidebar-label">Transfers</span>
|
||||||
|
|
||||||
|
{hasResumable && (
|
||||||
|
<>
|
||||||
|
<span className="sidebar-label" style={{ fontSize: 10, opacity: 0.6 }}>resumable</span>
|
||||||
|
{Object.entries(resumableFiles).map(([sha256, f]) => {
|
||||||
|
const pct = f.size > 0 ? Math.round((f.offset / f.size) * 100) : 0
|
||||||
|
return (
|
||||||
|
<div key={sha256} className="transfer-row">
|
||||||
|
<span className="transfer-name" title={f.name}>{f.name}</span>
|
||||||
|
<span className="transfer-meta">{fmt(f.offset)} / {fmt(f.size)} · {alias(f.from)} · will resume on reconnect</span>
|
||||||
|
<div className="transfer-progress">
|
||||||
|
<div className="transfer-progress-bar" style={{ width: `${pct}%`, opacity: 0.5 }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{Object.entries(pendingOffers).map(([xid, offer]) => (
|
{Object.entries(pendingOffers).map(([xid, offer]) => (
|
||||||
<div key={xid} className="transfer-row">
|
<div key={xid} className="transfer-row">
|
||||||
<span className="transfer-name" title={offer.name}>{offer.name}</span>
|
<span className="transfer-name" title={offer.name}>{offer.name}</span>
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ interface WasteState {
|
|||||||
|
|
||||||
// chat — keyed by room
|
// chat — keyed by room
|
||||||
messages: Record<string, ChatMessage[]>
|
messages: Record<string, ChatMessage[]>
|
||||||
|
// rooms for which we have received history: room → ts of last history message
|
||||||
|
historyCutoff: Record<string, number>
|
||||||
activeRoom: string
|
activeRoom: string
|
||||||
// user-created rooms, keyed by networkId
|
// user-created rooms, keyed by networkId
|
||||||
customRooms: Record<string, string[]>
|
customRooms: Record<string, string[]>
|
||||||
@@ -53,6 +55,8 @@ interface WasteState {
|
|||||||
pendingOffers: Record<string, { peerId: string; name: string; size: number }>
|
pendingOffers: Record<string, { peerId: string; name: string; size: number }>
|
||||||
// active in-progress transfers: xid → progress
|
// active in-progress transfers: xid → progress
|
||||||
fileProgress: Record<string, { peerId: string; name: string; received: number; total: number }>
|
fileProgress: Record<string, { peerId: string; name: string; received: number; total: number }>
|
||||||
|
// partial downloads found on daemon startup: sha256 → info
|
||||||
|
resumableFiles: Record<string, { name: string; from: string; size: number; offset: number }>
|
||||||
|
|
||||||
// actions
|
// actions
|
||||||
connect: (url: string) => void
|
connect: (url: string) => void
|
||||||
@@ -84,6 +88,7 @@ export const useWaste = create<WasteState>((set, get) => ({
|
|||||||
activeNetworkId: null,
|
activeNetworkId: null,
|
||||||
connectedPeers: [],
|
connectedPeers: [],
|
||||||
messages: {},
|
messages: {},
|
||||||
|
historyCutoff: {},
|
||||||
activeRoom: 'general',
|
activeRoom: 'general',
|
||||||
customRooms: {},
|
customRooms: {},
|
||||||
fileLists: {},
|
fileLists: {},
|
||||||
@@ -93,6 +98,7 @@ export const useWaste = create<WasteState>((set, get) => ({
|
|||||||
sharedFilesByNetwork: {},
|
sharedFilesByNetwork: {},
|
||||||
pendingOffers: {},
|
pendingOffers: {},
|
||||||
fileProgress: {},
|
fileProgress: {},
|
||||||
|
resumableFiles: {},
|
||||||
|
|
||||||
connect(url: string) {
|
connect(url: string) {
|
||||||
const adapter = new DaemonAdapter(url)
|
const adapter = new DaemonAdapter(url)
|
||||||
@@ -341,10 +347,13 @@ export const useWaste = create<WasteState>((set, get) => ({
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'file_complete': {
|
case 'file_complete': {
|
||||||
if (msg.path && msg.offer?.name) {
|
// Always clear progress — transfer_id is the xid in daemon mode; offer.xid in browser mode.
|
||||||
// clear progress entry
|
const xid = msg.transfer_id ?? msg.offer?.xid
|
||||||
const xid = msg.offer.xid
|
if (xid) {
|
||||||
set(s => { const fp = { ...s.fileProgress }; delete fp[xid]; return { fileProgress: fp } })
|
set(s => { const fp = { ...s.fileProgress }; delete fp[xid]; return { fileProgress: fp } })
|
||||||
|
}
|
||||||
|
// Browser mode: trigger download via anchor click.
|
||||||
|
if (msg.path && msg.offer?.name) {
|
||||||
const a = document.createElement('a')
|
const a = document.createElement('a')
|
||||||
a.href = msg.path
|
a.href = msg.path
|
||||||
a.download = msg.offer.name
|
a.download = msg.offer.name
|
||||||
@@ -352,6 +361,32 @@ export const useWaste = create<WasteState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
break
|
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<string, { name: string; from: string; size: number; offset: number }> = {}
|
||||||
|
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[]
|
||||||
|
if (!room || incoming.length === 0) break
|
||||||
|
set(s => {
|
||||||
|
const existing = s.messages[room] ?? []
|
||||||
|
const existingMids = new Set(existing.map(m => m.mid).filter(Boolean))
|
||||||
|
const fresh = incoming.filter(m => !m.mid || !existingMids.has(m.mid))
|
||||||
|
if (fresh.length === 0) return s
|
||||||
|
const merged = [...fresh, ...existing].sort((a, b) => a.ts - b.ts)
|
||||||
|
const cutoff = fresh[fresh.length - 1]?.ts ?? 0
|
||||||
|
return {
|
||||||
|
messages: { ...s.messages, [room]: merged },
|
||||||
|
historyCutoff: { ...s.historyCutoff, [room]: cutoff },
|
||||||
|
}
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -82,6 +82,10 @@ export type IpcMsgType =
|
|||||||
| 'shares_list'
|
| 'shares_list'
|
||||||
| 'peer_status'
|
| 'peer_status'
|
||||||
| 'error'
|
| 'error'
|
||||||
|
| 'history_loaded'
|
||||||
|
| 'room_created'
|
||||||
|
| 'create_room'
|
||||||
|
| 'resumable_transfers'
|
||||||
|
|
||||||
export interface IpcMessage {
|
export interface IpcMessage {
|
||||||
type: IpcMsgType
|
type: IpcMsgType
|
||||||
@@ -124,4 +128,8 @@ export interface IpcMessage {
|
|||||||
conn_state?: PeerConnState
|
conn_state?: PeerConnState
|
||||||
candidate_type?: CandidateType
|
candidate_type?: CandidateType
|
||||||
remote_address?: string
|
remote_address?: string
|
||||||
|
// history_loaded
|
||||||
|
messages?: ChatMessage[]
|
||||||
|
// resumable_transfers
|
||||||
|
resumable_files?: Array<{ name: string; sha256: string; from: string; size: number; offset: number }>
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user