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/dist/tokens.js
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tokens that are not passwords.
|
|
3
|
+
*
|
|
4
|
+
* Two things arrive at the same door and are the same kind of thing on the
|
|
5
|
+
* way in: the session a browser or a terminal gets after signing in, and the
|
|
6
|
+
* token a person makes on purpose to paste into a CI job. Both are opaque
|
|
7
|
+
* strings this server issued, both can be listed and revoked, and neither can
|
|
8
|
+
* be turned back into a password.
|
|
9
|
+
*
|
|
10
|
+
* They are deliberately NOT the JWT the auth module hands out. A JWT cannot be
|
|
11
|
+
* withdrawn before it expires -- nothing looks it up, that is the point of it
|
|
12
|
+
* -- and a token somebody pasted into a build server is exactly the one you
|
|
13
|
+
* want to be able to kill from a laptop. So the secret half is hashed like a
|
|
14
|
+
* password, the row is what makes the token real, and deleting the row is what
|
|
15
|
+
* makes it stop working.
|
|
16
|
+
*
|
|
17
|
+
* The shape is `nxa_<id>_<secret>`. The id is looked up; the secret is compared
|
|
18
|
+
* against its hash in constant time. Carrying the id means a token is one index
|
|
19
|
+
* hit rather than a scan over every hash on the site.
|
|
20
|
+
*/
|
|
21
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
22
|
+
/** Prefixed so a leaked token is greppable, and obvious in a log. */
|
|
23
|
+
export const TOKEN_PREFIX = "nxa_";
|
|
24
|
+
/** How long a sign-in lasts. Long, because signing in on a television is work. */
|
|
25
|
+
export const SESSION_DAYS = 90;
|
|
26
|
+
const TABLE = "nixamp_tokens";
|
|
27
|
+
const SCHEMA = `
|
|
28
|
+
CREATE TABLE IF NOT EXISTS ${TABLE} (
|
|
29
|
+
id TEXT PRIMARY KEY,
|
|
30
|
+
user_id TEXT NOT NULL,
|
|
31
|
+
email TEXT NOT NULL DEFAULT '',
|
|
32
|
+
kind TEXT NOT NULL DEFAULT 'session',
|
|
33
|
+
name TEXT NOT NULL DEFAULT '',
|
|
34
|
+
secret_hash TEXT NOT NULL,
|
|
35
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
36
|
+
expires_at TIMESTAMPTZ,
|
|
37
|
+
last_used_at TIMESTAMPTZ
|
|
38
|
+
);
|
|
39
|
+
CREATE INDEX IF NOT EXISTS ${TABLE}_user ON ${TABLE} (user_id);
|
|
40
|
+
`;
|
|
41
|
+
/** Make a token and the two halves it is made of. */
|
|
42
|
+
export function mintToken() {
|
|
43
|
+
const id = randomBytes(8).toString("hex");
|
|
44
|
+
const secret = randomBytes(32).toString("base64url");
|
|
45
|
+
return { id, secret, token: `${TOKEN_PREFIX}${id}_${secret}` };
|
|
46
|
+
}
|
|
47
|
+
/** Is this one of ours, rather than a JWT from the auth module? */
|
|
48
|
+
export function looksLikeToken(value) {
|
|
49
|
+
return value.startsWith(TOKEN_PREFIX);
|
|
50
|
+
}
|
|
51
|
+
/** Pull the id and the secret back out. Anything malformed is not a token. */
|
|
52
|
+
export function splitToken(value) {
|
|
53
|
+
if (!looksLikeToken(value))
|
|
54
|
+
return null;
|
|
55
|
+
const rest = value.slice(TOKEN_PREFIX.length);
|
|
56
|
+
const cut = rest.indexOf("_");
|
|
57
|
+
if (cut <= 0)
|
|
58
|
+
return null;
|
|
59
|
+
const id = rest.slice(0, cut);
|
|
60
|
+
const secret = rest.slice(cut + 1);
|
|
61
|
+
if (!/^[0-9a-f]+$/.test(id) || secret.length < 16)
|
|
62
|
+
return null;
|
|
63
|
+
return { id, secret };
|
|
64
|
+
}
|
|
65
|
+
export function hashSecret(secret) {
|
|
66
|
+
// A token secret is 32 random bytes, not a password: there is nothing to
|
|
67
|
+
// guess by dictionary, so a slow KDF would only slow down every request.
|
|
68
|
+
return createHash("sha256").update(secret).digest("hex");
|
|
69
|
+
}
|
|
70
|
+
/** Compare without letting the time taken say how much of it matched. */
|
|
71
|
+
function sameHash(left, right) {
|
|
72
|
+
const a = Buffer.from(left, "utf8");
|
|
73
|
+
const b = Buffer.from(right, "utf8");
|
|
74
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
75
|
+
}
|
|
76
|
+
function asTime(value) {
|
|
77
|
+
if (value instanceof Date)
|
|
78
|
+
return value.getTime();
|
|
79
|
+
if (typeof value === "string") {
|
|
80
|
+
const at = Date.parse(value);
|
|
81
|
+
return Number.isNaN(at) ? null : at;
|
|
82
|
+
}
|
|
83
|
+
if (typeof value === "number")
|
|
84
|
+
return value;
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
function toRecord(row) {
|
|
88
|
+
return {
|
|
89
|
+
id: String(row["id"] ?? ""),
|
|
90
|
+
name: String(row["name"] ?? ""),
|
|
91
|
+
kind: row["kind"] === "cli" ? "cli" : "session",
|
|
92
|
+
createdAt: asTime(row["created_at"]) ?? 0,
|
|
93
|
+
expiresAt: asTime(row["expires_at"]),
|
|
94
|
+
lastUsedAt: asTime(row["last_used_at"]),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
export class Tokens {
|
|
98
|
+
db;
|
|
99
|
+
now;
|
|
100
|
+
ready = null;
|
|
101
|
+
constructor(db, now = Date.now) {
|
|
102
|
+
this.db = db;
|
|
103
|
+
this.now = now;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Make the table, once per process, on first use. nixamp carries no
|
|
107
|
+
* migration runner, and asking anybody to run SQL by hand before they can
|
|
108
|
+
* sign in is a setup step too many.
|
|
109
|
+
*/
|
|
110
|
+
async ensure() {
|
|
111
|
+
this.ready ??= this.db.query(SCHEMA).then(() => undefined);
|
|
112
|
+
await this.ready;
|
|
113
|
+
}
|
|
114
|
+
async issue(options) {
|
|
115
|
+
await this.ensure();
|
|
116
|
+
const { id, secret, token } = mintToken();
|
|
117
|
+
const ttl = options.ttlMs === undefined ? SESSION_DAYS * 86_400_000 : options.ttlMs;
|
|
118
|
+
const createdAt = this.now();
|
|
119
|
+
const expiresAt = ttl === null ? null : createdAt + ttl;
|
|
120
|
+
await this.db.query(`INSERT INTO ${TABLE} (id, user_id, email, kind, name, secret_hash, created_at, expires_at)
|
|
121
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, [
|
|
122
|
+
id,
|
|
123
|
+
options.account.id,
|
|
124
|
+
options.account.email,
|
|
125
|
+
options.kind,
|
|
126
|
+
options.name ?? "",
|
|
127
|
+
hashSecret(secret),
|
|
128
|
+
new Date(createdAt).toISOString(),
|
|
129
|
+
expiresAt === null ? null : new Date(expiresAt).toISOString(),
|
|
130
|
+
]);
|
|
131
|
+
return {
|
|
132
|
+
id,
|
|
133
|
+
token,
|
|
134
|
+
name: options.name ?? "",
|
|
135
|
+
kind: options.kind,
|
|
136
|
+
createdAt,
|
|
137
|
+
expiresAt,
|
|
138
|
+
lastUsedAt: null,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
/** The account a token belongs to, or null for one this server will not accept. */
|
|
142
|
+
async verify(value) {
|
|
143
|
+
const parts = splitToken(value);
|
|
144
|
+
if (parts === null)
|
|
145
|
+
return null;
|
|
146
|
+
await this.ensure();
|
|
147
|
+
const { rows } = await this.db.query(`SELECT id, user_id, email, secret_hash, expires_at FROM ${TABLE} WHERE id = $1`, [parts.id]);
|
|
148
|
+
const row = rows[0];
|
|
149
|
+
if (!row)
|
|
150
|
+
return null;
|
|
151
|
+
if (!sameHash(String(row["secret_hash"] ?? ""), hashSecret(parts.secret)))
|
|
152
|
+
return null;
|
|
153
|
+
const expiresAt = asTime(row["expires_at"]);
|
|
154
|
+
if (expiresAt !== null && expiresAt <= this.now()) {
|
|
155
|
+
// Tidy it away on the way past rather than running a sweeper: an expired
|
|
156
|
+
// token is only ever noticed when somebody tries to use it.
|
|
157
|
+
await this.db.query(`DELETE FROM ${TABLE} WHERE id = $1`, [parts.id]).catch(() => { });
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
// Last used is what makes `nixamp token list` worth reading -- it is how
|
|
161
|
+
// you tell the token you forgot about from the one CI depends on.
|
|
162
|
+
await this.db
|
|
163
|
+
.query(`UPDATE ${TABLE} SET last_used_at = $2 WHERE id = $1`, [
|
|
164
|
+
parts.id,
|
|
165
|
+
new Date(this.now()).toISOString(),
|
|
166
|
+
])
|
|
167
|
+
.catch(() => { });
|
|
168
|
+
return { id: String(row["user_id"] ?? ""), email: String(row["email"] ?? "") };
|
|
169
|
+
}
|
|
170
|
+
/** Everything one account holds, newest first. Secrets are not in the table to leak. */
|
|
171
|
+
async list(userId, kind) {
|
|
172
|
+
await this.ensure();
|
|
173
|
+
const { rows } = await this.db.query(`SELECT id, name, kind, created_at, expires_at, last_used_at FROM ${TABLE}
|
|
174
|
+
WHERE user_id = $1 ${kind ? "AND kind = $2" : ""}
|
|
175
|
+
ORDER BY created_at DESC`, kind ? [userId, kind] : [userId]);
|
|
176
|
+
return rows.map(toRecord);
|
|
177
|
+
}
|
|
178
|
+
/** Scoped to the owner, so an id from somebody else's list revokes nothing. */
|
|
179
|
+
async revoke(userId, id) {
|
|
180
|
+
await this.ensure();
|
|
181
|
+
const { rows } = await this.db.query(`DELETE FROM ${TABLE} WHERE user_id = $1 AND id = $2 RETURNING id`, [
|
|
182
|
+
userId,
|
|
183
|
+
id,
|
|
184
|
+
]);
|
|
185
|
+
return rows.length > 0;
|
|
186
|
+
}
|
|
187
|
+
/** Signing out of one place should not sign you out of the build server. */
|
|
188
|
+
async revokeToken(value) {
|
|
189
|
+
const parts = splitToken(value);
|
|
190
|
+
if (parts === null)
|
|
191
|
+
return false;
|
|
192
|
+
await this.ensure();
|
|
193
|
+
const { rows } = await this.db.query(`DELETE FROM ${TABLE} WHERE id = $1 AND kind = 'session' RETURNING id`, [parts.id]);
|
|
194
|
+
return rows.length > 0;
|
|
195
|
+
}
|
|
196
|
+
}
|
package/package.json
CHANGED
package/src/accounts.ts
CHANGED
|
@@ -15,9 +15,14 @@
|
|
|
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, type Identity, type Users } from "./oauth.ts";
|
|
24
|
+
import type { Queryable } from "./follows.ts";
|
|
25
|
+
import { type IssuedToken, looksLikeToken, type TokenKind, type TokenRecord, Tokens } from "./tokens.ts";
|
|
21
26
|
|
|
22
27
|
export interface Account {
|
|
23
28
|
id: string;
|
|
@@ -47,8 +52,17 @@ export interface AccountsOptions {
|
|
|
47
52
|
secret: string;
|
|
48
53
|
/** Injected by the tests, which have no database. */
|
|
49
54
|
system?: AuthLike;
|
|
55
|
+
/** Also injected by the tests: the storage tokens and identities sit in. */
|
|
56
|
+
adapter?: AdapterLike;
|
|
50
57
|
}
|
|
51
58
|
|
|
59
|
+
/**
|
|
60
|
+
* The slice of the auth module's storage adapter the rest of this file needs.
|
|
61
|
+
* It is the same Postgres pool the users table lives in, which is why nixamp's
|
|
62
|
+
* two tables need no connection of their own.
|
|
63
|
+
*/
|
|
64
|
+
export interface AdapterLike extends Queryable, Users {}
|
|
65
|
+
|
|
52
66
|
/** The slice of the auth system nixamp uses. */
|
|
53
67
|
export interface AuthLike {
|
|
54
68
|
register(input: { email: string; password: string; autoVerify?: boolean }): Promise<unknown>;
|
|
@@ -93,14 +107,27 @@ export function checkCredentials(email: unknown, password: unknown): string {
|
|
|
93
107
|
|
|
94
108
|
export class Accounts {
|
|
95
109
|
private readonly system: AuthLike;
|
|
110
|
+
/** Null only where a test injected an auth system and no storage. */
|
|
111
|
+
readonly tokens: Tokens | null;
|
|
112
|
+
private readonly identities: Identities | null;
|
|
96
113
|
|
|
97
114
|
constructor(options: AccountsOptions) {
|
|
115
|
+
// The adapter is kept rather than only handed over: tokens and provider
|
|
116
|
+
// identities are nixamp's own tables in the same database, and a second
|
|
117
|
+
// pool for two small tables would be a second thing to configure.
|
|
118
|
+
const adapter =
|
|
119
|
+
options.adapter ??
|
|
120
|
+
(options.system
|
|
121
|
+
? null
|
|
122
|
+
: (new PostgresAdapter({ connectionString: options.connectionString }) as unknown as AdapterLike));
|
|
98
123
|
this.system =
|
|
99
124
|
options.system ??
|
|
100
125
|
(createAuthSystem({
|
|
101
|
-
adapter
|
|
126
|
+
adapter,
|
|
102
127
|
jwtSecret: options.secret,
|
|
103
128
|
}) as AuthLike);
|
|
129
|
+
this.tokens = adapter ? new Tokens(adapter) : null;
|
|
130
|
+
this.identities = adapter ? new Identities(adapter, adapter) : null;
|
|
104
131
|
}
|
|
105
132
|
|
|
106
133
|
async signUp(email: unknown, password: unknown): Promise<AuthResult> {
|
|
@@ -146,14 +173,88 @@ export class Accounts {
|
|
|
146
173
|
}
|
|
147
174
|
}
|
|
148
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Who a token belongs to, whichever kind of token it is.
|
|
178
|
+
*
|
|
179
|
+
* A `nxa_` token is one this server issued and can withdraw, so it is looked
|
|
180
|
+
* up. Anything else is a JWT from the auth module, which is self-describing
|
|
181
|
+
* and cannot be. Both answer the same shape, so nothing downstream has to
|
|
182
|
+
* know which door the caller came in by.
|
|
183
|
+
*/
|
|
149
184
|
async whoIs(token: string): Promise<Account | null> {
|
|
150
185
|
if (!token) return null;
|
|
186
|
+
if (looksLikeToken(token)) {
|
|
187
|
+
if (this.tokens === null) return null;
|
|
188
|
+
try {
|
|
189
|
+
return await this.tokens.verify(token);
|
|
190
|
+
} catch {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
151
194
|
try {
|
|
152
195
|
return readClaims(await this.system.validateToken(token));
|
|
153
196
|
} catch {
|
|
154
197
|
return null;
|
|
155
198
|
}
|
|
156
199
|
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The token a signed-in caller carries away.
|
|
203
|
+
*
|
|
204
|
+
* A revocable token is preferred to the module's JWT wherever there is
|
|
205
|
+
* storage to keep one in, because signing out of a laptop you no longer have
|
|
206
|
+
* should mean something. The JWT is the fallback, and the only difference to
|
|
207
|
+
* a caller is that one of the two can be taken away.
|
|
208
|
+
*/
|
|
209
|
+
async sessionFor(account: Account, fallback = ""): Promise<string> {
|
|
210
|
+
if (this.tokens === null) return fallback;
|
|
211
|
+
try {
|
|
212
|
+
return (await this.tokens.issue({ account, kind: "session" })).token;
|
|
213
|
+
} catch {
|
|
214
|
+
return fallback;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Sign in as whoever a provider says this is, making the account if it is new. */
|
|
219
|
+
async signInWith(identity: Identity): Promise<AuthResult> {
|
|
220
|
+
if (this.identities === null) return { ...NO_ACCOUNT, error: "this nixamp does not keep accounts" };
|
|
221
|
+
let account: Account | null = null;
|
|
222
|
+
try {
|
|
223
|
+
account = await this.identities.resolve(identity);
|
|
224
|
+
} catch {
|
|
225
|
+
account = null;
|
|
226
|
+
}
|
|
227
|
+
if (account === null) {
|
|
228
|
+
return { ...NO_ACCOUNT, error: `${identity.provider} did not give a verified email address` };
|
|
229
|
+
}
|
|
230
|
+
const token = await this.sessionFor(account);
|
|
231
|
+
if (!token) return { ...NO_ACCOUNT, error: "could not start a session" };
|
|
232
|
+
return { ok: true, account, token, error: "" };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** A token a person made on purpose, for a script that cannot sign in. */
|
|
236
|
+
async mintCliToken(account: Account, name: string, ttlMs: number | null = null): Promise<IssuedToken | null> {
|
|
237
|
+
return this.tokens === null ? null : this.tokens.issue({ account, kind: "cli", name, ttlMs });
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async listTokens(userId: string, kind?: TokenKind): Promise<TokenRecord[]> {
|
|
241
|
+
return this.tokens === null ? [] : this.tokens.list(userId, kind);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async revokeToken(userId: string, id: string): Promise<boolean> {
|
|
245
|
+
return this.tokens === null ? false : this.tokens.revoke(userId, id);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Signing out ends this session and leaves every other token alone. */
|
|
249
|
+
async endSession(token: string): Promise<void> {
|
|
250
|
+
if (this.tokens === null || !looksLikeToken(token)) return;
|
|
251
|
+
try {
|
|
252
|
+
await this.tokens.revokeToken(token);
|
|
253
|
+
} catch {
|
|
254
|
+
// A session that cannot be deleted still expires, and refusing to sign
|
|
255
|
+
// somebody out because the database blinked would be worse.
|
|
256
|
+
}
|
|
257
|
+
}
|
|
157
258
|
}
|
|
158
259
|
|
|
159
260
|
/** The bearer token on a request, from the header or the session cookie. */
|
package/src/attach.ts
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
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, type KeyEvent } from "@profullstack/hqtui";
|
|
19
|
+
import { resolveTarget } from "./admin.ts";
|
|
20
|
+
import { BAND_COUNT, createState, view, type State } from "./main.ts";
|
|
21
|
+
import type { Command, Snapshot } from "./protocol.ts";
|
|
22
|
+
import { KEY_HEADER } from "./share.ts";
|
|
23
|
+
|
|
24
|
+
/** How long to wait before trying the event stream again. */
|
|
25
|
+
const RECONNECT_MS = 1000;
|
|
26
|
+
|
|
27
|
+
/** A remote track has no path, because no filesystem path leaves the machine. */
|
|
28
|
+
export function applySnapshot(state: State, snapshot: Snapshot): void {
|
|
29
|
+
state.tracks = snapshot.tracks.map((track) => ({ path: "", ...track }));
|
|
30
|
+
state.index = snapshot.index;
|
|
31
|
+
state.playing = snapshot.playing;
|
|
32
|
+
state.position = snapshot.position;
|
|
33
|
+
state.levels = snapshot.levels;
|
|
34
|
+
state.silent = snapshot.silent;
|
|
35
|
+
state.root = snapshot.root;
|
|
36
|
+
state.note = snapshot.note;
|
|
37
|
+
|
|
38
|
+
// The bars come down the wire; the peaks that hang above them do not, and
|
|
39
|
+
// are a local decoration either way. Falling at the same rate the local
|
|
40
|
+
// player uses keeps the two looking like one program.
|
|
41
|
+
const bars = new Array(BAND_COUNT).fill(0).map((_, index) => snapshot.bars[index] ?? 0);
|
|
42
|
+
state.bars = bars;
|
|
43
|
+
state.peakHold = state.peakHold.map((peak, index) => Math.max(bars[index] as number, peak - 0.02));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Read the server's event stream, calling back with every snapshot.
|
|
48
|
+
*
|
|
49
|
+
* Reconnects for as long as it is wanted: a daemon restarting under an
|
|
50
|
+
* attached player should look like a pause, not a crash.
|
|
51
|
+
*/
|
|
52
|
+
export async function follow(
|
|
53
|
+
url: string,
|
|
54
|
+
headers: Record<string, string>,
|
|
55
|
+
onSnapshot: (snapshot: Snapshot) => void,
|
|
56
|
+
onTrouble: (why: string) => void,
|
|
57
|
+
signal: AbortSignal,
|
|
58
|
+
send: typeof fetch = fetch,
|
|
59
|
+
): Promise<void> {
|
|
60
|
+
while (!signal.aborted) {
|
|
61
|
+
try {
|
|
62
|
+
const answer = await send(`${url}/api/events`, { headers, signal });
|
|
63
|
+
if (!answer.ok || answer.body === null) throw new Error(`${answer.status}`);
|
|
64
|
+
onTrouble("");
|
|
65
|
+
// SSE frames are separated by a blank line, and arrive split across
|
|
66
|
+
// chunks in whatever way the network felt like.
|
|
67
|
+
let buffered = "";
|
|
68
|
+
const reader = answer.body.getReader();
|
|
69
|
+
const decoder = new TextDecoder();
|
|
70
|
+
for (;;) {
|
|
71
|
+
const { done, value } = await reader.read();
|
|
72
|
+
if (done) break;
|
|
73
|
+
buffered += decoder.decode(value, { stream: true });
|
|
74
|
+
let cut = buffered.indexOf("\n\n");
|
|
75
|
+
while (cut !== -1) {
|
|
76
|
+
const frame = buffered.slice(0, cut);
|
|
77
|
+
buffered = buffered.slice(cut + 2);
|
|
78
|
+
for (const line of frame.split("\n")) {
|
|
79
|
+
if (!line.startsWith("data:")) continue;
|
|
80
|
+
try {
|
|
81
|
+
onSnapshot(JSON.parse(line.slice(5).trim()) as Snapshot);
|
|
82
|
+
} catch {
|
|
83
|
+
// A frame we cannot read is one frame, not a reason to hang up.
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
cut = buffered.indexOf("\n\n");
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
} catch {
|
|
90
|
+
if (signal.aborted) return;
|
|
91
|
+
onTrouble(`cannot reach ${url}`);
|
|
92
|
+
}
|
|
93
|
+
if (signal.aborted) return;
|
|
94
|
+
await new Promise((done) => setTimeout(done, RECONNECT_MS));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Drive the daemon with the same keys that drive the local player. */
|
|
99
|
+
export function commandFor(key: string): Command | null {
|
|
100
|
+
switch (key) {
|
|
101
|
+
case "space":
|
|
102
|
+
return { type: "toggle" };
|
|
103
|
+
case "enter":
|
|
104
|
+
return { type: "play" };
|
|
105
|
+
case "s":
|
|
106
|
+
return { type: "stop" };
|
|
107
|
+
case "n":
|
|
108
|
+
case "right":
|
|
109
|
+
return { type: "next" };
|
|
110
|
+
case "p":
|
|
111
|
+
case "left":
|
|
112
|
+
return { type: "prev" };
|
|
113
|
+
default:
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** `nixamp attach` — the player, in front of whatever the daemon is doing. */
|
|
119
|
+
export async function attach(argv: string[]): Promise<number> {
|
|
120
|
+
let target: { url: string; key: string | null };
|
|
121
|
+
try {
|
|
122
|
+
target = resolveTarget(argv);
|
|
123
|
+
} catch (error) {
|
|
124
|
+
console.error((error as Error).message);
|
|
125
|
+
console.error(" `nixamp daemon start ~/Music` starts one, or press d in the player to hand it one.");
|
|
126
|
+
return 1;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const headers: Record<string, string> = target.key ? { [KEY_HEADER]: target.key } : {};
|
|
130
|
+
const state = createState([], target.url, false);
|
|
131
|
+
state.note = `attaching to ${target.url}...`;
|
|
132
|
+
|
|
133
|
+
const app = await createApp({ theme: themes.matrix, title: "nixamp", quitKeys: ["ctrl+c"] });
|
|
134
|
+
const stop = new AbortController();
|
|
135
|
+
|
|
136
|
+
const tell = (command: Command): void => {
|
|
137
|
+
void fetch(`${target.url}/api/command`, {
|
|
138
|
+
method: "POST",
|
|
139
|
+
headers: { ...headers, "content-type": "application/json" },
|
|
140
|
+
body: JSON.stringify(command),
|
|
141
|
+
}).catch(() => {
|
|
142
|
+
// The next snapshot says whether it landed; a failed keypress is not
|
|
143
|
+
// worth a dialog in a player.
|
|
144
|
+
});
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
app.on("key", (event: KeyEvent) => {
|
|
148
|
+
// q and d both leave, because both mean "I am done with this terminal".
|
|
149
|
+
// Neither stops the music, and the line printed on the way out says so.
|
|
150
|
+
if (event.key === "q" || event.key === "d") {
|
|
151
|
+
app.quit();
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (event.key === "up" || event.key === "down") {
|
|
155
|
+
const at = state.index + (event.key === "down" ? 1 : -1);
|
|
156
|
+
if (at < 0 || at >= state.tracks.length) return;
|
|
157
|
+
// Moved here as well as asked for, so the highlight does not wait for a
|
|
158
|
+
// round trip before it moves.
|
|
159
|
+
state.index = at;
|
|
160
|
+
app.invalidate();
|
|
161
|
+
tell({ type: "select", index: at });
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const command = commandFor(event.key);
|
|
165
|
+
if (command) tell(command);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
app.on("exit", () => stop.abort());
|
|
169
|
+
app.render((args) => view(args, state));
|
|
170
|
+
|
|
171
|
+
void follow(
|
|
172
|
+
target.url,
|
|
173
|
+
headers,
|
|
174
|
+
(snapshot) => {
|
|
175
|
+
applySnapshot(state, snapshot);
|
|
176
|
+
app.invalidate();
|
|
177
|
+
},
|
|
178
|
+
(why) => {
|
|
179
|
+
state.note = why;
|
|
180
|
+
app.invalidate();
|
|
181
|
+
},
|
|
182
|
+
stop.signal,
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
await app.start();
|
|
186
|
+
stop.abort();
|
|
187
|
+
console.log(`Detached. ${target.url} is still playing.`);
|
|
188
|
+
console.log(" nixamp attach come back");
|
|
189
|
+
console.log(" nixamp daemon stop when you are done");
|
|
190
|
+
return 0;
|
|
191
|
+
}
|