@elefunc/send 0.1.19 → 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/invite.ts +148 -0
- package/src/core/targeting.ts +15 -9
- package/src/index.ts +67 -24
- package/src/tui/app.ts +77 -87
- 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
|
@@ -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/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"],
|
|
@@ -78,6 +79,14 @@ const TUI_TOGGLE_OPTIONS = [
|
|
|
78
79
|
export const ACCEPT_SESSION_DEFAULTS = { autoAcceptIncoming: true, autoSaveIncoming: true } as const
|
|
79
80
|
const addOptions = (command: CliCommand, definitions: readonly (readonly [string, string])[]) =>
|
|
80
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
|
+
}
|
|
81
90
|
|
|
82
91
|
const requireSelfId = (value: string) => {
|
|
83
92
|
if (!SELF_ID_PATTERN.test(value)) throw new ExitError(INVALID_SELF_ID_MESSAGE, 1)
|
|
@@ -116,6 +125,16 @@ export const roomAnnouncement = (room: string, self: string, json = false) =>
|
|
|
116
125
|
|
|
117
126
|
const printRoomAnnouncement = (room: string, self: string, json = false) => console.log(roomAnnouncement(room, self, json))
|
|
118
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
|
+
|
|
119
138
|
const printEvent = (event: SessionEvent) => console.log(JSON.stringify(event))
|
|
120
139
|
|
|
121
140
|
const attachReporter = (session: SendSession, json = false) => {
|
|
@@ -222,9 +241,10 @@ const offerCommand = async (files: string[], options: Record<string, unknown>) =
|
|
|
222
241
|
const { SendSession } = await loadSessionRuntime()
|
|
223
242
|
const session = new SendSession(sessionConfigFrom(options, {}))
|
|
224
243
|
handleSignals(session)
|
|
225
|
-
|
|
244
|
+
printCommandAnnouncement("offer", session.room, displayPeerName(session.name, session.localId), !!options.json)
|
|
226
245
|
const detachReporter = attachReporter(session, !!options.json)
|
|
227
246
|
await session.connect()
|
|
247
|
+
printReadyStatus(session.room, !!options.json)
|
|
228
248
|
const targets = await waitForTargets(session, selectors, timeoutMs)
|
|
229
249
|
const transferIds = await session.queueFiles(files, targets.map(peer => peer.id))
|
|
230
250
|
const results = await waitForFinalTransfers(session, transferIds)
|
|
@@ -238,10 +258,10 @@ const acceptCommand = async (options: Record<string, unknown>) => {
|
|
|
238
258
|
const { SendSession } = await loadSessionRuntime()
|
|
239
259
|
const session = new SendSession(sessionConfigFrom(options, ACCEPT_SESSION_DEFAULTS))
|
|
240
260
|
handleSignals(session)
|
|
241
|
-
|
|
261
|
+
printCommandAnnouncement("accept", session.room, displayPeerName(session.name, session.localId), !!options.json)
|
|
242
262
|
const detachReporter = attachReporter(session, !!options.json)
|
|
243
263
|
await session.connect()
|
|
244
|
-
|
|
264
|
+
printReadyStatus(session.room, !!options.json)
|
|
245
265
|
if (options.once) {
|
|
246
266
|
for (;;) {
|
|
247
267
|
const saved = session.snapshot().transfers.find(transfer => transfer.direction === "in" && transfer.savedAt > 0)
|
|
@@ -280,73 +300,96 @@ const defaultCliHandlers: CliHandlers = {
|
|
|
280
300
|
tui: tuiCommand,
|
|
281
301
|
}
|
|
282
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
|
+
|
|
283
314
|
export const createCli = (handlers: CliHandlers = defaultCliHandlers) => {
|
|
284
|
-
const
|
|
315
|
+
const name = cliHelpDisplayName()
|
|
316
|
+
const cli = cac(name)
|
|
285
317
|
cli.usage("[command] [options]")
|
|
286
318
|
|
|
287
|
-
addOptions(cli.command("peers", "list discovered peers"), [
|
|
319
|
+
withTrailingHelpLine(addOptions(cli.command("peers", "list discovered peers"), [
|
|
288
320
|
...ROOM_SELF_OPTIONS,
|
|
289
321
|
["--wait <ms>", "discovery wait in milliseconds"],
|
|
290
322
|
["--json", "print a json snapshot"],
|
|
291
323
|
SAVE_DIR_OPTION,
|
|
292
324
|
...TURN_OPTIONS,
|
|
293
|
-
]).action(handlers.peers)
|
|
325
|
+
])).action(handlers.peers)
|
|
294
326
|
|
|
295
|
-
addOptions(cli.command("offer [...files]", "offer files to browser-compatible peers"), [
|
|
327
|
+
withTrailingHelpLine(addOptions(cli.command("offer [...files]", "offer files to browser-compatible peers"), [
|
|
296
328
|
...ROOM_SELF_OPTIONS,
|
|
297
|
-
["--to <peer>", "target
|
|
329
|
+
["--to <peer>", "target `name`, `name-id`, or `-id`; `.` targets all ready peers by default"],
|
|
298
330
|
["--wait-peer <ms>", "wait for eligible peers in milliseconds; omit to wait indefinitely"],
|
|
299
331
|
["--json", "emit ndjson events"],
|
|
300
332
|
SAVE_DIR_OPTION,
|
|
301
333
|
...TURN_OPTIONS,
|
|
302
|
-
]).action(handlers.offer)
|
|
334
|
+
])).action(handlers.offer)
|
|
303
335
|
|
|
304
|
-
addOptions(cli.command("accept", "receive and save files"), [
|
|
336
|
+
withTrailingHelpLine(addOptions(cli.command("accept", "receive and save files"), [
|
|
305
337
|
...ROOM_SELF_OPTIONS,
|
|
306
338
|
SAVE_DIR_OPTION,
|
|
307
339
|
OVERWRITE_OPTION,
|
|
308
340
|
["--once", "exit after the first saved incoming transfer"],
|
|
309
341
|
["--json", "emit ndjson events"],
|
|
310
342
|
...TURN_OPTIONS,
|
|
311
|
-
]).action(handlers.accept)
|
|
343
|
+
])).action(handlers.accept)
|
|
312
344
|
|
|
313
|
-
addOptions(cli.command("tui", "launch the interactive terminal UI"), [
|
|
345
|
+
withTrailingHelpLine(addOptions(cli.command("tui", "launch the interactive terminal UI"), [
|
|
314
346
|
...ROOM_SELF_OPTIONS,
|
|
315
347
|
...TUI_TOGGLE_OPTIONS,
|
|
316
348
|
["--events", "show the event log pane"],
|
|
317
349
|
SAVE_DIR_OPTION,
|
|
318
350
|
OVERWRITE_OPTION,
|
|
319
351
|
...TURN_OPTIONS,
|
|
320
|
-
]).action(handlers.tui)
|
|
352
|
+
])).action(handlers.tui)
|
|
321
353
|
|
|
322
354
|
cli.help(sections => {
|
|
323
355
|
const usage = sections.find(section => section.title === "Usage:")
|
|
324
|
-
if (usage) usage.body =
|
|
356
|
+
if (usage) usage.body = ` $ ${name} [command] [options]`
|
|
325
357
|
const moreInfoIndex = sections.findIndex(section => section.title?.startsWith("For more info"))
|
|
326
358
|
const defaultSection = {
|
|
327
359
|
title: "Default",
|
|
328
|
-
body:
|
|
360
|
+
body: ` ${name} with no command launches the terminal UI (same as \`${name} tui\`).`,
|
|
329
361
|
}
|
|
330
362
|
if (moreInfoIndex < 0) sections.push(defaultSection)
|
|
331
363
|
else sections.splice(moreInfoIndex, 0, defaultSection)
|
|
332
364
|
})
|
|
365
|
+
withTrailingHelpLine(cli.globalCommand)
|
|
333
366
|
|
|
334
367
|
return cli
|
|
335
368
|
}
|
|
336
369
|
|
|
337
|
-
const
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
|
342
381
|
}
|
|
343
382
|
|
|
344
383
|
export const runCli = async (argv = process.argv, handlers: CliHandlers = defaultCliHandlers) => {
|
|
345
384
|
const cli = createCli(handlers)
|
|
346
|
-
const command = explicitCommand(
|
|
385
|
+
const command = explicitCommand(argv, handlers)
|
|
347
386
|
const parsed = cli.parse(argv, { run: false }) as { options: Record<string, unknown> }
|
|
348
387
|
const helpRequested = !!parsed.options.help || !!parsed.options.h
|
|
349
|
-
if (!command
|
|
388
|
+
if (!command) {
|
|
389
|
+
if (helpRequested) {
|
|
390
|
+
printSubcommandHelp(argv, handlers, "tui")
|
|
391
|
+
return
|
|
392
|
+
}
|
|
350
393
|
await handlers.tui(parsed.options)
|
|
351
394
|
return
|
|
352
395
|
}
|
package/src/tui/app.ts
CHANGED
|
@@ -2,6 +2,19 @@ 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 {
|
|
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"
|
|
5
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"
|
|
@@ -87,6 +100,7 @@ export interface TuiActions {
|
|
|
87
100
|
closeInviteDropdown: TuiAction
|
|
88
101
|
copyWebInvite: TuiAction
|
|
89
102
|
copyCliInvite: TuiAction
|
|
103
|
+
copyLogs: TuiAction
|
|
90
104
|
jumpToRandomRoom: TuiAction
|
|
91
105
|
commitRoom: TuiAction
|
|
92
106
|
setRoomInput: (value: string) => void
|
|
@@ -129,7 +143,6 @@ const METRIC_BORDER_STYLE = { fg: rgb(20, 25, 32) } as const
|
|
|
129
143
|
const PRIMARY_TEXT_STYLE = { fg: rgb(255, 255, 255) } as const
|
|
130
144
|
const HEADING_TEXT_STYLE = { fg: rgb(255, 255, 255), bold: true } as const
|
|
131
145
|
const MUTED_TEXT_STYLE = { fg: rgb(159, 166, 178) } as const
|
|
132
|
-
const DEFAULT_WEB_URL = "https://rtme.sh/"
|
|
133
146
|
const DEFAULT_SAVE_DIR = resolve(process.cwd())
|
|
134
147
|
const ABOUT_ELEFUNC_URL = "https://rtme.sh/send"
|
|
135
148
|
const ABOUT_TITLE = "About Send"
|
|
@@ -141,7 +154,6 @@ const ABOUT_BULLETS = [
|
|
|
141
154
|
"• The CLI streams incoming saves straight to disk in the current save directory, with overwrite available through the CLI flag.",
|
|
142
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.",
|
|
143
156
|
] as const
|
|
144
|
-
const COPY_SERVICE_URL = "https://copy.rt.ht/"
|
|
145
157
|
const TRANSFER_DIRECTION_ARROW = {
|
|
146
158
|
out: { glyph: "↗", style: { fg: rgb(170, 217, 76), bold: true } },
|
|
147
159
|
in: { glyph: "↙", style: { fg: rgb(240, 113, 120), bold: true } },
|
|
@@ -160,8 +172,6 @@ export const isEditableFocusId = (focusedId: string | null) =>
|
|
|
160
172
|
focusedId === ROOM_INPUT_ID || focusedId === NAME_INPUT_ID || focusedId === PEER_SEARCH_INPUT_ID || focusedId === DRAFT_INPUT_ID
|
|
161
173
|
export const shouldSwallowQQuit = (focusedId: string | null) => !isEditableFocusId(focusedId)
|
|
162
174
|
|
|
163
|
-
const hashBool = (value: boolean) => value ? "1" : "0"
|
|
164
|
-
const safeShellArgPattern = /^[A-Za-z0-9._/:?=&,+@%-]+$/
|
|
165
175
|
const TUI_QUIT_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"] as const
|
|
166
176
|
type TuiQuitSignal = (typeof TUI_QUIT_SIGNALS)[number]
|
|
167
177
|
type ProcessSignalLike = {
|
|
@@ -177,63 +187,30 @@ type BunSpawn = (cmd: string[], options: {
|
|
|
177
187
|
type BunLike = { spawn?: BunSpawn }
|
|
178
188
|
type ShareUrlState = Pick<TuiState, "snapshot" | "hideTerminalPeers" | "autoAcceptIncoming" | "autoOfferOutgoing" | "autoSaveIncoming" | "overwriteIncoming">
|
|
179
189
|
type ShareCliState = ShareUrlState & Pick<TuiState, "sessionSeed" | "eventsExpanded">
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
if (!turnUrls.length) return []
|
|
205
|
-
const args: string[] = []
|
|
206
|
-
for (const turnUrl of turnUrls) appendCliFlag(args, "--turn-url", turnUrl)
|
|
207
|
-
appendCliFlag(args, "--turn-username", sessionSeed.turnUsername)
|
|
208
|
-
appendCliFlag(args, "--turn-credential", sessionSeed.turnCredential)
|
|
209
|
-
return args
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
export const resolveWebUrlBase = (value = process.env.SEND_WEB_URL) => {
|
|
213
|
-
const candidate = `${value ?? ""}`.trim() || DEFAULT_WEB_URL
|
|
214
|
-
try {
|
|
215
|
-
return new URL(candidate).toString()
|
|
216
|
-
} catch {
|
|
217
|
-
return DEFAULT_WEB_URL
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
const renderWebUrl = (state: ShareUrlState, baseUrl = DEFAULT_WEB_URL, omitDefaults = true) => {
|
|
222
|
-
const url = new URL(baseUrl)
|
|
223
|
-
url.hash = buildHashParams(state, omitDefaults).toString()
|
|
224
|
-
return url.toString()
|
|
225
|
-
}
|
|
226
|
-
const schemeLessUrlText = (text: string) => text.replace(/^[a-z]+:\/\//, "")
|
|
227
|
-
export const inviteWebLabel = (state: ShareUrlState, baseUrl = resolveWebUrlBase()) => schemeLessUrlText(webInviteUrl(state, baseUrl))
|
|
228
|
-
export const inviteCliPackageName = (baseUrl = resolveWebUrlBase()) => new URL(resolveWebUrlBase(baseUrl)).hostname
|
|
229
|
-
export const inviteCliCommand = (state: ShareUrlState) => {
|
|
230
|
-
const args: string[] = []
|
|
231
|
-
appendCliFlag(args, "--room", state.snapshot.room)
|
|
232
|
-
appendToggleCliFlags(args, state)
|
|
233
|
-
return args.join(" ")
|
|
234
|
-
}
|
|
235
|
-
export const inviteCliText = (state: ShareUrlState, baseUrl = resolveWebUrlBase()) => `bunx ${inviteCliPackageName(baseUrl)} ${inviteCliCommand(state)}`
|
|
236
|
-
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)
|
|
237
214
|
export const buildOsc52ClipboardSequence = (text: string) => text ? `\u001b]52;c;${Buffer.from(text).toString("base64")}\u0007` : ""
|
|
238
215
|
export const externalOpenCommand = (url: string, platform = process.platform) =>
|
|
239
216
|
platform === "darwin" ? ["open", url]
|
|
@@ -244,30 +221,17 @@ const getBackendRawWriter = (backend: unknown): BackendRawWrite | null => {
|
|
|
244
221
|
return typeof marker === "function" ? marker as BackendRawWrite : null
|
|
245
222
|
}
|
|
246
223
|
const getBunRuntime = () => (globalThis as typeof globalThis & { Bun?: BunLike }).Bun ?? null
|
|
247
|
-
const
|
|
248
|
-
const args = includePrefix ? ["bunx", "rtme.sh"] : []
|
|
249
|
-
appendCliFlag(args, "--room", state.snapshot.room)
|
|
250
|
-
if (includeSelf) appendCliFlag(args, "--self", displayPeerName(state.snapshot.name, state.snapshot.localId))
|
|
251
|
-
appendToggleCliFlags(args, state)
|
|
252
|
-
if (state.eventsExpanded) args.push("--events")
|
|
253
|
-
if (resolve(state.snapshot.saveDir) !== DEFAULT_SAVE_DIR) appendCliFlag(args, "--save-dir", state.snapshot.saveDir)
|
|
254
|
-
args.push(...shareTurnCliArgs(state.sessionSeed))
|
|
255
|
-
return args.join(" ")
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
export const webInviteUrl = (state: ShareUrlState, baseUrl = resolveWebUrlBase()) => {
|
|
259
|
-
return renderWebUrl(state, baseUrl)
|
|
260
|
-
}
|
|
224
|
+
export const webInviteUrl = (state: ShareUrlState, baseUrl = resolveWebUrlBase()) => formatWebInviteUrl(shareUrlOptions(state), baseUrl)
|
|
261
225
|
|
|
262
|
-
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)
|
|
263
227
|
|
|
264
|
-
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))
|
|
265
229
|
|
|
266
|
-
export const aboutCliCommand = (state: ShareCliState) =>
|
|
230
|
+
export const aboutCliCommand = (state: ShareCliState) => renderSharedCliCommand(shareCliOptions(state))
|
|
267
231
|
|
|
268
|
-
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)
|
|
269
233
|
|
|
270
|
-
export const resumeCliCommand = (state: ShareCliState) =>
|
|
234
|
+
export const resumeCliCommand = (state: ShareCliState) => renderSharedCliCommand(shareCliOptions(state), { includeSelf: true, includePrefix: true, packageName: "rtme.sh" })
|
|
271
235
|
|
|
272
236
|
export const resumeOutputLines = (state: ShareCliState) => [
|
|
273
237
|
"Rejoin with:",
|
|
@@ -312,6 +276,7 @@ export const createNoopTuiActions = (): TuiActions => ({
|
|
|
312
276
|
closeInviteDropdown: noop,
|
|
313
277
|
copyWebInvite: noop,
|
|
314
278
|
copyCliInvite: noop,
|
|
279
|
+
copyLogs: noop,
|
|
315
280
|
jumpToRandomRoom: noop,
|
|
316
281
|
commitRoom: noop,
|
|
317
282
|
setRoomInput: noop,
|
|
@@ -347,6 +312,15 @@ const shortText = (value: unknown, max = 88) => {
|
|
|
347
312
|
const text = typeof value === "string" ? value : JSON.stringify(value)
|
|
348
313
|
return text.length <= max ? text : `${text.slice(0, Math.max(0, max - 1))}…`
|
|
349
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")
|
|
350
324
|
const toggleIntent = (active: boolean) => active ? "success" : "secondary"
|
|
351
325
|
const statusKind = (socketState: SessionSnapshot["socketState"]) => socketState === "open" ? "online" : socketState === "connecting" ? "busy" : socketState === "error" ? "offline" : "away"
|
|
352
326
|
const peerConnectionStatusKind = (status: string) => ({
|
|
@@ -1276,8 +1250,8 @@ const renderEventsCard = (state: TuiState, actions: TuiActions) => denseSection(
|
|
|
1276
1250
|
id: "events-card",
|
|
1277
1251
|
title: "Events",
|
|
1278
1252
|
actions: [
|
|
1253
|
+
actionButton("copy-events", "Copy", actions.copyLogs, "secondary", !state.snapshot.logs.length),
|
|
1279
1254
|
actionButton("clear-events", "Clear", actions.clearLogs, "warning", !state.snapshot.logs.length),
|
|
1280
|
-
actionButton("hide-events", "Hide", actions.toggleEvents),
|
|
1281
1255
|
],
|
|
1282
1256
|
}, [
|
|
1283
1257
|
ui.box({ maxHeight: 24, overflow: "scroll" }, [
|
|
@@ -1394,23 +1368,30 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
|
|
|
1394
1368
|
return false
|
|
1395
1369
|
}
|
|
1396
1370
|
}
|
|
1397
|
-
const
|
|
1398
|
-
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))) => {
|
|
1399
1372
|
const rawWrite = getBackendRawWriter(app.backend)
|
|
1400
1373
|
if (rawWrite && await ensureOsc52Support()) {
|
|
1401
1374
|
const sequence = buildOsc52ClipboardSequence(payload)
|
|
1402
1375
|
if (sequence) {
|
|
1403
1376
|
try {
|
|
1404
1377
|
rawWrite(sequence)
|
|
1405
|
-
|
|
1378
|
+
finish({ text: notices.copied, variant: "success" })
|
|
1406
1379
|
return
|
|
1407
1380
|
} catch {}
|
|
1408
1381
|
}
|
|
1409
1382
|
}
|
|
1410
1383
|
const opened = openExternalUrl(inviteCopyUrl(payload))
|
|
1411
|
-
|
|
1412
|
-
? { text:
|
|
1413
|
-
: { 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)
|
|
1414
1395
|
}
|
|
1415
1396
|
|
|
1416
1397
|
const flushUpdate = () => {
|
|
@@ -1761,6 +1742,15 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
|
|
|
1761
1742
|
closeInviteDropdown: () => commit(current => current.inviteDropdownOpen ? { ...current, inviteDropdownOpen: false } : current),
|
|
1762
1743
|
copyWebInvite: () => { void copyInvitePayload(webInviteUrl(state), "WEB") },
|
|
1763
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
|
+
},
|
|
1764
1754
|
jumpToRandomRoom: () => replaceSession({ ...state.sessionSeed, room: uid(8) }, "Joined a new room."),
|
|
1765
1755
|
commitRoom,
|
|
1766
1756
|
setRoomInput: value => commit(current => ({ ...current, roomInput: value })),
|