nixamp 0.7.25 → 0.7.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -363,6 +363,18 @@ until you restart it.
363
363
  An added block can be taken back out from the playlist itself: its heading
364
364
  carries an `×`.
365
365
 
366
+ ## The noise it makes
367
+
368
+ nixamp plays a jingle when it starts, once per session, the way Winamp did.
369
+ Any mp3 in your home directory with `nixamp` in its name is used instead of the
370
+ ones that ship, and with more than one it picks at random -- a rotation you can
371
+ predict is one you stop hearing. `--no-jingle`, or `NIXAMP_NO_JINGLE=1`, turns
372
+ it off.
373
+
374
+ On the web it plays on a fresh page if the browser allows it, and otherwise on
375
+ your first click -- browsers spent a decade learning to refuse pages that make
376
+ noise unasked, and this one does not argue with them.
377
+
366
378
  ## How it works
367
379
 
368
380
  One decode feeds both your speakers and the display. `ffmpeg` writes raw 32-bit float samples to a pipe; nixamp reads every sample on its way past, runs an FFT over it, and hands the same bytes to `ffplay`.
package/dist/admin.d.ts CHANGED
@@ -39,6 +39,14 @@ export interface AdminOptions {
39
39
  label: string;
40
40
  url: string;
41
41
  }[];
42
+ /**
43
+ * The key that only watches, when this server has one.
44
+ *
45
+ * The links built from it are the ones to hand other people. Absent when
46
+ * pointed at a server by hand, since a listening key is not something you
47
+ * can work out from a control one.
48
+ */
49
+ listenKey?: string | null;
42
50
  /** What it is serving, so the admin view says so without being asked. */
43
51
  source: string;
44
52
  }
@@ -76,6 +84,8 @@ export interface View {
76
84
  url: string;
77
85
  }[];
78
86
  key: string | null;
87
+ /** The key that only watches, for the links you hand to other people. */
88
+ listenKey?: string | null;
79
89
  source: string;
80
90
  }
81
91
  export declare function draw(ui: Container, theme: Theme, view: View): void;
package/dist/admin.js CHANGED
@@ -39,6 +39,7 @@ export function resolveTarget(argv) {
39
39
  return {
40
40
  url: url_,
41
41
  key: key ?? state.key,
42
+ ...(state.listenKey ? { listenKey: state.listenKey } : {}),
42
43
  // A state file written before 0.5.3 has no list; loopback stands in.
43
44
  links: state.urls ?? [{ label: "here", url: daemonUrl(state) }],
44
45
  source: state.source,
@@ -176,7 +177,7 @@ export async function admin(argv) {
176
177
  app.on("exit", () => clearInterval(timer));
177
178
  app.render(({ ui, theme }) => draw(ui, theme, {
178
179
  url: target.url, report, snapshot, error, typing, restreaming, replacing,
179
- links: target.links, key: target.key, source: target.source,
180
+ links: target.links, key: target.key, listenKey: target.listenKey, source: target.source,
180
181
  }));
181
182
  await app.start();
182
183
  clearInterval(timer);
@@ -229,13 +230,31 @@ export function draw(ui, theme, view) {
229
230
  // reach it. The key is on them: without it every address is a 401.
230
231
  if (view.links.length > 0) {
231
232
  const width = Math.max(...view.links.map((link) => link.label.length));
232
- ui.panel({ title: "Share links", size: view.links.length + (view.source ? 3 : 2) }, (p) => {
233
+ // Both kinds, labelled, because they are not interchangeable and the
234
+ // operator had only ever been shown the one that drives. Handing that to
235
+ // somebody who wanted to watch hands them the controls with it, and
236
+ // there was nothing else to hand them.
237
+ // Counted rather than guessed: a heading of its own, the links, the second
238
+ // heading and its links when there is a viewing key, the source, and the
239
+ // two borders. A panel one line short silently drops the last row.
240
+ const rows = 1 + view.links.length
241
+ + (view.listenKey ? 1 + view.links.length : 0)
242
+ + (view.source ? 1 : 0);
243
+ ui.panel({ title: "Your links, and the ones to share", size: rows + 2 }, (p) => {
244
+ p.label("Yours -- these administer this server:");
233
245
  for (const link of view.links) {
234
- const full = shareLink(link.url, view.key);
235
- p.text(`${link.label.padEnd(width)} ${full}`, {
246
+ p.text(`${link.label.padEnd(width)} ${shareLink(link.url, view.key)}`, {
236
247
  fg: link.label === "on the internet" ? theme.accent : theme.foreground,
237
248
  });
238
249
  }
250
+ if (view.listenKey) {
251
+ p.label("To share -- these watch and listen, and change nothing:");
252
+ for (const link of view.links) {
253
+ p.text(`${link.label.padEnd(width)} ${shareLink(link.url, view.listenKey, false)}`, {
254
+ fg: link.label === "on the internet" ? theme.accent : theme.foreground,
255
+ });
256
+ }
257
+ }
239
258
  if (view.source)
240
259
  p.label(view.source);
241
260
  });
package/dist/daemon.d.ts CHANGED
@@ -4,6 +4,13 @@ export interface DaemonState {
4
4
  port: number;
5
5
  /** The share key, so `nixamp admin` can talk to it without being told. */
6
6
  key: string | null;
7
+ /**
8
+ * The key that only views, for handing to somebody who should watch.
9
+ *
10
+ * Absent on a state file written by an older nixamp, and absent on a server
11
+ * running with no key at all.
12
+ */
13
+ listenKey?: string | null;
7
14
  source: string;
8
15
  startedAt: number;
9
16
  /** Where its output went, for when it died and you want to know why. */
package/dist/daemon.js CHANGED
@@ -121,8 +121,11 @@ function spell(ms) {
121
121
  * typed the command.
122
122
  */
123
123
  export function daemonLines(state, uptimeMs) {
124
- // The daemon's own key is the one that administers, so it is an /a/ link.
124
+ // Two links per address: the one that drives this server and the one that
125
+ // only watches it. Printing just the first left the operator with nothing to
126
+ // hand anybody -- and handing over the wrong one hands over the controls.
125
127
  const link = (url) => shareLink(url, state.key ?? null);
128
+ const viewLink = (url) => shareLink(url, state.listenKey ?? null, false);
126
129
  // A state file written by an older nixamp has no list, so host and port
127
130
  // still stand in rather than printing nothing at all.
128
131
  const addresses = state.urls ?? [{ label: "here", url: daemonUrl(state) }];
@@ -131,6 +134,15 @@ export function daemonLines(state, uptimeMs) {
131
134
  for (const { label, url } of addresses)
132
135
  lines.push(` ${label.padEnd(width)} ${link(url)}`);
133
136
  lines.push(` ${"source".padEnd(width)} ${state.source}`);
137
+ // The viewing links, said separately and said as what they are. Somebody who
138
+ // should be able to watch your library should not be handed the link that
139
+ // re-streams it, and the operator had nothing else to give them.
140
+ if (state.listenKey) {
141
+ lines.push("", " To share -- these watch and listen, and change nothing:");
142
+ for (const { label, url } of addresses) {
143
+ lines.push(` ${label.padEnd(width)} ${viewLink(url)}`);
144
+ }
145
+ }
134
146
  if (uptimeMs !== undefined)
135
147
  lines.push(` ${"up".padEnd(width)} ${spell(uptimeMs)}`);
136
148
  if (state.guessedPublic) {
@@ -227,6 +239,7 @@ async function waitForAnnounce(log, timeoutMs) {
227
239
  host: String(parsed["host"]),
228
240
  port: Number(parsed["port"]),
229
241
  key: parsed["key"] ?? null,
242
+ ...(typeof parsed["listenKey"] === "string" ? { listenKey: parsed["listenKey"] } : {}),
230
243
  source: String(parsed["source"]),
231
244
  ...(Array.isArray(urls) ? { urls: urls } : {}),
232
245
  ...(typeof parsed["firewall"] === "string" ? { firewall: parsed["firewall"] } : {}),
@@ -0,0 +1,45 @@
1
+ import type { Tools } from "./audio.ts";
2
+ /** Turned off by `--no-jingle` or by setting this in the environment. */
3
+ export declare const OFF = "NIXAMP_NO_JINGLE";
4
+ /**
5
+ * Every jingle available, yours first, in a stable order.
6
+ *
7
+ * The match is deliberately loose -- any mp3 whose name contains "nixamp" --
8
+ * because the point is that a file you drop in your home directory is picked
9
+ * up, not that you named it exactly right. `001. NixAmp Whips the D-M-C-As.mp3`
10
+ * is a name a person actually uses, and an anchored pattern missed it.
11
+ */
12
+ export declare function findJingles(home?: string, packaged?: string[], read?: (dir: string) => string[]): string[];
13
+ /**
14
+ * One to play, chosen at random.
15
+ *
16
+ * Random rather than in turn: a rotation you can predict is one you stop
17
+ * hearing, and there is no state worth keeping between runs for this.
18
+ */
19
+ export declare function findJingle(home?: string, packaged?: string[], read?: (dir: string) => string[], pick?: (upTo: number) => number): string | null;
20
+ /**
21
+ * Play it, without making anybody wait for it.
22
+ *
23
+ * Detached and unwatched: a jingle that delays the player, or that fails
24
+ * loudly because a codec is missing, is worse than no jingle. Nothing here is
25
+ * awaited and nothing it does can stop a start.
26
+ */
27
+ export declare function playJingle(tools: Tools, path?: string | null,
28
+ /** Injected so a test can see what would be run on a machine with speakers. */
29
+ start?: Spawner, env?: Record<string, string | undefined>): boolean;
30
+ /**
31
+ * Play it and stop, with no window.
32
+ *
33
+ * `-nodisp` because a jingle is not something to open a window for, and
34
+ * `-autoexit` because a player that lingers after the sound is a process
35
+ * somebody has to notice and kill.
36
+ */
37
+ export declare const JINGLE_ARGS: readonly ["-autoexit", "-nodisp", "-loglevel", "quiet"];
38
+ type Spawner = (command: string, args: string[], options: {
39
+ stdio: "ignore";
40
+ detached: boolean;
41
+ }) => {
42
+ on: (event: "error", run: () => void) => unknown;
43
+ unref?: () => void;
44
+ };
45
+ export {};
package/dist/jingle.js ADDED
@@ -0,0 +1,129 @@
1
+ /**
2
+ * The noise nixamp makes when it wakes up.
3
+ *
4
+ * Winamp had one, and it is half of why anybody remembers Winamp. This plays
5
+ * once when a player starts and once on a fresh page at nixamp.com, and never
6
+ * again in that session -- a sound you like the first time is a sound you
7
+ * resent the fourth.
8
+ *
9
+ * Yours win: any mp3 in your home directory with "nixamp" in its name, so you
10
+ * can drop one in without touching anything. Otherwise the ones that ship are
11
+ * used, so it works on a machine that has never heard of any of this.
12
+ *
13
+ * With more than one, it picks at random rather than cycling, because a
14
+ * rotation you can predict is one you stop hearing.
15
+ */
16
+ import { spawn } from "node:child_process";
17
+ import { readdirSync, readFileSync } from "node:fs";
18
+ import { homedir } from "node:os";
19
+ import { join } from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+ /** Turned off by `--no-jingle` or by setting this in the environment. */
22
+ export const OFF = "NIXAMP_NO_JINGLE";
23
+ /**
24
+ * Every jingle available, yours first, in a stable order.
25
+ *
26
+ * The match is deliberately loose -- any mp3 whose name contains "nixamp" --
27
+ * because the point is that a file you drop in your home directory is picked
28
+ * up, not that you named it exactly right. `001. NixAmp Whips the D-M-C-As.mp3`
29
+ * is a name a person actually uses, and an anchored pattern missed it.
30
+ */
31
+ export function findJingles(home = homedir(), packaged = packagedJingles(), read = safeRead) {
32
+ // Guarded here rather than only inside the default reader: whether a jingle
33
+ // can be found is this function's promise to keep, and it should not depend
34
+ // on which reader it was handed.
35
+ let names;
36
+ try {
37
+ names = read(home);
38
+ }
39
+ catch {
40
+ names = [];
41
+ }
42
+ const mine = names
43
+ .filter((name) => name.toLowerCase().endsWith(".mp3") && name.toLowerCase().includes("nixamp"))
44
+ .sort()
45
+ .map((name) => join(home, name));
46
+ return mine.length > 0 ? mine : packaged;
47
+ }
48
+ /**
49
+ * One to play, chosen at random.
50
+ *
51
+ * Random rather than in turn: a rotation you can predict is one you stop
52
+ * hearing, and there is no state worth keeping between runs for this.
53
+ */
54
+ export function findJingle(home = homedir(), packaged = packagedJingles(), read = safeRead, pick = (upTo) => Math.floor(Math.random() * upTo)) {
55
+ const all = findJingles(home, packaged, read);
56
+ if (all.length === 0)
57
+ return null;
58
+ return all[Math.min(all.length - 1, Math.max(0, pick(all.length)))] ?? null;
59
+ }
60
+ function safeRead(dir) {
61
+ return readdirSync(dir);
62
+ }
63
+ /**
64
+ * The ones that ship, read from the list the web build writes.
65
+ *
66
+ * A list rather than a name, so adding another jingle is dropping a file in
67
+ * and rebuilding rather than editing this.
68
+ */
69
+ function packagedJingles() {
70
+ try {
71
+ // dist/jingle.js -> the package root -> web/dist, which is what `files`
72
+ // in package.json actually ships.
73
+ const dir = fileURLToPath(new URL("../web/dist/jingles", import.meta.url));
74
+ const listed = JSON.parse(readFileSync(join(dir, "index.json"), "utf8"));
75
+ if (!Array.isArray(listed))
76
+ return [];
77
+ return listed
78
+ .filter((name) => typeof name === "string")
79
+ .map((name) => join(dir, name));
80
+ }
81
+ catch {
82
+ return [];
83
+ }
84
+ }
85
+ /**
86
+ * Play it, without making anybody wait for it.
87
+ *
88
+ * Detached and unwatched: a jingle that delays the player, or that fails
89
+ * loudly because a codec is missing, is worse than no jingle. Nothing here is
90
+ * awaited and nothing it does can stop a start.
91
+ */
92
+ export function playJingle(tools, path = findJingle(),
93
+ /** Injected so a test can see what would be run on a machine with speakers. */
94
+ start = spawn, env = process.env) {
95
+ if (path === null)
96
+ return false;
97
+ if (env[OFF])
98
+ return false;
99
+ const play = tools.play;
100
+ if (play === null)
101
+ return false;
102
+ const [command, ...rest] = play;
103
+ if (command === undefined)
104
+ return false;
105
+ try {
106
+ const child = start(command, [...rest, ...JINGLE_ARGS, path], {
107
+ stdio: "ignore",
108
+ detached: false,
109
+ });
110
+ child.on("error", () => {
111
+ // No player, a file it will not open, or a machine with no sound card at
112
+ // all -- which is most servers. Silence is the fallback, and it is not
113
+ // worth a line of output on every start.
114
+ });
115
+ child.unref?.();
116
+ return true;
117
+ }
118
+ catch {
119
+ return false;
120
+ }
121
+ }
122
+ /**
123
+ * Play it and stop, with no window.
124
+ *
125
+ * `-nodisp` because a jingle is not something to open a window for, and
126
+ * `-autoexit` because a player that lingers after the sound is a process
127
+ * somebody has to notice and kill.
128
+ */
129
+ export const JINGLE_ARGS = ["-autoexit", "-nodisp", "-loglevel", "quiet"];
package/dist/main.js CHANGED
@@ -16,6 +16,7 @@ import { displayName, loadSource, loadTagged } from "./playlist.js";
16
16
  import { isRemote } from "./sources.js";
17
17
  import { DEFAULT_PORT } from "./server.js";
18
18
  import { shareLink } from "./share.js";
19
+ import { playJingle } from "./jingle.js";
19
20
  const FFT_SIZE = 2048;
20
21
  export const BAND_COUNT = 24;
21
22
  export function createState(tracks, root, silent) {
@@ -60,6 +61,10 @@ const HELP = `nixamp — it really whips the terminal's ass.
60
61
  A source is a directory, a file, an .m3u, an .m3u8, a .pls, or a URL to any
61
62
  of those.
62
63
 
64
+ It plays a jingle when it starts, picked at random. Any mp3 in your home
65
+ directory with nixamp in the name is yours and wins; otherwise the ones that
66
+ ship are used. --no-jingle, or NIXAMP_NO_JINGLE=1, for silence.
67
+
63
68
  Options for serve:
64
69
  -p, --port N port to listen on (default ${DEFAULT_PORT})
65
70
  -h, --host HOST address to bind (default 0.0.0.0, every interface)
@@ -344,6 +349,11 @@ export async function main() {
344
349
  const asked = first ?? ".";
345
350
  const target = isRemote(asked) ? asked : resolve(asked);
346
351
  const tools = detectTools();
352
+ // The noise it makes when it wakes up. Started before the library is walked
353
+ // so it plays over the wait rather than after it, and never awaited: a
354
+ // jingle that delays the player is worse than no jingle.
355
+ if (!rest.includes("--no-jingle"))
356
+ playJingle(tools);
347
357
  // Names now, tags later: an ffprobe per file over a large library is minutes
348
358
  // of a blank terminal before the player appears. The list is the same list;
349
359
  // only the titles arrive late, and they arrive into a player already running.
package/dist/server.d.ts CHANGED
@@ -31,6 +31,8 @@ export interface ServeOptions {
31
31
  key: boolean;
32
32
  /** Mint a new share key rather than reusing the one this port had. */
33
33
  newKey: boolean;
34
+ /** Start without the noise it makes when it wakes up. */
35
+ noJingle: boolean;
34
36
  /**
35
37
  * Ask the local firewall to let the port through, and put it back on the way
36
38
  * out. Off by default because it changes the machine, not just this process.
package/dist/server.js CHANGED
@@ -29,6 +29,7 @@ import { DeviceGrants } from "./device.js";
29
29
  import { BAD_KEY_LIMIT, callerOf, Guard, SIGN_IN_LIMIT } from "./guard.js";
30
30
  import { deviceDonePage, devicePage, exchangeCode, providersFrom, signInFailedPage, SignIn, } from "./oauth.js";
31
31
  import { needsAdmin, Owner } from "./owner.js";
32
+ import { playJingle } from "./jingle.js";
32
33
  import { stateDir } from "./daemon.js";
33
34
  import { readSession } from "./session.js";
34
35
  import { Directory, ENDED_TTL_MS, parseAnnouncement } from "./directory.js";
@@ -70,6 +71,7 @@ export function parseServeArgs(argv) {
70
71
  media: true,
71
72
  key: true,
72
73
  newKey: false,
74
+ noJingle: false,
73
75
  openPort: false,
74
76
  announce: false,
75
77
  directory: false,
@@ -162,6 +164,9 @@ export function parseServeArgs(argv) {
162
164
  else if (arg === "--new-key") {
163
165
  options.newKey = true;
164
166
  }
167
+ else if (arg === "--no-jingle") {
168
+ options.noJingle = true;
169
+ }
165
170
  else if (arg === "--ingest") {
166
171
  options.ingest = true;
167
172
  }
@@ -2607,6 +2612,10 @@ export async function serve(argv, version = "0.1.0") {
2607
2612
  // Empty on purpose. The walk happens below, once the port is open: it is
2608
2613
  // the slowest part of starting and nothing about it needs to happen first.
2609
2614
  const engine = new PlayerEngine([], root, tools);
2615
+ // A serving machine with speakers is still a player. A headless one has no
2616
+ // ffplay, and playJingle answers that by doing nothing.
2617
+ if (!options.noJingle)
2618
+ playJingle(tools);
2610
2619
  const web = options.web !== null ? resolve(options.web) : defaultWebDir();
2611
2620
  // The same keys this port used last time, so a link somebody was given
2612
2621
  // still works after a restart -- and a server is restarted to pick up a new
@@ -2962,6 +2971,10 @@ export async function serve(argv, version = "0.1.0") {
2962
2971
  host: options.host,
2963
2972
  port,
2964
2973
  key,
2974
+ // The other half of the pair. Without it `nixamp admin` can only draw
2975
+ // the links that administer, so the operator has nothing to hand
2976
+ // somebody who should be able to watch and not to drive.
2977
+ listenKey,
2965
2978
  source: root,
2966
2979
  urls: addresses,
2967
2980
  firewall,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.7.25",
3
+ "version": "0.7.28",
4
4
  "description": "It really whips the terminal's ass. A Winamp-shaped audio player for your terminal.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/admin.ts CHANGED
@@ -38,6 +38,14 @@ export interface AdminOptions {
38
38
  * anybody. These are the ones worth reading off the screen.
39
39
  */
40
40
  links: { label: string; url: string }[];
41
+ /**
42
+ * The key that only watches, when this server has one.
43
+ *
44
+ * The links built from it are the ones to hand other people. Absent when
45
+ * pointed at a server by hand, since a listening key is not something you
46
+ * can work out from a control one.
47
+ */
48
+ listenKey?: string | null;
41
49
  /** What it is serving, so the admin view says so without being asked. */
42
50
  source: string;
43
51
  }
@@ -76,6 +84,7 @@ export function resolveTarget(argv: string[]): AdminOptions {
76
84
  return {
77
85
  url: url_,
78
86
  key: key ?? state.key,
87
+ ...(state.listenKey ? { listenKey: state.listenKey } : {}),
79
88
  // A state file written before 0.5.3 has no list; loopback stands in.
80
89
  links: state.urls ?? [{ label: "here", url: daemonUrl(state) }],
81
90
  source: state.source,
@@ -198,7 +207,7 @@ export async function admin(argv: string[]): Promise<void> {
198
207
  app.on("exit", () => clearInterval(timer));
199
208
  app.render(({ ui, theme }) => draw(ui, theme, {
200
209
  url: target.url, report, snapshot, error, typing, restreaming, replacing,
201
- links: target.links, key: target.key, source: target.source,
210
+ links: target.links, key: target.key, listenKey: target.listenKey, source: target.source,
202
211
  }));
203
212
 
204
213
  await app.start();
@@ -242,6 +251,8 @@ export interface View {
242
251
  /** Labelled addresses, and the key that makes them work. */
243
252
  links: { label: string; url: string }[];
244
253
  key: string | null;
254
+ /** The key that only watches, for the links you hand to other people. */
255
+ listenKey?: string | null;
245
256
  source: string;
246
257
  }
247
258
 
@@ -276,13 +287,32 @@ export function draw(ui: Container, theme: Theme, view: View): void {
276
287
  // reach it. The key is on them: without it every address is a 401.
277
288
  if (view.links.length > 0) {
278
289
  const width = Math.max(...view.links.map((link) => link.label.length));
279
- ui.panel({ title: "Share links", size: view.links.length + (view.source ? 3 : 2) }, (p) => {
290
+ // Both kinds, labelled, because they are not interchangeable and the
291
+ // operator had only ever been shown the one that drives. Handing that to
292
+ // somebody who wanted to watch hands them the controls with it, and
293
+ // there was nothing else to hand them.
294
+ // Counted rather than guessed: a heading of its own, the links, the second
295
+ // heading and its links when there is a viewing key, the source, and the
296
+ // two borders. A panel one line short silently drops the last row.
297
+ const rows =
298
+ 1 + view.links.length
299
+ + (view.listenKey ? 1 + view.links.length : 0)
300
+ + (view.source ? 1 : 0);
301
+ ui.panel({ title: "Your links, and the ones to share", size: rows + 2 }, (p) => {
302
+ p.label("Yours -- these administer this server:");
280
303
  for (const link of view.links) {
281
- const full = shareLink(link.url, view.key);
282
- p.text(`${link.label.padEnd(width)} ${full}`, {
304
+ p.text(`${link.label.padEnd(width)} ${shareLink(link.url, view.key)}`, {
283
305
  fg: link.label === "on the internet" ? theme.accent : theme.foreground,
284
306
  });
285
307
  }
308
+ if (view.listenKey) {
309
+ p.label("To share -- these watch and listen, and change nothing:");
310
+ for (const link of view.links) {
311
+ p.text(`${link.label.padEnd(width)} ${shareLink(link.url, view.listenKey, false)}`, {
312
+ fg: link.label === "on the internet" ? theme.accent : theme.foreground,
313
+ });
314
+ }
315
+ }
286
316
  if (view.source) p.label(view.source);
287
317
  });
288
318
  }
package/src/daemon.ts CHANGED
@@ -18,6 +18,13 @@ export interface DaemonState {
18
18
  port: number;
19
19
  /** The share key, so `nixamp admin` can talk to it without being told. */
20
20
  key: string | null;
21
+ /**
22
+ * The key that only views, for handing to somebody who should watch.
23
+ *
24
+ * Absent on a state file written by an older nixamp, and absent on a server
25
+ * running with no key at all.
26
+ */
27
+ listenKey?: string | null;
21
28
  source: string;
22
29
  startedAt: number;
23
30
  /** Where its output went, for when it died and you want to know why. */
@@ -164,8 +171,11 @@ function spell(ms: number): string {
164
171
  * typed the command.
165
172
  */
166
173
  export function daemonLines(state: DaemonState, uptimeMs?: number): string[] {
167
- // The daemon's own key is the one that administers, so it is an /a/ link.
174
+ // Two links per address: the one that drives this server and the one that
175
+ // only watches it. Printing just the first left the operator with nothing to
176
+ // hand anybody -- and handing over the wrong one hands over the controls.
168
177
  const link = (url: string): string => shareLink(url, state.key ?? null);
178
+ const viewLink = (url: string): string => shareLink(url, state.listenKey ?? null, false);
169
179
  // A state file written by an older nixamp has no list, so host and port
170
180
  // still stand in rather than printing nothing at all.
171
181
  const addresses = state.urls ?? [{ label: "here", url: daemonUrl(state) }];
@@ -174,6 +184,16 @@ export function daemonLines(state: DaemonState, uptimeMs?: number): string[] {
174
184
  const lines = [`nixamp daemon running (pid ${state.pid})`];
175
185
  for (const { label, url } of addresses) lines.push(` ${label.padEnd(width)} ${link(url)}`);
176
186
  lines.push(` ${"source".padEnd(width)} ${state.source}`);
187
+
188
+ // The viewing links, said separately and said as what they are. Somebody who
189
+ // should be able to watch your library should not be handed the link that
190
+ // re-streams it, and the operator had nothing else to give them.
191
+ if (state.listenKey) {
192
+ lines.push("", " To share -- these watch and listen, and change nothing:");
193
+ for (const { label, url } of addresses) {
194
+ lines.push(` ${label.padEnd(width)} ${viewLink(url)}`);
195
+ }
196
+ }
177
197
  if (uptimeMs !== undefined) lines.push(` ${"up".padEnd(width)} ${spell(uptimeMs)}`);
178
198
 
179
199
  if (state.guessedPublic) {
@@ -300,6 +320,7 @@ async function waitForAnnounce(
300
320
  host: String(parsed["host"]),
301
321
  port: Number(parsed["port"]),
302
322
  key: (parsed["key"] as string | null) ?? null,
323
+ ...(typeof parsed["listenKey"] === "string" ? { listenKey: parsed["listenKey"] } : {}),
303
324
  source: String(parsed["source"]),
304
325
  ...(Array.isArray(urls) ? { urls: urls as { label: string; url: string }[] } : {}),
305
326
  ...(typeof parsed["firewall"] === "string" ? { firewall: parsed["firewall"] } : {}),