@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,39 @@
|
|
|
1
|
+
import { cleanName, displayPeerName } from "./protocol"
|
|
2
|
+
|
|
3
|
+
export interface TargetPeer {
|
|
4
|
+
id: string
|
|
5
|
+
name: string
|
|
6
|
+
ready: boolean
|
|
7
|
+
presence: "active" | "terminal"
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ResolveTargetsResult {
|
|
11
|
+
ok: boolean
|
|
12
|
+
peers: TargetPeer[]
|
|
13
|
+
error?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const BROADCAST_SELECTOR = "."
|
|
17
|
+
|
|
18
|
+
const uniquePeers = (peers: TargetPeer[]) => [...new Map(peers.map(peer => [peer.id, peer])).values()]
|
|
19
|
+
|
|
20
|
+
const matchesSelector = (peer: TargetPeer, selector: string) => selector === peer.id || selector === displayPeerName(peer.name, peer.id) || selector === cleanName(peer.name)
|
|
21
|
+
|
|
22
|
+
export const resolvePeerTargets = (peers: TargetPeer[], selectors: string[]): ResolveTargetsResult => {
|
|
23
|
+
const active = peers.filter(peer => peer.presence === "active")
|
|
24
|
+
const ready = active.filter(peer => peer.ready)
|
|
25
|
+
const requested = [...new Set(selectors.filter(Boolean))]
|
|
26
|
+
const normalized = requested.length ? requested : [BROADCAST_SELECTOR]
|
|
27
|
+
if (normalized.includes(BROADCAST_SELECTOR)) {
|
|
28
|
+
if (normalized.length > 1) return { ok: false, peers: [], error: "broadcast selector `.` cannot be combined with specific peers" }
|
|
29
|
+
return ready.length ? { ok: true, peers: ready } : { ok: false, peers: [], error: "no ready peers" }
|
|
30
|
+
}
|
|
31
|
+
const matches = uniquePeers(active.filter(peer => normalized.some(selector => matchesSelector(peer, selector))))
|
|
32
|
+
if (matches.length !== normalized.length) {
|
|
33
|
+
const missing = normalized.filter(selector => !matches.some(peer => matchesSelector(peer, selector)))
|
|
34
|
+
return { ok: false, peers: [], error: `no matching peer for ${missing.join(", ")}` }
|
|
35
|
+
}
|
|
36
|
+
const notReady = matches.filter(peer => !peer.ready)
|
|
37
|
+
if (notReady.length) return { ok: false, peers: [], error: `peer not ready: ${notReady.map(peer => displayPeerName(peer.name, peer.id)).join(", ")}` }
|
|
38
|
+
return { ok: true, peers: matches }
|
|
39
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { resolve } from "node:path"
|
|
3
|
+
import { cac, type CAC } from "cac"
|
|
4
|
+
import { cleanRoom } from "./core/protocol"
|
|
5
|
+
import { SendSession, type SessionConfig, type SessionEvent } from "./core/session"
|
|
6
|
+
import { resolvePeerTargets } from "./core/targeting"
|
|
7
|
+
import { startTui } from "./tui/app"
|
|
8
|
+
|
|
9
|
+
export class ExitError extends Error {
|
|
10
|
+
constructor(message: string, readonly code = 1) {
|
|
11
|
+
super(message)
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const WAIT_POLL_MS = 125
|
|
16
|
+
|
|
17
|
+
const toArray = (value: unknown): string[] => value == null ? [] : Array.isArray(value) ? value.flatMap(item => toArray(item)) : [`${value}`]
|
|
18
|
+
const splitList = (value: unknown) => toArray(value).flatMap((item: string) => item.split(",")).map((item: string) => item.trim()).filter(Boolean)
|
|
19
|
+
const firstNonEmptyText = (...values: unknown[]) => {
|
|
20
|
+
for (const value of values) {
|
|
21
|
+
const text = `${value ?? ""}`.trim()
|
|
22
|
+
if (text) return text
|
|
23
|
+
}
|
|
24
|
+
return undefined
|
|
25
|
+
}
|
|
26
|
+
const numberOption = (value: unknown, fallback: number) => {
|
|
27
|
+
const parsed = Number(value)
|
|
28
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback
|
|
29
|
+
}
|
|
30
|
+
const offerSelectors = (value: unknown) => {
|
|
31
|
+
const selectors = splitList(value)
|
|
32
|
+
return selectors.length ? selectors : ["."]
|
|
33
|
+
}
|
|
34
|
+
const waitPeerTimeout = (value: unknown) => {
|
|
35
|
+
if (value == null) return undefined
|
|
36
|
+
const parsed = Number(value)
|
|
37
|
+
if (!Number.isFinite(parsed) || parsed < 0) throw new ExitError("--wait-peer must be a finite non-negative number of milliseconds", 1)
|
|
38
|
+
return parsed
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const SELF_ID_LENGTH = 8
|
|
42
|
+
const SELF_ID_PATTERN = new RegExp(`^[a-z0-9]{${SELF_ID_LENGTH}}$`)
|
|
43
|
+
const SELF_HELP_TEXT = "self identity: name, name-ID, or -ID (use --self=-ID)"
|
|
44
|
+
const INVALID_SELF_ID_MESSAGE = `--self ID suffix must be exactly ${SELF_ID_LENGTH} lowercase alphanumeric characters`
|
|
45
|
+
|
|
46
|
+
const requireSelfId = (value: string) => {
|
|
47
|
+
if (!SELF_ID_PATTERN.test(value)) throw new ExitError(INVALID_SELF_ID_MESSAGE, 1)
|
|
48
|
+
return value
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const parseSelfOption = (value: unknown): Pick<SessionConfig, "name" | "localId"> => {
|
|
52
|
+
const self = `${value ?? ""}`.trim()
|
|
53
|
+
if (!self) return {}
|
|
54
|
+
if (self.startsWith("-")) return { localId: requireSelfId(self.slice(1)) }
|
|
55
|
+
const lastHyphen = self.lastIndexOf("-")
|
|
56
|
+
if (lastHyphen < 0) return { name: self }
|
|
57
|
+
const name = self.slice(0, lastHyphen)
|
|
58
|
+
return { name, localId: requireSelfId(self.slice(lastHyphen + 1)) }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const sessionConfigFrom = (options: Record<string, unknown>, defaults: { autoAcceptIncoming?: boolean; autoSaveIncoming?: boolean }): SessionConfig & { room: string } => {
|
|
62
|
+
const room = cleanRoom(firstNonEmptyText(options.room, process.env.SEND_ROOM))
|
|
63
|
+
const self = parseSelfOption(options.self ?? process.env.SEND_SELF)
|
|
64
|
+
return {
|
|
65
|
+
room,
|
|
66
|
+
...self,
|
|
67
|
+
saveDir: resolve(`${options.saveDir ?? process.env.SEND_SAVE_DIR ?? "downloads"}`),
|
|
68
|
+
autoAcceptIncoming: defaults.autoAcceptIncoming ?? false,
|
|
69
|
+
autoSaveIncoming: defaults.autoSaveIncoming ?? false,
|
|
70
|
+
turnUrls: splitList(options.turnUrl ?? process.env.SEND_TURN_URL),
|
|
71
|
+
turnUsername: `${options.turnUsername ?? process.env.SEND_TURN_USERNAME ?? ""}`.trim() || undefined,
|
|
72
|
+
turnCredential: `${options.turnCredential ?? process.env.SEND_TURN_CREDENTIAL ?? ""}`.trim() || undefined,
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const roomAnnouncement = (room: string, json = false) => json ? JSON.stringify({ type: "room", room }) : `room ${room}`
|
|
77
|
+
|
|
78
|
+
const printRoomAnnouncement = (room: string, json = false) => console.log(roomAnnouncement(room, json))
|
|
79
|
+
|
|
80
|
+
const printEvent = (event: SessionEvent) => console.log(JSON.stringify(event))
|
|
81
|
+
|
|
82
|
+
const attachReporter = (session: SendSession, json = false) => {
|
|
83
|
+
if (json) return session.onEvent(printEvent)
|
|
84
|
+
const seen = new Map<string, string>()
|
|
85
|
+
return session.onEvent(event => {
|
|
86
|
+
if (event.type === "saved") {
|
|
87
|
+
console.log(`saved ${event.transfer.name} -> ${event.transfer.savedPath}`)
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
if (event.type !== "transfer") return
|
|
91
|
+
const previous = seen.get(event.transfer.id)
|
|
92
|
+
if (previous === event.transfer.status) return
|
|
93
|
+
seen.set(event.transfer.id, event.transfer.status)
|
|
94
|
+
if (!["offered", "accepted", "sending", "receiving", "complete", "rejected", "cancelled", "error"].includes(event.transfer.status)) return
|
|
95
|
+
const peer = event.transfer.peerName || event.transfer.peerId
|
|
96
|
+
console.log(`${event.transfer.direction === "out" ? "send" : "recv"} ${event.transfer.status} ${event.transfer.name} ${peer}`)
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const waitForTargets = async (session: SendSession, selectors: string[], timeoutMs?: number) => {
|
|
101
|
+
const startedAt = Date.now()
|
|
102
|
+
let lastError = "no ready peers"
|
|
103
|
+
for (;;) {
|
|
104
|
+
const snapshot = session.snapshot()
|
|
105
|
+
const result = resolvePeerTargets(snapshot.peers.map(peer => ({ id: peer.id, name: peer.name, ready: peer.ready, presence: peer.presence })), selectors)
|
|
106
|
+
if (result.ok) return result.peers
|
|
107
|
+
lastError = result.error ?? lastError
|
|
108
|
+
if (timeoutMs === 0 || timeoutMs != null && Date.now() - startedAt >= timeoutMs) break
|
|
109
|
+
await Bun.sleep(WAIT_POLL_MS)
|
|
110
|
+
}
|
|
111
|
+
throw new ExitError(lastError, 2)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const waitForFinalTransfers = async (session: SendSession, transferIds: string[]) => {
|
|
115
|
+
for (;;) {
|
|
116
|
+
const done = transferIds.every(transferId => {
|
|
117
|
+
const transfer = session.getTransfer(transferId)
|
|
118
|
+
return !!transfer && ["complete", "rejected", "cancelled", "error"].includes(transfer.status)
|
|
119
|
+
})
|
|
120
|
+
if (done) return transferIds.map(transferId => session.getTransfer(transferId)).filter(Boolean)
|
|
121
|
+
await Bun.sleep(125)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const handleSignals = (session: SendSession) => {
|
|
126
|
+
const onSignal = async () => {
|
|
127
|
+
await session.close()
|
|
128
|
+
process.exit(130)
|
|
129
|
+
}
|
|
130
|
+
process.once("SIGINT", () => void onSignal())
|
|
131
|
+
process.once("SIGTERM", () => void onSignal())
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const peersCommand = async (options: Record<string, unknown>) => {
|
|
135
|
+
const session = new SendSession(sessionConfigFrom(options, {}))
|
|
136
|
+
handleSignals(session)
|
|
137
|
+
printRoomAnnouncement(session.room, !!options.json)
|
|
138
|
+
await session.connect()
|
|
139
|
+
await Bun.sleep(numberOption(options.wait, 3000))
|
|
140
|
+
const snapshot = session.snapshot()
|
|
141
|
+
if (options.json) {
|
|
142
|
+
console.log(JSON.stringify({
|
|
143
|
+
room: snapshot.room,
|
|
144
|
+
localId: snapshot.localId,
|
|
145
|
+
name: snapshot.name,
|
|
146
|
+
socketState: snapshot.socketState,
|
|
147
|
+
peers: snapshot.peers,
|
|
148
|
+
}))
|
|
149
|
+
} else if (!snapshot.peers.length) {
|
|
150
|
+
console.log(`no peers in ${snapshot.room}`)
|
|
151
|
+
} else {
|
|
152
|
+
for (const peer of snapshot.peers) console.log(`${peer.ready ? "*" : "-"} ${peer.displayName} ${peer.status}`)
|
|
153
|
+
}
|
|
154
|
+
await session.close()
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const offerCommand = async (files: string[], options: Record<string, unknown>) => {
|
|
158
|
+
if (!files.length) throw new ExitError("offer requires at least one file path", 1)
|
|
159
|
+
const selectors = offerSelectors(options.to)
|
|
160
|
+
const timeoutMs = waitPeerTimeout(options.waitPeer)
|
|
161
|
+
const session = new SendSession(sessionConfigFrom(options, {}))
|
|
162
|
+
handleSignals(session)
|
|
163
|
+
printRoomAnnouncement(session.room, !!options.json)
|
|
164
|
+
const detachReporter = attachReporter(session, !!options.json)
|
|
165
|
+
await session.connect()
|
|
166
|
+
const targets = await waitForTargets(session, selectors, timeoutMs)
|
|
167
|
+
const transferIds = await session.queueFiles(files, targets.map(peer => peer.id))
|
|
168
|
+
const results = await waitForFinalTransfers(session, transferIds)
|
|
169
|
+
detachReporter()
|
|
170
|
+
await session.close()
|
|
171
|
+
const failed = results.filter(transfer => transfer && ["rejected", "cancelled", "error"].includes(transfer.status))
|
|
172
|
+
if (failed.length) throw new ExitError(failed.map(transfer => `${transfer?.name}:${transfer?.status}`).join(", "), 3)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const acceptCommand = async (options: Record<string, unknown>) => {
|
|
176
|
+
const session = new SendSession(sessionConfigFrom(options, { autoAcceptIncoming: true, autoSaveIncoming: true }))
|
|
177
|
+
handleSignals(session)
|
|
178
|
+
printRoomAnnouncement(session.room, !!options.json)
|
|
179
|
+
const detachReporter = attachReporter(session, !!options.json)
|
|
180
|
+
await session.connect()
|
|
181
|
+
if (!options.json) console.log(`listening in ${session.room}`)
|
|
182
|
+
if (options.once) {
|
|
183
|
+
for (;;) {
|
|
184
|
+
const saved = session.snapshot().transfers.find(transfer => transfer.direction === "in" && transfer.savedAt > 0)
|
|
185
|
+
if (saved) break
|
|
186
|
+
await Bun.sleep(125)
|
|
187
|
+
}
|
|
188
|
+
detachReporter()
|
|
189
|
+
await session.close()
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
await new Promise(() => {})
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const tuiCommand = async (options: Record<string, unknown>) => {
|
|
196
|
+
const initialConfig = sessionConfigFrom(options, { autoAcceptIncoming: true, autoSaveIncoming: true })
|
|
197
|
+
await startTui(initialConfig, !!options.events)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export const createCli = () => {
|
|
201
|
+
const cli = cac("send")
|
|
202
|
+
|
|
203
|
+
cli
|
|
204
|
+
.command("peers", "list discovered peers")
|
|
205
|
+
.option("--room <room>", "room id; omit to create a random room")
|
|
206
|
+
.option("--self <self>", SELF_HELP_TEXT)
|
|
207
|
+
.option("--wait <ms>", "discovery wait in milliseconds")
|
|
208
|
+
.option("--json", "print a json snapshot")
|
|
209
|
+
.option("--save-dir <dir>", "save directory")
|
|
210
|
+
.option("--turn-url <url>", "custom TURN url, repeat or comma-separate")
|
|
211
|
+
.option("--turn-username <value>", "custom TURN username")
|
|
212
|
+
.option("--turn-credential <value>", "custom TURN credential")
|
|
213
|
+
.action(peersCommand)
|
|
214
|
+
|
|
215
|
+
cli
|
|
216
|
+
.command("offer [...files]", "offer files to browser-compatible peers")
|
|
217
|
+
.option("--room <room>", "room id; omit to create a random room")
|
|
218
|
+
.option("--self <self>", SELF_HELP_TEXT)
|
|
219
|
+
.option("--to <peer>", "target peer id or name-suffix, or `.` for all ready peers; default: `.`")
|
|
220
|
+
.option("--wait-peer <ms>", "wait for eligible peers in milliseconds; omit to wait indefinitely")
|
|
221
|
+
.option("--json", "emit ndjson events")
|
|
222
|
+
.option("--save-dir <dir>", "save directory")
|
|
223
|
+
.option("--turn-url <url>", "custom TURN url, repeat or comma-separate")
|
|
224
|
+
.option("--turn-username <value>", "custom TURN username")
|
|
225
|
+
.option("--turn-credential <value>", "custom TURN credential")
|
|
226
|
+
.action(offerCommand)
|
|
227
|
+
|
|
228
|
+
cli
|
|
229
|
+
.command("accept", "receive and save files")
|
|
230
|
+
.option("--room <room>", "room id; omit to create a random room")
|
|
231
|
+
.option("--self <self>", SELF_HELP_TEXT)
|
|
232
|
+
.option("--save-dir <dir>", "save directory")
|
|
233
|
+
.option("--once", "exit after the first saved incoming transfer")
|
|
234
|
+
.option("--json", "emit ndjson events")
|
|
235
|
+
.option("--turn-url <url>", "custom TURN url, repeat or comma-separate")
|
|
236
|
+
.option("--turn-username <value>", "custom TURN username")
|
|
237
|
+
.option("--turn-credential <value>", "custom TURN credential")
|
|
238
|
+
.action(acceptCommand)
|
|
239
|
+
|
|
240
|
+
cli
|
|
241
|
+
.command("tui", "launch the interactive terminal UI")
|
|
242
|
+
.option("--room <room>", "room id; omit to create a random room")
|
|
243
|
+
.option("--self <self>", SELF_HELP_TEXT)
|
|
244
|
+
.option("--events", "show the event log pane")
|
|
245
|
+
.option("--save-dir <dir>", "save directory")
|
|
246
|
+
.option("--turn-url <url>", "custom TURN url, repeat or comma-separate")
|
|
247
|
+
.option("--turn-username <value>", "custom TURN username")
|
|
248
|
+
.option("--turn-credential <value>", "custom TURN credential")
|
|
249
|
+
.action(tuiCommand)
|
|
250
|
+
|
|
251
|
+
cli.help()
|
|
252
|
+
|
|
253
|
+
return cli
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const ensureKnownCommand = (cli: CAC, argv: string[]) => {
|
|
257
|
+
const command = argv[2]
|
|
258
|
+
if (!command || command.startsWith("-")) return
|
|
259
|
+
if (cli.commands.some(entry => entry.isMatched(command))) return
|
|
260
|
+
throw new ExitError(`Unknown command \`${command}\``, 1)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export const runCli = async (argv = process.argv) => {
|
|
264
|
+
const cli = createCli()
|
|
265
|
+
ensureKnownCommand(cli, argv)
|
|
266
|
+
cli.parse(argv, { run: false })
|
|
267
|
+
await cli.runMatchedCommand()
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const main = async () => {
|
|
271
|
+
try {
|
|
272
|
+
await runCli()
|
|
273
|
+
} catch (error) {
|
|
274
|
+
if (error instanceof ExitError) {
|
|
275
|
+
console.error(error.message)
|
|
276
|
+
process.exit(error.code)
|
|
277
|
+
}
|
|
278
|
+
console.error(error instanceof Error ? error.message : `${error}`)
|
|
279
|
+
process.exit(1)
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if ((import.meta as ImportMeta & { main?: boolean }).main) void main()
|