@elefunc/send 0.1.18 → 0.1.20
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/README.md +3 -3
- package/package.json +1 -1
- package/src/core/files.ts +18 -3
- package/src/core/invite.ts +148 -0
- package/src/core/session.ts +53 -10
- package/src/core/targeting.ts +15 -9
- package/src/index.ts +71 -24
- package/src/tui/app.ts +97 -97
- package/src/types/bun-runtime.d.ts +1 -0
package/README.md
CHANGED
|
@@ -35,12 +35,12 @@ In the TUI, the room row includes a `📋` invite link that opens the equivalent
|
|
|
35
35
|
`--self` accepts three forms:
|
|
36
36
|
|
|
37
37
|
- `name`
|
|
38
|
-
- `name-
|
|
39
|
-
- `-
|
|
38
|
+
- `name-id`
|
|
39
|
+
- `-id` using the attached CLI form `--self=-ab12cd34`
|
|
40
40
|
|
|
41
41
|
`SEND_SELF` supports the same raw values, including `SEND_SELF=-ab12cd34`.
|
|
42
42
|
|
|
43
|
-
The
|
|
43
|
+
The `id` suffix must be exactly 8 lowercase alphanumeric characters.
|
|
44
44
|
|
|
45
45
|
## Examples
|
|
46
46
|
|
package/package.json
CHANGED
package/src/core/files.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { access, mkdir, open, rm, stat, type FileHandle } from "node:fs/promises"
|
|
1
|
+
import { access, mkdir, open, rename, rm, stat, type FileHandle } from "node:fs/promises"
|
|
2
2
|
import { basename, extname, join, resolve } from "node:path"
|
|
3
3
|
import { resolveUserPath } from "./paths"
|
|
4
4
|
|
|
@@ -101,8 +101,23 @@ export const uniqueOutputPath = async (directory: string, fileName: string, rese
|
|
|
101
101
|
}
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
export const
|
|
105
|
-
|
|
104
|
+
export const incomingOutputPath = async (directory: string, fileName: string, overwrite = false, reservedPaths: ReadonlySet<string> = new Set()) => {
|
|
105
|
+
await mkdir(directory, { recursive: true })
|
|
106
|
+
return overwrite ? join(directory, fileName) : uniqueOutputPath(directory, fileName, reservedPaths)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export const replaceOutputPath = async (sourcePath: string, destinationPath: string) => {
|
|
110
|
+
try {
|
|
111
|
+
await rename(sourcePath, destinationPath)
|
|
112
|
+
} catch (error) {
|
|
113
|
+
if (!await pathExists(destinationPath)) throw error
|
|
114
|
+
await removePath(destinationPath)
|
|
115
|
+
await rename(sourcePath, destinationPath)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export const saveIncomingFile = async (directory: string, fileName: string, data: Buffer, overwrite = false) => {
|
|
120
|
+
const path = await incomingOutputPath(directory, fileName, overwrite)
|
|
106
121
|
await Bun.write(path, data)
|
|
107
122
|
return path
|
|
108
123
|
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { resolve } from "node:path"
|
|
2
|
+
import { cleanRoom } from "./protocol"
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_WEB_URL = "https://rtme.sh/"
|
|
5
|
+
export const COPY_SERVICE_URL = "https://copy.rt.ht/"
|
|
6
|
+
|
|
7
|
+
export type ShareUrlOptions = {
|
|
8
|
+
room: string
|
|
9
|
+
clean?: boolean
|
|
10
|
+
accept?: boolean
|
|
11
|
+
offer?: boolean
|
|
12
|
+
save?: boolean
|
|
13
|
+
overwrite?: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type ShareCliCommandOptions = ShareUrlOptions & {
|
|
17
|
+
self?: string
|
|
18
|
+
events?: boolean
|
|
19
|
+
saveDir?: string
|
|
20
|
+
defaultSaveDir?: string
|
|
21
|
+
turnUrls?: readonly (string | null | undefined)[]
|
|
22
|
+
turnUsername?: string
|
|
23
|
+
turnCredential?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type JoinOutputKind = "offer" | "accept"
|
|
27
|
+
|
|
28
|
+
const hashBool = (value: boolean) => value ? "1" : "0"
|
|
29
|
+
const safeShellArgPattern = /^[A-Za-z0-9._/:?=&,+@%-]+$/
|
|
30
|
+
|
|
31
|
+
const normalizeShareUrlOptions = (options: ShareUrlOptions) => ({
|
|
32
|
+
room: cleanRoom(options.room),
|
|
33
|
+
clean: options.clean ?? true,
|
|
34
|
+
accept: options.accept ?? true,
|
|
35
|
+
offer: options.offer ?? true,
|
|
36
|
+
save: options.save ?? true,
|
|
37
|
+
overwrite: options.overwrite ?? false,
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
const shellQuote = (value: string) => safeShellArgPattern.test(value) ? value : `'${value.replaceAll("'", `'\"'\"'`)}'`
|
|
41
|
+
|
|
42
|
+
export const appendCliFlag = (args: string[], flag: string, value?: string | null) => {
|
|
43
|
+
const text = `${value ?? ""}`.trim()
|
|
44
|
+
if (!text) return
|
|
45
|
+
args.push(flag, shellQuote(text))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const appendToggleCliFlags = (args: string[], options: ShareUrlOptions) => {
|
|
49
|
+
const normalized = normalizeShareUrlOptions(options)
|
|
50
|
+
if (!normalized.clean) appendCliFlag(args, "--clean", "0")
|
|
51
|
+
if (!normalized.accept) appendCliFlag(args, "--accept", "0")
|
|
52
|
+
if (!normalized.offer) appendCliFlag(args, "--offer", "0")
|
|
53
|
+
if (!normalized.save) appendCliFlag(args, "--save", "0")
|
|
54
|
+
if (normalized.overwrite) args.push("--overwrite")
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const buildHashParams = (options: ShareUrlOptions, omitDefaults = false) => {
|
|
58
|
+
const normalized = normalizeShareUrlOptions(options)
|
|
59
|
+
const params = new URLSearchParams({ room: normalized.room })
|
|
60
|
+
if (!omitDefaults || !normalized.clean) params.set("clean", hashBool(normalized.clean))
|
|
61
|
+
if (!omitDefaults || !normalized.accept) params.set("accept", hashBool(normalized.accept))
|
|
62
|
+
if (!omitDefaults || !normalized.offer) params.set("offer", hashBool(normalized.offer))
|
|
63
|
+
if (!omitDefaults || !normalized.save) params.set("save", hashBool(normalized.save))
|
|
64
|
+
if (!omitDefaults || normalized.overwrite) params.set("overwrite", hashBool(normalized.overwrite))
|
|
65
|
+
return params
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const resolveWebUrlBase = (value = process.env.SEND_WEB_URL) => {
|
|
69
|
+
const candidate = `${value ?? ""}`.trim() || DEFAULT_WEB_URL
|
|
70
|
+
try {
|
|
71
|
+
return new URL(candidate).toString()
|
|
72
|
+
} catch {
|
|
73
|
+
return DEFAULT_WEB_URL
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const renderWebUrl = (options: ShareUrlOptions, baseUrl = DEFAULT_WEB_URL, omitDefaults = true) => {
|
|
78
|
+
const url = new URL(baseUrl)
|
|
79
|
+
url.hash = buildHashParams(options, omitDefaults).toString()
|
|
80
|
+
return url.toString()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const schemeLessUrlText = (text: string) => text.replace(/^[a-z]+:\/\//, "")
|
|
84
|
+
|
|
85
|
+
export const webInviteUrl = (options: ShareUrlOptions, baseUrl = resolveWebUrlBase()) => renderWebUrl(options, baseUrl)
|
|
86
|
+
|
|
87
|
+
export const inviteWebLabel = (options: ShareUrlOptions, baseUrl = resolveWebUrlBase()) => schemeLessUrlText(webInviteUrl(options, baseUrl))
|
|
88
|
+
|
|
89
|
+
export const inviteCliPackageName = (baseUrl = resolveWebUrlBase()) => new URL(resolveWebUrlBase(baseUrl)).hostname
|
|
90
|
+
|
|
91
|
+
export const inviteCliCommand = (options: ShareUrlOptions) => {
|
|
92
|
+
const normalized = normalizeShareUrlOptions(options)
|
|
93
|
+
const args: string[] = []
|
|
94
|
+
appendCliFlag(args, "--room", normalized.room)
|
|
95
|
+
appendToggleCliFlags(args, normalized)
|
|
96
|
+
return args.join(" ")
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export const inviteCliText = (options: ShareUrlOptions, baseUrl = resolveWebUrlBase()) => `bunx ${inviteCliPackageName(baseUrl)} ${inviteCliCommand(options)}`
|
|
100
|
+
|
|
101
|
+
export const inviteCopyUrl = (text: string) => `${COPY_SERVICE_URL}#${new URLSearchParams({ text })}`
|
|
102
|
+
|
|
103
|
+
export const shareTurnCliArgs = (options: Pick<ShareCliCommandOptions, "turnUrls" | "turnUsername" | "turnCredential">) => {
|
|
104
|
+
const turnUrls = [...new Set((options.turnUrls ?? []).map(url => `${url ?? ""}`.trim()).filter(Boolean))]
|
|
105
|
+
if (!turnUrls.length) return []
|
|
106
|
+
const args: string[] = []
|
|
107
|
+
for (const turnUrl of turnUrls) appendCliFlag(args, "--turn-url", turnUrl)
|
|
108
|
+
appendCliFlag(args, "--turn-username", options.turnUsername)
|
|
109
|
+
appendCliFlag(args, "--turn-credential", options.turnCredential)
|
|
110
|
+
return args
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export const renderCliCommand = (
|
|
114
|
+
options: ShareCliCommandOptions,
|
|
115
|
+
{ includeSelf = false, includePrefix = false, packageName }: { includeSelf?: boolean; includePrefix?: boolean; packageName?: string } = {},
|
|
116
|
+
) => {
|
|
117
|
+
const normalized = normalizeShareUrlOptions(options)
|
|
118
|
+
const args = includePrefix ? ["bunx", packageName || inviteCliPackageName(DEFAULT_WEB_URL)] : []
|
|
119
|
+
appendCliFlag(args, "--room", normalized.room)
|
|
120
|
+
if (includeSelf) appendCliFlag(args, "--self", options.self)
|
|
121
|
+
appendToggleCliFlags(args, normalized)
|
|
122
|
+
if (options.events) args.push("--events")
|
|
123
|
+
if (options.saveDir && (!options.defaultSaveDir || resolve(options.saveDir) !== options.defaultSaveDir)) appendCliFlag(args, "--save-dir", options.saveDir)
|
|
124
|
+
args.push(...shareTurnCliArgs(options))
|
|
125
|
+
return args.join(" ")
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const joinCliLabel = (kind: JoinOutputKind) => kind === "offer" ? "CLI (receive and save):" : "CLI (append file paths at the end):"
|
|
129
|
+
|
|
130
|
+
const joinCliCommand = (kind: JoinOutputKind, room: string, baseUrl = resolveWebUrlBase()) => {
|
|
131
|
+
const prefix = `bunx ${inviteCliPackageName(baseUrl)}`
|
|
132
|
+
const roomArgs = inviteCliCommand({ room })
|
|
133
|
+
return kind === "offer" ? `${prefix} accept ${roomArgs}` : `${prefix} offer ${roomArgs}`
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export const joinOutputLines = (kind: JoinOutputKind, room: string, baseUrl = resolveWebUrlBase()) => [
|
|
137
|
+
"Join with:",
|
|
138
|
+
"",
|
|
139
|
+
"Web (open in browser):",
|
|
140
|
+
webInviteUrl({ room }, baseUrl),
|
|
141
|
+
"",
|
|
142
|
+
joinCliLabel(kind),
|
|
143
|
+
joinCliCommand(kind, room, baseUrl),
|
|
144
|
+
"",
|
|
145
|
+
"TUI (interactive terminal UI):",
|
|
146
|
+
inviteCliText({ room }, baseUrl),
|
|
147
|
+
"",
|
|
148
|
+
]
|
package/src/core/session.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { open, rename, type FileHandle } from "node:fs/promises"
|
|
|
2
2
|
import { resolve } from "node:path"
|
|
3
3
|
import type { RTCDataChannel, RTCIceCandidateInit, RTCIceServer } from "werift"
|
|
4
4
|
import { RTCPeerConnection } from "werift"
|
|
5
|
-
import { closeLocalFile, loadLocalFiles, pathExists, readFileChunk, removePath, saveIncomingFile, uniqueOutputPath, writeFileChunk, type LocalFile } from "./files"
|
|
5
|
+
import { closeLocalFile, incomingOutputPath, loadLocalFiles, pathExists, readFileChunk, removePath, replaceOutputPath, saveIncomingFile, uniqueOutputPath, writeFileChunk, type LocalFile } from "./files"
|
|
6
6
|
import {
|
|
7
7
|
BASE_ICE_SERVERS,
|
|
8
8
|
BUFFER_HIGH,
|
|
@@ -176,6 +176,7 @@ export interface SessionConfig {
|
|
|
176
176
|
peerSelectionMemory?: Map<string, boolean>
|
|
177
177
|
autoAcceptIncoming?: boolean
|
|
178
178
|
autoSaveIncoming?: boolean
|
|
179
|
+
overwriteIncoming?: boolean
|
|
179
180
|
turnUrls?: string[]
|
|
180
181
|
turnUsername?: string
|
|
181
182
|
turnCredential?: string
|
|
@@ -184,6 +185,8 @@ export interface SessionConfig {
|
|
|
184
185
|
|
|
185
186
|
const LOG_LIMIT = 200
|
|
186
187
|
const STATS_POLL_MS = 1000
|
|
188
|
+
export const PULSE_PROBE_INTERVAL_MS = 15_000
|
|
189
|
+
export const PULSE_STALE_MS = 45_000
|
|
187
190
|
const PROFILE_URL = "https://ip.rt.ht/"
|
|
188
191
|
export interface PeerConnectivitySnapshot {
|
|
189
192
|
rttMs: number
|
|
@@ -194,9 +197,12 @@ export interface PeerConnectivitySnapshot {
|
|
|
194
197
|
|
|
195
198
|
export type TurnState = "none" | "idle" | "used"
|
|
196
199
|
export type PulseState = "idle" | "checking" | "open" | "error"
|
|
200
|
+
export type SettledPulseState = "idle" | "open" | "error"
|
|
201
|
+
export type SignalMetricState = "idle" | "connecting" | "checking" | "open" | "degraded" | "closed" | "error"
|
|
197
202
|
|
|
198
203
|
export interface PulseSnapshot {
|
|
199
204
|
state: PulseState
|
|
205
|
+
lastSettledState: SettledPulseState
|
|
200
206
|
at: number
|
|
201
207
|
ms: number
|
|
202
208
|
error: string
|
|
@@ -206,7 +212,27 @@ const progressOf = (transfer: TransferState) => transfer.size ? Math.max(0, Math
|
|
|
206
212
|
const isFinal = (transfer: TransferState) => FINAL_STATUSES.has(transfer.status as never)
|
|
207
213
|
export const candidateTypeLabel = (type: string) => ({ host: "Direct", srflx: "NAT", prflx: "NAT", relay: "TURN" }[type] || "—")
|
|
208
214
|
const emptyConnectivitySnapshot = (): PeerConnectivitySnapshot => ({ rttMs: Number.NaN, localCandidateType: "", remoteCandidateType: "", pathLabel: "—" })
|
|
209
|
-
const
|
|
215
|
+
const settledPulseState = (pulse?: Pick<PulseSnapshot, "state" | "lastSettledState"> | null): SettledPulseState =>
|
|
216
|
+
pulse?.lastSettledState === "open" || pulse?.lastSettledState === "error" || pulse?.lastSettledState === "idle"
|
|
217
|
+
? pulse.lastSettledState
|
|
218
|
+
: pulse?.state === "open"
|
|
219
|
+
? "open"
|
|
220
|
+
: pulse?.state === "error"
|
|
221
|
+
? "error"
|
|
222
|
+
: "idle"
|
|
223
|
+
const pulseIsFresh = (pulse: Pick<PulseSnapshot, "at" | "lastSettledState" | "state">, now = Date.now()) =>
|
|
224
|
+
settledPulseState(pulse) === "open" && Number.isFinite(pulse.at) && pulse.at > 0 && now - pulse.at <= PULSE_STALE_MS
|
|
225
|
+
export const signalMetricState = (socketState: SocketState, pulse: PulseSnapshot, now = Date.now()): SignalMetricState => {
|
|
226
|
+
if (socketState === "error") return "error"
|
|
227
|
+
if (socketState === "closed") return "closed"
|
|
228
|
+
if (socketState === "connecting") return "connecting"
|
|
229
|
+
if (socketState === "idle") return "idle"
|
|
230
|
+
const lastSettled = settledPulseState(pulse)
|
|
231
|
+
if (lastSettled === "idle") return "checking"
|
|
232
|
+
if (lastSettled === "error") return "degraded"
|
|
233
|
+
return pulseIsFresh(pulse, now) ? "open" : "degraded"
|
|
234
|
+
}
|
|
235
|
+
const emptyPulseSnapshot = (): PulseSnapshot => ({ state: "idle", lastSettledState: "idle", at: 0, ms: 0, error: "" })
|
|
210
236
|
|
|
211
237
|
export const sanitizeProfile = (profile?: PeerProfile): PeerProfile => ({
|
|
212
238
|
geo: {
|
|
@@ -389,6 +415,7 @@ export class SendSession {
|
|
|
389
415
|
|
|
390
416
|
private autoAcceptIncoming: boolean
|
|
391
417
|
private autoSaveIncoming: boolean
|
|
418
|
+
private readonly overwriteIncoming: boolean
|
|
392
419
|
private readonly reconnectSocket: boolean
|
|
393
420
|
private iceServers: RTCIceServer[]
|
|
394
421
|
private extraTurnServers: RTCIceServer[]
|
|
@@ -405,6 +432,7 @@ export class SendSession {
|
|
|
405
432
|
private socketToken = 0
|
|
406
433
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
|
407
434
|
private peerStatsTimer: ReturnType<typeof setInterval> | null = null
|
|
435
|
+
private pulseTimer: ReturnType<typeof setInterval> | null = null
|
|
408
436
|
private readonly pendingRtcCloses = new Set<Promise<void>>()
|
|
409
437
|
private lifecycleAbortController: AbortController | null = null
|
|
410
438
|
private stopped = false
|
|
@@ -418,6 +446,7 @@ export class SendSession {
|
|
|
418
446
|
this.peerSelectionMemory = config.peerSelectionMemory ?? new Map()
|
|
419
447
|
this.autoAcceptIncoming = !!config.autoAcceptIncoming
|
|
420
448
|
this.autoSaveIncoming = !!config.autoSaveIncoming
|
|
449
|
+
this.overwriteIncoming = !!config.overwriteIncoming
|
|
421
450
|
this.reconnectSocket = config.reconnectSocket ?? true
|
|
422
451
|
this.extraTurnServers = turnServers(config.turnUrls ?? [], config.turnUsername, config.turnCredential)
|
|
423
452
|
this.iceServers = [...BASE_ICE_SERVERS, ...this.extraTurnServers]
|
|
@@ -460,6 +489,7 @@ export class SendSession {
|
|
|
460
489
|
this.lifecycleAbortController?.abort()
|
|
461
490
|
this.lifecycleAbortController = typeof AbortController === "function" ? new AbortController() : null
|
|
462
491
|
this.startPeerStatsPolling()
|
|
492
|
+
this.startPulsePolling()
|
|
463
493
|
void this.loadLocalProfile()
|
|
464
494
|
void this.probePulse()
|
|
465
495
|
this.connectSocket()
|
|
@@ -469,6 +499,7 @@ export class SendSession {
|
|
|
469
499
|
async close() {
|
|
470
500
|
this.stopped = true
|
|
471
501
|
this.stopPeerStatsPolling()
|
|
502
|
+
this.stopPulsePolling()
|
|
472
503
|
if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
|
|
473
504
|
this.reconnectTimer = null
|
|
474
505
|
this.lifecycleAbortController?.abort()
|
|
@@ -763,7 +794,7 @@ export class SendSession {
|
|
|
763
794
|
if (transfer.savedPath) return transfer.savedPath
|
|
764
795
|
if (!transfer.data && transfer.buffers?.length) transfer.data = Buffer.concat(transfer.buffers)
|
|
765
796
|
if (!transfer.data) return null
|
|
766
|
-
transfer.savedPath = await saveIncomingFile(this.saveDir, transfer.name, transfer.data)
|
|
797
|
+
transfer.savedPath = await saveIncomingFile(this.saveDir, transfer.name, transfer.data, this.overwriteIncoming)
|
|
767
798
|
transfer.savedAt ||= Date.now()
|
|
768
799
|
this.emitTransferSaved(transfer)
|
|
769
800
|
this.notify()
|
|
@@ -788,8 +819,8 @@ export class SendSession {
|
|
|
788
819
|
}
|
|
789
820
|
|
|
790
821
|
private async createIncomingDiskState(fileName: string): Promise<IncomingDiskState> {
|
|
791
|
-
const finalPath = await
|
|
792
|
-
this.reservedSavePaths.add(finalPath)
|
|
822
|
+
const finalPath = await incomingOutputPath(this.saveDir, fileName || "download", this.overwriteIncoming, this.reservedSavePaths)
|
|
823
|
+
if (!this.overwriteIncoming) this.reservedSavePaths.add(finalPath)
|
|
793
824
|
for (let attempt = 0; ; attempt += 1) {
|
|
794
825
|
const tempPath = `${finalPath}.part.${uid(6)}${attempt ? `.${attempt}` : ""}`
|
|
795
826
|
try {
|
|
@@ -805,7 +836,7 @@ export class SendSession {
|
|
|
805
836
|
}
|
|
806
837
|
} catch (error) {
|
|
807
838
|
if ((error as NodeJS.ErrnoException | undefined)?.code === "EEXIST") continue
|
|
808
|
-
this.reservedSavePaths.delete(finalPath)
|
|
839
|
+
if (!this.overwriteIncoming) this.reservedSavePaths.delete(finalPath)
|
|
809
840
|
throw error
|
|
810
841
|
}
|
|
811
842
|
}
|
|
@@ -875,12 +906,13 @@ export class SendSession {
|
|
|
875
906
|
disk.closed = true
|
|
876
907
|
await disk.handle.close()
|
|
877
908
|
}
|
|
878
|
-
if (await pathExists(finalPath)) {
|
|
909
|
+
if (!this.overwriteIncoming && await pathExists(finalPath)) {
|
|
879
910
|
this.reservedSavePaths.delete(finalPath)
|
|
880
911
|
finalPath = await uniqueOutputPath(this.saveDir, transfer.name || "download", this.reservedSavePaths)
|
|
881
912
|
this.reservedSavePaths.add(finalPath)
|
|
882
913
|
}
|
|
883
|
-
await
|
|
914
|
+
if (this.overwriteIncoming) await replaceOutputPath(disk.tempPath, finalPath)
|
|
915
|
+
else await rename(disk.tempPath, finalPath)
|
|
884
916
|
transfer.savedPath = finalPath
|
|
885
917
|
transfer.savedAt ||= Date.now()
|
|
886
918
|
return finalPath
|
|
@@ -952,6 +984,17 @@ export class SendSession {
|
|
|
952
984
|
this.peerStatsTimer = null
|
|
953
985
|
}
|
|
954
986
|
|
|
987
|
+
private startPulsePolling() {
|
|
988
|
+
if (this.pulseTimer) return
|
|
989
|
+
this.pulseTimer = setInterval(() => void this.probePulse(), PULSE_PROBE_INTERVAL_MS)
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
private stopPulsePolling() {
|
|
993
|
+
if (!this.pulseTimer) return
|
|
994
|
+
clearInterval(this.pulseTimer)
|
|
995
|
+
this.pulseTimer = null
|
|
996
|
+
}
|
|
997
|
+
|
|
955
998
|
private async refreshPeerStats() {
|
|
956
999
|
if (this.stopped) return
|
|
957
1000
|
let dirty = false
|
|
@@ -1156,10 +1199,10 @@ export class SendSession {
|
|
|
1156
1199
|
const response = await fetch(SIGNAL_PULSE_URL, { cache: "no-store", signal: timeoutSignal(3500, this.lifecycleAbortController?.signal) })
|
|
1157
1200
|
if (!response.ok) throw new Error(`pulse ${response.status}`)
|
|
1158
1201
|
if (this.stopped) return
|
|
1159
|
-
this.pulse = { state: "open", at: Date.now(), ms: performance.now() - startedAt, error: "" }
|
|
1202
|
+
this.pulse = { state: "open", lastSettledState: "open", at: Date.now(), ms: performance.now() - startedAt, error: "" }
|
|
1160
1203
|
} catch (error) {
|
|
1161
1204
|
if (this.stopped) return
|
|
1162
|
-
this.pulse = { state: "error", at: Date.now(), ms: 0, error: `${error}` }
|
|
1205
|
+
this.pulse = { state: "error", lastSettledState: "error", at: Date.now(), ms: 0, error: `${error}` }
|
|
1163
1206
|
this.pushLog("pulse:error", { error: `${error}` }, "error")
|
|
1164
1207
|
}
|
|
1165
1208
|
if (this.stopped) return
|
package/src/core/targeting.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { cleanText, displayPeerName } from "./protocol"
|
|
2
2
|
|
|
3
3
|
export interface TargetPeer {
|
|
4
4
|
id: string
|
|
@@ -16,23 +16,29 @@ export interface ResolveTargetsResult {
|
|
|
16
16
|
const BROADCAST_SELECTOR = "."
|
|
17
17
|
|
|
18
18
|
const uniquePeers = (peers: TargetPeer[]) => [...new Map(peers.map(peer => [peer.id, peer])).values()]
|
|
19
|
-
|
|
20
|
-
const
|
|
19
|
+
const normalizeName = (value: unknown) => cleanText(value, 24).toLowerCase().replace(/[^a-z0-9]+/g, "").slice(0, 24)
|
|
20
|
+
const parseSelector = (selector: string) => {
|
|
21
|
+
const hyphen = selector.lastIndexOf("-")
|
|
22
|
+
return hyphen < 0
|
|
23
|
+
? { raw: selector, kind: "name" as const, value: normalizeName(selector) }
|
|
24
|
+
: { raw: selector, kind: "id" as const, value: selector.slice(hyphen + 1) }
|
|
25
|
+
}
|
|
26
|
+
const matchesSelector = (peer: TargetPeer, selector: ReturnType<typeof parseSelector>) =>
|
|
27
|
+
selector.kind === "name" ? normalizeName(peer.name) === selector.value : peer.id === selector.value
|
|
21
28
|
|
|
22
29
|
export const resolvePeerTargets = (peers: TargetPeer[], selectors: string[]): ResolveTargetsResult => {
|
|
23
30
|
const active = peers.filter(peer => peer.presence === "active")
|
|
24
|
-
const ready = active.filter(peer => peer.ready)
|
|
25
31
|
const requested = [...new Set(selectors.filter(Boolean))]
|
|
26
32
|
const normalized = requested.length ? requested : [BROADCAST_SELECTOR]
|
|
27
33
|
if (normalized.includes(BROADCAST_SELECTOR)) {
|
|
28
34
|
if (normalized.length > 1) return { ok: false, peers: [], error: "broadcast selector `.` cannot be combined with specific peers" }
|
|
35
|
+
const ready = active.filter(peer => peer.ready)
|
|
29
36
|
return ready.length ? { ok: true, peers: ready } : { ok: false, peers: [], error: "no ready peers" }
|
|
30
37
|
}
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
38
|
+
const parsed = normalized.map(parseSelector)
|
|
39
|
+
const missing = parsed.filter(selector => !active.some(peer => matchesSelector(peer, selector))).map(selector => selector.raw)
|
|
40
|
+
if (missing.length) return { ok: false, peers: [], error: `no matching peer for ${missing.join(", ")}` }
|
|
41
|
+
const matches = uniquePeers(active.flatMap(peer => parsed.some(selector => matchesSelector(peer, selector)) ? [peer] : []))
|
|
36
42
|
const notReady = matches.filter(peer => !peer.ready)
|
|
37
43
|
if (notReady.length) return { ok: false, peers: [], error: `peer not ready: ${notReady.map(peer => displayPeerName(peer.name, peer.id)).join(", ")}` }
|
|
38
44
|
return { ok: true, peers: matches }
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { resolve } from "node:path"
|
|
3
3
|
import { cac, type CAC } from "cac"
|
|
4
|
+
import { joinOutputLines, type JoinOutputKind } from "./core/invite"
|
|
4
5
|
import { cleanRoom, displayPeerName } from "./core/protocol"
|
|
5
6
|
import type { SendSession, SessionConfig, SessionEvent } from "./core/session"
|
|
6
7
|
import { resolvePeerTargets } from "./core/targeting"
|
|
@@ -55,8 +56,8 @@ const parseBinaryOptions = <K extends BinaryOptionKey>(options: Record<string, u
|
|
|
55
56
|
|
|
56
57
|
const SELF_ID_LENGTH = 8
|
|
57
58
|
const SELF_ID_PATTERN = new RegExp(`^[a-z0-9]{${SELF_ID_LENGTH}}$`)
|
|
58
|
-
const SELF_HELP_TEXT = "self identity: name
|
|
59
|
-
const INVALID_SELF_ID_MESSAGE = `--self
|
|
59
|
+
const SELF_HELP_TEXT = "self identity: `name`, `name-id`, or `-id`"
|
|
60
|
+
const INVALID_SELF_ID_MESSAGE = `--self id suffix must be exactly ${SELF_ID_LENGTH} lowercase alphanumeric characters`
|
|
60
61
|
type CliCommand = ReturnType<CAC["command"]>
|
|
61
62
|
const ROOM_SELF_OPTIONS = [
|
|
62
63
|
["--room <room>", "room id; omit to create a random room"],
|
|
@@ -67,6 +68,7 @@ const TURN_OPTIONS = [
|
|
|
67
68
|
["--turn-username <value>", "custom TURN username"],
|
|
68
69
|
["--turn-credential <value>", "custom TURN credential"],
|
|
69
70
|
] as const
|
|
71
|
+
const OVERWRITE_OPTION = ["--overwrite", "overwrite same-name saved files instead of creating copies"] as const
|
|
70
72
|
const SAVE_DIR_OPTION = ["--save-dir <dir>", "save directory"] as const
|
|
71
73
|
const TUI_TOGGLE_OPTIONS = [
|
|
72
74
|
["--clean <0|1>", "show only active peers when 1; show terminal peers too when 0"],
|
|
@@ -77,6 +79,14 @@ const TUI_TOGGLE_OPTIONS = [
|
|
|
77
79
|
export const ACCEPT_SESSION_DEFAULTS = { autoAcceptIncoming: true, autoSaveIncoming: true } as const
|
|
78
80
|
const addOptions = (command: CliCommand, definitions: readonly (readonly [string, string])[]) =>
|
|
79
81
|
definitions.reduce((next, [flag, description]) => next.option(flag, description), command)
|
|
82
|
+
const withTrailingHelpLine = <T extends { outputHelp: () => void }>(target: T) => {
|
|
83
|
+
const outputHelp = target.outputHelp.bind(target)
|
|
84
|
+
target.outputHelp = () => {
|
|
85
|
+
outputHelp()
|
|
86
|
+
console.info("")
|
|
87
|
+
}
|
|
88
|
+
return target
|
|
89
|
+
}
|
|
80
90
|
|
|
81
91
|
const requireSelfId = (value: string) => {
|
|
82
92
|
if (!SELF_ID_PATTERN.test(value)) throw new ExitError(INVALID_SELF_ID_MESSAGE, 1)
|
|
@@ -103,6 +113,7 @@ export const sessionConfigFrom = (options: Record<string, unknown>, defaults: {
|
|
|
103
113
|
saveDir: resolve(`${options.saveDir ?? process.env.SEND_SAVE_DIR ?? "."}`),
|
|
104
114
|
autoAcceptIncoming: accept ?? defaults.autoAcceptIncoming ?? false,
|
|
105
115
|
autoSaveIncoming: save ?? defaults.autoSaveIncoming ?? false,
|
|
116
|
+
overwriteIncoming: !!options.overwrite,
|
|
106
117
|
turnUrls: splitList(options.turnUrl ?? process.env.SEND_TURN_URL),
|
|
107
118
|
turnUsername: `${options.turnUsername ?? process.env.SEND_TURN_USERNAME ?? ""}`.trim() || undefined,
|
|
108
119
|
turnCredential: `${options.turnCredential ?? process.env.SEND_TURN_CREDENTIAL ?? ""}`.trim() || undefined,
|
|
@@ -114,6 +125,16 @@ export const roomAnnouncement = (room: string, self: string, json = false) =>
|
|
|
114
125
|
|
|
115
126
|
const printRoomAnnouncement = (room: string, self: string, json = false) => console.log(roomAnnouncement(room, self, json))
|
|
116
127
|
|
|
128
|
+
export const commandAnnouncement = (kind: JoinOutputKind, room: string, self: string, json = false) =>
|
|
129
|
+
json ? roomAnnouncement(room, self, true) : [roomAnnouncement(room, self), "", ...joinOutputLines(kind, room)].join("\n")
|
|
130
|
+
|
|
131
|
+
const printCommandAnnouncement = (kind: JoinOutputKind, room: string, self: string, json = false) => console.log(commandAnnouncement(kind, room, self, json))
|
|
132
|
+
export const readyStatusLine = (room: string, json = false) => json ? "" : `ready in ${room}`
|
|
133
|
+
const printReadyStatus = (room: string, json = false) => {
|
|
134
|
+
const line = readyStatusLine(room, json)
|
|
135
|
+
if (line) console.log(line)
|
|
136
|
+
}
|
|
137
|
+
|
|
117
138
|
const printEvent = (event: SessionEvent) => console.log(JSON.stringify(event))
|
|
118
139
|
|
|
119
140
|
const attachReporter = (session: SendSession, json = false) => {
|
|
@@ -220,9 +241,10 @@ const offerCommand = async (files: string[], options: Record<string, unknown>) =
|
|
|
220
241
|
const { SendSession } = await loadSessionRuntime()
|
|
221
242
|
const session = new SendSession(sessionConfigFrom(options, {}))
|
|
222
243
|
handleSignals(session)
|
|
223
|
-
|
|
244
|
+
printCommandAnnouncement("offer", session.room, displayPeerName(session.name, session.localId), !!options.json)
|
|
224
245
|
const detachReporter = attachReporter(session, !!options.json)
|
|
225
246
|
await session.connect()
|
|
247
|
+
printReadyStatus(session.room, !!options.json)
|
|
226
248
|
const targets = await waitForTargets(session, selectors, timeoutMs)
|
|
227
249
|
const transferIds = await session.queueFiles(files, targets.map(peer => peer.id))
|
|
228
250
|
const results = await waitForFinalTransfers(session, transferIds)
|
|
@@ -236,10 +258,10 @@ const acceptCommand = async (options: Record<string, unknown>) => {
|
|
|
236
258
|
const { SendSession } = await loadSessionRuntime()
|
|
237
259
|
const session = new SendSession(sessionConfigFrom(options, ACCEPT_SESSION_DEFAULTS))
|
|
238
260
|
handleSignals(session)
|
|
239
|
-
|
|
261
|
+
printCommandAnnouncement("accept", session.room, displayPeerName(session.name, session.localId), !!options.json)
|
|
240
262
|
const detachReporter = attachReporter(session, !!options.json)
|
|
241
263
|
await session.connect()
|
|
242
|
-
|
|
264
|
+
printReadyStatus(session.room, !!options.json)
|
|
243
265
|
if (options.once) {
|
|
244
266
|
for (;;) {
|
|
245
267
|
const saved = session.snapshot().transfers.find(transfer => transfer.direction === "in" && transfer.savedAt > 0)
|
|
@@ -278,71 +300,96 @@ const defaultCliHandlers: CliHandlers = {
|
|
|
278
300
|
tui: tuiCommand,
|
|
279
301
|
}
|
|
280
302
|
|
|
303
|
+
const fileNamePart = (value: string) => value.replace(/^.*[\\/]/, "") || value
|
|
304
|
+
const HELP_NAME_COLOR = "\x1b[38;5;214m"
|
|
305
|
+
const HELP_NAME_RESET = "\x1b[0m"
|
|
306
|
+
const colorCliHelpName = (value: string) => `${HELP_NAME_COLOR}${value}${HELP_NAME_RESET}`
|
|
307
|
+
const cliHelpPlainName = () => process.env.SEND_NAME?.trim() || fileNamePart(Bun.main)
|
|
308
|
+
const cliHelpDisplayName = () => {
|
|
309
|
+
const name = cliHelpPlainName()
|
|
310
|
+
if (!process.stdout.isTTY) return name
|
|
311
|
+
return process.env.SEND_NAME_COLORED?.trim() || colorCliHelpName(name)
|
|
312
|
+
}
|
|
313
|
+
|
|
281
314
|
export const createCli = (handlers: CliHandlers = defaultCliHandlers) => {
|
|
282
|
-
const
|
|
315
|
+
const name = cliHelpDisplayName()
|
|
316
|
+
const cli = cac(name)
|
|
283
317
|
cli.usage("[command] [options]")
|
|
284
318
|
|
|
285
|
-
addOptions(cli.command("peers", "list discovered peers"), [
|
|
319
|
+
withTrailingHelpLine(addOptions(cli.command("peers", "list discovered peers"), [
|
|
286
320
|
...ROOM_SELF_OPTIONS,
|
|
287
321
|
["--wait <ms>", "discovery wait in milliseconds"],
|
|
288
322
|
["--json", "print a json snapshot"],
|
|
289
323
|
SAVE_DIR_OPTION,
|
|
290
324
|
...TURN_OPTIONS,
|
|
291
|
-
]).action(handlers.peers)
|
|
325
|
+
])).action(handlers.peers)
|
|
292
326
|
|
|
293
|
-
addOptions(cli.command("offer [...files]", "offer files to browser-compatible peers"), [
|
|
327
|
+
withTrailingHelpLine(addOptions(cli.command("offer [...files]", "offer files to browser-compatible peers"), [
|
|
294
328
|
...ROOM_SELF_OPTIONS,
|
|
295
|
-
["--to <peer>", "target
|
|
329
|
+
["--to <peer>", "target `name`, `name-id`, or `-id`; `.` targets all ready peers by default"],
|
|
296
330
|
["--wait-peer <ms>", "wait for eligible peers in milliseconds; omit to wait indefinitely"],
|
|
297
331
|
["--json", "emit ndjson events"],
|
|
298
332
|
SAVE_DIR_OPTION,
|
|
299
333
|
...TURN_OPTIONS,
|
|
300
|
-
]).action(handlers.offer)
|
|
334
|
+
])).action(handlers.offer)
|
|
301
335
|
|
|
302
|
-
addOptions(cli.command("accept", "receive and save files"), [
|
|
336
|
+
withTrailingHelpLine(addOptions(cli.command("accept", "receive and save files"), [
|
|
303
337
|
...ROOM_SELF_OPTIONS,
|
|
304
338
|
SAVE_DIR_OPTION,
|
|
339
|
+
OVERWRITE_OPTION,
|
|
305
340
|
["--once", "exit after the first saved incoming transfer"],
|
|
306
341
|
["--json", "emit ndjson events"],
|
|
307
342
|
...TURN_OPTIONS,
|
|
308
|
-
]).action(handlers.accept)
|
|
343
|
+
])).action(handlers.accept)
|
|
309
344
|
|
|
310
|
-
addOptions(cli.command("tui", "launch the interactive terminal UI"), [
|
|
345
|
+
withTrailingHelpLine(addOptions(cli.command("tui", "launch the interactive terminal UI"), [
|
|
311
346
|
...ROOM_SELF_OPTIONS,
|
|
312
347
|
...TUI_TOGGLE_OPTIONS,
|
|
313
348
|
["--events", "show the event log pane"],
|
|
314
349
|
SAVE_DIR_OPTION,
|
|
350
|
+
OVERWRITE_OPTION,
|
|
315
351
|
...TURN_OPTIONS,
|
|
316
|
-
]).action(handlers.tui)
|
|
352
|
+
])).action(handlers.tui)
|
|
317
353
|
|
|
318
354
|
cli.help(sections => {
|
|
319
355
|
const usage = sections.find(section => section.title === "Usage:")
|
|
320
|
-
if (usage) usage.body =
|
|
356
|
+
if (usage) usage.body = ` $ ${name} [command] [options]`
|
|
321
357
|
const moreInfoIndex = sections.findIndex(section => section.title?.startsWith("For more info"))
|
|
322
358
|
const defaultSection = {
|
|
323
359
|
title: "Default",
|
|
324
|
-
body:
|
|
360
|
+
body: ` ${name} with no command launches the terminal UI (same as \`${name} tui\`).`,
|
|
325
361
|
}
|
|
326
362
|
if (moreInfoIndex < 0) sections.push(defaultSection)
|
|
327
363
|
else sections.splice(moreInfoIndex, 0, defaultSection)
|
|
328
364
|
})
|
|
365
|
+
withTrailingHelpLine(cli.globalCommand)
|
|
329
366
|
|
|
330
367
|
return cli
|
|
331
368
|
}
|
|
332
369
|
|
|
333
|
-
const
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
370
|
+
const argvPrefix = (argv: string[]) => [argv[0] ?? process.argv[0] ?? "bun", argv[1] ?? process.argv[1] ?? cliHelpPlainName()]
|
|
371
|
+
const printSubcommandHelp = (argv: string[], handlers: CliHandlers, subcommand: string) =>
|
|
372
|
+
void createCli(handlers).parse([...argvPrefix(argv), subcommand, "--help"], { run: false })
|
|
373
|
+
|
|
374
|
+
const explicitCommand = (argv: string[], handlers: CliHandlers) => {
|
|
375
|
+
const cli = createCli(handlers)
|
|
376
|
+
cli.showHelpOnExit = false
|
|
377
|
+
cli.parse(argv, { run: false })
|
|
378
|
+
if (cli.matchedCommandName) return cli.matchedCommandName
|
|
379
|
+
if (cli.args[0]) throw new ExitError(`Unknown command \`${cli.args[0]}\``, 1)
|
|
380
|
+
return undefined
|
|
338
381
|
}
|
|
339
382
|
|
|
340
383
|
export const runCli = async (argv = process.argv, handlers: CliHandlers = defaultCliHandlers) => {
|
|
341
384
|
const cli = createCli(handlers)
|
|
342
|
-
const command = explicitCommand(
|
|
385
|
+
const command = explicitCommand(argv, handlers)
|
|
343
386
|
const parsed = cli.parse(argv, { run: false }) as { options: Record<string, unknown> }
|
|
344
387
|
const helpRequested = !!parsed.options.help || !!parsed.options.h
|
|
345
|
-
if (!command
|
|
388
|
+
if (!command) {
|
|
389
|
+
if (helpRequested) {
|
|
390
|
+
printSubcommandHelp(argv, handlers, "tui")
|
|
391
|
+
return
|
|
392
|
+
}
|
|
346
393
|
await handlers.tui(parsed.options)
|
|
347
394
|
return
|
|
348
395
|
}
|
package/src/tui/app.ts
CHANGED
|
@@ -2,7 +2,20 @@ import { resolve } from "node:path"
|
|
|
2
2
|
import { BACKEND_RAW_WRITE_MARKER, rgb, ui, type BackendRawWrite, type BadgeVariant, type TextStyle, type UiEvent, type VNode } from "@rezi-ui/core"
|
|
3
3
|
import { createNodeApp } from "@rezi-ui/node"
|
|
4
4
|
import { inspectLocalFile } from "../core/files"
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_WEB_URL,
|
|
7
|
+
inviteCliCommand as formatInviteCliCommand,
|
|
8
|
+
inviteCliPackageName as formatInviteCliPackageName,
|
|
9
|
+
inviteCliText as formatInviteCliText,
|
|
10
|
+
inviteCopyUrl as formatInviteCopyUrl,
|
|
11
|
+
inviteWebLabel as formatInviteWebLabel,
|
|
12
|
+
renderCliCommand as renderSharedCliCommand,
|
|
13
|
+
renderWebUrl,
|
|
14
|
+
resolveWebUrlBase as resolveSharedWebUrlBase,
|
|
15
|
+
schemeLessUrlText,
|
|
16
|
+
webInviteUrl as formatWebInviteUrl,
|
|
17
|
+
} from "../core/invite"
|
|
18
|
+
import { isSessionAbortedError, SendSession, signalMetricState, type PeerSnapshot, type SessionConfig, type SessionSnapshot, type TransferSnapshot } from "../core/session"
|
|
6
19
|
import { cleanLocalId, cleanName, cleanRoom, displayPeerName, fallbackName, formatBytes, type LogEntry, peerDefaultsToken, type PeerProfile, uid } from "../core/protocol"
|
|
7
20
|
import { FILE_SEARCH_VISIBLE_ROWS, type FileSearchEvent, type FileSearchMatch, type FileSearchRequest } from "./file-search-protocol"
|
|
8
21
|
import { deriveFileSearchScope, formatFileSearchDisplayPath, normalizeSearchQuery, offsetFileSearchMatchIndices } from "./file-search"
|
|
@@ -72,6 +85,7 @@ export interface TuiState {
|
|
|
72
85
|
autoOfferOutgoing: boolean
|
|
73
86
|
autoAcceptIncoming: boolean
|
|
74
87
|
autoSaveIncoming: boolean
|
|
88
|
+
overwriteIncoming: boolean
|
|
75
89
|
hideTerminalPeers: boolean
|
|
76
90
|
eventsExpanded: boolean
|
|
77
91
|
offeringDrafts: boolean
|
|
@@ -86,6 +100,7 @@ export interface TuiActions {
|
|
|
86
100
|
closeInviteDropdown: TuiAction
|
|
87
101
|
copyWebInvite: TuiAction
|
|
88
102
|
copyCliInvite: TuiAction
|
|
103
|
+
copyLogs: TuiAction
|
|
89
104
|
jumpToRandomRoom: TuiAction
|
|
90
105
|
commitRoom: TuiAction
|
|
91
106
|
setRoomInput: (value: string) => void
|
|
@@ -128,14 +143,17 @@ const METRIC_BORDER_STYLE = { fg: rgb(20, 25, 32) } as const
|
|
|
128
143
|
const PRIMARY_TEXT_STYLE = { fg: rgb(255, 255, 255) } as const
|
|
129
144
|
const HEADING_TEXT_STYLE = { fg: rgb(255, 255, 255), bold: true } as const
|
|
130
145
|
const MUTED_TEXT_STYLE = { fg: rgb(159, 166, 178) } as const
|
|
131
|
-
const DEFAULT_WEB_URL = "https://rtme.sh/"
|
|
132
146
|
const DEFAULT_SAVE_DIR = resolve(process.cwd())
|
|
133
147
|
const ABOUT_ELEFUNC_URL = "https://rtme.sh/send"
|
|
134
148
|
const ABOUT_TITLE = "About Send"
|
|
135
149
|
const ABOUT_INTRO = "Peer-to-Peer Transfers – Web & CLI"
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
150
|
+
const ABOUT_BULLETS = [
|
|
151
|
+
"• Join a room, see who is there, and filter or select exactly which peers to target before offering files.",
|
|
152
|
+
"• File data does not travel through the signaling service; Send uses lightweight signaling to discover peers and negotiate WebRTC, then transfers directly peer-to-peer when possible, with TURN relay when needed.",
|
|
153
|
+
"• Incoming transfers can be auto-accepted and auto-saved, and same-name files can either stay as numbered copies or overwrite the original when that mode is enabled.",
|
|
154
|
+
"• The CLI streams incoming saves straight to disk in the current save directory, with overwrite available through the CLI flag.",
|
|
155
|
+
"• Other features include copyable web and CLI invites, rendered-peer filtering and selection, TURN sharing, and live connection insight like signaling state, RTT, data state, and path labels.",
|
|
156
|
+
] as const
|
|
139
157
|
const TRANSFER_DIRECTION_ARROW = {
|
|
140
158
|
out: { glyph: "↗", style: { fg: rgb(170, 217, 76), bold: true } },
|
|
141
159
|
in: { glyph: "↙", style: { fg: rgb(240, 113, 120), bold: true } },
|
|
@@ -154,8 +172,6 @@ export const isEditableFocusId = (focusedId: string | null) =>
|
|
|
154
172
|
focusedId === ROOM_INPUT_ID || focusedId === NAME_INPUT_ID || focusedId === PEER_SEARCH_INPUT_ID || focusedId === DRAFT_INPUT_ID
|
|
155
173
|
export const shouldSwallowQQuit = (focusedId: string | null) => !isEditableFocusId(focusedId)
|
|
156
174
|
|
|
157
|
-
const hashBool = (value: boolean) => value ? "1" : "0"
|
|
158
|
-
const safeShellArgPattern = /^[A-Za-z0-9._/:?=&,+@%-]+$/
|
|
159
175
|
const TUI_QUIT_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"] as const
|
|
160
176
|
type TuiQuitSignal = (typeof TUI_QUIT_SIGNALS)[number]
|
|
161
177
|
type ProcessSignalLike = {
|
|
@@ -169,63 +185,32 @@ type BunSpawn = (cmd: string[], options: {
|
|
|
169
185
|
stderr?: "pipe" | "inherit" | "ignore"
|
|
170
186
|
}) => { unref?: () => void }
|
|
171
187
|
type BunLike = { spawn?: BunSpawn }
|
|
172
|
-
type ShareUrlState = Pick<TuiState, "snapshot" | "hideTerminalPeers" | "autoAcceptIncoming" | "autoOfferOutgoing" | "autoSaveIncoming">
|
|
188
|
+
type ShareUrlState = Pick<TuiState, "snapshot" | "hideTerminalPeers" | "autoAcceptIncoming" | "autoOfferOutgoing" | "autoSaveIncoming" | "overwriteIncoming">
|
|
173
189
|
type ShareCliState = ShareUrlState & Pick<TuiState, "sessionSeed" | "eventsExpanded">
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
for (const turnUrl of turnUrls) appendCliFlag(args, "--turn-url", turnUrl)
|
|
199
|
-
appendCliFlag(args, "--turn-username", sessionSeed.turnUsername)
|
|
200
|
-
appendCliFlag(args, "--turn-credential", sessionSeed.turnCredential)
|
|
201
|
-
return args
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
export const resolveWebUrlBase = (value = process.env.SEND_WEB_URL) => {
|
|
205
|
-
const candidate = `${value ?? ""}`.trim() || DEFAULT_WEB_URL
|
|
206
|
-
try {
|
|
207
|
-
return new URL(candidate).toString()
|
|
208
|
-
} catch {
|
|
209
|
-
return DEFAULT_WEB_URL
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
const renderWebUrl = (state: ShareUrlState, baseUrl = DEFAULT_WEB_URL, omitDefaults = true) => {
|
|
214
|
-
const url = new URL(baseUrl)
|
|
215
|
-
url.hash = buildHashParams(state, omitDefaults).toString()
|
|
216
|
-
return url.toString()
|
|
217
|
-
}
|
|
218
|
-
const schemeLessUrlText = (text: string) => text.replace(/^[a-z]+:\/\//, "")
|
|
219
|
-
export const inviteWebLabel = (state: ShareUrlState, baseUrl = resolveWebUrlBase()) => schemeLessUrlText(webInviteUrl(state, baseUrl))
|
|
220
|
-
export const inviteCliPackageName = (baseUrl = resolveWebUrlBase()) => new URL(resolveWebUrlBase(baseUrl)).hostname
|
|
221
|
-
export const inviteCliCommand = (state: ShareUrlState) => {
|
|
222
|
-
const args: string[] = []
|
|
223
|
-
appendCliFlag(args, "--room", state.snapshot.room)
|
|
224
|
-
appendToggleCliFlags(args, state)
|
|
225
|
-
return args.join(" ")
|
|
226
|
-
}
|
|
227
|
-
export const inviteCliText = (state: ShareUrlState, baseUrl = resolveWebUrlBase()) => `bunx ${inviteCliPackageName(baseUrl)} ${inviteCliCommand(state)}`
|
|
228
|
-
export const inviteCopyUrl = (text: string) => `${COPY_SERVICE_URL}#${new URLSearchParams({ text })}`
|
|
190
|
+
const shareUrlOptions = (state: ShareUrlState) => ({
|
|
191
|
+
room: cleanRoom(state.snapshot.room),
|
|
192
|
+
clean: state.hideTerminalPeers,
|
|
193
|
+
accept: state.autoAcceptIncoming,
|
|
194
|
+
offer: state.autoOfferOutgoing,
|
|
195
|
+
save: state.autoSaveIncoming,
|
|
196
|
+
overwrite: state.overwriteIncoming,
|
|
197
|
+
})
|
|
198
|
+
const shareCliOptions = (state: ShareCliState) => ({
|
|
199
|
+
...shareUrlOptions(state),
|
|
200
|
+
self: displayPeerName(state.snapshot.name, state.snapshot.localId),
|
|
201
|
+
events: state.eventsExpanded,
|
|
202
|
+
saveDir: state.snapshot.saveDir,
|
|
203
|
+
defaultSaveDir: DEFAULT_SAVE_DIR,
|
|
204
|
+
turnUrls: state.sessionSeed.turnUrls,
|
|
205
|
+
turnUsername: state.sessionSeed.turnUsername,
|
|
206
|
+
turnCredential: state.sessionSeed.turnCredential,
|
|
207
|
+
})
|
|
208
|
+
export const resolveWebUrlBase = (value = process.env.SEND_WEB_URL) => resolveSharedWebUrlBase(value)
|
|
209
|
+
export const inviteWebLabel = (state: ShareUrlState, baseUrl = resolveWebUrlBase()) => formatInviteWebLabel(shareUrlOptions(state), baseUrl)
|
|
210
|
+
export const inviteCliPackageName = (baseUrl = resolveWebUrlBase()) => formatInviteCliPackageName(baseUrl)
|
|
211
|
+
export const inviteCliCommand = (state: ShareUrlState) => formatInviteCliCommand(shareUrlOptions(state))
|
|
212
|
+
export const inviteCliText = (state: ShareUrlState, baseUrl = resolveWebUrlBase()) => formatInviteCliText(shareUrlOptions(state), baseUrl)
|
|
213
|
+
export const inviteCopyUrl = (text: string) => formatInviteCopyUrl(text)
|
|
229
214
|
export const buildOsc52ClipboardSequence = (text: string) => text ? `\u001b]52;c;${Buffer.from(text).toString("base64")}\u0007` : ""
|
|
230
215
|
export const externalOpenCommand = (url: string, platform = process.platform) =>
|
|
231
216
|
platform === "darwin" ? ["open", url]
|
|
@@ -236,30 +221,17 @@ const getBackendRawWriter = (backend: unknown): BackendRawWrite | null => {
|
|
|
236
221
|
return typeof marker === "function" ? marker as BackendRawWrite : null
|
|
237
222
|
}
|
|
238
223
|
const getBunRuntime = () => (globalThis as typeof globalThis & { Bun?: BunLike }).Bun ?? null
|
|
239
|
-
const
|
|
240
|
-
const args = includePrefix ? ["bunx", "rtme.sh"] : []
|
|
241
|
-
appendCliFlag(args, "--room", state.snapshot.room)
|
|
242
|
-
if (includeSelf) appendCliFlag(args, "--self", displayPeerName(state.snapshot.name, state.snapshot.localId))
|
|
243
|
-
appendToggleCliFlags(args, state)
|
|
244
|
-
if (state.eventsExpanded) args.push("--events")
|
|
245
|
-
if (resolve(state.snapshot.saveDir) !== DEFAULT_SAVE_DIR) appendCliFlag(args, "--save-dir", state.snapshot.saveDir)
|
|
246
|
-
args.push(...shareTurnCliArgs(state.sessionSeed))
|
|
247
|
-
return args.join(" ")
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
export const webInviteUrl = (state: ShareUrlState, baseUrl = resolveWebUrlBase()) => {
|
|
251
|
-
return renderWebUrl(state, baseUrl)
|
|
252
|
-
}
|
|
224
|
+
export const webInviteUrl = (state: ShareUrlState, baseUrl = resolveWebUrlBase()) => formatWebInviteUrl(shareUrlOptions(state), baseUrl)
|
|
253
225
|
|
|
254
|
-
export const aboutWebUrl = (state: ShareUrlState, baseUrl = DEFAULT_WEB_URL) => renderWebUrl(state, baseUrl)
|
|
226
|
+
export const aboutWebUrl = (state: ShareUrlState, baseUrl = DEFAULT_WEB_URL) => renderWebUrl(shareUrlOptions(state), baseUrl)
|
|
255
227
|
|
|
256
|
-
export const aboutWebLabel = (state: ShareUrlState, baseUrl = DEFAULT_WEB_URL) => aboutWebUrl(state, baseUrl)
|
|
228
|
+
export const aboutWebLabel = (state: ShareUrlState, baseUrl = DEFAULT_WEB_URL) => schemeLessUrlText(aboutWebUrl(state, baseUrl))
|
|
257
229
|
|
|
258
|
-
export const aboutCliCommand = (state: ShareCliState) =>
|
|
230
|
+
export const aboutCliCommand = (state: ShareCliState) => renderSharedCliCommand(shareCliOptions(state))
|
|
259
231
|
|
|
260
|
-
export const resumeWebUrl = (state: ShareUrlState, baseUrl = DEFAULT_WEB_URL) => renderWebUrl(state, baseUrl)
|
|
232
|
+
export const resumeWebUrl = (state: ShareUrlState, baseUrl = DEFAULT_WEB_URL) => renderWebUrl(shareUrlOptions(state), baseUrl)
|
|
261
233
|
|
|
262
|
-
export const resumeCliCommand = (state: ShareCliState) =>
|
|
234
|
+
export const resumeCliCommand = (state: ShareCliState) => renderSharedCliCommand(shareCliOptions(state), { includeSelf: true, includePrefix: true, packageName: "rtme.sh" })
|
|
263
235
|
|
|
264
236
|
export const resumeOutputLines = (state: ShareCliState) => [
|
|
265
237
|
"Rejoin with:",
|
|
@@ -304,6 +276,7 @@ export const createNoopTuiActions = (): TuiActions => ({
|
|
|
304
276
|
closeInviteDropdown: noop,
|
|
305
277
|
copyWebInvite: noop,
|
|
306
278
|
copyCliInvite: noop,
|
|
279
|
+
copyLogs: noop,
|
|
307
280
|
jumpToRandomRoom: noop,
|
|
308
281
|
commitRoom: noop,
|
|
309
282
|
setRoomInput: noop,
|
|
@@ -339,6 +312,15 @@ const shortText = (value: unknown, max = 88) => {
|
|
|
339
312
|
const text = typeof value === "string" ? value : JSON.stringify(value)
|
|
340
313
|
return text.length <= max ? text : `${text.slice(0, Math.max(0, max - 1))}…`
|
|
341
314
|
}
|
|
315
|
+
const formatLogPayloadText = (payload: unknown) => {
|
|
316
|
+
if (typeof payload === "string") return payload
|
|
317
|
+
try {
|
|
318
|
+
return JSON.stringify(payload, null, 2) ?? `${payload}`
|
|
319
|
+
} catch {
|
|
320
|
+
return `${payload}`
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
export const formatLogsForCopy = (logs: readonly LogEntry[]) => logs.map(log => `${timeFormat.format(log.at)} ${log.kind}\n${formatLogPayloadText(log.payload)}`).join("\n\n")
|
|
342
324
|
const toggleIntent = (active: boolean) => active ? "success" : "secondary"
|
|
343
325
|
const statusKind = (socketState: SessionSnapshot["socketState"]) => socketState === "open" ? "online" : socketState === "connecting" ? "busy" : socketState === "error" ? "offline" : "away"
|
|
344
326
|
const peerConnectionStatusKind = (status: string) => ({
|
|
@@ -393,7 +375,7 @@ const statusVariant = (status: TransferSnapshot["status"]): BadgeVariant => ({
|
|
|
393
375
|
cancelling: "warning",
|
|
394
376
|
"awaiting-done": "info",
|
|
395
377
|
}[status] || "default") as BadgeVariant
|
|
396
|
-
const statusToneVariant = (value: string): BadgeVariant => ({
|
|
378
|
+
export const statusToneVariant = (value: string): BadgeVariant => ({
|
|
397
379
|
open: "success",
|
|
398
380
|
connected: "success",
|
|
399
381
|
complete: "success",
|
|
@@ -411,6 +393,7 @@ const statusToneVariant = (value: string): BadgeVariant => ({
|
|
|
411
393
|
pending: "warning",
|
|
412
394
|
cancelling: "warning",
|
|
413
395
|
checking: "warning",
|
|
396
|
+
degraded: "warning",
|
|
414
397
|
disconnected: "warning",
|
|
415
398
|
left: "warning",
|
|
416
399
|
rejected: "warning",
|
|
@@ -730,6 +713,7 @@ export const createInitialTuiState = (initialConfig: SessionConfig, showEvents =
|
|
|
730
713
|
const sessionSeed = normalizeSessionSeed(initialConfig)
|
|
731
714
|
const autoAcceptIncoming = initialConfig.autoAcceptIncoming ?? true
|
|
732
715
|
const autoSaveIncoming = initialConfig.autoSaveIncoming ?? true
|
|
716
|
+
const overwriteIncoming = !!initialConfig.overwriteIncoming
|
|
733
717
|
const peerSelectionByRoom = new Map<string, Map<string, boolean>>()
|
|
734
718
|
const session = makeSession(sessionSeed, autoAcceptIncoming, autoSaveIncoming, roomPeerSelectionMemory(peerSelectionByRoom, sessionSeed.room))
|
|
735
719
|
const focusState = deriveBootFocusState(sessionSeed.name)
|
|
@@ -753,6 +737,7 @@ export const createInitialTuiState = (initialConfig: SessionConfig, showEvents =
|
|
|
753
737
|
autoOfferOutgoing: launchOptions.offer ?? true,
|
|
754
738
|
autoAcceptIncoming,
|
|
755
739
|
autoSaveIncoming,
|
|
740
|
+
overwriteIncoming,
|
|
756
741
|
hideTerminalPeers: launchOptions.clean ?? true,
|
|
757
742
|
eventsExpanded: showEvents,
|
|
758
743
|
offeringDrafts: false,
|
|
@@ -854,8 +839,7 @@ const renderAboutModal = (_state: TuiState, actions: TuiActions) => {
|
|
|
854
839
|
title: ABOUT_TITLE,
|
|
855
840
|
content: ui.column({ gap: 1 }, [
|
|
856
841
|
ui.text(ABOUT_INTRO, { id: "about-intro", variant: "heading", wrap: true }),
|
|
857
|
-
ui.text(
|
|
858
|
-
ui.text(ABOUT_RUNTIME, { id: "about-runtime", wrap: true }),
|
|
842
|
+
...ABOUT_BULLETS.map((line, index) => ui.text(line, { id: `about-bullet-${index + 1}`, wrap: true })),
|
|
859
843
|
]),
|
|
860
844
|
actions: [
|
|
861
845
|
ui.link({
|
|
@@ -957,8 +941,7 @@ const renderSelfCard = (state: TuiState, actions: TuiActions) => denseSection({
|
|
|
957
941
|
ui.text(`-${state.snapshot.localId}`),
|
|
958
942
|
]),
|
|
959
943
|
ui.row({ gap: 0, wrap: true }, [
|
|
960
|
-
renderSelfMetric("Signaling", state.snapshot.socketState),
|
|
961
|
-
renderSelfMetric("Pulse", state.snapshot.pulse.state),
|
|
944
|
+
renderSelfMetric("Signaling", signalMetricState(state.snapshot.socketState, state.snapshot.pulse)),
|
|
962
945
|
renderSelfMetric("TURN", state.snapshot.turnState),
|
|
963
946
|
]),
|
|
964
947
|
ui.column({ gap: 0 }, [
|
|
@@ -1000,10 +983,10 @@ const renderPeerRow = (peer: PeerSnapshot, turnShareEnabled: boolean, actions: T
|
|
|
1000
983
|
]),
|
|
1001
984
|
ui.row({ gap: 0 }, [
|
|
1002
985
|
renderPeerMetric("RTT", formatPeerRtt(peer.rttMs)),
|
|
1003
|
-
renderPeerMetric("
|
|
986
|
+
renderPeerMetric("TURN", peer.turnState, true),
|
|
1004
987
|
]),
|
|
1005
988
|
ui.row({ gap: 0 }, [
|
|
1006
|
-
renderPeerMetric("
|
|
989
|
+
renderPeerMetric("Data", peer.dataState, true),
|
|
1007
990
|
renderPeerMetric("Path", peer.pathLabel || "—"),
|
|
1008
991
|
]),
|
|
1009
992
|
ui.column({ gap: 0 }, [
|
|
@@ -1141,7 +1124,7 @@ const transferActionButtons = (transfer: TransferSnapshot, actions: TuiActions):
|
|
|
1141
1124
|
return []
|
|
1142
1125
|
}
|
|
1143
1126
|
|
|
1144
|
-
const renderTransferFact = (label: string, value: string) => ui.box({ minWidth: 12 }, [
|
|
1127
|
+
const renderTransferFact = (label: string, value: string) => ui.box({ minWidth: 12, border: "single", borderStyle: METRIC_BORDER_STYLE }, [
|
|
1145
1128
|
ui.column({ gap: 0 }, [
|
|
1146
1129
|
ui.text(label, { variant: "caption" }),
|
|
1147
1130
|
ui.text(value),
|
|
@@ -1267,8 +1250,8 @@ const renderEventsCard = (state: TuiState, actions: TuiActions) => denseSection(
|
|
|
1267
1250
|
id: "events-card",
|
|
1268
1251
|
title: "Events",
|
|
1269
1252
|
actions: [
|
|
1253
|
+
actionButton("copy-events", "Copy", actions.copyLogs, "secondary", !state.snapshot.logs.length),
|
|
1270
1254
|
actionButton("clear-events", "Clear", actions.clearLogs, "warning", !state.snapshot.logs.length),
|
|
1271
|
-
actionButton("hide-events", "Hide", actions.toggleEvents),
|
|
1272
1255
|
],
|
|
1273
1256
|
}, [
|
|
1274
1257
|
ui.box({ maxHeight: 24, overflow: "scroll" }, [
|
|
@@ -1385,23 +1368,30 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
|
|
|
1385
1368
|
return false
|
|
1386
1369
|
}
|
|
1387
1370
|
}
|
|
1388
|
-
const
|
|
1389
|
-
const closeDropdown = (notice: Notice) => commit(current => withNotice({ ...current, inviteDropdownOpen: false }, notice))
|
|
1371
|
+
const copyTextPayload = async (payload: string, notices: { copied: string; opened: string; failed: string }, finish = (notice: Notice) => commit(current => withNotice(current, notice))) => {
|
|
1390
1372
|
const rawWrite = getBackendRawWriter(app.backend)
|
|
1391
1373
|
if (rawWrite && await ensureOsc52Support()) {
|
|
1392
1374
|
const sequence = buildOsc52ClipboardSequence(payload)
|
|
1393
1375
|
if (sequence) {
|
|
1394
1376
|
try {
|
|
1395
1377
|
rawWrite(sequence)
|
|
1396
|
-
|
|
1378
|
+
finish({ text: notices.copied, variant: "success" })
|
|
1397
1379
|
return
|
|
1398
1380
|
} catch {}
|
|
1399
1381
|
}
|
|
1400
1382
|
}
|
|
1401
1383
|
const opened = openExternalUrl(inviteCopyUrl(payload))
|
|
1402
|
-
|
|
1403
|
-
? { text:
|
|
1404
|
-
: { text:
|
|
1384
|
+
finish(opened
|
|
1385
|
+
? { text: notices.opened, variant: "info" }
|
|
1386
|
+
: { text: notices.failed, variant: "error" })
|
|
1387
|
+
}
|
|
1388
|
+
const copyInvitePayload = async (payload: string, label: "WEB" | "CLI") => {
|
|
1389
|
+
const closeDropdown = (notice: Notice) => commit(current => withNotice({ ...current, inviteDropdownOpen: false }, notice))
|
|
1390
|
+
await copyTextPayload(payload, {
|
|
1391
|
+
copied: `Copied ${label} invite.`,
|
|
1392
|
+
opened: `Opened ${label} copy link.`,
|
|
1393
|
+
failed: `Unable to copy ${label} invite.`,
|
|
1394
|
+
}, closeDropdown)
|
|
1405
1395
|
}
|
|
1406
1396
|
|
|
1407
1397
|
const flushUpdate = () => {
|
|
@@ -1676,6 +1666,7 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
|
|
|
1676
1666
|
filePreview: resetFilePreview(),
|
|
1677
1667
|
drafts: [],
|
|
1678
1668
|
offeringDrafts: false,
|
|
1669
|
+
overwriteIncoming: !!nextSeed.overwriteIncoming,
|
|
1679
1670
|
...(options.reseedBootFocus
|
|
1680
1671
|
? deriveBootFocusState(nextSeed.name, current.focusRequestEpoch + 1)
|
|
1681
1672
|
: {
|
|
@@ -1751,6 +1742,15 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
|
|
|
1751
1742
|
closeInviteDropdown: () => commit(current => current.inviteDropdownOpen ? { ...current, inviteDropdownOpen: false } : current),
|
|
1752
1743
|
copyWebInvite: () => { void copyInvitePayload(webInviteUrl(state), "WEB") },
|
|
1753
1744
|
copyCliInvite: () => { void copyInvitePayload(inviteCliText(state), "CLI") },
|
|
1745
|
+
copyLogs: () => {
|
|
1746
|
+
const payload = formatLogsForCopy(state.snapshot.logs)
|
|
1747
|
+
if (!payload) return
|
|
1748
|
+
void copyTextPayload(payload, {
|
|
1749
|
+
copied: "Copied events.",
|
|
1750
|
+
opened: "Opened event copy link.",
|
|
1751
|
+
failed: "Unable to copy events.",
|
|
1752
|
+
})
|
|
1753
|
+
},
|
|
1754
1754
|
jumpToRandomRoom: () => replaceSession({ ...state.sessionSeed, room: uid(8) }, "Joined a new room."),
|
|
1755
1755
|
commitRoom,
|
|
1756
1756
|
setRoomInput: value => commit(current => ({ ...current, roomInput: value })),
|