@elefunc/send 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +57 -0
- package/package.json +52 -0
- package/patches/@rezi-ui%2Fcore@0.1.0-alpha.60.patch +227 -0
- package/patches/werift@0.22.9.patch +31 -0
- package/src/core/files.ts +79 -0
- package/src/core/paths.ts +19 -0
- package/src/core/protocol.ts +241 -0
- package/src/core/session.ts +1435 -0
- package/src/core/targeting.ts +39 -0
- package/src/index.ts +283 -0
- package/src/tui/app.ts +1442 -0
- package/src/tui/file-search-protocol.ts +48 -0
- package/src/tui/file-search.ts +282 -0
- package/src/tui/file-search.worker.ts +127 -0
- package/src/tui/rezi-checkbox-click.ts +63 -0
- package/src/types/bun-runtime.d.ts +5 -0
- package/src/types/bun-test.d.ts +9 -0
|
@@ -0,0 +1,1435 @@
|
|
|
1
|
+
import { resolve } from "node:path"
|
|
2
|
+
import type { RTCDataChannel, RTCIceCandidateInit, RTCIceServer } from "werift"
|
|
3
|
+
import { RTCPeerConnection } from "werift"
|
|
4
|
+
import { loadLocalFiles, readFileChunk, saveIncomingFile, type LocalFile } from "./files"
|
|
5
|
+
import {
|
|
6
|
+
BASE_ICE_SERVERS,
|
|
7
|
+
BUFFER_HIGH,
|
|
8
|
+
CHUNK,
|
|
9
|
+
FINAL_STATUSES,
|
|
10
|
+
SENDABLE_STATUSES,
|
|
11
|
+
SIGNAL_WS_URL,
|
|
12
|
+
buildCliProfile,
|
|
13
|
+
cleanText,
|
|
14
|
+
cleanLocalId,
|
|
15
|
+
cleanName,
|
|
16
|
+
cleanRoom,
|
|
17
|
+
displayPeerName,
|
|
18
|
+
fallbackName,
|
|
19
|
+
formatEta,
|
|
20
|
+
formatRate,
|
|
21
|
+
signalEpoch,
|
|
22
|
+
stamp,
|
|
23
|
+
turnStateLabel,
|
|
24
|
+
type CandidateSignal,
|
|
25
|
+
type DataMessage,
|
|
26
|
+
type DescriptionSignal,
|
|
27
|
+
type Direction,
|
|
28
|
+
type LogEntry,
|
|
29
|
+
type PeerProfile,
|
|
30
|
+
type Presence,
|
|
31
|
+
type SignalMessage,
|
|
32
|
+
type SocketState,
|
|
33
|
+
type TransferStatus,
|
|
34
|
+
uid,
|
|
35
|
+
} from "./protocol"
|
|
36
|
+
|
|
37
|
+
interface PeerState {
|
|
38
|
+
id: string
|
|
39
|
+
name: string
|
|
40
|
+
presence: Presence
|
|
41
|
+
selected: boolean
|
|
42
|
+
polite: boolean
|
|
43
|
+
pc: RTCPeerConnection | null
|
|
44
|
+
dc: RTCDataChannel | null
|
|
45
|
+
rtcEpoch: number
|
|
46
|
+
remoteEpoch: number
|
|
47
|
+
makingOffer: boolean
|
|
48
|
+
createdAt: number
|
|
49
|
+
lastSeenAt: number
|
|
50
|
+
outgoingQueue: string[]
|
|
51
|
+
activeOutgoing: string
|
|
52
|
+
activeIncoming: string
|
|
53
|
+
profile?: PeerProfile
|
|
54
|
+
turnAvailable: boolean
|
|
55
|
+
terminalReason: string
|
|
56
|
+
lastError: string
|
|
57
|
+
connectivity: PeerConnectivitySnapshot
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface TransferState {
|
|
61
|
+
id: string
|
|
62
|
+
peerId: string
|
|
63
|
+
peerName: string
|
|
64
|
+
direction: Direction
|
|
65
|
+
status: TransferStatus
|
|
66
|
+
name: string
|
|
67
|
+
size: number
|
|
68
|
+
type: string
|
|
69
|
+
lastModified: number
|
|
70
|
+
totalChunks: number
|
|
71
|
+
chunkSize: number
|
|
72
|
+
bytes: number
|
|
73
|
+
chunks: number
|
|
74
|
+
speed: number
|
|
75
|
+
eta: number
|
|
76
|
+
error: string
|
|
77
|
+
createdAt: number
|
|
78
|
+
updatedAt: number
|
|
79
|
+
startedAt: number
|
|
80
|
+
endedAt: number
|
|
81
|
+
savedAt: number
|
|
82
|
+
savedPath?: string
|
|
83
|
+
file?: LocalFile
|
|
84
|
+
buffers?: Buffer[]
|
|
85
|
+
data?: Buffer
|
|
86
|
+
inFlight: boolean
|
|
87
|
+
cancel: boolean
|
|
88
|
+
cancelReason?: string
|
|
89
|
+
cancelSource?: "local" | "remote"
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface PeerSnapshot {
|
|
93
|
+
id: string
|
|
94
|
+
name: string
|
|
95
|
+
displayName: string
|
|
96
|
+
presence: Presence
|
|
97
|
+
selected: boolean
|
|
98
|
+
selectable: boolean
|
|
99
|
+
ready: boolean
|
|
100
|
+
status: string
|
|
101
|
+
turn: string
|
|
102
|
+
turnState: TurnState
|
|
103
|
+
dataState: string
|
|
104
|
+
lastError: string
|
|
105
|
+
profile?: PeerProfile
|
|
106
|
+
rttMs: number
|
|
107
|
+
localCandidateType: string
|
|
108
|
+
remoteCandidateType: string
|
|
109
|
+
pathLabel: string
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface TransferSnapshot {
|
|
113
|
+
id: string
|
|
114
|
+
peerId: string
|
|
115
|
+
peerName: string
|
|
116
|
+
direction: Direction
|
|
117
|
+
status: TransferStatus
|
|
118
|
+
name: string
|
|
119
|
+
size: number
|
|
120
|
+
bytes: number
|
|
121
|
+
progress: number
|
|
122
|
+
speedText: string
|
|
123
|
+
etaText: string
|
|
124
|
+
error: string
|
|
125
|
+
createdAt: number
|
|
126
|
+
updatedAt: number
|
|
127
|
+
startedAt: number
|
|
128
|
+
endedAt: number
|
|
129
|
+
savedAt: number
|
|
130
|
+
savedPath?: string
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface SessionSnapshot {
|
|
134
|
+
localId: string
|
|
135
|
+
name: string
|
|
136
|
+
room: string
|
|
137
|
+
socketState: SocketState
|
|
138
|
+
turn: string
|
|
139
|
+
turnState: TurnState
|
|
140
|
+
profile?: PeerProfile
|
|
141
|
+
pulse: PulseSnapshot
|
|
142
|
+
saveDir: string
|
|
143
|
+
peers: PeerSnapshot[]
|
|
144
|
+
transfers: TransferSnapshot[]
|
|
145
|
+
logs: LogEntry[]
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export type SessionEvent =
|
|
149
|
+
| { type: "socket"; socketState: SocketState }
|
|
150
|
+
| { type: "peer"; peer: PeerSnapshot }
|
|
151
|
+
| { type: "transfer"; transfer: TransferSnapshot }
|
|
152
|
+
| { type: "saved"; transfer: TransferSnapshot }
|
|
153
|
+
| { type: "log"; log: LogEntry }
|
|
154
|
+
|
|
155
|
+
export interface SessionConfig {
|
|
156
|
+
room?: string
|
|
157
|
+
localId?: string
|
|
158
|
+
name?: string
|
|
159
|
+
saveDir?: string
|
|
160
|
+
autoAcceptIncoming?: boolean
|
|
161
|
+
autoSaveIncoming?: boolean
|
|
162
|
+
turnUrls?: string[]
|
|
163
|
+
turnUsername?: string
|
|
164
|
+
turnCredential?: string
|
|
165
|
+
reconnectSocket?: boolean
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const LOG_LIMIT = 200
|
|
169
|
+
const STATS_POLL_MS = 1000
|
|
170
|
+
const PROFILE_URL = "https://ip.rt.ht/"
|
|
171
|
+
const PULSE_URL = "https://sig.efn.kr/pulse"
|
|
172
|
+
|
|
173
|
+
type StatsEntry = { id?: string; type?: string; [key: string]: unknown }
|
|
174
|
+
|
|
175
|
+
export interface PeerConnectivitySnapshot {
|
|
176
|
+
rttMs: number
|
|
177
|
+
localCandidateType: string
|
|
178
|
+
remoteCandidateType: string
|
|
179
|
+
pathLabel: string
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export type TurnState = "none" | "idle" | "used"
|
|
183
|
+
export type PulseState = "idle" | "checking" | "open" | "error"
|
|
184
|
+
|
|
185
|
+
export interface PulseSnapshot {
|
|
186
|
+
state: PulseState
|
|
187
|
+
at: number
|
|
188
|
+
ms: number
|
|
189
|
+
error: string
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const progressOf = (transfer: TransferState) => transfer.size ? Math.max(0, Math.min(100, transfer.bytes / transfer.size * 100)) : FINAL_STATUSES.has(transfer.status as never) ? 100 : 0
|
|
193
|
+
const isFinal = (transfer: TransferState) => FINAL_STATUSES.has(transfer.status as never)
|
|
194
|
+
const candidateTypeRank = (type: string) => ({ host: 0, srflx: 1, prflx: 1, relay: 2 }[type] ?? Number.NaN)
|
|
195
|
+
export const candidateTypeLabel = (type: string) => ({ host: "Direct", srflx: "NAT", prflx: "NAT", relay: "TURN" }[type] || "—")
|
|
196
|
+
const emptyConnectivitySnapshot = (): PeerConnectivitySnapshot => ({ rttMs: Number.NaN, localCandidateType: "", remoteCandidateType: "", pathLabel: "—" })
|
|
197
|
+
const emptyPulseSnapshot = (): PulseSnapshot => ({ state: "idle", at: 0, ms: 0, error: "" })
|
|
198
|
+
|
|
199
|
+
export const sanitizeProfile = (profile?: PeerProfile): PeerProfile => ({
|
|
200
|
+
geo: {
|
|
201
|
+
city: cleanText(profile?.geo?.city, 48),
|
|
202
|
+
region: cleanText(profile?.geo?.region, 48),
|
|
203
|
+
country: cleanText(profile?.geo?.country, 12),
|
|
204
|
+
timezone: cleanText(profile?.geo?.timezone, 48),
|
|
205
|
+
},
|
|
206
|
+
network: {
|
|
207
|
+
colo: cleanText(profile?.network?.colo, 12),
|
|
208
|
+
asOrganization: cleanText(profile?.network?.asOrganization, 72),
|
|
209
|
+
asn: Number(profile?.network?.asn) || 0,
|
|
210
|
+
ip: cleanText(profile?.network?.ip, 80),
|
|
211
|
+
},
|
|
212
|
+
ua: {
|
|
213
|
+
browser: cleanText(profile?.ua?.browser, 32),
|
|
214
|
+
os: cleanText(profile?.ua?.os, 32),
|
|
215
|
+
device: cleanText(profile?.ua?.device, 16),
|
|
216
|
+
},
|
|
217
|
+
defaults: {
|
|
218
|
+
autoAcceptIncoming: typeof profile?.defaults?.autoAcceptIncoming === "boolean" ? profile.defaults.autoAcceptIncoming : undefined,
|
|
219
|
+
autoSaveIncoming: typeof profile?.defaults?.autoSaveIncoming === "boolean" ? profile.defaults.autoSaveIncoming : undefined,
|
|
220
|
+
},
|
|
221
|
+
ready: !!profile?.ready,
|
|
222
|
+
error: cleanText(profile?.error, 120),
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
export const localProfileFromResponse = (data: unknown, error = ""): PeerProfile => {
|
|
226
|
+
const value = data as {
|
|
227
|
+
cf?: { city?: unknown; region?: unknown; country?: unknown; timezone?: unknown; colo?: unknown; asOrganization?: unknown; asn?: unknown }
|
|
228
|
+
hs?: Record<string, unknown>
|
|
229
|
+
} | null
|
|
230
|
+
const cleaned = (input: unknown, max: number) => cleanText(input, max) || undefined
|
|
231
|
+
return sanitizeProfile({
|
|
232
|
+
geo: {
|
|
233
|
+
city: cleaned(value?.cf?.city, 48),
|
|
234
|
+
region: cleaned(value?.cf?.region, 48),
|
|
235
|
+
country: cleaned(value?.cf?.country, 12),
|
|
236
|
+
timezone: cleaned(value?.cf?.timezone, 48),
|
|
237
|
+
},
|
|
238
|
+
network: {
|
|
239
|
+
colo: cleaned(value?.cf?.colo, 12),
|
|
240
|
+
asOrganization: cleaned(value?.cf?.asOrganization, 72),
|
|
241
|
+
asn: Number(value?.cf?.asn) || 0,
|
|
242
|
+
ip: cleaned(value?.hs?.["cf-connecting-ip"] || value?.hs?.["x-real-ip"], 80),
|
|
243
|
+
},
|
|
244
|
+
ua: buildCliProfile().ua,
|
|
245
|
+
ready: !!value?.cf,
|
|
246
|
+
error,
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export const turnUsageState = (
|
|
251
|
+
hasTurn: boolean,
|
|
252
|
+
peers: Iterable<{ presence?: Presence; pc?: { connectionState?: string | null } | null; connectivity?: Partial<PeerConnectivitySnapshot> | null }>,
|
|
253
|
+
): TurnState => {
|
|
254
|
+
if (!hasTurn) return "none"
|
|
255
|
+
for (const peer of peers) {
|
|
256
|
+
if (peer?.presence !== "active") continue
|
|
257
|
+
if (peer?.pc?.connectionState !== "connected") continue
|
|
258
|
+
if (peer?.connectivity?.localCandidateType === "relay") return "used"
|
|
259
|
+
}
|
|
260
|
+
return "idle"
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const timeoutSignal = (ms: number) => typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function" ? AbortSignal.timeout(ms) : undefined
|
|
264
|
+
|
|
265
|
+
const statsEntriesFromReport = (report: unknown): StatsEntry[] => {
|
|
266
|
+
if (!report) return []
|
|
267
|
+
if (Array.isArray(report)) return report as StatsEntry[]
|
|
268
|
+
if (typeof report === "object") {
|
|
269
|
+
const values = report as { values?: () => Iterable<StatsEntry> }
|
|
270
|
+
if (typeof values.values === "function") return [...values.values()]
|
|
271
|
+
const forEachable = report as { forEach?: (fn: (value: StatsEntry) => void) => void }
|
|
272
|
+
if (typeof forEachable.forEach === "function") {
|
|
273
|
+
const entries: StatsEntry[] = []
|
|
274
|
+
forEachable.forEach(value => entries.push(value))
|
|
275
|
+
return entries
|
|
276
|
+
}
|
|
277
|
+
const iterable = report as Iterable<StatsEntry>
|
|
278
|
+
if (typeof (iterable as { [Symbol.iterator]?: () => Iterator<StatsEntry> })[Symbol.iterator] === "function") return [...iterable]
|
|
279
|
+
}
|
|
280
|
+
return []
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const candidatePairEntries = (entries: StatsEntry[], transportId?: string) => entries.filter(entry =>
|
|
284
|
+
entry.type === "candidate-pair"
|
|
285
|
+
&& (!transportId || entry.transportId === transportId))
|
|
286
|
+
|
|
287
|
+
const preferredCandidatePair = (entries: StatsEntry[]) => {
|
|
288
|
+
let selectedFallback: StatsEntry | null = null
|
|
289
|
+
let succeededFallback: StatsEntry | null = null
|
|
290
|
+
for (const entry of entries) {
|
|
291
|
+
const selected = entry.selected === true || entry.nominated === true
|
|
292
|
+
const succeeded = entry.state === "succeeded"
|
|
293
|
+
if (selected && succeeded) return entry
|
|
294
|
+
if (!selectedFallback && selected) selectedFallback = entry
|
|
295
|
+
if (!succeededFallback && succeeded) succeededFallback = entry
|
|
296
|
+
}
|
|
297
|
+
return selectedFallback || succeededFallback
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const selectedCandidatePair = (entries: StatsEntry[], byId: Map<string, StatsEntry>) => {
|
|
301
|
+
const transport = entries.find(entry => entry.type === "transport" && typeof entry.selectedCandidatePairId === "string")
|
|
302
|
+
const transportId = typeof transport?.id === "string" ? transport.id : undefined
|
|
303
|
+
if (transport && typeof transport.selectedCandidatePairId === "string") {
|
|
304
|
+
const pair = byId.get(transport.selectedCandidatePairId)
|
|
305
|
+
if (pair?.type === "candidate-pair") return pair
|
|
306
|
+
}
|
|
307
|
+
return preferredCandidatePair(candidatePairEntries(entries, transportId)) || preferredCandidatePair(candidatePairEntries(entries))
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export const connectivitySnapshotFromReport = (report: unknown, previous: PeerConnectivitySnapshot = emptyConnectivitySnapshot()): PeerConnectivitySnapshot => {
|
|
311
|
+
const entries = statsEntriesFromReport(report)
|
|
312
|
+
const byId = new Map(entries.flatMap(entry => typeof entry.id === "string" && entry.id ? [[entry.id, entry] as const] : []))
|
|
313
|
+
const pair = selectedCandidatePair(entries, byId)
|
|
314
|
+
const rttMs = typeof pair?.currentRoundTripTime === "number" ? pair.currentRoundTripTime * 1000 : previous.rttMs
|
|
315
|
+
const localCandidate = typeof pair?.localCandidateId === "string" ? byId.get(pair.localCandidateId) : undefined
|
|
316
|
+
const remoteCandidate = typeof pair?.remoteCandidateId === "string" ? byId.get(pair.remoteCandidateId) : undefined
|
|
317
|
+
const localCandidateType = typeof localCandidate?.candidateType === "string" ? localCandidate.candidateType : ""
|
|
318
|
+
const remoteCandidateType = typeof remoteCandidate?.candidateType === "string" ? remoteCandidate.candidateType : ""
|
|
319
|
+
if (!pair || !Number.isFinite(candidateTypeRank(localCandidateType)) || !Number.isFinite(candidateTypeRank(remoteCandidateType))) {
|
|
320
|
+
return { ...previous, rttMs }
|
|
321
|
+
}
|
|
322
|
+
return {
|
|
323
|
+
...previous,
|
|
324
|
+
rttMs,
|
|
325
|
+
localCandidateType,
|
|
326
|
+
remoteCandidateType,
|
|
327
|
+
pathLabel: `${candidateTypeLabel(localCandidateType)} ↔ ${candidateTypeLabel(remoteCandidateType)}`,
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const sameConnectivity = (left: PeerConnectivitySnapshot, right: PeerConnectivitySnapshot) =>
|
|
332
|
+
left.rttMs === right.rttMs
|
|
333
|
+
&& left.localCandidateType === right.localCandidateType
|
|
334
|
+
&& left.remoteCandidateType === right.remoteCandidateType
|
|
335
|
+
&& left.pathLabel === right.pathLabel
|
|
336
|
+
|
|
337
|
+
const turnServers = (urls: string[], username?: string, credential?: string): RTCIceServer[] => [...new Set(urls.filter(Boolean))].map(urls => ({ urls, ...(username ? { username } : {}), ...(credential ? { credential } : {}) }))
|
|
338
|
+
|
|
339
|
+
const messageString = async (value: unknown) => {
|
|
340
|
+
if (typeof value === "string") return value
|
|
341
|
+
if (value instanceof Uint8Array) return new TextDecoder().decode(value)
|
|
342
|
+
if (value instanceof ArrayBuffer) return new TextDecoder().decode(value)
|
|
343
|
+
if (value instanceof Blob) return value.text()
|
|
344
|
+
return `${value ?? ""}`
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export class SendSession {
|
|
348
|
+
readonly localId: string
|
|
349
|
+
profile = sanitizeProfile(buildCliProfile())
|
|
350
|
+
readonly saveDir: string
|
|
351
|
+
readonly room: string
|
|
352
|
+
turnAvailable: boolean
|
|
353
|
+
name: string
|
|
354
|
+
socketState: SocketState = "idle"
|
|
355
|
+
pulse: PulseSnapshot = emptyPulseSnapshot()
|
|
356
|
+
|
|
357
|
+
private autoAcceptIncoming: boolean
|
|
358
|
+
private autoSaveIncoming: boolean
|
|
359
|
+
private readonly reconnectSocket: boolean
|
|
360
|
+
private readonly iceServers: RTCIceServer[]
|
|
361
|
+
private readonly peers = new Map<string, PeerState>()
|
|
362
|
+
private readonly transfers = new Map<string, TransferState>()
|
|
363
|
+
private readonly logs: LogEntry[] = []
|
|
364
|
+
private readonly subscribers = new Set<() => void>()
|
|
365
|
+
private readonly eventSubscribers = new Set<(event: SessionEvent) => void>()
|
|
366
|
+
|
|
367
|
+
private rtcEpochCounter = 0
|
|
368
|
+
private socket: WebSocket | null = null
|
|
369
|
+
private socketToken = 0
|
|
370
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
|
371
|
+
private peerStatsTimer: ReturnType<typeof setInterval> | null = null
|
|
372
|
+
private stopped = false
|
|
373
|
+
|
|
374
|
+
constructor(config: SessionConfig) {
|
|
375
|
+
this.localId = cleanLocalId(config.localId)
|
|
376
|
+
this.room = cleanRoom(config.room)
|
|
377
|
+
this.name = cleanName(config.name ?? fallbackName)
|
|
378
|
+
this.saveDir = resolve(config.saveDir ?? resolve(process.cwd(), "downloads"))
|
|
379
|
+
this.autoAcceptIncoming = !!config.autoAcceptIncoming
|
|
380
|
+
this.autoSaveIncoming = !!config.autoSaveIncoming
|
|
381
|
+
this.reconnectSocket = config.reconnectSocket ?? true
|
|
382
|
+
const extraTurn = turnServers(config.turnUrls ?? [], config.turnUsername, config.turnCredential)
|
|
383
|
+
this.iceServers = [...BASE_ICE_SERVERS, ...extraTurn]
|
|
384
|
+
this.turnAvailable = extraTurn.length > 0
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
subscribe(listener: () => void) {
|
|
388
|
+
this.subscribers.add(listener)
|
|
389
|
+
return () => this.subscribers.delete(listener)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
onEvent(listener: (event: SessionEvent) => void) {
|
|
393
|
+
this.eventSubscribers.add(listener)
|
|
394
|
+
return () => this.eventSubscribers.delete(listener)
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
snapshot(): SessionSnapshot {
|
|
398
|
+
return {
|
|
399
|
+
localId: this.localId,
|
|
400
|
+
name: this.name,
|
|
401
|
+
room: this.room,
|
|
402
|
+
socketState: this.socketState,
|
|
403
|
+
turn: turnStateLabel(this.turnAvailable),
|
|
404
|
+
turnState: this.selfTurnState(),
|
|
405
|
+
profile: this.advertisedProfile(),
|
|
406
|
+
pulse: { ...this.pulse },
|
|
407
|
+
saveDir: this.saveDir,
|
|
408
|
+
peers: [...this.peers.values()]
|
|
409
|
+
.map(peer => this.peerSnapshot(peer))
|
|
410
|
+
.sort((left, right) => left.presence === right.presence ? left.displayName.localeCompare(right.displayName) : left.presence === "active" ? -1 : 1),
|
|
411
|
+
transfers: [...this.transfers.values()]
|
|
412
|
+
.map(transfer => this.transferSnapshot(transfer))
|
|
413
|
+
.sort((left, right) => right.createdAt - left.createdAt),
|
|
414
|
+
logs: [...this.logs],
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async connect(timeoutMs = 10_000) {
|
|
419
|
+
this.stopped = false
|
|
420
|
+
this.startPeerStatsPolling()
|
|
421
|
+
void this.loadLocalProfile()
|
|
422
|
+
void this.probePulse()
|
|
423
|
+
this.connectSocket()
|
|
424
|
+
await this.waitFor(() => this.socketState === "open", timeoutMs)
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
async close() {
|
|
428
|
+
this.stopped = true
|
|
429
|
+
this.stopPeerStatsPolling()
|
|
430
|
+
if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
|
|
431
|
+
if (this.socket?.readyState === WebSocket.OPEN) this.sendSignal({ kind: "bye" })
|
|
432
|
+
const socket = this.socket
|
|
433
|
+
this.socket = null
|
|
434
|
+
this.socketToken += 1
|
|
435
|
+
if (socket) try { socket.close(1000, "normal") } catch {}
|
|
436
|
+
for (const peer of this.peers.values()) this.destroyPeer(peer, "session-close")
|
|
437
|
+
this.notify()
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
activePeers() {
|
|
441
|
+
return [...this.peers.values()].filter(peer => peer.presence === "active")
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
readyPeers() {
|
|
445
|
+
return this.activePeers().filter(peer => this.isPeerReady(peer))
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
selectedReadyPeers() {
|
|
449
|
+
return this.readyPeers().filter(peer => peer.selected)
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
setPeerSelected(peerId: string, selected: boolean) {
|
|
453
|
+
const peer = this.peers.get(peerId)
|
|
454
|
+
if (!peer || peer.presence !== "active") return false
|
|
455
|
+
peer.selected = selected
|
|
456
|
+
this.emit({ type: "peer", peer: this.peerSnapshot(peer) })
|
|
457
|
+
this.notify()
|
|
458
|
+
return true
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
togglePeerSelection(peerId: string) {
|
|
462
|
+
const peer = this.peers.get(peerId)
|
|
463
|
+
return peer ? this.setPeerSelected(peerId, !peer.selected) : false
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
clearLogs() {
|
|
467
|
+
this.logs.length = 0
|
|
468
|
+
this.notify()
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
setName(value: string) {
|
|
472
|
+
const next = cleanName(value)
|
|
473
|
+
if (next === this.name) return this.name
|
|
474
|
+
this.name = next
|
|
475
|
+
this.sendSignal({ kind: "name", name: this.name })
|
|
476
|
+
this.notify()
|
|
477
|
+
return this.name
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
async setAutoAcceptIncoming(enabled: boolean) {
|
|
481
|
+
const next = !!enabled
|
|
482
|
+
const changed = next !== this.autoAcceptIncoming
|
|
483
|
+
this.autoAcceptIncoming = next
|
|
484
|
+
if (changed) this.broadcastProfile()
|
|
485
|
+
if (!changed || !next) {
|
|
486
|
+
this.notify()
|
|
487
|
+
return 0
|
|
488
|
+
}
|
|
489
|
+
let accepted = 0
|
|
490
|
+
for (const transfer of this.transfers.values()) {
|
|
491
|
+
if (transfer.direction !== "in" || transfer.status !== "pending") continue
|
|
492
|
+
if (await this.acceptTransfer(transfer.id)) accepted += 1
|
|
493
|
+
}
|
|
494
|
+
this.notify()
|
|
495
|
+
return accepted
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
async setAutoSaveIncoming(enabled: boolean) {
|
|
499
|
+
const next = !!enabled
|
|
500
|
+
const changed = next !== this.autoSaveIncoming
|
|
501
|
+
this.autoSaveIncoming = next
|
|
502
|
+
if (changed) this.broadcastProfile()
|
|
503
|
+
if (!changed || !next) {
|
|
504
|
+
this.notify()
|
|
505
|
+
return 0
|
|
506
|
+
}
|
|
507
|
+
let saved = 0
|
|
508
|
+
for (const transfer of this.transfers.values()) {
|
|
509
|
+
if (transfer.direction !== "in" || transfer.status !== "complete" || transfer.savedAt > 0) continue
|
|
510
|
+
if (await this.saveTransfer(transfer.id)) saved += 1
|
|
511
|
+
}
|
|
512
|
+
this.notify()
|
|
513
|
+
return saved
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
cancelPendingOffers() {
|
|
517
|
+
let cancelled = 0
|
|
518
|
+
for (const transfer of this.transfers.values()) {
|
|
519
|
+
if (transfer.direction !== "out" || !["queued", "offered"].includes(transfer.status)) continue
|
|
520
|
+
if (this.cancelTransfer(transfer.id)) cancelled += 1
|
|
521
|
+
}
|
|
522
|
+
return cancelled
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
clearCompletedTransfers() {
|
|
526
|
+
let cleared = 0
|
|
527
|
+
for (const [transferId, transfer] of this.transfers.entries()) {
|
|
528
|
+
if (transfer.status !== "complete") continue
|
|
529
|
+
this.transfers.delete(transferId)
|
|
530
|
+
cleared += 1
|
|
531
|
+
}
|
|
532
|
+
if (cleared) {
|
|
533
|
+
for (const peer of this.peers.values()) peer.outgoingQueue = peer.outgoingQueue.filter(transferId => this.transfers.has(transferId))
|
|
534
|
+
this.notify()
|
|
535
|
+
}
|
|
536
|
+
return cleared
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
clearFailedTransfers() {
|
|
540
|
+
let cleared = 0
|
|
541
|
+
for (const [transferId, transfer] of this.transfers.entries()) {
|
|
542
|
+
if (!["rejected", "cancelled", "error"].includes(transfer.status)) continue
|
|
543
|
+
this.transfers.delete(transferId)
|
|
544
|
+
cleared += 1
|
|
545
|
+
}
|
|
546
|
+
if (cleared) {
|
|
547
|
+
for (const peer of this.peers.values()) peer.outgoingQueue = peer.outgoingQueue.filter(transferId => this.transfers.has(transferId))
|
|
548
|
+
this.notify()
|
|
549
|
+
}
|
|
550
|
+
return cleared
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
async queueFiles(paths: string[], peerIds: string[]) {
|
|
554
|
+
const files = await loadLocalFiles(paths)
|
|
555
|
+
const peers = peerIds.map(peerId => this.peers.get(peerId)).filter((peer): peer is PeerState => !!peer && this.isPeerReady(peer))
|
|
556
|
+
if (!files.length) throw new Error("no files to offer")
|
|
557
|
+
if (!peers.length) throw new Error("no ready peers selected")
|
|
558
|
+
const created: string[] = []
|
|
559
|
+
for (const peer of peers) {
|
|
560
|
+
for (const file of files) {
|
|
561
|
+
const transfer = this.buildOutgoingTransfer(peer, file)
|
|
562
|
+
this.transfers.set(transfer.id, transfer)
|
|
563
|
+
peer.outgoingQueue.push(transfer.id)
|
|
564
|
+
created.push(transfer.id)
|
|
565
|
+
this.emit({ type: "transfer", transfer: this.transferSnapshot(transfer) })
|
|
566
|
+
}
|
|
567
|
+
this.flushOffers(peer)
|
|
568
|
+
}
|
|
569
|
+
this.notify()
|
|
570
|
+
return created
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
async offerToSelectedPeers(paths: string[]) {
|
|
574
|
+
return this.queueFiles(paths, this.selectedReadyPeers().map(peer => peer.id))
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
async acceptTransfer(transferId: string) {
|
|
578
|
+
const transfer = this.transfers.get(transferId)
|
|
579
|
+
if (!transfer || transfer.direction !== "in" || isFinal(transfer)) return false
|
|
580
|
+
const peer = this.peers.get(transfer.peerId)
|
|
581
|
+
if (!peer || !this.isPeerReady(peer)) return false
|
|
582
|
+
if (!this.sendDataControl(peer, { kind: "file-accept", transferId })) return false
|
|
583
|
+
transfer.status = "accepted"
|
|
584
|
+
this.noteTransfer(transfer)
|
|
585
|
+
this.emit({ type: "transfer", transfer: this.transferSnapshot(transfer) })
|
|
586
|
+
this.notify()
|
|
587
|
+
return true
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
rejectTransfer(transferId: string, reason = "rejected") {
|
|
591
|
+
const transfer = this.transfers.get(transferId)
|
|
592
|
+
if (!transfer || transfer.direction !== "in" || isFinal(transfer)) return false
|
|
593
|
+
const peer = this.peers.get(transfer.peerId)
|
|
594
|
+
if (peer) this.sendDataControl(peer, { kind: "file-reject", transferId, reason })
|
|
595
|
+
this.completeTransfer(transfer, "rejected", reason)
|
|
596
|
+
return true
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
cancelTransfer(transferId: string) {
|
|
600
|
+
const transfer = this.transfers.get(transferId)
|
|
601
|
+
if (!transfer || isFinal(transfer)) return false
|
|
602
|
+
const peer = this.peers.get(transfer.peerId)
|
|
603
|
+
transfer.cancel = true
|
|
604
|
+
transfer.cancelSource = "local"
|
|
605
|
+
transfer.cancelReason = transfer.direction === "out" ? "sender cancelled" : "receiver cancelled"
|
|
606
|
+
|
|
607
|
+
if (transfer.direction === "out") {
|
|
608
|
+
if (transfer.status === "queued" || transfer.status === "offered" || transfer.status === "accepted") {
|
|
609
|
+
if (transfer.status !== "queued" && peer) this.sendDataControl(peer, { kind: "file-cancel", transferId, reason: transfer.cancelReason })
|
|
610
|
+
this.completeTransfer(transfer, "cancelled", transfer.cancelReason)
|
|
611
|
+
if (peer) this.pumpSender(peer)
|
|
612
|
+
return true
|
|
613
|
+
}
|
|
614
|
+
if (transfer.inFlight) {
|
|
615
|
+
transfer.status = "cancelling"
|
|
616
|
+
this.emit({ type: "transfer", transfer: this.transferSnapshot(transfer) })
|
|
617
|
+
this.notify()
|
|
618
|
+
return true
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
if (transfer.direction === "in") {
|
|
623
|
+
if (transfer.status === "pending") return this.rejectTransfer(transfer.id, transfer.cancelReason)
|
|
624
|
+
if (transfer.status === "accepted") {
|
|
625
|
+
if (peer) this.sendDataControl(peer, { kind: "file-cancel", transferId, reason: transfer.cancelReason })
|
|
626
|
+
this.completeTransfer(transfer, "cancelled", transfer.cancelReason)
|
|
627
|
+
return true
|
|
628
|
+
}
|
|
629
|
+
if (transfer.status === "receiving") {
|
|
630
|
+
if (peer) this.sendDataControl(peer, { kind: "file-cancel", transferId, reason: transfer.cancelReason })
|
|
631
|
+
transfer.status = "cancelling"
|
|
632
|
+
this.emit({ type: "transfer", transfer: this.transferSnapshot(transfer) })
|
|
633
|
+
this.notify()
|
|
634
|
+
return true
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
if (peer) this.sendDataControl(peer, { kind: "file-cancel", transferId, reason: transfer.cancelReason })
|
|
639
|
+
this.completeTransfer(transfer, "cancelled", transfer.cancelReason)
|
|
640
|
+
return true
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
async saveTransfer(transferId: string) {
|
|
644
|
+
const transfer = this.transfers.get(transferId)
|
|
645
|
+
if (!transfer || transfer.direction !== "in" || transfer.status !== "complete") return null
|
|
646
|
+
if (!transfer.data && transfer.buffers?.length) transfer.data = Buffer.concat(transfer.buffers)
|
|
647
|
+
if (!transfer.data) return null
|
|
648
|
+
transfer.savedPath ||= await saveIncomingFile(this.saveDir, transfer.name, transfer.data)
|
|
649
|
+
transfer.savedAt ||= Date.now()
|
|
650
|
+
const snapshot = this.transferSnapshot(transfer)
|
|
651
|
+
this.pushLog("transfer:saved", { transferId: transfer.id, path: transfer.savedPath })
|
|
652
|
+
this.emit({ type: "saved", transfer: snapshot })
|
|
653
|
+
this.notify()
|
|
654
|
+
return transfer.savedPath
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
getTransfer(transferId: string) {
|
|
658
|
+
return this.transfers.get(transferId)
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
async waitFor(predicate: () => boolean, timeoutMs: number) {
|
|
662
|
+
if (predicate()) return
|
|
663
|
+
await new Promise<void>((resolveWait, rejectWait) => {
|
|
664
|
+
const timeout = setTimeout(() => {
|
|
665
|
+
unsubscribe()
|
|
666
|
+
rejectWait(new Error(`timed out after ${timeoutMs}ms`))
|
|
667
|
+
}, timeoutMs)
|
|
668
|
+
const unsubscribe = this.subscribe(() => {
|
|
669
|
+
if (!predicate()) return
|
|
670
|
+
clearTimeout(timeout)
|
|
671
|
+
unsubscribe()
|
|
672
|
+
resolveWait()
|
|
673
|
+
})
|
|
674
|
+
})
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
async waitForTransfers(transferIds: string[], timeoutMs: number) {
|
|
678
|
+
await this.waitFor(() => transferIds.every(transferId => {
|
|
679
|
+
const transfer = this.transfers.get(transferId)
|
|
680
|
+
return !!transfer && isFinal(transfer)
|
|
681
|
+
}), timeoutMs)
|
|
682
|
+
return transferIds.map(transferId => this.transfers.get(transferId)).filter((transfer): transfer is TransferState => !!transfer)
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
private nextRtcEpoch() {
|
|
686
|
+
this.rtcEpochCounter += 1
|
|
687
|
+
return this.rtcEpochCounter
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
private notify() {
|
|
691
|
+
for (const listener of this.subscribers) listener()
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
private emit(event: SessionEvent) {
|
|
695
|
+
for (const listener of this.eventSubscribers) listener(event)
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
private startPeerStatsPolling() {
|
|
699
|
+
if (this.peerStatsTimer) return
|
|
700
|
+
this.peerStatsTimer = setInterval(() => void this.refreshPeerStats(), STATS_POLL_MS)
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
private stopPeerStatsPolling() {
|
|
704
|
+
if (!this.peerStatsTimer) return
|
|
705
|
+
clearInterval(this.peerStatsTimer)
|
|
706
|
+
this.peerStatsTimer = null
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
private async refreshPeerStats() {
|
|
710
|
+
if (this.stopped) return
|
|
711
|
+
let dirty = false
|
|
712
|
+
for (const peer of this.peers.values()) {
|
|
713
|
+
if (peer.presence !== "active" || !peer.pc) continue
|
|
714
|
+
try {
|
|
715
|
+
const next = connectivitySnapshotFromReport(await peer.pc.getStats(), peer.connectivity)
|
|
716
|
+
if (sameConnectivity(peer.connectivity, next)) continue
|
|
717
|
+
peer.connectivity = next
|
|
718
|
+
dirty = true
|
|
719
|
+
this.emit({ type: "peer", peer: this.peerSnapshot(peer) })
|
|
720
|
+
} catch {}
|
|
721
|
+
}
|
|
722
|
+
if (dirty) this.notify()
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
private pushLog(kind: string, payload: unknown, level: "info" | "error" = "info") {
|
|
726
|
+
const log = { id: uid(6), at: Date.now(), kind, level, payload }
|
|
727
|
+
this.logs.unshift(log)
|
|
728
|
+
this.logs.length = Math.min(this.logs.length, LOG_LIMIT)
|
|
729
|
+
this.emit({ type: "log", log })
|
|
730
|
+
this.notify()
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
private peerSnapshot(peer: PeerState): PeerSnapshot {
|
|
734
|
+
return {
|
|
735
|
+
id: peer.id,
|
|
736
|
+
name: peer.name || fallbackName,
|
|
737
|
+
displayName: displayPeerName(peer.name || fallbackName, peer.id),
|
|
738
|
+
presence: peer.presence,
|
|
739
|
+
selected: peer.selected,
|
|
740
|
+
selectable: this.peerSelectable(peer),
|
|
741
|
+
ready: this.isPeerReady(peer),
|
|
742
|
+
status: this.peerStatus(peer),
|
|
743
|
+
turn: turnStateLabel(peer.turnAvailable),
|
|
744
|
+
turnState: this.peerTurnState(peer),
|
|
745
|
+
dataState: this.peerDataState(peer),
|
|
746
|
+
lastError: peer.lastError,
|
|
747
|
+
profile: peer.profile,
|
|
748
|
+
rttMs: peer.connectivity.rttMs,
|
|
749
|
+
localCandidateType: peer.connectivity.localCandidateType,
|
|
750
|
+
remoteCandidateType: peer.connectivity.remoteCandidateType,
|
|
751
|
+
pathLabel: peer.connectivity.pathLabel,
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
private selfTurnState(): TurnState {
|
|
756
|
+
return turnUsageState(this.turnAvailable, this.peers.values())
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
private transferSnapshot(transfer: TransferState): TransferSnapshot {
|
|
760
|
+
return {
|
|
761
|
+
id: transfer.id,
|
|
762
|
+
peerId: transfer.peerId,
|
|
763
|
+
peerName: transfer.peerName,
|
|
764
|
+
direction: transfer.direction,
|
|
765
|
+
status: transfer.status,
|
|
766
|
+
name: transfer.name,
|
|
767
|
+
size: transfer.size,
|
|
768
|
+
bytes: transfer.bytes,
|
|
769
|
+
progress: progressOf(transfer),
|
|
770
|
+
speedText: formatRate(transfer.speed),
|
|
771
|
+
etaText: formatEta(transfer.eta),
|
|
772
|
+
error: transfer.error,
|
|
773
|
+
createdAt: transfer.createdAt,
|
|
774
|
+
updatedAt: transfer.updatedAt,
|
|
775
|
+
startedAt: transfer.startedAt,
|
|
776
|
+
endedAt: transfer.endedAt,
|
|
777
|
+
savedAt: transfer.savedAt,
|
|
778
|
+
savedPath: transfer.savedPath,
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
private peerStatus(peer: PeerState) {
|
|
783
|
+
if (peer.presence === "terminal") return peer.terminalReason || "closed"
|
|
784
|
+
if (peer.pc) return peer.pc.connectionState
|
|
785
|
+
return "idle"
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
private peerDataState(peer: PeerState) {
|
|
789
|
+
if (peer.dc?.readyState) return peer.dc.readyState
|
|
790
|
+
if (peer.presence === "terminal" || peer.pc?.connectionState === "closed") return "closed"
|
|
791
|
+
return "—"
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
private peerTurnState(peer: PeerState): TurnState {
|
|
795
|
+
if (!peer.turnAvailable) return "none"
|
|
796
|
+
return peer.connectivity.remoteCandidateType === "relay" ? "used" : "idle"
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
private peerSelectable(peer: PeerState) {
|
|
800
|
+
if (peer.presence === "terminal") return false
|
|
801
|
+
return !["closed", "failed", "disconnected"].includes(this.peerStatus(peer))
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
private isPeerReady(peer: PeerState) {
|
|
805
|
+
return peer.presence === "active" && peer.pc?.connectionState === "connected" && peer.dc?.readyState === "open"
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
private noteTransfer(transfer: TransferState) {
|
|
809
|
+
transfer.updatedAt = Date.now()
|
|
810
|
+
const elapsed = Math.max((transfer.updatedAt - (transfer.startedAt || transfer.createdAt)) / 1000, 0.001)
|
|
811
|
+
transfer.speed = transfer.bytes / elapsed
|
|
812
|
+
transfer.eta = transfer.speed ? Math.max(0, (transfer.size - transfer.bytes) / transfer.speed) : Infinity
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
private completeTransfer(transfer: TransferState, status: TransferStatus, error = "") {
|
|
816
|
+
transfer.status = status
|
|
817
|
+
transfer.error = error
|
|
818
|
+
transfer.inFlight = false
|
|
819
|
+
transfer.endedAt = Date.now()
|
|
820
|
+
this.noteTransfer(transfer)
|
|
821
|
+
if (status !== "complete" && transfer.direction === "in") {
|
|
822
|
+
transfer.buffers = []
|
|
823
|
+
transfer.data = undefined
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
const peer = this.peers.get(transfer.peerId)
|
|
827
|
+
if (peer) {
|
|
828
|
+
if (peer.activeOutgoing === transfer.id) peer.activeOutgoing = ""
|
|
829
|
+
if (peer.activeIncoming === transfer.id) peer.activeIncoming = ""
|
|
830
|
+
peer.outgoingQueue = peer.outgoingQueue.filter(queuedId => queuedId !== transfer.id || SENDABLE_STATUSES.has(this.transfers.get(queuedId)?.status as never))
|
|
831
|
+
if (transfer.direction === "out") queueMicrotask(() => this.pumpSender(peer))
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
const snapshot = this.transferSnapshot(transfer)
|
|
835
|
+
this.emit({ type: "transfer", transfer: snapshot })
|
|
836
|
+
if (status === "complete" && transfer.direction === "in" && this.autoSaveIncoming) void this.saveTransfer(transfer.id)
|
|
837
|
+
this.notify()
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
private connectSocket() {
|
|
841
|
+
if (this.stopped) return
|
|
842
|
+
if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
|
|
843
|
+
const token = ++this.socketToken
|
|
844
|
+
const socket = new WebSocket(`${SIGNAL_WS_URL}?i=${encodeURIComponent(this.room)}`)
|
|
845
|
+
this.socket = socket
|
|
846
|
+
this.socketState = "connecting"
|
|
847
|
+
this.emit({ type: "socket", socketState: this.socketState })
|
|
848
|
+
this.notify()
|
|
849
|
+
|
|
850
|
+
socket.onopen = () => {
|
|
851
|
+
if (token !== this.socketToken || this.stopped) return
|
|
852
|
+
this.socketState = "open"
|
|
853
|
+
this.emit({ type: "socket", socketState: this.socketState })
|
|
854
|
+
this.pushLog("signal:socket-open", { room: this.room, localId: this.localId })
|
|
855
|
+
this.sendSignal({ kind: "hello", ...this.presencePayload({}) })
|
|
856
|
+
this.broadcastProfile()
|
|
857
|
+
this.notify()
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
socket.onmessage = async event => {
|
|
861
|
+
if (token !== this.socketToken || this.stopped) return
|
|
862
|
+
await this.onSignalMessage(await messageString(event.data))
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
socket.onerror = () => {
|
|
866
|
+
if (token !== this.socketToken || this.stopped) return
|
|
867
|
+
this.socketState = "error"
|
|
868
|
+
this.emit({ type: "socket", socketState: this.socketState })
|
|
869
|
+
this.notify()
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
socket.onclose = () => {
|
|
873
|
+
if (token !== this.socketToken || this.stopped) return
|
|
874
|
+
this.socketState = "closed"
|
|
875
|
+
this.emit({ type: "socket", socketState: this.socketState })
|
|
876
|
+
this.notify()
|
|
877
|
+
if (this.reconnectSocket) this.reconnectTimer = setTimeout(() => this.connectSocket(), 2000)
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
private async loadLocalProfile() {
|
|
882
|
+
try {
|
|
883
|
+
const response = await fetch(PROFILE_URL, { cache: "no-store", signal: timeoutSignal(4000) })
|
|
884
|
+
if (!response.ok) throw new Error(`profile ${response.status}`)
|
|
885
|
+
this.profile = localProfileFromResponse(await response.json())
|
|
886
|
+
} catch (error) {
|
|
887
|
+
this.profile = localProfileFromResponse(null, `${error}`)
|
|
888
|
+
this.pushLog("profile:error", { error: `${error}` }, "error")
|
|
889
|
+
}
|
|
890
|
+
this.broadcastProfile()
|
|
891
|
+
this.notify()
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
private async probePulse() {
|
|
895
|
+
const startedAt = performance.now()
|
|
896
|
+
this.pulse = { ...this.pulse, state: "checking", error: "" }
|
|
897
|
+
this.notify()
|
|
898
|
+
try {
|
|
899
|
+
const response = await fetch(PULSE_URL, { cache: "no-store", signal: timeoutSignal(3500) })
|
|
900
|
+
if (!response.ok) throw new Error(`pulse ${response.status}`)
|
|
901
|
+
this.pulse = { state: "open", at: Date.now(), ms: performance.now() - startedAt, error: "" }
|
|
902
|
+
} catch (error) {
|
|
903
|
+
this.pulse = { state: "error", at: Date.now(), ms: 0, error: `${error}` }
|
|
904
|
+
this.pushLog("pulse:error", { error: `${error}` }, "error")
|
|
905
|
+
}
|
|
906
|
+
this.notify()
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
private presencePayload(extra: Record<string, unknown>) {
|
|
910
|
+
return { name: this.name, turnAvailable: this.turnAvailable, profile: this.advertisedProfile(), ...extra }
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
private advertisedProfile(profile = this.profile) {
|
|
914
|
+
return sanitizeProfile({
|
|
915
|
+
...(profile ?? {}),
|
|
916
|
+
defaults: {
|
|
917
|
+
autoAcceptIncoming: this.autoAcceptIncoming,
|
|
918
|
+
autoSaveIncoming: this.autoSaveIncoming,
|
|
919
|
+
},
|
|
920
|
+
})
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
private broadcastProfile() {
|
|
924
|
+
this.sendSignal({ kind: "profile", profile: this.advertisedProfile(), turnAvailable: this.turnAvailable })
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
private sendSignal(payload: { kind: string; to?: string; [key: string]: unknown }) {
|
|
928
|
+
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) return false
|
|
929
|
+
const message = { room: this.room, from: this.localId, to: "*", at: stamp(), ...payload }
|
|
930
|
+
this.socket.send(JSON.stringify(message))
|
|
931
|
+
this.pushLog("signal:out", message)
|
|
932
|
+
return true
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
private sendPeerHello(peer: PeerState, extra: Record<string, unknown> = {}) {
|
|
936
|
+
return this.sendSignal({ kind: "hello", to: peer.id, ...this.presencePayload({ rtcEpoch: peer.rtcEpoch, ...extra }) })
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
private sendDataControl(peer: PeerState, payload: { kind: string; [key: string]: unknown }, channel = peer.dc, rtcEpoch = peer.rtcEpoch) {
|
|
940
|
+
if (!this.isCurrentPeerChannel(peer, channel, rtcEpoch)) return false
|
|
941
|
+
const activeChannel = channel!
|
|
942
|
+
const message = { room: this.room, from: this.localId, to: peer.id, at: stamp(), ...payload }
|
|
943
|
+
activeChannel.send(JSON.stringify(message))
|
|
944
|
+
this.pushLog("data:out", message)
|
|
945
|
+
return true
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
private isCurrentPeerChannel(peer: PeerState | undefined, channel: RTCDataChannel | null | undefined, rtcEpoch = peer?.rtcEpoch) {
|
|
949
|
+
return !!peer && !!channel && peer.rtcEpoch === rtcEpoch && peer.dc === channel && channel.readyState === "open"
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
private buildPeer(remoteId: string) {
|
|
953
|
+
const peer: PeerState = {
|
|
954
|
+
id: remoteId,
|
|
955
|
+
name: fallbackName,
|
|
956
|
+
presence: "active",
|
|
957
|
+
selected: true,
|
|
958
|
+
polite: this.localId > remoteId,
|
|
959
|
+
pc: null,
|
|
960
|
+
dc: null,
|
|
961
|
+
rtcEpoch: 0,
|
|
962
|
+
remoteEpoch: 0,
|
|
963
|
+
makingOffer: false,
|
|
964
|
+
createdAt: Date.now(),
|
|
965
|
+
lastSeenAt: Date.now(),
|
|
966
|
+
outgoingQueue: [],
|
|
967
|
+
activeOutgoing: "",
|
|
968
|
+
activeIncoming: "",
|
|
969
|
+
turnAvailable: false,
|
|
970
|
+
terminalReason: "",
|
|
971
|
+
lastError: "",
|
|
972
|
+
connectivity: emptyConnectivitySnapshot(),
|
|
973
|
+
}
|
|
974
|
+
this.peers.set(remoteId, peer)
|
|
975
|
+
this.ensurePeerConnection(peer, "create")
|
|
976
|
+
this.emit({ type: "peer", peer: this.peerSnapshot(peer) })
|
|
977
|
+
this.notify()
|
|
978
|
+
return peer
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
private syncPeerPresence(remoteId: string, name?: string, profile?: PeerProfile, turnAvailable?: boolean) {
|
|
982
|
+
const peer = this.peers.get(remoteId) ?? this.buildPeer(remoteId)
|
|
983
|
+
peer.lastSeenAt = Date.now()
|
|
984
|
+
peer.presence = "active"
|
|
985
|
+
peer.terminalReason = ""
|
|
986
|
+
if (name != null) {
|
|
987
|
+
peer.name = cleanName(name)
|
|
988
|
+
for (const transfer of this.transfers.values()) if (transfer.peerId === remoteId) transfer.peerName = peer.name
|
|
989
|
+
}
|
|
990
|
+
if (typeof turnAvailable === "boolean") peer.turnAvailable = turnAvailable
|
|
991
|
+
if (profile) peer.profile = sanitizeProfile(profile)
|
|
992
|
+
this.emit({ type: "peer", peer: this.peerSnapshot(peer) })
|
|
993
|
+
return peer
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
private syncPeerSignal(peer: PeerState, kind: string, remoteEpoch = 0) {
|
|
997
|
+
const epoch = signalEpoch(remoteEpoch)
|
|
998
|
+
if (epoch && epoch < peer.remoteEpoch) return null
|
|
999
|
+
if (epoch) peer.remoteEpoch = epoch
|
|
1000
|
+
if (kind !== "bye" && (!peer.pc || peer.pc.connectionState === "closed" || peer.dc?.readyState === "closed")) this.ensurePeerConnection(peer, `signal:${kind}`)
|
|
1001
|
+
return peer
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
private ensurePeerConnection(peer: PeerState, reason: string) {
|
|
1005
|
+
const epoch = this.nextRtcEpoch()
|
|
1006
|
+
peer.rtcEpoch = epoch
|
|
1007
|
+
peer.lastError = ""
|
|
1008
|
+
peer.makingOffer = false
|
|
1009
|
+
this.closePeerRTC(peer)
|
|
1010
|
+
const pc = new RTCPeerConnection({ iceServers: this.iceServers })
|
|
1011
|
+
peer.pc = pc
|
|
1012
|
+
pc.onicecandidate = ({ candidate }) => {
|
|
1013
|
+
if (peer.rtcEpoch !== epoch || !candidate) return
|
|
1014
|
+
this.sendSignal({ kind: "candidate", to: peer.id, rtcEpoch: epoch, candidate: candidate.toJSON() })
|
|
1015
|
+
}
|
|
1016
|
+
pc.ondatachannel = ({ channel }) => {
|
|
1017
|
+
if (peer.rtcEpoch !== epoch) return
|
|
1018
|
+
this.attachChannel(peer, channel, epoch)
|
|
1019
|
+
}
|
|
1020
|
+
pc.onnegotiationneeded = async () => {
|
|
1021
|
+
if (peer.rtcEpoch !== epoch || peer.pc !== pc) return
|
|
1022
|
+
try {
|
|
1023
|
+
peer.makingOffer = true
|
|
1024
|
+
await pc.setLocalDescription()
|
|
1025
|
+
if (peer.rtcEpoch !== epoch || peer.pc !== pc || !pc.localDescription) return
|
|
1026
|
+
this.sendSignal({ kind: "description", to: peer.id, rtcEpoch: epoch, description: pc.localDescription.toSdp() })
|
|
1027
|
+
} catch (error) {
|
|
1028
|
+
peer.lastError = `${error}`
|
|
1029
|
+
this.pushLog("rtc:negotiation-error", { peer: peer.id, reason, error: `${error}` }, "error")
|
|
1030
|
+
this.failPeerTransfers(peer, "failed")
|
|
1031
|
+
} finally {
|
|
1032
|
+
if (peer.rtcEpoch === epoch) peer.makingOffer = false
|
|
1033
|
+
this.emit({ type: "peer", peer: this.peerSnapshot(peer) })
|
|
1034
|
+
this.notify()
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
pc.onconnectionstatechange = () => {
|
|
1038
|
+
if (peer.rtcEpoch !== epoch) return
|
|
1039
|
+
peer.lastSeenAt = Date.now()
|
|
1040
|
+
if (pc.connectionState === "failed" || pc.connectionState === "closed") {
|
|
1041
|
+
peer.lastError ||= pc.connectionState
|
|
1042
|
+
this.failPeerTransfers(peer, pc.connectionState)
|
|
1043
|
+
}
|
|
1044
|
+
if (pc.connectionState === "connected") void this.refreshPeerStats()
|
|
1045
|
+
this.emit({ type: "peer", peer: this.peerSnapshot(peer) })
|
|
1046
|
+
this.notify()
|
|
1047
|
+
}
|
|
1048
|
+
pc.oniceconnectionstatechange = () => {
|
|
1049
|
+
if (peer.rtcEpoch !== epoch) return
|
|
1050
|
+
peer.lastSeenAt = Date.now()
|
|
1051
|
+
if (pc.iceConnectionState === "connected" || pc.iceConnectionState === "completed") void this.refreshPeerStats()
|
|
1052
|
+
this.emit({ type: "peer", peer: this.peerSnapshot(peer) })
|
|
1053
|
+
this.notify()
|
|
1054
|
+
}
|
|
1055
|
+
if (this.localId < peer.id) this.attachChannel(peer, pc.createDataChannel("data", { ordered: true }), epoch)
|
|
1056
|
+
this.pushLog("rtc:peer-open", { peer: peer.id, reason, rtcEpoch: epoch })
|
|
1057
|
+
return peer
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
private attachChannel(peer: PeerState, channel: RTCDataChannel, epoch: number) {
|
|
1061
|
+
if (peer.rtcEpoch !== epoch) {
|
|
1062
|
+
try { channel.close() } catch {}
|
|
1063
|
+
return
|
|
1064
|
+
}
|
|
1065
|
+
channel.bufferedAmountLowThreshold = CHUNK
|
|
1066
|
+
peer.dc = channel
|
|
1067
|
+
channel.onopen = () => {
|
|
1068
|
+
if (peer.rtcEpoch !== epoch) return
|
|
1069
|
+
peer.lastSeenAt = Date.now()
|
|
1070
|
+
this.pushLog("dc:open", { peer: peer.id, rtcEpoch: epoch })
|
|
1071
|
+
this.flushOffers(peer)
|
|
1072
|
+
this.pumpSender(peer)
|
|
1073
|
+
void this.refreshPeerStats()
|
|
1074
|
+
this.emit({ type: "peer", peer: this.peerSnapshot(peer) })
|
|
1075
|
+
this.notify()
|
|
1076
|
+
}
|
|
1077
|
+
channel.onclose = () => {
|
|
1078
|
+
if (peer.rtcEpoch !== epoch) return
|
|
1079
|
+
peer.lastSeenAt = Date.now()
|
|
1080
|
+
if (peer.presence === "active") peer.lastError ||= "channel closed"
|
|
1081
|
+
this.emit({ type: "peer", peer: this.peerSnapshot(peer) })
|
|
1082
|
+
this.notify()
|
|
1083
|
+
}
|
|
1084
|
+
channel.onerror = event => {
|
|
1085
|
+
if (peer.rtcEpoch !== epoch) return
|
|
1086
|
+
peer.lastError = `${event.error ?? "channel error"}`
|
|
1087
|
+
this.emit({ type: "peer", peer: this.peerSnapshot(peer) })
|
|
1088
|
+
this.notify()
|
|
1089
|
+
}
|
|
1090
|
+
channel.onmessage = ({ data }) => void this.onDataMessage(peer, data)
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
private closePeerRTC(peer: PeerState) {
|
|
1094
|
+
const dc = peer.dc
|
|
1095
|
+
const pc = peer.pc
|
|
1096
|
+
peer.dc = null
|
|
1097
|
+
peer.pc = null
|
|
1098
|
+
try { dc?.close() } catch {}
|
|
1099
|
+
void pc?.close().catch(() => {})
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
private failPeerTransfers(peer: PeerState, reason: string) {
|
|
1103
|
+
for (const transfer of this.transfers.values()) if (transfer.peerId === peer.id && !isFinal(transfer)) this.completeTransfer(transfer, "error", reason)
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
private destroyPeer(peer: PeerState, reason: string) {
|
|
1107
|
+
peer.presence = "terminal"
|
|
1108
|
+
peer.selected = false
|
|
1109
|
+
peer.terminalReason = reason
|
|
1110
|
+
this.failPeerTransfers(peer, reason)
|
|
1111
|
+
this.closePeerRTC(peer)
|
|
1112
|
+
this.emit({ type: "peer", peer: this.peerSnapshot(peer) })
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
private async onSignalMessage(raw: string) {
|
|
1116
|
+
const message = JSON.parse(raw) as SignalMessage
|
|
1117
|
+
if (message.room !== this.room || message.from === this.localId || (message.to && message.to !== "*" && message.to !== this.localId)) return
|
|
1118
|
+
this.pushLog("signal:in", message)
|
|
1119
|
+
|
|
1120
|
+
if (message.kind === "hello") {
|
|
1121
|
+
const peer = this.syncPeerSignal(this.syncPeerPresence(message.from, message.name, message.profile, message.turnAvailable), "hello", message.rtcEpoch)
|
|
1122
|
+
if (peer && !message.reply) this.sendPeerHello(peer, { reply: true })
|
|
1123
|
+
this.notify()
|
|
1124
|
+
return
|
|
1125
|
+
}
|
|
1126
|
+
if (message.kind === "name") {
|
|
1127
|
+
this.syncPeerPresence(message.from, message.name)
|
|
1128
|
+
this.notify()
|
|
1129
|
+
return
|
|
1130
|
+
}
|
|
1131
|
+
if (message.kind === "profile") {
|
|
1132
|
+
this.syncPeerSignal(this.syncPeerPresence(message.from, message.name, message.profile, message.turnAvailable), "profile", message.rtcEpoch)
|
|
1133
|
+
this.notify()
|
|
1134
|
+
return
|
|
1135
|
+
}
|
|
1136
|
+
if (message.kind === "bye") {
|
|
1137
|
+
const peer = this.peers.get(message.from)
|
|
1138
|
+
if (peer) this.destroyPeer(peer, "peer-left")
|
|
1139
|
+
this.notify()
|
|
1140
|
+
return
|
|
1141
|
+
}
|
|
1142
|
+
if (message.kind === "description") {
|
|
1143
|
+
await this.onDescriptionSignal(message)
|
|
1144
|
+
return
|
|
1145
|
+
}
|
|
1146
|
+
if (message.kind === "candidate") {
|
|
1147
|
+
await this.onCandidateSignal(message)
|
|
1148
|
+
return
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
private async onDescriptionSignal(message: DescriptionSignal) {
|
|
1153
|
+
const peer = this.syncPeerSignal(this.syncPeerPresence(message.from, message.name, message.profile, message.turnAvailable), "description", message.rtcEpoch)
|
|
1154
|
+
if (!peer?.pc) return
|
|
1155
|
+
const offerCollision = message.description.type === "offer" && !peer.makingOffer && peer.pc.signalingState !== "stable"
|
|
1156
|
+
if (!peer.polite && offerCollision) return
|
|
1157
|
+
try {
|
|
1158
|
+
await peer.pc.setRemoteDescription(message.description)
|
|
1159
|
+
if (message.description.type === "offer") {
|
|
1160
|
+
await peer.pc.setLocalDescription()
|
|
1161
|
+
if (peer.pc.localDescription) this.sendSignal({ kind: "description", to: peer.id, rtcEpoch: peer.rtcEpoch, description: peer.pc.localDescription.toSdp() })
|
|
1162
|
+
}
|
|
1163
|
+
} catch (error) {
|
|
1164
|
+
peer.lastError = `${error}`
|
|
1165
|
+
this.pushLog("rtc:description-error", { peer: peer.id, error: `${error}` }, "error")
|
|
1166
|
+
this.failPeerTransfers(peer, "failed")
|
|
1167
|
+
}
|
|
1168
|
+
this.emit({ type: "peer", peer: this.peerSnapshot(peer) })
|
|
1169
|
+
this.notify()
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
private async onCandidateSignal(message: CandidateSignal) {
|
|
1173
|
+
const peer = this.syncPeerSignal(this.syncPeerPresence(message.from, message.name, message.profile, message.turnAvailable), "candidate", message.rtcEpoch)
|
|
1174
|
+
if (!peer?.pc) return
|
|
1175
|
+
try {
|
|
1176
|
+
await peer.pc.addIceCandidate(message.candidate as RTCIceCandidateInit)
|
|
1177
|
+
} catch (error) {
|
|
1178
|
+
this.pushLog("rtc:candidate-error", { peer: peer.id, error: `${error}` }, "error")
|
|
1179
|
+
}
|
|
1180
|
+
this.emit({ type: "peer", peer: this.peerSnapshot(peer) })
|
|
1181
|
+
this.notify()
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
private buildOutgoingTransfer(peer: PeerState, file: LocalFile): TransferState {
|
|
1185
|
+
return {
|
|
1186
|
+
id: uid(12),
|
|
1187
|
+
peerId: peer.id,
|
|
1188
|
+
peerName: peer.name,
|
|
1189
|
+
direction: "out",
|
|
1190
|
+
status: "queued",
|
|
1191
|
+
name: file.name,
|
|
1192
|
+
size: file.size,
|
|
1193
|
+
type: file.type,
|
|
1194
|
+
lastModified: file.lastModified,
|
|
1195
|
+
totalChunks: Math.ceil(file.size / CHUNK),
|
|
1196
|
+
chunkSize: CHUNK,
|
|
1197
|
+
bytes: 0,
|
|
1198
|
+
chunks: 0,
|
|
1199
|
+
speed: 0,
|
|
1200
|
+
eta: Infinity,
|
|
1201
|
+
error: "",
|
|
1202
|
+
createdAt: Date.now(),
|
|
1203
|
+
updatedAt: 0,
|
|
1204
|
+
startedAt: 0,
|
|
1205
|
+
endedAt: 0,
|
|
1206
|
+
savedAt: 0,
|
|
1207
|
+
file,
|
|
1208
|
+
inFlight: false,
|
|
1209
|
+
cancel: false,
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
private flushOffers(peer: PeerState) {
|
|
1214
|
+
if (!this.isCurrentPeerChannel(peer, peer.dc)) return
|
|
1215
|
+
const queued = peer.outgoingQueue.map(transferId => this.transfers.get(transferId)).filter((transfer): transfer is TransferState => !!transfer && transfer.status === "queued")
|
|
1216
|
+
for (const transfer of queued) {
|
|
1217
|
+
transfer.status = "offered"
|
|
1218
|
+
if (!this.sendDataControl(peer, {
|
|
1219
|
+
kind: "file-offer",
|
|
1220
|
+
transferId: transfer.id,
|
|
1221
|
+
name: transfer.name,
|
|
1222
|
+
size: transfer.size,
|
|
1223
|
+
type: transfer.type,
|
|
1224
|
+
lastModified: transfer.lastModified,
|
|
1225
|
+
chunkSize: transfer.chunkSize,
|
|
1226
|
+
totalChunks: transfer.totalChunks,
|
|
1227
|
+
})) {
|
|
1228
|
+
transfer.status = "queued"
|
|
1229
|
+
continue
|
|
1230
|
+
}
|
|
1231
|
+
this.emit({ type: "transfer", transfer: this.transferSnapshot(transfer) })
|
|
1232
|
+
}
|
|
1233
|
+
this.notify()
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
private pumpSender(peer: PeerState) {
|
|
1237
|
+
if (peer.activeOutgoing || !this.isCurrentPeerChannel(peer, peer.dc)) return
|
|
1238
|
+
const transfer = peer.outgoingQueue.map(transferId => this.transfers.get(transferId)).find((next): next is TransferState => !!next && next.status === "accepted")
|
|
1239
|
+
if (!transfer || !peer.dc) return
|
|
1240
|
+
peer.activeOutgoing = transfer.id
|
|
1241
|
+
if (!this.sendDataControl(peer, { kind: "file-start", transferId: transfer.id }, peer.dc, peer.rtcEpoch)) {
|
|
1242
|
+
peer.activeOutgoing = ""
|
|
1243
|
+
this.notify()
|
|
1244
|
+
return
|
|
1245
|
+
}
|
|
1246
|
+
void this.sendFile(peer, transfer, peer.dc, peer.rtcEpoch)
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
private async waitForDrain(peer: PeerState, transfer: TransferState, channel: RTCDataChannel, rtcEpoch: number) {
|
|
1250
|
+
while (channel.bufferedAmount > BUFFER_HIGH) {
|
|
1251
|
+
this.assertSendAttempt(peer, transfer, channel, rtcEpoch)
|
|
1252
|
+
await Bun.sleep(10)
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
private assertSendAttempt(peer: PeerState, transfer: TransferState, channel: RTCDataChannel, rtcEpoch: number) {
|
|
1257
|
+
if (transfer.cancel) throw new Error(transfer.cancelReason || "cancelled")
|
|
1258
|
+
if (peer.activeOutgoing !== transfer.id || !this.isCurrentPeerChannel(peer, channel, rtcEpoch)) throw new Error("closed")
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
private async sendFile(peer: PeerState, transfer: TransferState, channel: RTCDataChannel, rtcEpoch: number) {
|
|
1262
|
+
transfer.status = "sending"
|
|
1263
|
+
transfer.startedAt ||= Date.now()
|
|
1264
|
+
transfer.inFlight = true
|
|
1265
|
+
this.noteTransfer(transfer)
|
|
1266
|
+
this.emit({ type: "transfer", transfer: this.transferSnapshot(transfer) })
|
|
1267
|
+
this.notify()
|
|
1268
|
+
try {
|
|
1269
|
+
for (let offset = 0; offset < transfer.size; offset += CHUNK) {
|
|
1270
|
+
this.assertSendAttempt(peer, transfer, channel, rtcEpoch)
|
|
1271
|
+
await this.waitForDrain(peer, transfer, channel, rtcEpoch)
|
|
1272
|
+
const chunk = await readFileChunk(transfer.file!, offset, CHUNK)
|
|
1273
|
+
this.assertSendAttempt(peer, transfer, channel, rtcEpoch)
|
|
1274
|
+
channel.send(chunk)
|
|
1275
|
+
transfer.bytes += chunk.byteLength
|
|
1276
|
+
transfer.chunks += 1
|
|
1277
|
+
this.noteTransfer(transfer)
|
|
1278
|
+
this.notify()
|
|
1279
|
+
}
|
|
1280
|
+
this.assertSendAttempt(peer, transfer, channel, rtcEpoch)
|
|
1281
|
+
transfer.status = "awaiting-done"
|
|
1282
|
+
transfer.inFlight = false
|
|
1283
|
+
this.noteTransfer(transfer)
|
|
1284
|
+
this.emit({ type: "transfer", transfer: this.transferSnapshot(transfer) })
|
|
1285
|
+
if (!this.sendDataControl(peer, { kind: "file-end", transferId: transfer.id, size: transfer.size, totalChunks: transfer.totalChunks }, channel, rtcEpoch)) throw new Error("closed")
|
|
1286
|
+
} catch (error) {
|
|
1287
|
+
transfer.inFlight = false
|
|
1288
|
+
if (transfer.cancel) {
|
|
1289
|
+
const reason = transfer.cancelReason || "cancelled"
|
|
1290
|
+
if (transfer.cancelSource !== "remote") this.sendDataControl(peer, { kind: "file-cancel", transferId: transfer.id, reason }, channel, rtcEpoch)
|
|
1291
|
+
this.completeTransfer(transfer, "cancelled", reason)
|
|
1292
|
+
} else {
|
|
1293
|
+
const reason = `${error}`.includes("closed") ? "closed" : `${error}`
|
|
1294
|
+
if (reason !== "closed") this.sendDataControl(peer, { kind: "file-error", transferId: transfer.id, reason }, channel, rtcEpoch)
|
|
1295
|
+
this.completeTransfer(transfer, "error", reason)
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
private async onDataMessage(peer: PeerState, raw: string | Buffer) {
|
|
1301
|
+
peer.lastSeenAt = Date.now()
|
|
1302
|
+
if (typeof raw === "string") {
|
|
1303
|
+
const message = JSON.parse(raw) as DataMessage
|
|
1304
|
+
await this.handleTransferControl(peer, message)
|
|
1305
|
+
return
|
|
1306
|
+
}
|
|
1307
|
+
this.onBinary(peer, raw)
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
private onBinary(peer: PeerState, data: Buffer) {
|
|
1311
|
+
const transfer = this.transfers.get(peer.activeIncoming)
|
|
1312
|
+
if (!transfer || transfer.status !== "receiving") return
|
|
1313
|
+
transfer.buffers ||= []
|
|
1314
|
+
transfer.buffers.push(data)
|
|
1315
|
+
transfer.bytes += data.byteLength
|
|
1316
|
+
transfer.chunks += 1
|
|
1317
|
+
this.noteTransfer(transfer)
|
|
1318
|
+
this.notify()
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
private async handleTransferControl(peer: PeerState, message: DataMessage) {
|
|
1322
|
+
this.pushLog("data:in", message)
|
|
1323
|
+
if (message.to && message.to !== this.localId && message.to !== "*") return
|
|
1324
|
+
switch (message.kind) {
|
|
1325
|
+
case "file-offer": {
|
|
1326
|
+
if (!this.transfers.has(message.transferId)) {
|
|
1327
|
+
const transfer: TransferState = {
|
|
1328
|
+
id: message.transferId,
|
|
1329
|
+
peerId: peer.id,
|
|
1330
|
+
peerName: peer.name,
|
|
1331
|
+
direction: "in",
|
|
1332
|
+
status: "pending",
|
|
1333
|
+
name: message.name,
|
|
1334
|
+
size: message.size,
|
|
1335
|
+
type: message.type,
|
|
1336
|
+
lastModified: message.lastModified,
|
|
1337
|
+
totalChunks: message.totalChunks || Math.ceil(message.size / (message.chunkSize || CHUNK)),
|
|
1338
|
+
chunkSize: message.chunkSize || CHUNK,
|
|
1339
|
+
bytes: 0,
|
|
1340
|
+
chunks: 0,
|
|
1341
|
+
speed: 0,
|
|
1342
|
+
eta: Infinity,
|
|
1343
|
+
error: "",
|
|
1344
|
+
createdAt: Date.now(),
|
|
1345
|
+
updatedAt: 0,
|
|
1346
|
+
startedAt: 0,
|
|
1347
|
+
endedAt: 0,
|
|
1348
|
+
savedAt: 0,
|
|
1349
|
+
buffers: [],
|
|
1350
|
+
inFlight: false,
|
|
1351
|
+
cancel: false,
|
|
1352
|
+
}
|
|
1353
|
+
this.transfers.set(message.transferId, transfer)
|
|
1354
|
+
this.emit({ type: "transfer", transfer: this.transferSnapshot(transfer) })
|
|
1355
|
+
}
|
|
1356
|
+
if (this.autoAcceptIncoming) await this.acceptTransfer(message.transferId)
|
|
1357
|
+
break
|
|
1358
|
+
}
|
|
1359
|
+
case "file-accept": {
|
|
1360
|
+
const transfer = this.transfers.get(message.transferId)
|
|
1361
|
+
if (transfer && transfer.direction === "out" && transfer.status === "offered") {
|
|
1362
|
+
transfer.status = "accepted"
|
|
1363
|
+
this.noteTransfer(transfer)
|
|
1364
|
+
this.emit({ type: "transfer", transfer: this.transferSnapshot(transfer) })
|
|
1365
|
+
this.pumpSender(peer)
|
|
1366
|
+
}
|
|
1367
|
+
break
|
|
1368
|
+
}
|
|
1369
|
+
case "file-start": {
|
|
1370
|
+
const transfer = this.transfers.get(message.transferId)
|
|
1371
|
+
if (transfer && transfer.direction === "in" && transfer.status === "accepted") {
|
|
1372
|
+
peer.activeIncoming = transfer.id
|
|
1373
|
+
transfer.status = "receiving"
|
|
1374
|
+
transfer.startedAt ||= Date.now()
|
|
1375
|
+
transfer.buffers = []
|
|
1376
|
+
this.noteTransfer(transfer)
|
|
1377
|
+
this.emit({ type: "transfer", transfer: this.transferSnapshot(transfer) })
|
|
1378
|
+
}
|
|
1379
|
+
break
|
|
1380
|
+
}
|
|
1381
|
+
case "file-reject": {
|
|
1382
|
+
const transfer = this.transfers.get(message.transferId)
|
|
1383
|
+
if (transfer) this.completeTransfer(transfer, "rejected", message.reason)
|
|
1384
|
+
break
|
|
1385
|
+
}
|
|
1386
|
+
case "file-end": {
|
|
1387
|
+
const transfer = this.transfers.get(message.transferId)
|
|
1388
|
+
if (!transfer || transfer.direction !== "in") break
|
|
1389
|
+
if (transfer.status === "cancelling") {
|
|
1390
|
+
this.completeTransfer(transfer, "cancelled", transfer.cancelReason || "cancelled")
|
|
1391
|
+
break
|
|
1392
|
+
}
|
|
1393
|
+
if (transfer.status !== "receiving") {
|
|
1394
|
+
this.completeTransfer(transfer, "error", `unexpected end while ${transfer.status}`)
|
|
1395
|
+
break
|
|
1396
|
+
}
|
|
1397
|
+
const data = Buffer.concat(transfer.buffers || [])
|
|
1398
|
+
if (data.byteLength !== message.size) {
|
|
1399
|
+
const reason = `size mismatch: ${data.byteLength} vs ${message.size}`
|
|
1400
|
+
this.sendDataControl(peer, { kind: "file-error", transferId: transfer.id, reason })
|
|
1401
|
+
this.completeTransfer(transfer, "error", reason)
|
|
1402
|
+
break
|
|
1403
|
+
}
|
|
1404
|
+
transfer.data = data
|
|
1405
|
+
transfer.buffers = []
|
|
1406
|
+
this.sendDataControl(peer, { kind: "file-done", transferId: transfer.id, size: transfer.bytes, totalChunks: transfer.chunks })
|
|
1407
|
+
this.completeTransfer(transfer, "complete")
|
|
1408
|
+
break
|
|
1409
|
+
}
|
|
1410
|
+
case "file-done": {
|
|
1411
|
+
const transfer = this.transfers.get(message.transferId)
|
|
1412
|
+
if (transfer && transfer.direction === "out") this.completeTransfer(transfer, "complete")
|
|
1413
|
+
break
|
|
1414
|
+
}
|
|
1415
|
+
case "file-cancel":
|
|
1416
|
+
case "file-error": {
|
|
1417
|
+
const transfer = this.transfers.get(message.transferId)
|
|
1418
|
+
if (!transfer || isFinal(transfer)) break
|
|
1419
|
+
const status = message.kind === "file-error" ? "error" : "cancelled"
|
|
1420
|
+
if (transfer.direction === "out" && transfer.inFlight) {
|
|
1421
|
+
transfer.cancel = true
|
|
1422
|
+
transfer.cancelReason = message.reason
|
|
1423
|
+
transfer.cancelSource = "remote"
|
|
1424
|
+
if (status === "cancelled") transfer.status = "cancelling"
|
|
1425
|
+
this.emit({ type: "transfer", transfer: this.transferSnapshot(transfer) })
|
|
1426
|
+
this.notify()
|
|
1427
|
+
} else {
|
|
1428
|
+
this.completeTransfer(transfer, status, message.reason)
|
|
1429
|
+
}
|
|
1430
|
+
break
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
this.notify()
|
|
1434
|
+
}
|
|
1435
|
+
}
|