@elefunc/send 0.1.19 → 0.1.21
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 +78 -34
- package/src/tui/app.ts +79 -89
- 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,11 +56,11 @@ 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
|
-
["--room <room>", "room id
|
|
63
|
+
["--room <room>", "room id", { default: "<random>" }],
|
|
63
64
|
["--self <self>", SELF_HELP_TEXT],
|
|
64
65
|
] as const
|
|
65
66
|
const TURN_OPTIONS = [
|
|
@@ -68,16 +69,25 @@ const TURN_OPTIONS = [
|
|
|
68
69
|
["--turn-credential <value>", "custom TURN credential"],
|
|
69
70
|
] as const
|
|
70
71
|
const OVERWRITE_OPTION = ["--overwrite", "overwrite same-name saved files instead of creating copies"] as const
|
|
71
|
-
const SAVE_DIR_OPTION = ["--save-dir <dir>", "save directory"] as const
|
|
72
|
+
const SAVE_DIR_OPTION = ["--save-dir <dir>", "save directory", { default: "." }] as const
|
|
72
73
|
const TUI_TOGGLE_OPTIONS = [
|
|
73
|
-
["--clean <0|1>", "show only active peers when 1; show terminal peers too when 0"],
|
|
74
|
-
["--accept <0|1>", "auto-accept incoming offers: 1 on, 0 off"],
|
|
75
|
-
["--offer <0|1>", "auto-offer drafts to matching ready peers: 1 on, 0 off"],
|
|
76
|
-
["--save <0|1>", "auto-save completed incoming files: 1 on, 0 off"],
|
|
74
|
+
["--clean <0|1>", "show only active peers when 1; show terminal peers too when 0", { default: 1 }],
|
|
75
|
+
["--accept <0|1>", "auto-accept incoming offers: 1 on, 0 off", { default: 1 }],
|
|
76
|
+
["--offer <0|1>", "auto-offer drafts to matching ready peers: 1 on, 0 off", { default: 1 }],
|
|
77
|
+
["--save <0|1>", "auto-save completed incoming files: 1 on, 0 off", { default: 1 }],
|
|
77
78
|
] as const
|
|
78
79
|
export const ACCEPT_SESSION_DEFAULTS = { autoAcceptIncoming: true, autoSaveIncoming: true } as const
|
|
79
|
-
|
|
80
|
-
|
|
80
|
+
type CliOptionDefinition = readonly [flag: string, description: string, config?: { default?: unknown }]
|
|
81
|
+
const addOptions = (command: CliCommand, definitions: readonly CliOptionDefinition[]) =>
|
|
82
|
+
definitions.reduce((next, [flag, description, config]) => next.option(flag, description, config), command)
|
|
83
|
+
const withTrailingHelpLine = <T extends { outputHelp: () => void }>(target: T) => {
|
|
84
|
+
const outputHelp = target.outputHelp.bind(target)
|
|
85
|
+
target.outputHelp = () => {
|
|
86
|
+
outputHelp()
|
|
87
|
+
console.info("")
|
|
88
|
+
}
|
|
89
|
+
return target
|
|
90
|
+
}
|
|
81
91
|
|
|
82
92
|
const requireSelfId = (value: string) => {
|
|
83
93
|
if (!SELF_ID_PATTERN.test(value)) throw new ExitError(INVALID_SELF_ID_MESSAGE, 1)
|
|
@@ -116,6 +126,16 @@ export const roomAnnouncement = (room: string, self: string, json = false) =>
|
|
|
116
126
|
|
|
117
127
|
const printRoomAnnouncement = (room: string, self: string, json = false) => console.log(roomAnnouncement(room, self, json))
|
|
118
128
|
|
|
129
|
+
export const commandAnnouncement = (kind: JoinOutputKind, room: string, self: string, json = false) =>
|
|
130
|
+
json ? roomAnnouncement(room, self, true) : [roomAnnouncement(room, self), "", ...joinOutputLines(kind, room)].join("\n")
|
|
131
|
+
|
|
132
|
+
const printCommandAnnouncement = (kind: JoinOutputKind, room: string, self: string, json = false) => console.log(commandAnnouncement(kind, room, self, json))
|
|
133
|
+
export const readyStatusLine = (room: string, json = false) => json ? "" : `ready in ${room}`
|
|
134
|
+
const printReadyStatus = (room: string, json = false) => {
|
|
135
|
+
const line = readyStatusLine(room, json)
|
|
136
|
+
if (line) console.log(line)
|
|
137
|
+
}
|
|
138
|
+
|
|
119
139
|
const printEvent = (event: SessionEvent) => console.log(JSON.stringify(event))
|
|
120
140
|
|
|
121
141
|
const attachReporter = (session: SendSession, json = false) => {
|
|
@@ -222,9 +242,10 @@ const offerCommand = async (files: string[], options: Record<string, unknown>) =
|
|
|
222
242
|
const { SendSession } = await loadSessionRuntime()
|
|
223
243
|
const session = new SendSession(sessionConfigFrom(options, {}))
|
|
224
244
|
handleSignals(session)
|
|
225
|
-
|
|
245
|
+
printCommandAnnouncement("offer", session.room, displayPeerName(session.name, session.localId), !!options.json)
|
|
226
246
|
const detachReporter = attachReporter(session, !!options.json)
|
|
227
247
|
await session.connect()
|
|
248
|
+
printReadyStatus(session.room, !!options.json)
|
|
228
249
|
const targets = await waitForTargets(session, selectors, timeoutMs)
|
|
229
250
|
const transferIds = await session.queueFiles(files, targets.map(peer => peer.id))
|
|
230
251
|
const results = await waitForFinalTransfers(session, transferIds)
|
|
@@ -238,10 +259,10 @@ const acceptCommand = async (options: Record<string, unknown>) => {
|
|
|
238
259
|
const { SendSession } = await loadSessionRuntime()
|
|
239
260
|
const session = new SendSession(sessionConfigFrom(options, ACCEPT_SESSION_DEFAULTS))
|
|
240
261
|
handleSignals(session)
|
|
241
|
-
|
|
262
|
+
printCommandAnnouncement("accept", session.room, displayPeerName(session.name, session.localId), !!options.json)
|
|
242
263
|
const detachReporter = attachReporter(session, !!options.json)
|
|
243
264
|
await session.connect()
|
|
244
|
-
|
|
265
|
+
printReadyStatus(session.room, !!options.json)
|
|
245
266
|
if (options.once) {
|
|
246
267
|
for (;;) {
|
|
247
268
|
const saved = session.snapshot().transfers.find(transfer => transfer.direction === "in" && transfer.savedAt > 0)
|
|
@@ -280,73 +301,96 @@ const defaultCliHandlers: CliHandlers = {
|
|
|
280
301
|
tui: tuiCommand,
|
|
281
302
|
}
|
|
282
303
|
|
|
304
|
+
const fileNamePart = (value: string) => value.replace(/^.*[\\/]/, "") || value
|
|
305
|
+
const HELP_NAME_COLOR = "\x1b[38;5;214m"
|
|
306
|
+
const HELP_NAME_RESET = "\x1b[0m"
|
|
307
|
+
const colorCliHelpName = (value: string) => `${HELP_NAME_COLOR}${value}${HELP_NAME_RESET}`
|
|
308
|
+
const cliHelpPlainName = () => process.env.SEND_NAME?.trim() || fileNamePart(Bun.main)
|
|
309
|
+
const cliHelpDisplayName = () => {
|
|
310
|
+
const name = cliHelpPlainName()
|
|
311
|
+
if (!process.stdout.isTTY) return name
|
|
312
|
+
return process.env.SEND_NAME_COLORED?.trim() || colorCliHelpName(name)
|
|
313
|
+
}
|
|
314
|
+
|
|
283
315
|
export const createCli = (handlers: CliHandlers = defaultCliHandlers) => {
|
|
284
|
-
const
|
|
316
|
+
const name = cliHelpDisplayName()
|
|
317
|
+
const cli = cac(name)
|
|
285
318
|
cli.usage("[command] [options]")
|
|
286
319
|
|
|
287
|
-
addOptions(cli.command("peers", "list discovered peers"), [
|
|
320
|
+
withTrailingHelpLine(addOptions(cli.command("peers", "list discovered peers").ignoreOptionDefaultValue(), [
|
|
288
321
|
...ROOM_SELF_OPTIONS,
|
|
289
|
-
["--wait <ms>", "discovery wait in milliseconds"],
|
|
322
|
+
["--wait <ms>", "discovery wait in milliseconds", { default: 3000 }],
|
|
290
323
|
["--json", "print a json snapshot"],
|
|
291
324
|
SAVE_DIR_OPTION,
|
|
292
325
|
...TURN_OPTIONS,
|
|
293
|
-
]).action(handlers.peers)
|
|
326
|
+
])).action(handlers.peers)
|
|
294
327
|
|
|
295
|
-
addOptions(cli.command("offer [...files]", "offer files to browser-compatible peers"), [
|
|
328
|
+
withTrailingHelpLine(addOptions(cli.command("offer [...files]", "offer files to browser-compatible peers").ignoreOptionDefaultValue(), [
|
|
296
329
|
...ROOM_SELF_OPTIONS,
|
|
297
|
-
["--to <peer>", "target
|
|
298
|
-
["--wait-peer <ms>", "wait for eligible peers in milliseconds
|
|
330
|
+
["--to <peer>", "target `name`, `name-id`, or `-id`", { default: "." }],
|
|
331
|
+
["--wait-peer <ms>", "wait for eligible peers in milliseconds", { default: "<infinite>" }],
|
|
299
332
|
["--json", "emit ndjson events"],
|
|
300
333
|
SAVE_DIR_OPTION,
|
|
301
334
|
...TURN_OPTIONS,
|
|
302
|
-
]).action(handlers.offer)
|
|
335
|
+
])).action(handlers.offer)
|
|
303
336
|
|
|
304
|
-
addOptions(cli.command("accept", "receive and save files"), [
|
|
337
|
+
withTrailingHelpLine(addOptions(cli.command("accept", "receive and save files").ignoreOptionDefaultValue(), [
|
|
305
338
|
...ROOM_SELF_OPTIONS,
|
|
306
339
|
SAVE_DIR_OPTION,
|
|
307
340
|
OVERWRITE_OPTION,
|
|
308
341
|
["--once", "exit after the first saved incoming transfer"],
|
|
309
342
|
["--json", "emit ndjson events"],
|
|
310
343
|
...TURN_OPTIONS,
|
|
311
|
-
]).action(handlers.accept)
|
|
344
|
+
])).action(handlers.accept)
|
|
312
345
|
|
|
313
|
-
addOptions(cli.command("tui", "launch the interactive terminal UI"), [
|
|
346
|
+
withTrailingHelpLine(addOptions(cli.command("tui", "launch the interactive terminal UI").ignoreOptionDefaultValue(), [
|
|
314
347
|
...ROOM_SELF_OPTIONS,
|
|
315
348
|
...TUI_TOGGLE_OPTIONS,
|
|
316
349
|
["--events", "show the event log pane"],
|
|
317
350
|
SAVE_DIR_OPTION,
|
|
318
351
|
OVERWRITE_OPTION,
|
|
319
352
|
...TURN_OPTIONS,
|
|
320
|
-
]).action(handlers.tui)
|
|
353
|
+
])).action(handlers.tui)
|
|
321
354
|
|
|
322
355
|
cli.help(sections => {
|
|
323
356
|
const usage = sections.find(section => section.title === "Usage:")
|
|
324
|
-
if (usage) usage.body =
|
|
357
|
+
if (usage) usage.body = ` $ ${name} [command] [options]`
|
|
325
358
|
const moreInfoIndex = sections.findIndex(section => section.title?.startsWith("For more info"))
|
|
326
359
|
const defaultSection = {
|
|
327
360
|
title: "Default",
|
|
328
|
-
body:
|
|
361
|
+
body: ` ${name} with no command launches the terminal UI (same as \`${name} tui\`).`,
|
|
329
362
|
}
|
|
330
363
|
if (moreInfoIndex < 0) sections.push(defaultSection)
|
|
331
364
|
else sections.splice(moreInfoIndex, 0, defaultSection)
|
|
332
365
|
})
|
|
366
|
+
withTrailingHelpLine(cli.globalCommand)
|
|
333
367
|
|
|
334
368
|
return cli
|
|
335
369
|
}
|
|
336
370
|
|
|
337
|
-
const
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
371
|
+
const argvPrefix = (argv: string[]) => [argv[0] ?? process.argv[0] ?? "bun", argv[1] ?? process.argv[1] ?? cliHelpPlainName()]
|
|
372
|
+
const printSubcommandHelp = (argv: string[], handlers: CliHandlers, subcommand: string) =>
|
|
373
|
+
void createCli(handlers).parse([...argvPrefix(argv), subcommand, "--help"], { run: false })
|
|
374
|
+
|
|
375
|
+
const explicitCommand = (argv: string[], handlers: CliHandlers) => {
|
|
376
|
+
const cli = createCli(handlers)
|
|
377
|
+
cli.showHelpOnExit = false
|
|
378
|
+
cli.parse(argv, { run: false })
|
|
379
|
+
if (cli.matchedCommandName) return cli.matchedCommandName
|
|
380
|
+
if (cli.args[0]) throw new ExitError(`Unknown command \`${cli.args[0]}\``, 1)
|
|
381
|
+
return undefined
|
|
342
382
|
}
|
|
343
383
|
|
|
344
384
|
export const runCli = async (argv = process.argv, handlers: CliHandlers = defaultCliHandlers) => {
|
|
345
385
|
const cli = createCli(handlers)
|
|
346
|
-
const command = explicitCommand(
|
|
386
|
+
const command = explicitCommand(argv, handlers)
|
|
347
387
|
const parsed = cli.parse(argv, { run: false }) as { options: Record<string, unknown> }
|
|
348
388
|
const helpRequested = !!parsed.options.help || !!parsed.options.h
|
|
349
|
-
if (!command
|
|
389
|
+
if (!command) {
|
|
390
|
+
if (helpRequested) {
|
|
391
|
+
printSubcommandHelp(argv, handlers, "tui")
|
|
392
|
+
return
|
|
393
|
+
}
|
|
350
394
|
await handlers.tui(parsed.options)
|
|
351
395
|
return
|
|
352
396
|
}
|
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,11 +1250,11 @@ 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
|
-
ui.box({ maxHeight: 24, overflow: "scroll" }, [
|
|
1257
|
+
ui.box({ id: "events-viewport", maxHeight: 24, overflow: "scroll", border: "none" }, [
|
|
1284
1258
|
state.snapshot.logs.length
|
|
1285
1259
|
? ui.column({ gap: 0 }, state.snapshot.logs.slice(0, 20).map(renderLogRow))
|
|
1286
1260
|
: ui.empty("No events"),
|
|
@@ -1327,7 +1301,7 @@ export const renderTuiView = (state: TuiState, actions: TuiActions): VNode => {
|
|
|
1327
1301
|
...transferCards,
|
|
1328
1302
|
]),
|
|
1329
1303
|
]),
|
|
1330
|
-
state.eventsExpanded ? ui.box({ id: "events-shell", width: 28, minHeight: 0 }, [renderEventsCard(state, actions)]) : null,
|
|
1304
|
+
state.eventsExpanded ? ui.box({ id: "events-shell", width: 28, minHeight: 0, border: "none" }, [renderEventsCard(state, actions)]) : null,
|
|
1331
1305
|
]),
|
|
1332
1306
|
footer: renderFooter(state),
|
|
1333
1307
|
p: 0,
|
|
@@ -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 })),
|