@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 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-ID`
39
- - `-ID` using the attached CLI form `--self=-ab12cd34`
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 ID suffix must be exactly 8 lowercase alphanumeric characters.
43
+ The `id` suffix must be exactly 8 lowercase alphanumeric characters.
44
44
 
45
45
  ## Examples
46
46
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elefunc/send",
3
- "version": "0.1.19",
3
+ "version": "0.1.20",
4
4
  "description": "Browser-compatible file transfer CLI and TUI powered by Bun, WebRTC, and Rezi.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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
+ ]
@@ -1,4 +1,4 @@
1
- import { cleanName, displayPeerName } from "./protocol"
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 matchesSelector = (peer: TargetPeer, selector: string) => selector === peer.id || selector === displayPeerName(peer.name, peer.id) || selector === cleanName(peer.name)
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 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
- }
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, name-ID, or -ID (use --self=-ID)"
59
- const INVALID_SELF_ID_MESSAGE = `--self ID suffix must be exactly ${SELF_ID_LENGTH} lowercase alphanumeric characters`
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
- printRoomAnnouncement(session.room, displayPeerName(session.name, session.localId), !!options.json)
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
- printRoomAnnouncement(session.room, displayPeerName(session.name, session.localId), !!options.json)
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
- if (!options.json) console.log(`listening in ${session.room}`)
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 cli = cac("send")
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 peer id or name-suffix, or `.` for all ready peers; default: `.`"],
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 = " $ send [command] [options]"
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: " send with no command launches the terminal UI (same as `send tui`).",
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 explicitCommand = (cli: CAC, argv: string[]) => {
338
- const command = argv[2]
339
- if (!command || command.startsWith("-")) return undefined
340
- if (cli.commands.some(entry => entry.isMatched(command))) return command
341
- throw new ExitError(`Unknown command \`${command}\``, 1)
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(cli, argv)
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 && !helpRequested) {
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 shellQuote = (value: string) => safeShellArgPattern.test(value) ? value : `'${value.replaceAll("'", `'\"'\"'`)}'`
181
- const appendCliFlag = (args: string[], flag: string, value?: string | null) => {
182
- const text = `${value ?? ""}`.trim()
183
- if (!text) return
184
- args.push(flag, shellQuote(text))
185
- }
186
- const SHARE_TOGGLE_FLAGS = [
187
- ["clean", "hideTerminalPeers", "--clean"],
188
- ["accept", "autoAcceptIncoming", "--accept"],
189
- ["offer", "autoOfferOutgoing", "--offer"],
190
- ["save", "autoSaveIncoming", "--save"],
191
- ] as const satisfies readonly (readonly [string, keyof ShareUrlState, string])[]
192
- const appendToggleCliFlags = (args: string[], state: ShareUrlState) => {
193
- for (const [, stateKey, flag] of SHARE_TOGGLE_FLAGS) if (!state[stateKey]) appendCliFlag(args, flag, "0")
194
- if (state.overwriteIncoming) args.push("--overwrite")
195
- }
196
- const buildHashParams = (state: ShareUrlState, omitDefaults = false) => {
197
- const params = new URLSearchParams({ room: cleanRoom(state.snapshot.room) })
198
- for (const [key, stateKey] of SHARE_TOGGLE_FLAGS) if (!omitDefaults || !state[stateKey]) params.set(key, hashBool(state[stateKey]))
199
- if (!omitDefaults || state.overwriteIncoming) params.set("overwrite", hashBool(state.overwriteIncoming))
200
- return params
201
- }
202
- const shareTurnCliArgs = (sessionSeed: SessionSeed) => {
203
- const turnUrls = [...new Set((sessionSeed.turnUrls ?? []).map(url => `${url ?? ""}`.trim()).filter(Boolean))]
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 renderCliCommand = (state: ShareCliState, { includeSelf = false, includePrefix = false } = {}) => {
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).replace(/^https:\/\//, "")
228
+ export const aboutWebLabel = (state: ShareUrlState, baseUrl = DEFAULT_WEB_URL) => schemeLessUrlText(aboutWebUrl(state, baseUrl))
265
229
 
266
- export const aboutCliCommand = (state: ShareCliState) => renderCliCommand(state)
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) => renderCliCommand(state, { includeSelf: true, includePrefix: true })
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 copyInvitePayload = async (payload: string, label: "WEB" | "CLI") => {
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
- closeDropdown({ text: `Copied ${label} invite.`, variant: "success" })
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
- closeDropdown(opened
1412
- ? { text: `Opened ${label} copy link.`, variant: "info" }
1413
- : { text: `Unable to copy ${label} invite.`, variant: "error" })
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 })),
@@ -1,5 +1,6 @@
1
1
  declare const Bun: {
2
2
  file(path: string): Blob & { type: string }
3
+ main: string
3
4
  write(path: string, data: string | Blob | ArrayBuffer | ArrayBufferView): Promise<number>
4
5
  sleep(ms: number): Promise<void>
5
6
  }