@elefunc/send 0.1.7 → 0.1.9
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/package.json +1 -1
- package/src/index.ts +75 -50
- package/src/tui/app.ts +221 -27
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -37,11 +37,45 @@ const waitPeerTimeout = (value: unknown) => {
|
|
|
37
37
|
if (!Number.isFinite(parsed) || parsed < 0) throw new ExitError("--wait-peer must be a finite non-negative number of milliseconds", 1)
|
|
38
38
|
return parsed
|
|
39
39
|
}
|
|
40
|
+
const BINARY_OPTION_FLAGS = {
|
|
41
|
+
clean: "--clean",
|
|
42
|
+
accept: "--accept",
|
|
43
|
+
offer: "--offer",
|
|
44
|
+
save: "--save",
|
|
45
|
+
} as const
|
|
46
|
+
type BinaryOptionKey = keyof typeof BINARY_OPTION_FLAGS
|
|
47
|
+
const binaryOption = (value: unknown, flag: string) => {
|
|
48
|
+
if (value == null) return undefined
|
|
49
|
+
if (value === 1 || value === "1") return true
|
|
50
|
+
if (value === 0 || value === "0") return false
|
|
51
|
+
throw new ExitError(`${flag} must be 0 or 1`, 1)
|
|
52
|
+
}
|
|
53
|
+
const parseBinaryOptions = <K extends BinaryOptionKey>(options: Record<string, unknown>, keys: readonly K[]) =>
|
|
54
|
+
Object.fromEntries(keys.map(key => [key, binaryOption(options[key], BINARY_OPTION_FLAGS[key])])) as Record<K, boolean | undefined>
|
|
40
55
|
|
|
41
56
|
const SELF_ID_LENGTH = 8
|
|
42
57
|
const SELF_ID_PATTERN = new RegExp(`^[a-z0-9]{${SELF_ID_LENGTH}}$`)
|
|
43
58
|
const SELF_HELP_TEXT = "self identity: name, name-ID, or -ID (use --self=-ID)"
|
|
44
59
|
const INVALID_SELF_ID_MESSAGE = `--self ID suffix must be exactly ${SELF_ID_LENGTH} lowercase alphanumeric characters`
|
|
60
|
+
type CliCommand = ReturnType<CAC["command"]>
|
|
61
|
+
const ROOM_SELF_OPTIONS = [
|
|
62
|
+
["--room <room>", "room id; omit to create a random room"],
|
|
63
|
+
["--self <self>", SELF_HELP_TEXT],
|
|
64
|
+
] as const
|
|
65
|
+
const TURN_OPTIONS = [
|
|
66
|
+
["--turn-url <url>", "custom TURN url, repeat or comma-separate"],
|
|
67
|
+
["--turn-username <value>", "custom TURN username"],
|
|
68
|
+
["--turn-credential <value>", "custom TURN credential"],
|
|
69
|
+
] as const
|
|
70
|
+
const SAVE_DIR_OPTION = ["--save-dir <dir>", "save directory"] as const
|
|
71
|
+
const TUI_TOGGLE_OPTIONS = [
|
|
72
|
+
["--clean <0|1>", "show only active peers when 1; show terminal peers too when 0"],
|
|
73
|
+
["--accept <0|1>", "auto-accept incoming offers: 1 on, 0 off"],
|
|
74
|
+
["--offer <0|1>", "auto-offer drafts to matching ready peers: 1 on, 0 off"],
|
|
75
|
+
["--save <0|1>", "auto-save completed incoming files: 1 on, 0 off"],
|
|
76
|
+
] as const
|
|
77
|
+
const addOptions = (command: CliCommand, definitions: readonly (readonly [string, string])[]) =>
|
|
78
|
+
definitions.reduce((next, [flag, description]) => next.option(flag, description), command)
|
|
45
79
|
|
|
46
80
|
const requireSelfId = (value: string) => {
|
|
47
81
|
if (!SELF_ID_PATTERN.test(value)) throw new ExitError(INVALID_SELF_ID_MESSAGE, 1)
|
|
@@ -61,12 +95,13 @@ const parseSelfOption = (value: unknown): Pick<SessionConfig, "name" | "localId"
|
|
|
61
95
|
export const sessionConfigFrom = (options: Record<string, unknown>, defaults: { autoAcceptIncoming?: boolean; autoSaveIncoming?: boolean }): SessionConfig & { room: string } => {
|
|
62
96
|
const room = cleanRoom(firstNonEmptyText(options.room, process.env.SEND_ROOM))
|
|
63
97
|
const self = parseSelfOption(options.self ?? process.env.SEND_SELF)
|
|
98
|
+
const { accept, save } = parseBinaryOptions(options, ["accept", "save"] as const)
|
|
64
99
|
return {
|
|
65
100
|
room,
|
|
66
101
|
...self,
|
|
67
102
|
saveDir: resolve(`${options.saveDir ?? process.env.SEND_SAVE_DIR ?? "downloads"}`),
|
|
68
|
-
autoAcceptIncoming: defaults.autoAcceptIncoming ?? false,
|
|
69
|
-
autoSaveIncoming: defaults.autoSaveIncoming ?? false,
|
|
103
|
+
autoAcceptIncoming: accept ?? defaults.autoAcceptIncoming ?? false,
|
|
104
|
+
autoSaveIncoming: save ?? defaults.autoSaveIncoming ?? false,
|
|
70
105
|
turnUrls: splitList(options.turnUrl ?? process.env.SEND_TURN_URL),
|
|
71
106
|
turnUsername: `${options.turnUsername ?? process.env.SEND_TURN_USERNAME ?? ""}`.trim() || undefined,
|
|
72
107
|
turnCredential: `${options.turnCredential ?? process.env.SEND_TURN_CREDENTIAL ?? ""}`.trim() || undefined,
|
|
@@ -218,8 +253,13 @@ const acceptCommand = async (options: Record<string, unknown>) => {
|
|
|
218
253
|
|
|
219
254
|
const tuiCommand = async (options: Record<string, unknown>) => {
|
|
220
255
|
const initialConfig = sessionConfigFrom(options, { autoAcceptIncoming: true, autoSaveIncoming: true })
|
|
256
|
+
const { clean, offer } = parseBinaryOptions(options, ["clean", "offer"] as const)
|
|
221
257
|
const { startTui } = await loadTuiRuntime()
|
|
222
|
-
await startTui(initialConfig,
|
|
258
|
+
await startTui(initialConfig, {
|
|
259
|
+
events: !!options.events,
|
|
260
|
+
clean: clean ?? true,
|
|
261
|
+
offer: offer ?? true,
|
|
262
|
+
})
|
|
223
263
|
}
|
|
224
264
|
|
|
225
265
|
type CliHandlers = {
|
|
@@ -240,53 +280,38 @@ export const createCli = (handlers: CliHandlers = defaultCliHandlers) => {
|
|
|
240
280
|
const cli = cac("send")
|
|
241
281
|
cli.usage("[command] [options]")
|
|
242
282
|
|
|
243
|
-
cli
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
cli
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
.option("--turn-url <url>", "custom TURN url, repeat or comma-separate")
|
|
276
|
-
.option("--turn-username <value>", "custom TURN username")
|
|
277
|
-
.option("--turn-credential <value>", "custom TURN credential")
|
|
278
|
-
.action(handlers.accept)
|
|
279
|
-
|
|
280
|
-
cli
|
|
281
|
-
.command("tui", "launch the interactive terminal UI")
|
|
282
|
-
.option("--room <room>", "room id; omit to create a random room")
|
|
283
|
-
.option("--self <self>", SELF_HELP_TEXT)
|
|
284
|
-
.option("--events", "show the event log pane")
|
|
285
|
-
.option("--save-dir <dir>", "save directory")
|
|
286
|
-
.option("--turn-url <url>", "custom TURN url, repeat or comma-separate")
|
|
287
|
-
.option("--turn-username <value>", "custom TURN username")
|
|
288
|
-
.option("--turn-credential <value>", "custom TURN credential")
|
|
289
|
-
.action(handlers.tui)
|
|
283
|
+
addOptions(cli.command("peers", "list discovered peers"), [
|
|
284
|
+
...ROOM_SELF_OPTIONS,
|
|
285
|
+
["--wait <ms>", "discovery wait in milliseconds"],
|
|
286
|
+
["--json", "print a json snapshot"],
|
|
287
|
+
SAVE_DIR_OPTION,
|
|
288
|
+
...TURN_OPTIONS,
|
|
289
|
+
]).action(handlers.peers)
|
|
290
|
+
|
|
291
|
+
addOptions(cli.command("offer [...files]", "offer files to browser-compatible peers"), [
|
|
292
|
+
...ROOM_SELF_OPTIONS,
|
|
293
|
+
["--to <peer>", "target peer id or name-suffix, or `.` for all ready peers; default: `.`"],
|
|
294
|
+
["--wait-peer <ms>", "wait for eligible peers in milliseconds; omit to wait indefinitely"],
|
|
295
|
+
["--json", "emit ndjson events"],
|
|
296
|
+
SAVE_DIR_OPTION,
|
|
297
|
+
...TURN_OPTIONS,
|
|
298
|
+
]).action(handlers.offer)
|
|
299
|
+
|
|
300
|
+
addOptions(cli.command("accept", "receive and save files"), [
|
|
301
|
+
...ROOM_SELF_OPTIONS,
|
|
302
|
+
SAVE_DIR_OPTION,
|
|
303
|
+
["--once", "exit after the first saved incoming transfer"],
|
|
304
|
+
["--json", "emit ndjson events"],
|
|
305
|
+
...TURN_OPTIONS,
|
|
306
|
+
]).action(handlers.accept)
|
|
307
|
+
|
|
308
|
+
addOptions(cli.command("tui", "launch the interactive terminal UI"), [
|
|
309
|
+
...ROOM_SELF_OPTIONS,
|
|
310
|
+
...TUI_TOGGLE_OPTIONS,
|
|
311
|
+
["--events", "show the event log pane"],
|
|
312
|
+
SAVE_DIR_OPTION,
|
|
313
|
+
...TURN_OPTIONS,
|
|
314
|
+
]).action(handlers.tui)
|
|
290
315
|
|
|
291
316
|
cli.help(sections => {
|
|
292
317
|
const usage = sections.find(section => section.title === "Usage:")
|
package/src/tui/app.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolve } from "node:path"
|
|
1
2
|
import { rgb, ui, type BadgeVariant, type VNode } from "@rezi-ui/core"
|
|
2
3
|
import { createNodeApp } from "@rezi-ui/node"
|
|
3
4
|
import { inspectLocalFile } from "../core/files"
|
|
@@ -37,6 +38,7 @@ type DenseSectionOptions = {
|
|
|
37
38
|
border?: "rounded" | "single" | "none"
|
|
38
39
|
flex?: number
|
|
39
40
|
}
|
|
41
|
+
type TuiLaunchOptions = { events?: boolean; clean?: boolean; offer?: boolean }
|
|
40
42
|
|
|
41
43
|
export type VisiblePane = "peers" | "transfers" | "logs"
|
|
42
44
|
|
|
@@ -45,6 +47,7 @@ export interface TuiState {
|
|
|
45
47
|
sessionSeed: SessionSeed
|
|
46
48
|
peerSelectionByRoom: Map<string, Map<string, boolean>>
|
|
47
49
|
snapshot: SessionSnapshot
|
|
50
|
+
aboutOpen: boolean
|
|
48
51
|
peerSearch: string
|
|
49
52
|
focusedId: string | null
|
|
50
53
|
roomInput: string
|
|
@@ -67,6 +70,8 @@ export interface TuiState {
|
|
|
67
70
|
|
|
68
71
|
export interface TuiActions {
|
|
69
72
|
toggleEvents: TuiAction
|
|
73
|
+
openAbout: TuiAction
|
|
74
|
+
closeAbout: TuiAction
|
|
70
75
|
jumpToRandomRoom: TuiAction
|
|
71
76
|
commitRoom: TuiAction
|
|
72
77
|
setRoomInput: (value: string) => void
|
|
@@ -101,11 +106,20 @@ const ROOM_INPUT_ID = "room-input"
|
|
|
101
106
|
const NAME_INPUT_ID = "name-input"
|
|
102
107
|
const PEER_SEARCH_INPUT_ID = "peer-search-input"
|
|
103
108
|
const DRAFT_INPUT_ID = "draft-input"
|
|
109
|
+
const ABOUT_TRIGGER_ID = "open-about"
|
|
104
110
|
const TRANSPARENT_BORDER_STYLE = { fg: rgb(7, 10, 12) } as const
|
|
105
111
|
const METRIC_BORDER_STYLE = { fg: rgb(20, 25, 32) } as const
|
|
106
112
|
const PRIMARY_TEXT_STYLE = { fg: rgb(255, 255, 255) } as const
|
|
107
113
|
const HEADING_TEXT_STYLE = { fg: rgb(255, 255, 255), bold: true } as const
|
|
108
114
|
const DEFAULT_WEB_URL = "https://send.rt.ht/"
|
|
115
|
+
const DEFAULT_SAVE_DIR = resolve(process.cwd(), "downloads")
|
|
116
|
+
const ABOUT_ELEFUNC_URL = "https://elefunc.com"
|
|
117
|
+
const ABOUT_TITLE = "About Send"
|
|
118
|
+
const ABOUT_INTRO = "Peer-to-Peer Transfers for the Web and CLI"
|
|
119
|
+
const ABOUT_SUMMARY = "Join the same room, see who is there, and offer files directly to selected peers."
|
|
120
|
+
const ABOUT_RUNTIME = "Send uses lightweight signaling to discover peers and negotiate WebRTC. Files move over WebRTC data channels, using direct paths when possible and TURN relay when needed."
|
|
121
|
+
const ABOUT_CLI_LABEL = "bunx @elefunc/send@latest"
|
|
122
|
+
const ABOUT_WEB_LINK_LABEL = "Web"
|
|
109
123
|
const TRANSFER_DIRECTION_ARROW = {
|
|
110
124
|
out: { glyph: "↗", style: { fg: rgb(170, 217, 76), bold: true } },
|
|
111
125
|
in: { glyph: "↙", style: { fg: rgb(240, 113, 120), bold: true } },
|
|
@@ -119,8 +133,50 @@ const pluralRules = new Intl.PluralRules()
|
|
|
119
133
|
export const visiblePanes = (showEvents: boolean): VisiblePane[] => showEvents ? ["peers", "transfers", "logs"] : ["peers", "transfers"]
|
|
120
134
|
|
|
121
135
|
const noop = () => {}
|
|
136
|
+
export const isEditableFocusId = (focusedId: string | null) =>
|
|
137
|
+
focusedId === ROOM_INPUT_ID || focusedId === NAME_INPUT_ID || focusedId === PEER_SEARCH_INPUT_ID || focusedId === DRAFT_INPUT_ID
|
|
138
|
+
export const shouldSwallowQQuit = (focusedId: string | null) => !isEditableFocusId(focusedId)
|
|
122
139
|
|
|
123
140
|
const hashBool = (value: boolean) => value ? "1" : "0"
|
|
141
|
+
const safeShellArgPattern = /^[A-Za-z0-9._/:?=&,+@%-]+$/
|
|
142
|
+
const TUI_QUIT_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"] as const
|
|
143
|
+
type TuiQuitSignal = (typeof TUI_QUIT_SIGNALS)[number]
|
|
144
|
+
type ProcessSignalLike = {
|
|
145
|
+
on?: (signal: TuiQuitSignal, handler: () => void) => unknown
|
|
146
|
+
off?: (signal: TuiQuitSignal, handler: () => void) => unknown
|
|
147
|
+
removeListener?: (signal: TuiQuitSignal, handler: () => void) => unknown
|
|
148
|
+
}
|
|
149
|
+
type ShareUrlState = Pick<TuiState, "snapshot" | "hideTerminalPeers" | "autoAcceptIncoming" | "autoOfferOutgoing" | "autoSaveIncoming">
|
|
150
|
+
type ShareCliState = ShareUrlState & Pick<TuiState, "sessionSeed" | "eventsExpanded">
|
|
151
|
+
const shellQuote = (value: string) => safeShellArgPattern.test(value) ? value : `'${value.replaceAll("'", `'\"'\"'`)}'`
|
|
152
|
+
const appendCliFlag = (args: string[], flag: string, value?: string | null) => {
|
|
153
|
+
const text = `${value ?? ""}`.trim()
|
|
154
|
+
if (!text) return
|
|
155
|
+
args.push(flag, shellQuote(text))
|
|
156
|
+
}
|
|
157
|
+
const SHARE_TOGGLE_FLAGS = [
|
|
158
|
+
["clean", "hideTerminalPeers", "--clean"],
|
|
159
|
+
["accept", "autoAcceptIncoming", "--accept"],
|
|
160
|
+
["offer", "autoOfferOutgoing", "--offer"],
|
|
161
|
+
["save", "autoSaveIncoming", "--save"],
|
|
162
|
+
] as const satisfies readonly (readonly [string, keyof ShareUrlState, string])[]
|
|
163
|
+
const appendToggleCliFlags = (args: string[], state: ShareUrlState) => {
|
|
164
|
+
for (const [, stateKey, flag] of SHARE_TOGGLE_FLAGS) if (!state[stateKey]) appendCliFlag(args, flag, "0")
|
|
165
|
+
}
|
|
166
|
+
const buildHashParams = (state: ShareUrlState, omitDefaults = false) => {
|
|
167
|
+
const params = new URLSearchParams({ room: cleanRoom(state.snapshot.room) })
|
|
168
|
+
for (const [key, stateKey] of SHARE_TOGGLE_FLAGS) if (!omitDefaults || !state[stateKey]) params.set(key, hashBool(state[stateKey]))
|
|
169
|
+
return params
|
|
170
|
+
}
|
|
171
|
+
const shareTurnCliArgs = (sessionSeed: SessionSeed) => {
|
|
172
|
+
const turnUrls = [...new Set((sessionSeed.turnUrls ?? []).map(url => `${url ?? ""}`.trim()).filter(Boolean))]
|
|
173
|
+
if (!turnUrls.length) return []
|
|
174
|
+
const args: string[] = []
|
|
175
|
+
for (const turnUrl of turnUrls) appendCliFlag(args, "--turn-url", turnUrl)
|
|
176
|
+
appendCliFlag(args, "--turn-username", sessionSeed.turnUsername)
|
|
177
|
+
appendCliFlag(args, "--turn-credential", sessionSeed.turnCredential)
|
|
178
|
+
return args
|
|
179
|
+
}
|
|
124
180
|
|
|
125
181
|
export const resolveWebUrlBase = (value = process.env.SEND_WEB_URL) => {
|
|
126
182
|
const candidate = `${value ?? ""}`.trim() || DEFAULT_WEB_URL
|
|
@@ -131,23 +187,75 @@ export const resolveWebUrlBase = (value = process.env.SEND_WEB_URL) => {
|
|
|
131
187
|
}
|
|
132
188
|
}
|
|
133
189
|
|
|
134
|
-
|
|
135
|
-
state: Pick<TuiState, "snapshot" | "hideTerminalPeers" | "autoAcceptIncoming" | "autoOfferOutgoing" | "autoSaveIncoming">,
|
|
136
|
-
baseUrl = resolveWebUrlBase(),
|
|
137
|
-
) => {
|
|
190
|
+
const renderWebUrl = (state: ShareUrlState, baseUrl = DEFAULT_WEB_URL, omitDefaults = true) => {
|
|
138
191
|
const url = new URL(baseUrl)
|
|
139
|
-
url.hash =
|
|
140
|
-
room: cleanRoom(state.snapshot.room),
|
|
141
|
-
clean: hashBool(state.hideTerminalPeers),
|
|
142
|
-
accept: hashBool(state.autoAcceptIncoming),
|
|
143
|
-
offer: hashBool(state.autoOfferOutgoing),
|
|
144
|
-
save: hashBool(state.autoSaveIncoming),
|
|
145
|
-
}).toString()
|
|
192
|
+
url.hash = buildHashParams(state, omitDefaults).toString()
|
|
146
193
|
return url.toString()
|
|
147
194
|
}
|
|
195
|
+
const renderCliCommand = (state: ShareCliState, { includeSelf = false, includePrefix = false } = {}) => {
|
|
196
|
+
const args = includePrefix ? ["bunx", "@elefunc/send@latest"] : []
|
|
197
|
+
appendCliFlag(args, "--room", state.snapshot.room)
|
|
198
|
+
if (includeSelf) appendCliFlag(args, "--self", displayPeerName(state.snapshot.name, state.snapshot.localId))
|
|
199
|
+
appendToggleCliFlags(args, state)
|
|
200
|
+
if (state.eventsExpanded) args.push("--events")
|
|
201
|
+
if (resolve(state.snapshot.saveDir) !== DEFAULT_SAVE_DIR) appendCliFlag(args, "--save-dir", state.snapshot.saveDir)
|
|
202
|
+
args.push(...shareTurnCliArgs(state.sessionSeed))
|
|
203
|
+
return args.join(" ")
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export const webInviteUrl = (state: ShareUrlState, baseUrl = resolveWebUrlBase()) => {
|
|
207
|
+
return renderWebUrl(state, baseUrl)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export const aboutWebUrl = (state: ShareUrlState, baseUrl = DEFAULT_WEB_URL) => renderWebUrl(state, baseUrl)
|
|
211
|
+
|
|
212
|
+
export const aboutWebLabel = (state: ShareUrlState, baseUrl = DEFAULT_WEB_URL) => aboutWebUrl(state, baseUrl).replace(/^https:\/\//, "")
|
|
213
|
+
|
|
214
|
+
export const aboutCliCommand = (state: ShareCliState) => renderCliCommand(state)
|
|
215
|
+
|
|
216
|
+
export const resumeWebUrl = (state: ShareUrlState, baseUrl = DEFAULT_WEB_URL) => renderWebUrl(state, baseUrl)
|
|
217
|
+
|
|
218
|
+
export const resumeCliCommand = (state: ShareCliState) => renderCliCommand(state, { includeSelf: true, includePrefix: true })
|
|
219
|
+
|
|
220
|
+
export const resumeOutputLines = (state: ShareCliState) => [
|
|
221
|
+
"Rejoin with:",
|
|
222
|
+
"",
|
|
223
|
+
"Web",
|
|
224
|
+
resumeWebUrl(state),
|
|
225
|
+
"",
|
|
226
|
+
"CLI",
|
|
227
|
+
resumeCliCommand(state),
|
|
228
|
+
"",
|
|
229
|
+
]
|
|
230
|
+
|
|
231
|
+
export const createQuitController = (processLike: ProcessSignalLike | null = process) => {
|
|
232
|
+
let settled = false
|
|
233
|
+
let resolvePromise = () => {}
|
|
234
|
+
const promise = new Promise<void>(resolve => { resolvePromise = resolve })
|
|
235
|
+
const requestStop = () => {
|
|
236
|
+
if (settled) return false
|
|
237
|
+
settled = true
|
|
238
|
+
resolvePromise()
|
|
239
|
+
return true
|
|
240
|
+
}
|
|
241
|
+
const handler = () => { requestStop() }
|
|
242
|
+
for (const signal of TUI_QUIT_SIGNALS) processLike?.on?.(signal, handler)
|
|
243
|
+
return {
|
|
244
|
+
promise,
|
|
245
|
+
requestStop,
|
|
246
|
+
detach: () => {
|
|
247
|
+
for (const signal of TUI_QUIT_SIGNALS) {
|
|
248
|
+
processLike?.off?.(signal, handler)
|
|
249
|
+
processLike?.removeListener?.(signal, handler)
|
|
250
|
+
}
|
|
251
|
+
},
|
|
252
|
+
}
|
|
253
|
+
}
|
|
148
254
|
|
|
149
255
|
export const createNoopTuiActions = (): TuiActions => ({
|
|
150
256
|
toggleEvents: noop,
|
|
257
|
+
openAbout: noop,
|
|
258
|
+
closeAbout: noop,
|
|
151
259
|
jumpToRandomRoom: noop,
|
|
152
260
|
commitRoom: noop,
|
|
153
261
|
setRoomInput: noop,
|
|
@@ -481,7 +589,7 @@ export const groupTransfersByPeer = (transfers: TransferSnapshot[], peers: PeerS
|
|
|
481
589
|
return [...groups.values()]
|
|
482
590
|
}
|
|
483
591
|
|
|
484
|
-
export const createInitialTuiState = (initialConfig: SessionConfig, showEvents = false): TuiState => {
|
|
592
|
+
export const createInitialTuiState = (initialConfig: SessionConfig, showEvents = false, launchOptions: Pick<TuiLaunchOptions, "clean" | "offer"> = {}): TuiState => {
|
|
485
593
|
const sessionSeed = normalizeSessionSeed(initialConfig)
|
|
486
594
|
const autoAcceptIncoming = initialConfig.autoAcceptIncoming ?? true
|
|
487
595
|
const autoSaveIncoming = initialConfig.autoSaveIncoming ?? true
|
|
@@ -493,6 +601,7 @@ export const createInitialTuiState = (initialConfig: SessionConfig, showEvents =
|
|
|
493
601
|
sessionSeed,
|
|
494
602
|
peerSelectionByRoom,
|
|
495
603
|
snapshot: session.snapshot(),
|
|
604
|
+
aboutOpen: false,
|
|
496
605
|
peerSearch: "",
|
|
497
606
|
focusedId: null,
|
|
498
607
|
roomInput: sessionSeed.room,
|
|
@@ -502,10 +611,10 @@ export const createInitialTuiState = (initialConfig: SessionConfig, showEvents =
|
|
|
502
611
|
draftInputKeyVersion: 0,
|
|
503
612
|
filePreview: emptyFilePreviewState(),
|
|
504
613
|
drafts: [],
|
|
505
|
-
autoOfferOutgoing: true,
|
|
614
|
+
autoOfferOutgoing: launchOptions.offer ?? true,
|
|
506
615
|
autoAcceptIncoming,
|
|
507
616
|
autoSaveIncoming,
|
|
508
|
-
hideTerminalPeers: true,
|
|
617
|
+
hideTerminalPeers: launchOptions.clean ?? true,
|
|
509
618
|
eventsExpanded: showEvents,
|
|
510
619
|
offeringDrafts: false,
|
|
511
620
|
notice: { text: "Tab focus", variant: "info" },
|
|
@@ -594,9 +703,64 @@ const renderHeaderBrand = () => ui.row({ id: "brand-title", gap: 1, items: "cent
|
|
|
594
703
|
const renderHeader = (state: TuiState, actions: TuiActions) => denseSection({
|
|
595
704
|
id: "header-shell",
|
|
596
705
|
titleNode: renderHeaderBrand(),
|
|
597
|
-
actions: [
|
|
706
|
+
actions: [
|
|
707
|
+
toggleButton("toggle-events", "Events", state.eventsExpanded, actions.toggleEvents),
|
|
708
|
+
actionButton(ABOUT_TRIGGER_ID, "About", actions.openAbout),
|
|
709
|
+
],
|
|
598
710
|
}, [])
|
|
599
711
|
|
|
712
|
+
const renderAboutModal = (state: TuiState, actions: TuiActions) => {
|
|
713
|
+
const cliCommand = aboutCliCommand(state)
|
|
714
|
+
const cliCopyText = `${ABOUT_CLI_LABEL} ${cliCommand}`
|
|
715
|
+
const cliCopyUrl = `https://copy.rt.ht/#${new URLSearchParams({ text: cliCopyText })}`
|
|
716
|
+
const currentWebUrl = aboutWebUrl(state)
|
|
717
|
+
const currentWebLabel = aboutWebLabel(state)
|
|
718
|
+
return ui.modal({
|
|
719
|
+
id: "about-modal",
|
|
720
|
+
title: ABOUT_TITLE,
|
|
721
|
+
content: ui.column({ gap: 1 }, [
|
|
722
|
+
ui.text(ABOUT_INTRO, { id: "about-intro", variant: "heading", wrap: true }),
|
|
723
|
+
ui.text(ABOUT_SUMMARY, { id: "about-summary", wrap: true }),
|
|
724
|
+
ui.text(ABOUT_RUNTIME, { id: "about-runtime", wrap: true }),
|
|
725
|
+
ui.column({ gap: 0 }, [
|
|
726
|
+
ui.text(ABOUT_CLI_LABEL, { id: "about-cli-label", variant: "caption", wrap: true }),
|
|
727
|
+
ui.link({
|
|
728
|
+
id: "about-current-cli",
|
|
729
|
+
label: cliCommand,
|
|
730
|
+
accessibleLabel: "Copy current CLI command",
|
|
731
|
+
url: cliCopyUrl,
|
|
732
|
+
}),
|
|
733
|
+
]),
|
|
734
|
+
ui.column({ gap: 0 }, [
|
|
735
|
+
ui.text(ABOUT_WEB_LINK_LABEL, { id: "about-web-link-label", variant: "caption", wrap: true }),
|
|
736
|
+
ui.link({
|
|
737
|
+
id: "about-current-web-link",
|
|
738
|
+
label: currentWebLabel,
|
|
739
|
+
accessibleLabel: "Open current web link",
|
|
740
|
+
url: currentWebUrl,
|
|
741
|
+
}),
|
|
742
|
+
]),
|
|
743
|
+
]),
|
|
744
|
+
actions: [
|
|
745
|
+
ui.link({
|
|
746
|
+
id: "about-elefunc-link",
|
|
747
|
+
label: "elefunc.com",
|
|
748
|
+
accessibleLabel: "Open Elefunc website",
|
|
749
|
+
url: ABOUT_ELEFUNC_URL,
|
|
750
|
+
}),
|
|
751
|
+
actionButton("close-about", "Close", actions.closeAbout, "primary"),
|
|
752
|
+
],
|
|
753
|
+
width: 72,
|
|
754
|
+
maxWidth: 84,
|
|
755
|
+
minWidth: 54,
|
|
756
|
+
frameStyle: { background: rgb(0, 0, 0) },
|
|
757
|
+
backdrop: { variant: "none" },
|
|
758
|
+
initialFocus: "close-about",
|
|
759
|
+
returnFocusTo: ABOUT_TRIGGER_ID,
|
|
760
|
+
onClose: actions.closeAbout,
|
|
761
|
+
})
|
|
762
|
+
}
|
|
763
|
+
|
|
600
764
|
const renderRoomCard = (state: TuiState, actions: TuiActions) => denseSection({
|
|
601
765
|
id: "room-card",
|
|
602
766
|
}, [
|
|
@@ -630,6 +794,14 @@ const renderSelfMetric = (label: string, value: string) => ui.box({ flex: 1, min
|
|
|
630
794
|
])
|
|
631
795
|
|
|
632
796
|
const renderSelfProfileLine = (value: string) => ui.text(value || "—")
|
|
797
|
+
const ipLookupUrl = (value: string) => value ? `https://gi.rt.ht/:${encodeURIComponent(value)}` : null
|
|
798
|
+
const renderIpProfileLine = (value: string) => {
|
|
799
|
+
const ip = value || ""
|
|
800
|
+
const url = ipLookupUrl(ip)
|
|
801
|
+
return url
|
|
802
|
+
? ui.link({ label: ip, url, accessibleLabel: `Open IP lookup for ${ip}` })
|
|
803
|
+
: ui.text("—")
|
|
804
|
+
}
|
|
633
805
|
const formatPeerRtt = (value: number) => Number.isFinite(value) ? `${countFormat.format(Math.round(value))}ms` : "—"
|
|
634
806
|
const renderPeerMetric = (label: string, value: string, asTag = false) => ui.box({ flex: 1, minWidth: 10, border: "single", borderStyle: METRIC_BORDER_STYLE }, [
|
|
635
807
|
ui.column({ gap: 0 }, [
|
|
@@ -663,7 +835,7 @@ const renderSelfCard = (state: TuiState, actions: TuiActions) => denseSection({
|
|
|
663
835
|
renderSelfProfileLine(geoSummary(state.snapshot.profile)),
|
|
664
836
|
renderSelfProfileLine(netSummary(state.snapshot.profile)),
|
|
665
837
|
renderSelfProfileLine(uaSummary(state.snapshot.profile)),
|
|
666
|
-
|
|
838
|
+
renderIpProfileLine(profileIp(state.snapshot.profile)),
|
|
667
839
|
]),
|
|
668
840
|
])
|
|
669
841
|
|
|
@@ -708,7 +880,7 @@ const renderPeerRow = (peer: PeerSnapshot, turnShareEnabled: boolean, actions: T
|
|
|
708
880
|
renderSelfProfileLine(geoSummary(peer.profile)),
|
|
709
881
|
renderSelfProfileLine(netSummary(peer.profile)),
|
|
710
882
|
renderSelfProfileLine(uaSummary(peer.profile)),
|
|
711
|
-
|
|
883
|
+
renderIpProfileLine(profileIp(peer.profile)),
|
|
712
884
|
]),
|
|
713
885
|
peer.lastError ? ui.callout(peer.lastError, { variant: "error" }) : null,
|
|
714
886
|
]),
|
|
@@ -1021,7 +1193,7 @@ export const renderTuiView = (state: TuiState, actions: TuiActions): VNode => {
|
|
|
1021
1193
|
gap: 0,
|
|
1022
1194
|
})
|
|
1023
1195
|
|
|
1024
|
-
|
|
1196
|
+
const basePage = state.pendingFocusTarget
|
|
1025
1197
|
? ui.focusTrap({
|
|
1026
1198
|
id: `focus-request-${state.focusRequestEpoch}`,
|
|
1027
1199
|
key: `focus-request-${state.focusRequestEpoch}`,
|
|
@@ -1029,6 +1201,10 @@ export const renderTuiView = (state: TuiState, actions: TuiActions): VNode => {
|
|
|
1029
1201
|
initialFocus: state.pendingFocusTarget,
|
|
1030
1202
|
}, [page])
|
|
1031
1203
|
: page
|
|
1204
|
+
|
|
1205
|
+
return state.aboutOpen
|
|
1206
|
+
? ui.layers([basePage, renderAboutModal(state, actions)])
|
|
1207
|
+
: basePage
|
|
1032
1208
|
}
|
|
1033
1209
|
|
|
1034
1210
|
const withNotice = (state: TuiState, notice: Notice): TuiState => ({ ...state, notice })
|
|
@@ -1043,10 +1219,11 @@ export const withAcceptedDraftInput = (state: TuiState, draftInput: string, file
|
|
|
1043
1219
|
focusRequestEpoch: state.focusRequestEpoch + 1,
|
|
1044
1220
|
}, notice)
|
|
1045
1221
|
|
|
1046
|
-
export const startTui = async (initialConfig: SessionConfig,
|
|
1222
|
+
export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiLaunchOptions = {}) => {
|
|
1047
1223
|
await installCheckboxClickPatch()
|
|
1048
|
-
const initialState = createInitialTuiState(initialConfig,
|
|
1224
|
+
const initialState = createInitialTuiState(initialConfig, !!launchOptions.events, launchOptions)
|
|
1049
1225
|
const app = createNodeApp<TuiState>({ initialState })
|
|
1226
|
+
const quitController = createQuitController()
|
|
1050
1227
|
let state = initialState
|
|
1051
1228
|
let unsubscribe = () => {}
|
|
1052
1229
|
let stopping = false
|
|
@@ -1086,11 +1263,7 @@ export const startTui = async (initialConfig: SessionConfig, showEvents = false)
|
|
|
1086
1263
|
const requestStop = () => {
|
|
1087
1264
|
if (stopping) return
|
|
1088
1265
|
stopping = true
|
|
1089
|
-
|
|
1090
|
-
process.kill(process.pid, "SIGINT")
|
|
1091
|
-
} catch {
|
|
1092
|
-
void app.stop()
|
|
1093
|
-
}
|
|
1266
|
+
quitController.requestStop()
|
|
1094
1267
|
}
|
|
1095
1268
|
|
|
1096
1269
|
const resetFilePreview = (overrides: Partial<FilePreviewState> = {}): FilePreviewState => ({
|
|
@@ -1368,6 +1541,8 @@ export const startTui = async (initialConfig: SessionConfig, showEvents = false)
|
|
|
1368
1541
|
|
|
1369
1542
|
const actions: TuiActions = {
|
|
1370
1543
|
toggleEvents: () => commit(current => ({ ...withNotice(current, { text: current.eventsExpanded ? "Events hidden." : "Events shown.", variant: "info" }), eventsExpanded: !current.eventsExpanded })),
|
|
1544
|
+
openAbout: () => commit(current => ({ ...current, aboutOpen: true })),
|
|
1545
|
+
closeAbout: () => commit(current => ({ ...current, aboutOpen: false })),
|
|
1371
1546
|
jumpToRandomRoom: () => replaceSession({ ...state.sessionSeed, room: uid(8) }, "Joined a new room."),
|
|
1372
1547
|
commitRoom,
|
|
1373
1548
|
setRoomInput: value => commit(current => ({ ...current, roomInput: value })),
|
|
@@ -1523,6 +1698,11 @@ export const startTui = async (initialConfig: SessionConfig, showEvents = false)
|
|
|
1523
1698
|
})
|
|
1524
1699
|
app.keys({
|
|
1525
1700
|
"ctrl+c": { description: "Quit", handler: requestStop },
|
|
1701
|
+
q: {
|
|
1702
|
+
description: "no-op",
|
|
1703
|
+
when: ctx => shouldSwallowQQuit(ctx.focusedId),
|
|
1704
|
+
handler: noop,
|
|
1705
|
+
},
|
|
1526
1706
|
tab: {
|
|
1527
1707
|
description: "Accept focused preview row",
|
|
1528
1708
|
when: ctx => ctx.focusedId === DRAFT_INPUT_ID && !!selectedFilePreviewMatch(state) && filePreviewVisible(state),
|
|
@@ -1593,17 +1773,31 @@ export const startTui = async (initialConfig: SessionConfig, showEvents = false)
|
|
|
1593
1773
|
stopping = true
|
|
1594
1774
|
unsubscribe()
|
|
1595
1775
|
stopPreviewSession()
|
|
1776
|
+
try {
|
|
1777
|
+
await app.stop()
|
|
1778
|
+
} catch {}
|
|
1596
1779
|
await state.session.close()
|
|
1597
1780
|
}
|
|
1598
1781
|
|
|
1782
|
+
const printResumeOutput = async () => {
|
|
1783
|
+
const output = `${resumeOutputLines(state).join("\n")}\n`
|
|
1784
|
+
await new Promise<void>(resolve => process.stdout.write(output, () => resolve()))
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1599
1787
|
try {
|
|
1600
1788
|
bindSession(state.session)
|
|
1601
|
-
await app.
|
|
1789
|
+
await app.start()
|
|
1790
|
+
await quitController.promise
|
|
1602
1791
|
} finally {
|
|
1603
1792
|
try {
|
|
1604
1793
|
await stop()
|
|
1605
1794
|
} finally {
|
|
1606
|
-
|
|
1795
|
+
try {
|
|
1796
|
+
app.dispose()
|
|
1797
|
+
} finally {
|
|
1798
|
+
await printResumeOutput()
|
|
1799
|
+
quitController.detach()
|
|
1800
|
+
}
|
|
1607
1801
|
}
|
|
1608
1802
|
}
|
|
1609
1803
|
}
|