nixamp 0.4.0 → 0.5.0

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/src/device.ts ADDED
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Signing in on one screen for a session on another.
3
+ *
4
+ * This is the device authorization grant (RFC 8628), which is the flow a
5
+ * television has been using to sign you in for years: the terminal shows a
6
+ * short code, you open a page on whatever device has a keyboard and a browser,
7
+ * you type the code, and the terminal -- which was polling all along -- is
8
+ * signed in.
9
+ *
10
+ * It is the right shape for nixamp for the same reason a magic link is the
11
+ * wrong one: the thing being signed in has no browser to hand off to, and may
12
+ * not be the device where you read your mail. It also means the CLI never sees
13
+ * a password or a provider token, only the session it ends up with.
14
+ *
15
+ * Grants are held in memory. They live ten minutes, they are worth nothing
16
+ * after they are redeemed, and a restart costing somebody a retyped code is a
17
+ * better trade than a table to migrate.
18
+ */
19
+ import { randomBytes } from "node:crypto";
20
+
21
+ /** Long enough to walk to another room, short enough that a stolen code is stale. */
22
+ export const GRANT_TTL_MS = 600_000;
23
+
24
+ /** What the CLI is told to wait between polls, in seconds. */
25
+ export const POLL_INTERVAL_SECONDS = 5;
26
+
27
+ /**
28
+ * Not a timestamp, so the first poll is never mistaken for a fast one. Zero
29
+ * would be, and a clock that starts at zero is exactly what a test has.
30
+ */
31
+ const NEVER_POLLED = -1;
32
+
33
+ /**
34
+ * No vowels, so the generator cannot produce a word; no 0/O or 1/I, so nobody
35
+ * mistypes one for the other. This is the alphabet RFC 8628 suggests.
36
+ */
37
+ const ALPHABET = "BCDFGHJKLMNPQRSTVWXZ";
38
+
39
+ export interface Grant {
40
+ deviceCode: string;
41
+ userCode: string;
42
+ createdAt: number;
43
+ expiresAt: number;
44
+ lastPolledAt: number;
45
+ /** Set once somebody approved it in a browser. */
46
+ session: { token: string; email: string } | null;
47
+ denied: boolean;
48
+ }
49
+
50
+ export type PollStatus =
51
+ | { status: "pending" }
52
+ | { status: "slow_down" }
53
+ | { status: "expired" }
54
+ | { status: "denied" }
55
+ | { status: "ok"; token: string; email: string };
56
+
57
+ function randomFrom(alphabet: string, length: number, bytes: Uint8Array): string {
58
+ let out = "";
59
+ for (let index = 0; index < length; index += 1) {
60
+ out += alphabet[(bytes[index] as number) % alphabet.length];
61
+ }
62
+ return out;
63
+ }
64
+
65
+ /** `WXYZ-4RTB`. Hyphenated because it is read aloud and typed by hand. */
66
+ export function makeUserCode(random: (size: number) => Uint8Array): string {
67
+ const bytes = random(8);
68
+ return `${randomFrom(ALPHABET, 4, bytes.subarray(0, 4))}-${randomFrom(ALPHABET, 4, bytes.subarray(4, 8))}`;
69
+ }
70
+
71
+ /** Accept what a person typed however they typed it: lower case, no hyphen. */
72
+ export function normalizeUserCode(value: unknown): string {
73
+ if (typeof value !== "string") return "";
74
+ const bare = value.toUpperCase().replace(/[^A-Z0-9]/g, "");
75
+ if (bare.length !== 8) return "";
76
+ return `${bare.slice(0, 4)}-${bare.slice(4)}`;
77
+ }
78
+
79
+ export interface DeviceOptions {
80
+ now?: () => number;
81
+ random?: (size: number) => Uint8Array;
82
+ /** How often a waiting terminal is told to ask. Five seconds is RFC 8628's. */
83
+ intervalSeconds?: number;
84
+ }
85
+
86
+ export class DeviceGrants {
87
+ private readonly byDevice = new Map<string, Grant>();
88
+ private readonly byUser = new Map<string, Grant>();
89
+ private readonly now: () => number;
90
+ private readonly random: (size: number) => Uint8Array;
91
+ /** Told to the terminal, and enforced here: the two must be the same number. */
92
+ readonly interval: number;
93
+
94
+ constructor(options: DeviceOptions = {}) {
95
+ this.now = options.now ?? Date.now;
96
+ this.random = options.random ?? ((size: number) => randomBytes(size));
97
+ this.interval = Math.max(1, options.intervalSeconds ?? POLL_INTERVAL_SECONDS);
98
+ }
99
+
100
+ start(): Grant {
101
+ this.sweep();
102
+ const at = this.now();
103
+ // A user code can collide -- there are only 20^8 of them and they are
104
+ // short-lived -- so it is retried rather than handed out twice.
105
+ let userCode = makeUserCode(this.random);
106
+ for (let tries = 0; this.byUser.has(userCode) && tries < 10; tries += 1) {
107
+ userCode = makeUserCode(this.random);
108
+ }
109
+ const grant: Grant = {
110
+ deviceCode: Buffer.from(this.random(32)).toString("base64url"),
111
+ userCode,
112
+ createdAt: at,
113
+ expiresAt: at + GRANT_TTL_MS,
114
+ lastPolledAt: NEVER_POLLED,
115
+ session: null,
116
+ denied: false,
117
+ };
118
+ this.byDevice.set(grant.deviceCode, grant);
119
+ this.byUser.set(grant.userCode, grant);
120
+ return grant;
121
+ }
122
+
123
+ /** The grant behind a code somebody typed, if it is still worth anything. */
124
+ find(userCode: string): Grant | null {
125
+ const grant = this.byUser.get(normalizeUserCode(userCode));
126
+ if (!grant || grant.expiresAt <= this.now() || grant.session !== null || grant.denied) return null;
127
+ return grant;
128
+ }
129
+
130
+ approve(userCode: string, session: { token: string; email: string }): boolean {
131
+ const grant = this.find(userCode);
132
+ if (grant === null) return false;
133
+ grant.session = session;
134
+ return true;
135
+ }
136
+
137
+ deny(userCode: string): boolean {
138
+ const grant = this.find(userCode);
139
+ if (grant === null) return false;
140
+ grant.denied = true;
141
+ return true;
142
+ }
143
+
144
+ /**
145
+ * What the waiting terminal is told. A grant is forgotten the moment it
146
+ * answers with a session, so the same device code cannot be redeemed twice.
147
+ */
148
+ poll(deviceCode: string): PollStatus {
149
+ const at = this.now();
150
+ const grant = this.byDevice.get(deviceCode);
151
+ if (!grant || grant.expiresAt <= at) {
152
+ this.forget(grant);
153
+ return { status: "expired" };
154
+ }
155
+ // Polling faster than it was told to is answered with slow_down rather
156
+ // than an answer, which is what RFC 8628 asks of a server.
157
+ if (grant.lastPolledAt !== NEVER_POLLED && at - grant.lastPolledAt < this.interval * 1000 - 250) {
158
+ return { status: "slow_down" };
159
+ }
160
+ grant.lastPolledAt = at;
161
+ if (grant.denied) {
162
+ this.forget(grant);
163
+ return { status: "denied" };
164
+ }
165
+ if (grant.session === null) return { status: "pending" };
166
+ this.forget(grant);
167
+ return { status: "ok", token: grant.session.token, email: grant.session.email };
168
+ }
169
+
170
+ private forget(grant: Grant | undefined): void {
171
+ if (!grant) return;
172
+ this.byDevice.delete(grant.deviceCode);
173
+ this.byUser.delete(grant.userCode);
174
+ }
175
+
176
+ sweep(): void {
177
+ const at = this.now();
178
+ for (const grant of this.byDevice.values()) {
179
+ if (grant.expiresAt <= at) this.forget(grant);
180
+ }
181
+ }
182
+
183
+ get size(): number {
184
+ return this.byDevice.size;
185
+ }
186
+
187
+ /** The grants nobody has answered yet, newest last. */
188
+ pending(): Grant[] {
189
+ const at = this.now();
190
+ return [...this.byDevice.values()].filter(
191
+ (grant) => grant.expiresAt > at && grant.session === null && !grant.denied,
192
+ );
193
+ }
194
+ }
package/src/main.ts CHANGED
@@ -18,6 +18,7 @@ import { version } from "./meta.ts";
18
18
  import { displayName, loadSource } from "./playlist.ts";
19
19
  import { isRemote } from "./sources.ts";
20
20
  import { DEFAULT_PORT } from "./server.ts";
21
+ import type { DaemonState } from "./daemon.ts";
21
22
 
22
23
  const FFT_SIZE = 2048;
23
24
  export const BAND_COUNT = 24;
@@ -69,9 +70,11 @@ const HELP = `nixamp — it really whips the terminal's ass.
69
70
  nixamp [source] play it in the terminal
70
71
  nixamp serve [source] [options] play here, and hand out a browser remote
71
72
  nixamp daemon start|stop|status serve in the background, and let go of it
73
+ nixamp attach put the player back in front of the daemon
72
74
  nixamp admin [--url U] [--key K] who is connected, and re-stream to them
73
- nixamp login [--signup] sign in to nixamp.com
75
+ nixamp login [--with github] sign in to nixamp.com, in a browser or here
74
76
  nixamp logout / whoami forget it, or check it
77
+ nixamp token create|list|revoke tokens for a machine that cannot sign in
75
78
  nixamp update [version] re-run the installer, keeping your choices
76
79
  nixamp uninstall [--yes] remove everything the installer created
77
80
 
@@ -95,10 +98,114 @@ Options for serve:
95
98
  --x402 charge for listening once more than 5 people are listening
96
99
  --no-x402 never charge
97
100
 
101
+ Options for login:
102
+ --with NAME sign in with a provider (github, google) in a browser
103
+ --device approve in a browser, whichever way it is signed in
104
+ --password ask for an address and a password here instead
105
+ --token T keep a token made with \`nixamp token create\`
106
+ --signup make an account with an address and a password
107
+ --no-browser print the URL rather than trying to open one
108
+ --site URL somewhere other than https://nixamp.com
109
+
110
+ NIXAMP_TOKEN in the environment is a signed-in nixamp with no login at all,
111
+ which is what a build server wants.
112
+
113
+ Keys in the player:
114
+ space play/pause enter play s stop n/p next/previous up/down choose
115
+ d detach: hand the music to a daemon and get the terminal back
116
+ q quit
117
+
98
118
  -v, --version print the version
99
- --help print this
119
+ -h, --help print this. \`nixamp help <command>\` says more about one
100
120
  `;
101
121
 
122
+ /** `-h`, `--help`, or the word, which is what people type when they forget. */
123
+ export function isHelp(arg: string | undefined): boolean {
124
+ return arg === "-h" || arg === "--help" || arg === "help";
125
+ }
126
+
127
+ /**
128
+ * Was help asked for, given what the command already means by its flags?
129
+ *
130
+ * `serve` has had `-h HOST` since the beginning, and `daemon start` passes its
131
+ * flags straight through, so for those two `-h` is a bind address and only the
132
+ * spelled-out forms ask for help. Everywhere else `-h` is help, because that
133
+ * is what it is everywhere else.
134
+ */
135
+ export function wantsHelp(first: string | undefined, rest: string[]): boolean {
136
+ if (isHelp(first)) return true;
137
+ const shortIsHost = first === "serve" || first === "daemon";
138
+ return rest.some((arg) => (shortIsHost ? arg !== "-h" && isHelp(arg) : isHelp(arg)));
139
+ }
140
+
141
+ /**
142
+ * Longer help, one command at a time.
143
+ *
144
+ * The summary in HELP is a list of what exists; these say how each is used,
145
+ * which is the thing you want at the moment you ask, and the thing that makes
146
+ * the summary unreadable if it is folded in.
147
+ */
148
+ const TOPICS: Record<string, string> = {
149
+ login: `nixamp login — sign in to nixamp.com.
150
+
151
+ nixamp login choose how: a provider in a browser, or a password
152
+ nixamp login --with github go straight to a provider (github, google)
153
+ nixamp login --device approve in a browser you are already signed in to
154
+ nixamp login --password an address and a password, here in the terminal
155
+ nixamp login --token TOKEN keep a token made with \`nixamp token create\`
156
+ nixamp signup make an account with an address and a password
157
+
158
+ A provider sign-in never asks this terminal for anything secret. It shows a
159
+ short code, you approve it in a browser on whatever device has a keyboard, and
160
+ this terminal ends up holding the session. That works over ssh, and it works on
161
+ a television, which is why it is the default.
162
+
163
+ --no-browser print the URL rather than trying to open one
164
+ --site URL somewhere other than https://nixamp.com
165
+
166
+ NIXAMP_TOKEN in the environment is a signed-in nixamp with no login at all.
167
+ `,
168
+ token: `nixamp token — tokens for a machine that cannot sign in.
169
+
170
+ nixamp token create --name ci make one, and print it once
171
+ nixamp token list id, when it was made, when it was last used
172
+ nixamp token revoke ID stop it working, everywhere, now
173
+
174
+ A token is shown once because the server keeps only its hash. Put it in the
175
+ environment as NIXAMP_TOKEN, or keep it here with \`nixamp login --token\`.
176
+ Signing out does not touch it: that is what it is for.
177
+ `,
178
+ daemon: `nixamp daemon — a nixamp that outlives the terminal that started it.
179
+
180
+ nixamp daemon start [source] [serve options] start it, detached
181
+ nixamp daemon status where it is, and how long
182
+ nixamp daemon stop stop it
183
+
184
+ It is \`nixamp serve\` with nobody holding its terminal, so it keeps playing and
185
+ keeps serving its browser remote. One per user.
186
+
187
+ nixamp attach put the player back in front of it
188
+ nixamp admin who is connected, and re-stream to them
189
+
190
+ From inside the player, d hands the music to a daemon without stopping it.
191
+ `,
192
+ attach: `nixamp attach — the player, in front of the running daemon.
193
+
194
+ The same view and the same keys as the local player, except that the music is
195
+ the daemon's: keys are sent to it, and what you see is what it is doing. Any
196
+ number of terminals may attach at once.
197
+
198
+ nixamp attach the daemon on this machine
199
+ nixamp attach --url URL [--key K] a nixamp somewhere else
200
+
201
+ q or d leaves; neither stops anything. \`nixamp daemon stop\` is what stops it.
202
+ `,
203
+ };
204
+
205
+ export function helpFor(topic: string | undefined): string {
206
+ return (topic ? TOPICS[topic] : undefined) ?? HELP;
207
+ }
208
+
102
209
  /**
103
210
  * `nixamp daemon <start|stop|status>`.
104
211
  *
@@ -154,7 +261,14 @@ async function runDaemon(argv: string[]): Promise<number> {
154
261
  return 0;
155
262
  }
156
263
 
157
- console.error(`nixamp daemon: unknown action ${action}. Try start, stop or status.`);
264
+ // `nixamp daemon attach` is what people try before `nixamp attach`, so it is
265
+ // the same thing rather than an error about a word that means what it says.
266
+ if (action === "attach") {
267
+ const { attach } = await import("./attach.ts");
268
+ return attach(rest);
269
+ }
270
+
271
+ console.error(`nixamp daemon: unknown action ${action}. Try start, stop, status or attach.`);
158
272
  return 64;
159
273
  }
160
274
 
@@ -166,6 +280,19 @@ async function runDaemon(argv: string[]): Promise<number> {
166
280
  export async function main(): Promise<void> {
167
281
  const [first, ...rest] = process.argv.slice(2);
168
282
 
283
+ // Asked for however anybody asks for it. `nixamp help serve` and
284
+ // `nixamp serve --help` are the same question, so they get the same answer.
285
+ if (wantsHelp(first, rest)) {
286
+ console.log(helpFor(isHelp(first) ? rest[0] : first));
287
+ return;
288
+ }
289
+
290
+ if (first === "attach") {
291
+ const { attach } = await import("./attach.ts");
292
+ process.exitCode = await attach(rest);
293
+ return;
294
+ }
295
+
169
296
  if (first === "serve") {
170
297
  const { serve } = await import("./server.ts");
171
298
  await serve(rest, version());
@@ -185,6 +312,11 @@ export async function main(): Promise<void> {
185
312
  process.exitCode = await login(first === "signup" ? [...rest, "--signup"] : rest);
186
313
  return;
187
314
  }
315
+ if (first === "token" || first === "tokens") {
316
+ const { tokens } = await import("./session.ts");
317
+ process.exitCode = await tokens(rest);
318
+ return;
319
+ }
188
320
  if (first === "logout" || first === "whoami") {
189
321
  const session = await import("./session.ts");
190
322
  process.exitCode = first === "logout" ? session.logout() : await session.whoami();
@@ -196,7 +328,6 @@ export async function main(): Promise<void> {
196
328
  return;
197
329
  }
198
330
  if (first === "--version" || first === "-v") { console.log(version()); return; }
199
- if (first === "--help") { console.log(HELP); return; }
200
331
 
201
332
  // resolve() would turn https://host/x into /cwd/https:/host/x, so a URL is
202
333
  // left exactly as it was typed.
@@ -211,6 +342,10 @@ export async function main(): Promise<void> {
211
342
 
212
343
  const state = createState(tracks, target, tools.play === null);
213
344
  const app = await createApp({ theme: themes.matrix, title: "nixamp", quitKeys: ["ctrl+c"] });
345
+ // Set when d handed the music to a daemon, and printed after the TUI is
346
+ // gone. A field rather than a local, because a local assigned only inside a
347
+ // closure stays narrowed to null for the checker.
348
+ const handoff: { to: { daemon: DaemonState; url: string } | null } = { to: null };
214
349
 
215
350
  const analyser = new Analyser(FFT_SIZE, RATE);
216
351
  const edges = bandEdges(BAND_COUNT, RATE, FFT_SIZE);
@@ -269,9 +404,36 @@ export async function main(): Promise<void> {
269
404
  app.invalidate();
270
405
  };
271
406
 
407
+ /**
408
+ * Hand the music to a daemon and give the terminal back.
409
+ *
410
+ * The local stream is stopped first, because two processes fighting over the
411
+ * audio device is a worse experience than a second of silence. What comes
412
+ * back is where it went, so `nixamp attach` is a suggestion rather than a
413
+ * thing to remember.
414
+ */
415
+ const detach = async (): Promise<void> => {
416
+ state.note = "Handing over to a daemon...";
417
+ app.invalidate();
418
+ stream.stop();
419
+ state.playing = false;
420
+ try {
421
+ const d = await import("./daemon.ts");
422
+ const daemon = await d.start([target], fileURLToPath(new URL("./main.js", import.meta.url)));
423
+ handoff.to = { daemon, url: d.daemonUrl(daemon) };
424
+ app.quit();
425
+ } catch (error) {
426
+ // Most often: a daemon is already running, which is worth saying rather
427
+ // than leaving somebody looking at a player that stopped for no reason.
428
+ state.note = (error as Error).message;
429
+ app.invalidate();
430
+ }
431
+ };
432
+
272
433
  app.on("key", (event: KeyEvent) => {
273
434
  switch (event.key) {
274
435
  case "q": stream.stop(); app.quit(); return;
436
+ case "d": void detach(); return;
275
437
  case "space": state.playing ? stopAll() : play(); return;
276
438
  case "enter": play(); return;
277
439
  case "s": stopAll(); return;
@@ -291,6 +453,14 @@ export async function main(): Promise<void> {
291
453
  app.on("exit", () => stream.stop());
292
454
  app.render((args) => view(args, state));
293
455
  await app.start();
456
+
457
+ const handed = handoff.to;
458
+ if (handed !== null) {
459
+ console.log(`Detached. Still playing as pid ${handed.daemon.pid}.`);
460
+ console.log(` ${handed.daemon.key ? `${handed.url}/s/${handed.daemon.key}` : handed.url}`);
461
+ console.log(" nixamp attach come back to it");
462
+ console.log(" nixamp daemon stop when you are done");
463
+ }
294
464
  }
295
465
 
296
466