@elefunc/send 0.1.7 → 0.1.8
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 +194 -25
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 = "Room-based peer-to-peer file transfers for the web and terminal"
|
|
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 } },
|
|
@@ -121,6 +135,45 @@ export const visiblePanes = (showEvents: boolean): VisiblePane[] => showEvents ?
|
|
|
121
135
|
const noop = () => {}
|
|
122
136
|
|
|
123
137
|
const hashBool = (value: boolean) => value ? "1" : "0"
|
|
138
|
+
const safeShellArgPattern = /^[A-Za-z0-9._/:?=&,+@%-]+$/
|
|
139
|
+
const TUI_QUIT_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"] as const
|
|
140
|
+
type TuiQuitSignal = (typeof TUI_QUIT_SIGNALS)[number]
|
|
141
|
+
type ProcessSignalLike = {
|
|
142
|
+
on?: (signal: TuiQuitSignal, handler: () => void) => unknown
|
|
143
|
+
off?: (signal: TuiQuitSignal, handler: () => void) => unknown
|
|
144
|
+
removeListener?: (signal: TuiQuitSignal, handler: () => void) => unknown
|
|
145
|
+
}
|
|
146
|
+
type ShareUrlState = Pick<TuiState, "snapshot" | "hideTerminalPeers" | "autoAcceptIncoming" | "autoOfferOutgoing" | "autoSaveIncoming">
|
|
147
|
+
type ShareCliState = ShareUrlState & Pick<TuiState, "sessionSeed" | "eventsExpanded">
|
|
148
|
+
const shellQuote = (value: string) => safeShellArgPattern.test(value) ? value : `'${value.replaceAll("'", `'\"'\"'`)}'`
|
|
149
|
+
const appendCliFlag = (args: string[], flag: string, value?: string | null) => {
|
|
150
|
+
const text = `${value ?? ""}`.trim()
|
|
151
|
+
if (!text) return
|
|
152
|
+
args.push(flag, shellQuote(text))
|
|
153
|
+
}
|
|
154
|
+
const SHARE_TOGGLE_FLAGS = [
|
|
155
|
+
["clean", "hideTerminalPeers", "--clean"],
|
|
156
|
+
["accept", "autoAcceptIncoming", "--accept"],
|
|
157
|
+
["offer", "autoOfferOutgoing", "--offer"],
|
|
158
|
+
["save", "autoSaveIncoming", "--save"],
|
|
159
|
+
] as const satisfies readonly (readonly [string, keyof ShareUrlState, string])[]
|
|
160
|
+
const appendToggleCliFlags = (args: string[], state: ShareUrlState) => {
|
|
161
|
+
for (const [, stateKey, flag] of SHARE_TOGGLE_FLAGS) if (!state[stateKey]) appendCliFlag(args, flag, "0")
|
|
162
|
+
}
|
|
163
|
+
const buildHashParams = (state: ShareUrlState, omitDefaults = false) => {
|
|
164
|
+
const params = new URLSearchParams({ room: cleanRoom(state.snapshot.room) })
|
|
165
|
+
for (const [key, stateKey] of SHARE_TOGGLE_FLAGS) if (!omitDefaults || !state[stateKey]) params.set(key, hashBool(state[stateKey]))
|
|
166
|
+
return params
|
|
167
|
+
}
|
|
168
|
+
const shareTurnCliArgs = (sessionSeed: SessionSeed) => {
|
|
169
|
+
const turnUrls = [...new Set((sessionSeed.turnUrls ?? []).map(url => `${url ?? ""}`.trim()).filter(Boolean))]
|
|
170
|
+
if (!turnUrls.length) return []
|
|
171
|
+
const args: string[] = []
|
|
172
|
+
for (const turnUrl of turnUrls) appendCliFlag(args, "--turn-url", turnUrl)
|
|
173
|
+
appendCliFlag(args, "--turn-username", sessionSeed.turnUsername)
|
|
174
|
+
appendCliFlag(args, "--turn-credential", sessionSeed.turnCredential)
|
|
175
|
+
return args
|
|
176
|
+
}
|
|
124
177
|
|
|
125
178
|
export const resolveWebUrlBase = (value = process.env.SEND_WEB_URL) => {
|
|
126
179
|
const candidate = `${value ?? ""}`.trim() || DEFAULT_WEB_URL
|
|
@@ -131,23 +184,75 @@ export const resolveWebUrlBase = (value = process.env.SEND_WEB_URL) => {
|
|
|
131
184
|
}
|
|
132
185
|
}
|
|
133
186
|
|
|
134
|
-
|
|
135
|
-
state: Pick<TuiState, "snapshot" | "hideTerminalPeers" | "autoAcceptIncoming" | "autoOfferOutgoing" | "autoSaveIncoming">,
|
|
136
|
-
baseUrl = resolveWebUrlBase(),
|
|
137
|
-
) => {
|
|
187
|
+
const renderWebUrl = (state: ShareUrlState, baseUrl = DEFAULT_WEB_URL, omitDefaults = true) => {
|
|
138
188
|
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()
|
|
189
|
+
url.hash = buildHashParams(state, omitDefaults).toString()
|
|
146
190
|
return url.toString()
|
|
147
191
|
}
|
|
192
|
+
const renderCliCommand = (state: ShareCliState, { includeSelf = false, includePrefix = false } = {}) => {
|
|
193
|
+
const args = includePrefix ? ["bunx", "@elefunc/send@latest"] : []
|
|
194
|
+
appendCliFlag(args, "--room", state.snapshot.room)
|
|
195
|
+
if (includeSelf) appendCliFlag(args, "--self", displayPeerName(state.snapshot.name, state.snapshot.localId))
|
|
196
|
+
appendToggleCliFlags(args, state)
|
|
197
|
+
if (state.eventsExpanded) args.push("--events")
|
|
198
|
+
if (resolve(state.snapshot.saveDir) !== DEFAULT_SAVE_DIR) appendCliFlag(args, "--save-dir", state.snapshot.saveDir)
|
|
199
|
+
args.push(...shareTurnCliArgs(state.sessionSeed))
|
|
200
|
+
return args.join(" ")
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export const webInviteUrl = (state: ShareUrlState, baseUrl = resolveWebUrlBase()) => {
|
|
204
|
+
return renderWebUrl(state, baseUrl, false)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export const aboutWebUrl = (state: ShareUrlState, baseUrl = DEFAULT_WEB_URL) => renderWebUrl(state, baseUrl)
|
|
208
|
+
|
|
209
|
+
export const aboutWebLabel = (state: ShareUrlState, baseUrl = DEFAULT_WEB_URL) => aboutWebUrl(state, baseUrl).replace(/^https:\/\//, "")
|
|
210
|
+
|
|
211
|
+
export const aboutCliCommand = (state: ShareCliState) => renderCliCommand(state)
|
|
212
|
+
|
|
213
|
+
export const resumeWebUrl = (state: ShareUrlState, baseUrl = DEFAULT_WEB_URL) => renderWebUrl(state, baseUrl)
|
|
214
|
+
|
|
215
|
+
export const resumeCliCommand = (state: ShareCliState) => renderCliCommand(state, { includeSelf: true, includePrefix: true })
|
|
216
|
+
|
|
217
|
+
export const resumeOutputLines = (state: ShareCliState) => [
|
|
218
|
+
"Rejoin with:",
|
|
219
|
+
"",
|
|
220
|
+
"Web",
|
|
221
|
+
resumeWebUrl(state),
|
|
222
|
+
"",
|
|
223
|
+
"CLI",
|
|
224
|
+
resumeCliCommand(state),
|
|
225
|
+
"",
|
|
226
|
+
]
|
|
227
|
+
|
|
228
|
+
export const createQuitController = (processLike: ProcessSignalLike | null = process) => {
|
|
229
|
+
let settled = false
|
|
230
|
+
let resolvePromise = () => {}
|
|
231
|
+
const promise = new Promise<void>(resolve => { resolvePromise = resolve })
|
|
232
|
+
const requestStop = () => {
|
|
233
|
+
if (settled) return false
|
|
234
|
+
settled = true
|
|
235
|
+
resolvePromise()
|
|
236
|
+
return true
|
|
237
|
+
}
|
|
238
|
+
const handler = () => { requestStop() }
|
|
239
|
+
for (const signal of TUI_QUIT_SIGNALS) processLike?.on?.(signal, handler)
|
|
240
|
+
return {
|
|
241
|
+
promise,
|
|
242
|
+
requestStop,
|
|
243
|
+
detach: () => {
|
|
244
|
+
for (const signal of TUI_QUIT_SIGNALS) {
|
|
245
|
+
processLike?.off?.(signal, handler)
|
|
246
|
+
processLike?.removeListener?.(signal, handler)
|
|
247
|
+
}
|
|
248
|
+
},
|
|
249
|
+
}
|
|
250
|
+
}
|
|
148
251
|
|
|
149
252
|
export const createNoopTuiActions = (): TuiActions => ({
|
|
150
253
|
toggleEvents: noop,
|
|
254
|
+
openAbout: noop,
|
|
255
|
+
closeAbout: noop,
|
|
151
256
|
jumpToRandomRoom: noop,
|
|
152
257
|
commitRoom: noop,
|
|
153
258
|
setRoomInput: noop,
|
|
@@ -481,7 +586,7 @@ export const groupTransfersByPeer = (transfers: TransferSnapshot[], peers: PeerS
|
|
|
481
586
|
return [...groups.values()]
|
|
482
587
|
}
|
|
483
588
|
|
|
484
|
-
export const createInitialTuiState = (initialConfig: SessionConfig, showEvents = false): TuiState => {
|
|
589
|
+
export const createInitialTuiState = (initialConfig: SessionConfig, showEvents = false, launchOptions: Pick<TuiLaunchOptions, "clean" | "offer"> = {}): TuiState => {
|
|
485
590
|
const sessionSeed = normalizeSessionSeed(initialConfig)
|
|
486
591
|
const autoAcceptIncoming = initialConfig.autoAcceptIncoming ?? true
|
|
487
592
|
const autoSaveIncoming = initialConfig.autoSaveIncoming ?? true
|
|
@@ -493,6 +598,7 @@ export const createInitialTuiState = (initialConfig: SessionConfig, showEvents =
|
|
|
493
598
|
sessionSeed,
|
|
494
599
|
peerSelectionByRoom,
|
|
495
600
|
snapshot: session.snapshot(),
|
|
601
|
+
aboutOpen: false,
|
|
496
602
|
peerSearch: "",
|
|
497
603
|
focusedId: null,
|
|
498
604
|
roomInput: sessionSeed.room,
|
|
@@ -502,10 +608,10 @@ export const createInitialTuiState = (initialConfig: SessionConfig, showEvents =
|
|
|
502
608
|
draftInputKeyVersion: 0,
|
|
503
609
|
filePreview: emptyFilePreviewState(),
|
|
504
610
|
drafts: [],
|
|
505
|
-
autoOfferOutgoing: true,
|
|
611
|
+
autoOfferOutgoing: launchOptions.offer ?? true,
|
|
506
612
|
autoAcceptIncoming,
|
|
507
613
|
autoSaveIncoming,
|
|
508
|
-
hideTerminalPeers: true,
|
|
614
|
+
hideTerminalPeers: launchOptions.clean ?? true,
|
|
509
615
|
eventsExpanded: showEvents,
|
|
510
616
|
offeringDrafts: false,
|
|
511
617
|
notice: { text: "Tab focus", variant: "info" },
|
|
@@ -594,9 +700,55 @@ const renderHeaderBrand = () => ui.row({ id: "brand-title", gap: 1, items: "cent
|
|
|
594
700
|
const renderHeader = (state: TuiState, actions: TuiActions) => denseSection({
|
|
595
701
|
id: "header-shell",
|
|
596
702
|
titleNode: renderHeaderBrand(),
|
|
597
|
-
actions: [
|
|
703
|
+
actions: [
|
|
704
|
+
toggleButton("toggle-events", "Events", state.eventsExpanded, actions.toggleEvents),
|
|
705
|
+
actionButton(ABOUT_TRIGGER_ID, "About", actions.openAbout),
|
|
706
|
+
],
|
|
598
707
|
}, [])
|
|
599
708
|
|
|
709
|
+
const renderAboutModal = (state: TuiState, actions: TuiActions) => {
|
|
710
|
+
const cliCommand = aboutCliCommand(state)
|
|
711
|
+
const currentWebUrl = aboutWebUrl(state)
|
|
712
|
+
const currentWebLabel = aboutWebLabel(state)
|
|
713
|
+
return ui.modal({
|
|
714
|
+
id: "about-modal",
|
|
715
|
+
title: ABOUT_TITLE,
|
|
716
|
+
content: ui.column({ gap: 1 }, [
|
|
717
|
+
ui.text(ABOUT_INTRO, { id: "about-intro", variant: "heading" }),
|
|
718
|
+
ui.text(ABOUT_SUMMARY, { id: "about-summary" }),
|
|
719
|
+
ui.text(ABOUT_RUNTIME, { id: "about-runtime" }),
|
|
720
|
+
ui.column({ gap: 0 }, [
|
|
721
|
+
ui.text(ABOUT_CLI_LABEL, { id: "about-cli-label", variant: "caption" }),
|
|
722
|
+
ui.text(cliCommand, { id: "about-current-cli" }),
|
|
723
|
+
]),
|
|
724
|
+
ui.column({ gap: 0 }, [
|
|
725
|
+
ui.text(ABOUT_WEB_LINK_LABEL, { id: "about-web-link-label", variant: "caption" }),
|
|
726
|
+
ui.link({
|
|
727
|
+
id: "about-current-web-link",
|
|
728
|
+
label: currentWebLabel,
|
|
729
|
+
accessibleLabel: "Open current web link",
|
|
730
|
+
url: currentWebUrl,
|
|
731
|
+
}),
|
|
732
|
+
]),
|
|
733
|
+
]),
|
|
734
|
+
actions: [
|
|
735
|
+
ui.link({
|
|
736
|
+
id: "about-elefunc-link",
|
|
737
|
+
label: "elefunc.com",
|
|
738
|
+
accessibleLabel: "Open Elefunc website",
|
|
739
|
+
url: ABOUT_ELEFUNC_URL,
|
|
740
|
+
}),
|
|
741
|
+
actionButton("close-about", "Close", actions.closeAbout, "primary"),
|
|
742
|
+
],
|
|
743
|
+
width: 72,
|
|
744
|
+
maxWidth: 84,
|
|
745
|
+
minWidth: 54,
|
|
746
|
+
initialFocus: "close-about",
|
|
747
|
+
returnFocusTo: ABOUT_TRIGGER_ID,
|
|
748
|
+
onClose: actions.closeAbout,
|
|
749
|
+
})
|
|
750
|
+
}
|
|
751
|
+
|
|
600
752
|
const renderRoomCard = (state: TuiState, actions: TuiActions) => denseSection({
|
|
601
753
|
id: "room-card",
|
|
602
754
|
}, [
|
|
@@ -1021,7 +1173,7 @@ export const renderTuiView = (state: TuiState, actions: TuiActions): VNode => {
|
|
|
1021
1173
|
gap: 0,
|
|
1022
1174
|
})
|
|
1023
1175
|
|
|
1024
|
-
|
|
1176
|
+
const basePage = state.pendingFocusTarget
|
|
1025
1177
|
? ui.focusTrap({
|
|
1026
1178
|
id: `focus-request-${state.focusRequestEpoch}`,
|
|
1027
1179
|
key: `focus-request-${state.focusRequestEpoch}`,
|
|
@@ -1029,6 +1181,10 @@ export const renderTuiView = (state: TuiState, actions: TuiActions): VNode => {
|
|
|
1029
1181
|
initialFocus: state.pendingFocusTarget,
|
|
1030
1182
|
}, [page])
|
|
1031
1183
|
: page
|
|
1184
|
+
|
|
1185
|
+
return state.aboutOpen
|
|
1186
|
+
? ui.layers([basePage, renderAboutModal(state, actions)])
|
|
1187
|
+
: basePage
|
|
1032
1188
|
}
|
|
1033
1189
|
|
|
1034
1190
|
const withNotice = (state: TuiState, notice: Notice): TuiState => ({ ...state, notice })
|
|
@@ -1043,10 +1199,11 @@ export const withAcceptedDraftInput = (state: TuiState, draftInput: string, file
|
|
|
1043
1199
|
focusRequestEpoch: state.focusRequestEpoch + 1,
|
|
1044
1200
|
}, notice)
|
|
1045
1201
|
|
|
1046
|
-
export const startTui = async (initialConfig: SessionConfig,
|
|
1202
|
+
export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiLaunchOptions = {}) => {
|
|
1047
1203
|
await installCheckboxClickPatch()
|
|
1048
|
-
const initialState = createInitialTuiState(initialConfig,
|
|
1204
|
+
const initialState = createInitialTuiState(initialConfig, !!launchOptions.events, launchOptions)
|
|
1049
1205
|
const app = createNodeApp<TuiState>({ initialState })
|
|
1206
|
+
const quitController = createQuitController()
|
|
1050
1207
|
let state = initialState
|
|
1051
1208
|
let unsubscribe = () => {}
|
|
1052
1209
|
let stopping = false
|
|
@@ -1086,11 +1243,7 @@ export const startTui = async (initialConfig: SessionConfig, showEvents = false)
|
|
|
1086
1243
|
const requestStop = () => {
|
|
1087
1244
|
if (stopping) return
|
|
1088
1245
|
stopping = true
|
|
1089
|
-
|
|
1090
|
-
process.kill(process.pid, "SIGINT")
|
|
1091
|
-
} catch {
|
|
1092
|
-
void app.stop()
|
|
1093
|
-
}
|
|
1246
|
+
quitController.requestStop()
|
|
1094
1247
|
}
|
|
1095
1248
|
|
|
1096
1249
|
const resetFilePreview = (overrides: Partial<FilePreviewState> = {}): FilePreviewState => ({
|
|
@@ -1368,6 +1521,8 @@ export const startTui = async (initialConfig: SessionConfig, showEvents = false)
|
|
|
1368
1521
|
|
|
1369
1522
|
const actions: TuiActions = {
|
|
1370
1523
|
toggleEvents: () => commit(current => ({ ...withNotice(current, { text: current.eventsExpanded ? "Events hidden." : "Events shown.", variant: "info" }), eventsExpanded: !current.eventsExpanded })),
|
|
1524
|
+
openAbout: () => commit(current => ({ ...current, aboutOpen: true })),
|
|
1525
|
+
closeAbout: () => commit(current => ({ ...current, aboutOpen: false })),
|
|
1371
1526
|
jumpToRandomRoom: () => replaceSession({ ...state.sessionSeed, room: uid(8) }, "Joined a new room."),
|
|
1372
1527
|
commitRoom,
|
|
1373
1528
|
setRoomInput: value => commit(current => ({ ...current, roomInput: value })),
|
|
@@ -1593,17 +1748,31 @@ export const startTui = async (initialConfig: SessionConfig, showEvents = false)
|
|
|
1593
1748
|
stopping = true
|
|
1594
1749
|
unsubscribe()
|
|
1595
1750
|
stopPreviewSession()
|
|
1751
|
+
try {
|
|
1752
|
+
await app.stop()
|
|
1753
|
+
} catch {}
|
|
1596
1754
|
await state.session.close()
|
|
1597
1755
|
}
|
|
1598
1756
|
|
|
1757
|
+
const printResumeOutput = async () => {
|
|
1758
|
+
const output = `${resumeOutputLines(state).join("\n")}\n`
|
|
1759
|
+
await new Promise<void>(resolve => process.stdout.write(output, () => resolve()))
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1599
1762
|
try {
|
|
1600
1763
|
bindSession(state.session)
|
|
1601
|
-
await app.
|
|
1764
|
+
await app.start()
|
|
1765
|
+
await quitController.promise
|
|
1602
1766
|
} finally {
|
|
1603
1767
|
try {
|
|
1604
1768
|
await stop()
|
|
1605
1769
|
} finally {
|
|
1606
|
-
|
|
1770
|
+
try {
|
|
1771
|
+
app.dispose()
|
|
1772
|
+
} finally {
|
|
1773
|
+
await printResumeOutput()
|
|
1774
|
+
quitController.detach()
|
|
1775
|
+
}
|
|
1607
1776
|
}
|
|
1608
1777
|
}
|
|
1609
1778
|
}
|