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/README.md +55 -6
- package/dist/accounts.d.ts +40 -0
- package/dist/accounts.js +95 -2
- package/dist/attach.d.ts +15 -0
- package/dist/attach.js +174 -0
- package/dist/device.d.ts +67 -0
- package/dist/device.js +157 -0
- package/dist/main.d.ts +12 -0
- package/dist/main.js +168 -7
- package/dist/oauth.d.ts +108 -0
- package/dist/oauth.js +302 -0
- package/dist/server.d.ts +8 -0
- package/dist/server.js +242 -2
- package/dist/session.d.ts +73 -2
- package/dist/session.js +308 -9
- package/dist/tokens.d.ts +63 -0
- package/dist/tokens.js +196 -0
- package/package.json +1 -1
- package/src/accounts.ts +103 -2
- package/src/attach.ts +191 -0
- package/src/device.ts +194 -0
- package/src/main.ts +174 -4
- package/src/oauth.ts +391 -0
- package/src/server.ts +278 -2
- package/src/session.ts +351 -9
- package/src/tokens.ts +247 -0
- package/web/dist/assets/index-pztl5rKf.js +1 -0
- package/web/dist/index.html +2 -1
- package/web/dist/sw.js +2 -2
- package/web/dist/assets/index-qRguFskX.js +0 -1
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 [--
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|