Compare commits
3 Commits
3a105e2c9a
...
bd7879ce7b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd7879ce7b | ||
|
|
6a3ad787d5 | ||
|
|
153426367d |
@@ -758,11 +758,12 @@ func (s *Session) SendFile(path string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// §9: send file-done before closing the file DC so the control channel
|
||||||
|
// is still reachable when the message is delivered.
|
||||||
|
s.controlSend(map[string]string{"type": "file-done", "xid": xid, "sha256": sha256hex})
|
||||||
if err := dc.Close(); err != nil {
|
if err := dc.Close(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// §9: send file-done with the pre-computed hash so the receiver can verify.
|
|
||||||
s.controlSend(map[string]string{"type": "file-done", "xid": xid, "sha256": sha256hex})
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -776,13 +777,20 @@ func (s *Session) AcceptOffer(offer FileOffer, downloadDir string) {
|
|||||||
if info, err := os.Stat(partPath); err == nil && info.Size() < offer.Size {
|
if info, err := os.Stat(partPath); err == nil && info.Size() < offer.Size {
|
||||||
offset = info.Size()
|
offset = info.Size()
|
||||||
}
|
}
|
||||||
s.controlSend(map[string]string{"type": "file-accept", "xid": offer.XID, "offset": fmt.Sprint(offset)})
|
// Register doneCh before sending file-accept so file-done can never arrive
|
||||||
|
// before the channel exists (small files finish before wireFileRecv runs).
|
||||||
|
doneCh := make(chan string, 1)
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
|
if s.pendingDones == nil {
|
||||||
|
s.pendingDones = make(map[string]chan string)
|
||||||
|
}
|
||||||
|
s.pendingDones[offer.XID] = doneCh
|
||||||
if s.pendingRecvs == nil {
|
if s.pendingRecvs == nil {
|
||||||
s.pendingRecvs = make(map[string]*recvState)
|
s.pendingRecvs = make(map[string]*recvState)
|
||||||
}
|
}
|
||||||
s.pendingRecvs[offer.XID] = &recvState{offer: offer, dir: downloadDir, have: offset}
|
s.pendingRecvs[offer.XID] = &recvState{offer: offer, dir: downloadDir, have: offset, doneCh: doneCh}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
s.controlSend(map[string]string{"type": "file-accept", "xid": offer.XID, "offset": fmt.Sprint(offset)})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Session) RejectOffer(xid string) {
|
func (s *Session) RejectOffer(xid string) {
|
||||||
@@ -790,10 +798,11 @@ func (s *Session) RejectOffer(xid string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type recvState struct {
|
type recvState struct {
|
||||||
offer FileOffer
|
offer FileOffer
|
||||||
dir string
|
dir string
|
||||||
have int64
|
have int64
|
||||||
f *os.File
|
f *os.File
|
||||||
|
doneCh chan string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Session) wireFileRecv(dc *webrtc.DataChannel) {
|
func (s *Session) wireFileRecv(dc *webrtc.DataChannel) {
|
||||||
@@ -884,13 +893,9 @@ func (s *Session) wireFileRecv(dc *webrtc.DataChannel) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// §9: wait for file-done and verify sha256 before reporting success.
|
// §9: wait for file-done and verify sha256 before reporting success.
|
||||||
doneCh := make(chan string, 1)
|
// doneCh was registered in AcceptOffer before file-accept was sent,
|
||||||
s.mu.Lock()
|
// so file-done can never arrive before the channel exists.
|
||||||
if s.pendingDones == nil {
|
doneCh := rs.doneCh
|
||||||
s.pendingDones = make(map[string]chan string)
|
|
||||||
}
|
|
||||||
s.pendingDones[xid] = doneCh
|
|
||||||
s.mu.Unlock()
|
|
||||||
var expectedSHA256 string
|
var expectedSHA256 string
|
||||||
select {
|
select {
|
||||||
case expectedSHA256 = <-doneCh:
|
case expectedSHA256 = <-doneCh:
|
||||||
|
|||||||
@@ -278,6 +278,19 @@
|
|||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.presence-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 6px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.presence-online { background: var(--accent); box-shadow: 0 0 4px var(--accent); }
|
||||||
|
.presence-offline { background: var(--muted); opacity: 0.5; }
|
||||||
|
.presence-unknown { background: var(--muted); opacity: 0.25; }
|
||||||
|
|
||||||
.peer-id {
|
.peer-id {
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import QRCode from 'qrcode'
|
import QRCode from 'qrcode'
|
||||||
import { FlitSession, type FileOfferEvent, type FileProgressEvent, type FileRecvEvent, type FileSendProgressEvent, type FileSentEvent, type FlitConfig } from './transport/flit'
|
import { FlitSession, queryPresence, netHash, type FileOfferEvent, type FileProgressEvent, type FileRecvEvent, type FileSendProgressEvent, type FileSentEvent, type FlitConfig } from './transport/flit'
|
||||||
import { createInvite, decodeInvite, pairRoomName } from './pairing/ephemeral'
|
import { createInvite, decodeInvite, pairRoomName } from './pairing/ephemeral'
|
||||||
import { QrScanner } from './pairing/QrScanner'
|
import { QrScanner } from './pairing/QrScanner'
|
||||||
import { addTrusted, listTrusted, removeTrusted, setAutoAccept, shouldAutoAccept, type TrustedPeer } from './pairing/keyring'
|
import { addTrusted, listTrusted, removeTrusted, setAutoAccept, shouldAutoAccept, type TrustedPeer } from './pairing/keyring'
|
||||||
@@ -40,6 +40,7 @@ function App() {
|
|||||||
const [sentFiles, setSentFiles] = useState<FileSentEvent[]>([])
|
const [sentFiles, setSentFiles] = useState<FileSentEvent[]>([])
|
||||||
const [scanning, setScanning] = useState(false)
|
const [scanning, setScanning] = useState(false)
|
||||||
const [myId, setMyId] = useState<string | null>(null)
|
const [myId, setMyId] = useState<string | null>(null)
|
||||||
|
const [presence, setPresence] = useState<Record<string, boolean | null>>({})
|
||||||
const [addIdInput, setAddIdInput] = useState('')
|
const [addIdInput, setAddIdInput] = useState('')
|
||||||
const [copied, setCopied] = useState<'snip' | 'id' | null>(null)
|
const [copied, setCopied] = useState<'snip' | 'id' | null>(null)
|
||||||
const sessionRef = useRef<FlitSession | null>(null)
|
const sessionRef = useRef<FlitSession | null>(null)
|
||||||
@@ -65,6 +66,32 @@ function App() {
|
|||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Presence check for known devices (waste-go EXT-009). Each pairing has its
|
||||||
|
// own deterministic anchor room, so this is one short-lived query per
|
||||||
|
// device — no persistent connection held while idle.
|
||||||
|
async function refreshPresence() {
|
||||||
|
if (!myId || trusted.length === 0) return
|
||||||
|
const signalURL = cfg().signalURL
|
||||||
|
const results = await Promise.all(trusted.map(async (p) => {
|
||||||
|
try {
|
||||||
|
const hash = await netHash(pairRoomName(myId, p.id))
|
||||||
|
const online = await queryPresence(signalURL, hash, p.id)
|
||||||
|
return [p.id, online] as const
|
||||||
|
} catch {
|
||||||
|
return [p.id, null] as const
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
setPresence(Object.fromEntries(results))
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode !== 'known' || phase !== 'idle' || !myId || trusted.length === 0) return
|
||||||
|
void refreshPresence()
|
||||||
|
const iv = setInterval(() => void refreshPresence(), 15000)
|
||||||
|
return () => clearInterval(iv)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [mode, phase, myId, trusted])
|
||||||
|
|
||||||
function peerEvents() {
|
function peerEvents() {
|
||||||
return {
|
return {
|
||||||
connected: (v: boolean, pid: string) => { setVerified(v); verifiedRef.current = v; setPeerId(pid); setPhase('connected') },
|
connected: (v: boolean, pid: string) => { setVerified(v); verifiedRef.current = v; setPeerId(pid); setPhase('connected') },
|
||||||
@@ -259,7 +286,13 @@ function App() {
|
|||||||
<li key={p.id} className="known-row">
|
<li key={p.id} className="known-row">
|
||||||
<div className="known-top">
|
<div className="known-top">
|
||||||
<div className="known-info">
|
<div className="known-info">
|
||||||
<span className="known-label">{p.label}</span>
|
<span className="known-label">
|
||||||
|
<span
|
||||||
|
className={`presence-dot ${presence[p.id] === true ? 'presence-online' : presence[p.id] === false ? 'presence-offline' : 'presence-unknown'}`}
|
||||||
|
title={presence[p.id] === true ? 'Online' : presence[p.id] === false ? 'Offline' : 'Unknown'}
|
||||||
|
/>
|
||||||
|
{p.label}
|
||||||
|
</span>
|
||||||
<span className="peer-id">{p.id.slice(0, 16)}…</span>
|
<span className="peer-id">{p.id.slice(0, 16)}…</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="known-secondary">
|
<div className="known-secondary">
|
||||||
|
|||||||
@@ -79,6 +79,38 @@ export async function netHash(name: string): Promise<string> {
|
|||||||
throw new Error('SHA-256 not available in this browser runtime')
|
throw new Error('SHA-256 not available in this browser runtime')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// queryPresence asks the anchor whether `id` is currently connected in
|
||||||
|
// network `net` (waste-go EXT-009). Opens a short-lived WS connection —
|
||||||
|
// no join, no identity signature required, since the query is anchor-local
|
||||||
|
// and scoped to a (net, id) pair the caller must already know. Resolves
|
||||||
|
// `null` on timeout/error (treat the same as "unknown", not "offline").
|
||||||
|
export function queryPresence(url: string, net: string, id: string, timeoutMs = 4000): Promise<boolean | null> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let settled = false
|
||||||
|
const ws = new WebSocket(url)
|
||||||
|
const finish = (result: boolean | null) => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
clearTimeout(timer)
|
||||||
|
try { ws.close() } catch { /* ignore */ }
|
||||||
|
resolve(result)
|
||||||
|
}
|
||||||
|
const timer = setTimeout(() => finish(null), timeoutMs)
|
||||||
|
ws.onopen = () => {
|
||||||
|
try { ws.send(JSON.stringify({ type: 'presence_query', net, id })) } catch { finish(null) }
|
||||||
|
}
|
||||||
|
ws.onmessage = (ev) => {
|
||||||
|
let m: Record<string, unknown>
|
||||||
|
try { m = JSON.parse(ev.data) } catch { return }
|
||||||
|
if (m['type'] === 'presence' && m['id'] === id && m['net'] === net) {
|
||||||
|
finish(Boolean(m['online']))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ws.onerror = () => finish(null)
|
||||||
|
ws.onclose = () => finish(null)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ── Identity ─────────────────────────────────────────────────────────────────
|
// ── Identity ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export class Identity {
|
export class Identity {
|
||||||
@@ -510,9 +542,10 @@ export class PeerConn {
|
|||||||
this.events.fileSendProgress?.({ peer: this.peerId, xid, name: file.name, sent: resumeOffset + localOffset, total: file.size })
|
this.events.fileSendProgress?.({ peer: this.peerId, xid, name: file.name, sent: resumeOffset + localOffset, total: file.size })
|
||||||
await new Promise(r => setTimeout(r, 0))
|
await new Promise(r => setTimeout(r, 0))
|
||||||
}
|
}
|
||||||
dc.close()
|
// §9: send file-done before closing the file DC so the control channel
|
||||||
// §9: send file-done with sha256 so receiver can verify integrity.
|
// is still healthy when the message is dispatched.
|
||||||
this._dc({ type: 'file-done', xid, sha256: hash })
|
this._dc({ type: 'file-done', xid, sha256: hash })
|
||||||
|
dc.close()
|
||||||
this.events.fileSent?.({ peer: this.peerId, xid, name: file.name })
|
this.events.fileSent?.({ peer: this.peerId, xid, name: file.name })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user