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/README.md
CHANGED
|
@@ -78,22 +78,55 @@ If you would rather not pipe a script into a shell, `npm i -g nixamp` and
|
|
|
78
78
|
## Signing in
|
|
79
79
|
|
|
80
80
|
An account on nixamp.com is what lets you publish, be paid, and administer a
|
|
81
|
-
server you own.
|
|
81
|
+
server you own. Three ways in, because a terminal is a bad place to be asked
|
|
82
|
+
for a password:
|
|
82
83
|
|
|
83
84
|
```
|
|
84
|
-
nixamp login
|
|
85
|
+
nixamp login # choose: a provider in a browser, or a password
|
|
86
|
+
nixamp login --with github # straight to GitHub (or google)
|
|
87
|
+
nixamp login --password # an address and a password, here
|
|
85
88
|
nixamp whoami
|
|
86
89
|
nixamp logout
|
|
87
90
|
```
|
|
88
91
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
92
|
+
`--with github` is OAuth 2.0 through the device grant (RFC 8628), which is how
|
|
93
|
+
a television has signed you in for years: the terminal shows a short code, you
|
|
94
|
+
approve it in a browser on whatever device has a keyboard, and the terminal ends
|
|
95
|
+
up holding the session. It never sees your password or the provider's token, and
|
|
96
|
+
it works over ssh.
|
|
97
|
+
|
|
98
|
+
The PWA and the desktop app offer the same providers, since an account made by
|
|
99
|
+
signing in with GitHub has no password to type anywhere. The CLI keeps its token
|
|
100
|
+
beside the daemon's state, mode 600, so signing in there and in the desktop app
|
|
101
|
+
are the same thing on disk. A password, where one is used, is read with the echo
|
|
102
|
+
off and is never written down.
|
|
93
103
|
|
|
94
104
|
No magic link. A link in an inbox is no use on a television, or on a phone that
|
|
95
105
|
is not the one you read mail on.
|
|
96
106
|
|
|
107
|
+
### Tokens, for a machine that cannot sign in
|
|
108
|
+
|
|
109
|
+
```
|
|
110
|
+
nixamp token create --name ci # printed once, and only once
|
|
111
|
+
nixamp token list
|
|
112
|
+
nixamp token revoke <id>
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
`NIXAMP_TOKEN` in the environment is a signed-in nixamp with no login at all,
|
|
116
|
+
which is the only thing that works in CI. Tokens are stored as hashes and can be
|
|
117
|
+
withdrawn from anywhere; signing out does not touch them, which is the point of
|
|
118
|
+
them. Sessions are the same kind of thing with an expiry, so `nixamp logout`
|
|
119
|
+
really does end one.
|
|
120
|
+
|
|
121
|
+
Providers are configured per deployment, and only a provider with both halves is
|
|
122
|
+
offered:
|
|
123
|
+
|
|
124
|
+
```
|
|
125
|
+
GITHUB_CLIENT_ID=… GITHUB_CLIENT_SECRET=… nixamp serve --directory
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
The callback to register is `https://your-site/api/v1/<provider>/oauth/callback`.
|
|
129
|
+
|
|
97
130
|
Running the account side of nixamp.com needs Postgres:
|
|
98
131
|
|
|
99
132
|
```
|
|
@@ -235,6 +268,21 @@ Start writes down where it went and the key it minted, waits until the server
|
|
|
235
268
|
is actually answering before saying it started, and prints the share link. It is
|
|
236
269
|
one daemon per user, and the state lives in `$XDG_STATE_HOME/nixamp`.
|
|
237
270
|
|
|
271
|
+
### Detaching, and coming back
|
|
272
|
+
|
|
273
|
+
`d` in the player hands the music to a daemon and gives you your terminal back.
|
|
274
|
+
Nothing stops. `nixamp attach` puts the player back in front of it:
|
|
275
|
+
|
|
276
|
+
```
|
|
277
|
+
nixamp attach # the daemon on this machine
|
|
278
|
+
nixamp attach --url URL --key KEY # a nixamp somewhere else
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
An attached player is the same view and the same keys; the difference is that
|
|
282
|
+
the keys are sent to the daemon and what you see is what the daemon is doing.
|
|
283
|
+
Any number of terminals may attach at once. `q` or `d` leaves without stopping
|
|
284
|
+
anything, which is what `nixamp daemon stop` is for.
|
|
285
|
+
|
|
238
286
|
## Who may administer a server
|
|
239
287
|
|
|
240
288
|
Two ways to be allowed, and they answer different questions.
|
|
@@ -382,6 +430,7 @@ A bare `ffmpeg` on `PATH` is used when there is one; `mise` shims are detected a
|
|
|
382
430
|
| `↑` `↓` | Move through the playlist |
|
|
383
431
|
| `n` `p` (or `→` `←`) | Next and previous track |
|
|
384
432
|
| `s` | Stop |
|
|
433
|
+
| `d` | Detach: hand the music to a daemon and keep the terminal |
|
|
385
434
|
| `q` | Quit |
|
|
386
435
|
|
|
387
436
|
## Formats
|
package/dist/accounts.d.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { type Identity, type Users } from "./oauth.ts";
|
|
2
|
+
import type { Queryable } from "./follows.ts";
|
|
3
|
+
import { type IssuedToken, type TokenKind, type TokenRecord, Tokens } from "./tokens.ts";
|
|
1
4
|
export interface Account {
|
|
2
5
|
id: string;
|
|
3
6
|
email: string;
|
|
@@ -16,6 +19,15 @@ export interface AccountsOptions {
|
|
|
16
19
|
secret: string;
|
|
17
20
|
/** Injected by the tests, which have no database. */
|
|
18
21
|
system?: AuthLike;
|
|
22
|
+
/** Also injected by the tests: the storage tokens and identities sit in. */
|
|
23
|
+
adapter?: AdapterLike;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The slice of the auth module's storage adapter the rest of this file needs.
|
|
27
|
+
* It is the same Postgres pool the users table lives in, which is why nixamp's
|
|
28
|
+
* two tables need no connection of their own.
|
|
29
|
+
*/
|
|
30
|
+
export interface AdapterLike extends Queryable, Users {
|
|
19
31
|
}
|
|
20
32
|
/** The slice of the auth system nixamp uses. */
|
|
21
33
|
export interface AuthLike {
|
|
@@ -38,10 +50,38 @@ export declare function readClaims(value: unknown): Account | null;
|
|
|
38
50
|
export declare function checkCredentials(email: unknown, password: unknown): string;
|
|
39
51
|
export declare class Accounts {
|
|
40
52
|
private readonly system;
|
|
53
|
+
/** Null only where a test injected an auth system and no storage. */
|
|
54
|
+
readonly tokens: Tokens | null;
|
|
55
|
+
private readonly identities;
|
|
41
56
|
constructor(options: AccountsOptions);
|
|
42
57
|
signUp(email: unknown, password: unknown): Promise<AuthResult>;
|
|
43
58
|
signIn(email: unknown, password: unknown): Promise<AuthResult>;
|
|
59
|
+
/**
|
|
60
|
+
* Who a token belongs to, whichever kind of token it is.
|
|
61
|
+
*
|
|
62
|
+
* A `nxa_` token is one this server issued and can withdraw, so it is looked
|
|
63
|
+
* up. Anything else is a JWT from the auth module, which is self-describing
|
|
64
|
+
* and cannot be. Both answer the same shape, so nothing downstream has to
|
|
65
|
+
* know which door the caller came in by.
|
|
66
|
+
*/
|
|
44
67
|
whoIs(token: string): Promise<Account | null>;
|
|
68
|
+
/**
|
|
69
|
+
* The token a signed-in caller carries away.
|
|
70
|
+
*
|
|
71
|
+
* A revocable token is preferred to the module's JWT wherever there is
|
|
72
|
+
* storage to keep one in, because signing out of a laptop you no longer have
|
|
73
|
+
* should mean something. The JWT is the fallback, and the only difference to
|
|
74
|
+
* a caller is that one of the two can be taken away.
|
|
75
|
+
*/
|
|
76
|
+
sessionFor(account: Account, fallback?: string): Promise<string>;
|
|
77
|
+
/** Sign in as whoever a provider says this is, making the account if it is new. */
|
|
78
|
+
signInWith(identity: Identity): Promise<AuthResult>;
|
|
79
|
+
/** A token a person made on purpose, for a script that cannot sign in. */
|
|
80
|
+
mintCliToken(account: Account, name: string, ttlMs?: number | null): Promise<IssuedToken | null>;
|
|
81
|
+
listTokens(userId: string, kind?: TokenKind): Promise<TokenRecord[]>;
|
|
82
|
+
revokeToken(userId: string, id: string): Promise<boolean>;
|
|
83
|
+
/** Signing out ends this session and leaves every other token alone. */
|
|
84
|
+
endSession(token: string): Promise<void>;
|
|
45
85
|
}
|
|
46
86
|
/** The bearer token on a request, from the header or the session cookie. */
|
|
47
87
|
export declare function tokenFrom(headers: Record<string, string | string[] | undefined>): string;
|
package/dist/accounts.js
CHANGED
|
@@ -15,9 +15,13 @@
|
|
|
15
15
|
* would be nothing to click.
|
|
16
16
|
*
|
|
17
17
|
* No magic link: a link in an inbox is no use on a television or a phone that
|
|
18
|
-
* is not the one you read mail on.
|
|
18
|
+
* is not the one you read mail on. There is now a third way in that suits a
|
|
19
|
+
* terminal better than either -- OAuth 2.0 reached through the device grant in
|
|
20
|
+
* device.ts -- and what it ends with is a token from tokens.ts.
|
|
19
21
|
*/
|
|
20
22
|
import { createAuthSystem, PostgresAdapter } from "@profullstack/auth-system";
|
|
23
|
+
import { Identities } from "./oauth.js";
|
|
24
|
+
import { looksLikeToken, Tokens } from "./tokens.js";
|
|
21
25
|
const NO_ACCOUNT = { ok: false, account: null, token: "", error: "" };
|
|
22
26
|
/**
|
|
23
27
|
* The same sentence for a wrong password and an address with no account.
|
|
@@ -60,13 +64,25 @@ export function checkCredentials(email, password) {
|
|
|
60
64
|
}
|
|
61
65
|
export class Accounts {
|
|
62
66
|
system;
|
|
67
|
+
/** Null only where a test injected an auth system and no storage. */
|
|
68
|
+
tokens;
|
|
69
|
+
identities;
|
|
63
70
|
constructor(options) {
|
|
71
|
+
// The adapter is kept rather than only handed over: tokens and provider
|
|
72
|
+
// identities are nixamp's own tables in the same database, and a second
|
|
73
|
+
// pool for two small tables would be a second thing to configure.
|
|
74
|
+
const adapter = options.adapter ??
|
|
75
|
+
(options.system
|
|
76
|
+
? null
|
|
77
|
+
: new PostgresAdapter({ connectionString: options.connectionString }));
|
|
64
78
|
this.system =
|
|
65
79
|
options.system ??
|
|
66
80
|
createAuthSystem({
|
|
67
|
-
adapter
|
|
81
|
+
adapter,
|
|
68
82
|
jwtSecret: options.secret,
|
|
69
83
|
});
|
|
84
|
+
this.tokens = adapter ? new Tokens(adapter) : null;
|
|
85
|
+
this.identities = adapter ? new Identities(adapter, adapter) : null;
|
|
70
86
|
}
|
|
71
87
|
async signUp(email, password) {
|
|
72
88
|
const wrong = checkCredentials(email, password);
|
|
@@ -112,9 +128,27 @@ export class Accounts {
|
|
|
112
128
|
return { ...NO_ACCOUNT, error: REFUSED };
|
|
113
129
|
}
|
|
114
130
|
}
|
|
131
|
+
/**
|
|
132
|
+
* Who a token belongs to, whichever kind of token it is.
|
|
133
|
+
*
|
|
134
|
+
* A `nxa_` token is one this server issued and can withdraw, so it is looked
|
|
135
|
+
* up. Anything else is a JWT from the auth module, which is self-describing
|
|
136
|
+
* and cannot be. Both answer the same shape, so nothing downstream has to
|
|
137
|
+
* know which door the caller came in by.
|
|
138
|
+
*/
|
|
115
139
|
async whoIs(token) {
|
|
116
140
|
if (!token)
|
|
117
141
|
return null;
|
|
142
|
+
if (looksLikeToken(token)) {
|
|
143
|
+
if (this.tokens === null)
|
|
144
|
+
return null;
|
|
145
|
+
try {
|
|
146
|
+
return await this.tokens.verify(token);
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
118
152
|
try {
|
|
119
153
|
return readClaims(await this.system.validateToken(token));
|
|
120
154
|
}
|
|
@@ -122,6 +156,65 @@ export class Accounts {
|
|
|
122
156
|
return null;
|
|
123
157
|
}
|
|
124
158
|
}
|
|
159
|
+
/**
|
|
160
|
+
* The token a signed-in caller carries away.
|
|
161
|
+
*
|
|
162
|
+
* A revocable token is preferred to the module's JWT wherever there is
|
|
163
|
+
* storage to keep one in, because signing out of a laptop you no longer have
|
|
164
|
+
* should mean something. The JWT is the fallback, and the only difference to
|
|
165
|
+
* a caller is that one of the two can be taken away.
|
|
166
|
+
*/
|
|
167
|
+
async sessionFor(account, fallback = "") {
|
|
168
|
+
if (this.tokens === null)
|
|
169
|
+
return fallback;
|
|
170
|
+
try {
|
|
171
|
+
return (await this.tokens.issue({ account, kind: "session" })).token;
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
return fallback;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/** Sign in as whoever a provider says this is, making the account if it is new. */
|
|
178
|
+
async signInWith(identity) {
|
|
179
|
+
if (this.identities === null)
|
|
180
|
+
return { ...NO_ACCOUNT, error: "this nixamp does not keep accounts" };
|
|
181
|
+
let account = null;
|
|
182
|
+
try {
|
|
183
|
+
account = await this.identities.resolve(identity);
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
account = null;
|
|
187
|
+
}
|
|
188
|
+
if (account === null) {
|
|
189
|
+
return { ...NO_ACCOUNT, error: `${identity.provider} did not give a verified email address` };
|
|
190
|
+
}
|
|
191
|
+
const token = await this.sessionFor(account);
|
|
192
|
+
if (!token)
|
|
193
|
+
return { ...NO_ACCOUNT, error: "could not start a session" };
|
|
194
|
+
return { ok: true, account, token, error: "" };
|
|
195
|
+
}
|
|
196
|
+
/** A token a person made on purpose, for a script that cannot sign in. */
|
|
197
|
+
async mintCliToken(account, name, ttlMs = null) {
|
|
198
|
+
return this.tokens === null ? null : this.tokens.issue({ account, kind: "cli", name, ttlMs });
|
|
199
|
+
}
|
|
200
|
+
async listTokens(userId, kind) {
|
|
201
|
+
return this.tokens === null ? [] : this.tokens.list(userId, kind);
|
|
202
|
+
}
|
|
203
|
+
async revokeToken(userId, id) {
|
|
204
|
+
return this.tokens === null ? false : this.tokens.revoke(userId, id);
|
|
205
|
+
}
|
|
206
|
+
/** Signing out ends this session and leaves every other token alone. */
|
|
207
|
+
async endSession(token) {
|
|
208
|
+
if (this.tokens === null || !looksLikeToken(token))
|
|
209
|
+
return;
|
|
210
|
+
try {
|
|
211
|
+
await this.tokens.revokeToken(token);
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
// A session that cannot be deleted still expires, and refusing to sign
|
|
215
|
+
// somebody out because the database blinked would be worse.
|
|
216
|
+
}
|
|
217
|
+
}
|
|
125
218
|
}
|
|
126
219
|
/** The bearer token on a request, from the header or the session cookie. */
|
|
127
220
|
export function tokenFrom(headers) {
|
package/dist/attach.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type State } from "./main.ts";
|
|
2
|
+
import type { Command, Snapshot } from "./protocol.ts";
|
|
3
|
+
/** A remote track has no path, because no filesystem path leaves the machine. */
|
|
4
|
+
export declare function applySnapshot(state: State, snapshot: Snapshot): void;
|
|
5
|
+
/**
|
|
6
|
+
* Read the server's event stream, calling back with every snapshot.
|
|
7
|
+
*
|
|
8
|
+
* Reconnects for as long as it is wanted: a daemon restarting under an
|
|
9
|
+
* attached player should look like a pause, not a crash.
|
|
10
|
+
*/
|
|
11
|
+
export declare function follow(url: string, headers: Record<string, string>, onSnapshot: (snapshot: Snapshot) => void, onTrouble: (why: string) => void, signal: AbortSignal, send?: typeof fetch): Promise<void>;
|
|
12
|
+
/** Drive the daemon with the same keys that drive the local player. */
|
|
13
|
+
export declare function commandFor(key: string): Command | null;
|
|
14
|
+
/** `nixamp attach` — the player, in front of whatever the daemon is doing. */
|
|
15
|
+
export declare function attach(argv: string[]): Promise<number>;
|
package/dist/attach.js
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Walking away from the player, and coming back to it.
|
|
3
|
+
*
|
|
4
|
+
* The daemon has always been able to outlive the terminal that started it.
|
|
5
|
+
* What was missing was the pair of moves that makes that worth having: `d` in
|
|
6
|
+
* the player hands the music to a daemon and gives you your terminal back, and
|
|
7
|
+
* `nixamp attach` puts the same player back in front of the same music.
|
|
8
|
+
*
|
|
9
|
+
* Attaching is not a second player. It is the same view, drawn from the
|
|
10
|
+
* daemon's snapshot instead of a local stream, with the keys sent as commands
|
|
11
|
+
* over the API a browser remote already uses. So the analyser moves, the track
|
|
12
|
+
* list is the daemon's, and nothing has to agree twice about what a player
|
|
13
|
+
* looks like.
|
|
14
|
+
*
|
|
15
|
+
* Quitting an attached player stops nothing. That is the whole point: it is
|
|
16
|
+
* tmux's detach, not a stop button.
|
|
17
|
+
*/
|
|
18
|
+
import { createApp, themes } from "@profullstack/hqtui";
|
|
19
|
+
import { resolveTarget } from "./admin.js";
|
|
20
|
+
import { BAND_COUNT, createState, view } from "./main.js";
|
|
21
|
+
import { KEY_HEADER } from "./share.js";
|
|
22
|
+
/** How long to wait before trying the event stream again. */
|
|
23
|
+
const RECONNECT_MS = 1000;
|
|
24
|
+
/** A remote track has no path, because no filesystem path leaves the machine. */
|
|
25
|
+
export function applySnapshot(state, snapshot) {
|
|
26
|
+
state.tracks = snapshot.tracks.map((track) => ({ path: "", ...track }));
|
|
27
|
+
state.index = snapshot.index;
|
|
28
|
+
state.playing = snapshot.playing;
|
|
29
|
+
state.position = snapshot.position;
|
|
30
|
+
state.levels = snapshot.levels;
|
|
31
|
+
state.silent = snapshot.silent;
|
|
32
|
+
state.root = snapshot.root;
|
|
33
|
+
state.note = snapshot.note;
|
|
34
|
+
// The bars come down the wire; the peaks that hang above them do not, and
|
|
35
|
+
// are a local decoration either way. Falling at the same rate the local
|
|
36
|
+
// player uses keeps the two looking like one program.
|
|
37
|
+
const bars = new Array(BAND_COUNT).fill(0).map((_, index) => snapshot.bars[index] ?? 0);
|
|
38
|
+
state.bars = bars;
|
|
39
|
+
state.peakHold = state.peakHold.map((peak, index) => Math.max(bars[index], peak - 0.02));
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Read the server's event stream, calling back with every snapshot.
|
|
43
|
+
*
|
|
44
|
+
* Reconnects for as long as it is wanted: a daemon restarting under an
|
|
45
|
+
* attached player should look like a pause, not a crash.
|
|
46
|
+
*/
|
|
47
|
+
export async function follow(url, headers, onSnapshot, onTrouble, signal, send = fetch) {
|
|
48
|
+
while (!signal.aborted) {
|
|
49
|
+
try {
|
|
50
|
+
const answer = await send(`${url}/api/events`, { headers, signal });
|
|
51
|
+
if (!answer.ok || answer.body === null)
|
|
52
|
+
throw new Error(`${answer.status}`);
|
|
53
|
+
onTrouble("");
|
|
54
|
+
// SSE frames are separated by a blank line, and arrive split across
|
|
55
|
+
// chunks in whatever way the network felt like.
|
|
56
|
+
let buffered = "";
|
|
57
|
+
const reader = answer.body.getReader();
|
|
58
|
+
const decoder = new TextDecoder();
|
|
59
|
+
for (;;) {
|
|
60
|
+
const { done, value } = await reader.read();
|
|
61
|
+
if (done)
|
|
62
|
+
break;
|
|
63
|
+
buffered += decoder.decode(value, { stream: true });
|
|
64
|
+
let cut = buffered.indexOf("\n\n");
|
|
65
|
+
while (cut !== -1) {
|
|
66
|
+
const frame = buffered.slice(0, cut);
|
|
67
|
+
buffered = buffered.slice(cut + 2);
|
|
68
|
+
for (const line of frame.split("\n")) {
|
|
69
|
+
if (!line.startsWith("data:"))
|
|
70
|
+
continue;
|
|
71
|
+
try {
|
|
72
|
+
onSnapshot(JSON.parse(line.slice(5).trim()));
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// A frame we cannot read is one frame, not a reason to hang up.
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
cut = buffered.indexOf("\n\n");
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
if (signal.aborted)
|
|
84
|
+
return;
|
|
85
|
+
onTrouble(`cannot reach ${url}`);
|
|
86
|
+
}
|
|
87
|
+
if (signal.aborted)
|
|
88
|
+
return;
|
|
89
|
+
await new Promise((done) => setTimeout(done, RECONNECT_MS));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/** Drive the daemon with the same keys that drive the local player. */
|
|
93
|
+
export function commandFor(key) {
|
|
94
|
+
switch (key) {
|
|
95
|
+
case "space":
|
|
96
|
+
return { type: "toggle" };
|
|
97
|
+
case "enter":
|
|
98
|
+
return { type: "play" };
|
|
99
|
+
case "s":
|
|
100
|
+
return { type: "stop" };
|
|
101
|
+
case "n":
|
|
102
|
+
case "right":
|
|
103
|
+
return { type: "next" };
|
|
104
|
+
case "p":
|
|
105
|
+
case "left":
|
|
106
|
+
return { type: "prev" };
|
|
107
|
+
default:
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/** `nixamp attach` — the player, in front of whatever the daemon is doing. */
|
|
112
|
+
export async function attach(argv) {
|
|
113
|
+
let target;
|
|
114
|
+
try {
|
|
115
|
+
target = resolveTarget(argv);
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
console.error(error.message);
|
|
119
|
+
console.error(" `nixamp daemon start ~/Music` starts one, or press d in the player to hand it one.");
|
|
120
|
+
return 1;
|
|
121
|
+
}
|
|
122
|
+
const headers = target.key ? { [KEY_HEADER]: target.key } : {};
|
|
123
|
+
const state = createState([], target.url, false);
|
|
124
|
+
state.note = `attaching to ${target.url}...`;
|
|
125
|
+
const app = await createApp({ theme: themes.matrix, title: "nixamp", quitKeys: ["ctrl+c"] });
|
|
126
|
+
const stop = new AbortController();
|
|
127
|
+
const tell = (command) => {
|
|
128
|
+
void fetch(`${target.url}/api/command`, {
|
|
129
|
+
method: "POST",
|
|
130
|
+
headers: { ...headers, "content-type": "application/json" },
|
|
131
|
+
body: JSON.stringify(command),
|
|
132
|
+
}).catch(() => {
|
|
133
|
+
// The next snapshot says whether it landed; a failed keypress is not
|
|
134
|
+
// worth a dialog in a player.
|
|
135
|
+
});
|
|
136
|
+
};
|
|
137
|
+
app.on("key", (event) => {
|
|
138
|
+
// q and d both leave, because both mean "I am done with this terminal".
|
|
139
|
+
// Neither stops the music, and the line printed on the way out says so.
|
|
140
|
+
if (event.key === "q" || event.key === "d") {
|
|
141
|
+
app.quit();
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (event.key === "up" || event.key === "down") {
|
|
145
|
+
const at = state.index + (event.key === "down" ? 1 : -1);
|
|
146
|
+
if (at < 0 || at >= state.tracks.length)
|
|
147
|
+
return;
|
|
148
|
+
// Moved here as well as asked for, so the highlight does not wait for a
|
|
149
|
+
// round trip before it moves.
|
|
150
|
+
state.index = at;
|
|
151
|
+
app.invalidate();
|
|
152
|
+
tell({ type: "select", index: at });
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const command = commandFor(event.key);
|
|
156
|
+
if (command)
|
|
157
|
+
tell(command);
|
|
158
|
+
});
|
|
159
|
+
app.on("exit", () => stop.abort());
|
|
160
|
+
app.render((args) => view(args, state));
|
|
161
|
+
void follow(target.url, headers, (snapshot) => {
|
|
162
|
+
applySnapshot(state, snapshot);
|
|
163
|
+
app.invalidate();
|
|
164
|
+
}, (why) => {
|
|
165
|
+
state.note = why;
|
|
166
|
+
app.invalidate();
|
|
167
|
+
}, stop.signal);
|
|
168
|
+
await app.start();
|
|
169
|
+
stop.abort();
|
|
170
|
+
console.log(`Detached. ${target.url} is still playing.`);
|
|
171
|
+
console.log(" nixamp attach come back");
|
|
172
|
+
console.log(" nixamp daemon stop when you are done");
|
|
173
|
+
return 0;
|
|
174
|
+
}
|
package/dist/device.d.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/** Long enough to walk to another room, short enough that a stolen code is stale. */
|
|
2
|
+
export declare const GRANT_TTL_MS = 600000;
|
|
3
|
+
/** What the CLI is told to wait between polls, in seconds. */
|
|
4
|
+
export declare const POLL_INTERVAL_SECONDS = 5;
|
|
5
|
+
export interface Grant {
|
|
6
|
+
deviceCode: string;
|
|
7
|
+
userCode: string;
|
|
8
|
+
createdAt: number;
|
|
9
|
+
expiresAt: number;
|
|
10
|
+
lastPolledAt: number;
|
|
11
|
+
/** Set once somebody approved it in a browser. */
|
|
12
|
+
session: {
|
|
13
|
+
token: string;
|
|
14
|
+
email: string;
|
|
15
|
+
} | null;
|
|
16
|
+
denied: boolean;
|
|
17
|
+
}
|
|
18
|
+
export type PollStatus = {
|
|
19
|
+
status: "pending";
|
|
20
|
+
} | {
|
|
21
|
+
status: "slow_down";
|
|
22
|
+
} | {
|
|
23
|
+
status: "expired";
|
|
24
|
+
} | {
|
|
25
|
+
status: "denied";
|
|
26
|
+
} | {
|
|
27
|
+
status: "ok";
|
|
28
|
+
token: string;
|
|
29
|
+
email: string;
|
|
30
|
+
};
|
|
31
|
+
/** `WXYZ-4RTB`. Hyphenated because it is read aloud and typed by hand. */
|
|
32
|
+
export declare function makeUserCode(random: (size: number) => Uint8Array): string;
|
|
33
|
+
/** Accept what a person typed however they typed it: lower case, no hyphen. */
|
|
34
|
+
export declare function normalizeUserCode(value: unknown): string;
|
|
35
|
+
export interface DeviceOptions {
|
|
36
|
+
now?: () => number;
|
|
37
|
+
random?: (size: number) => Uint8Array;
|
|
38
|
+
/** How often a waiting terminal is told to ask. Five seconds is RFC 8628's. */
|
|
39
|
+
intervalSeconds?: number;
|
|
40
|
+
}
|
|
41
|
+
export declare class DeviceGrants {
|
|
42
|
+
private readonly byDevice;
|
|
43
|
+
private readonly byUser;
|
|
44
|
+
private readonly now;
|
|
45
|
+
private readonly random;
|
|
46
|
+
/** Told to the terminal, and enforced here: the two must be the same number. */
|
|
47
|
+
readonly interval: number;
|
|
48
|
+
constructor(options?: DeviceOptions);
|
|
49
|
+
start(): Grant;
|
|
50
|
+
/** The grant behind a code somebody typed, if it is still worth anything. */
|
|
51
|
+
find(userCode: string): Grant | null;
|
|
52
|
+
approve(userCode: string, session: {
|
|
53
|
+
token: string;
|
|
54
|
+
email: string;
|
|
55
|
+
}): boolean;
|
|
56
|
+
deny(userCode: string): boolean;
|
|
57
|
+
/**
|
|
58
|
+
* What the waiting terminal is told. A grant is forgotten the moment it
|
|
59
|
+
* answers with a session, so the same device code cannot be redeemed twice.
|
|
60
|
+
*/
|
|
61
|
+
poll(deviceCode: string): PollStatus;
|
|
62
|
+
private forget;
|
|
63
|
+
sweep(): void;
|
|
64
|
+
get size(): number;
|
|
65
|
+
/** The grants nobody has answered yet, newest last. */
|
|
66
|
+
pending(): Grant[];
|
|
67
|
+
}
|