pi-open-deskos 0.2.1 → 0.4.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/config.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { isIP } from "node:net";
3
+ import { readFileSync } from "node:fs";
2
4
  import os from "node:os";
3
- import { type DeskLinkConfig } from "./types.ts";
5
+ import path from "node:path";
6
+ import { type OdkLinkConfig } from "./types.ts";
4
7
 
5
8
  /** A reporting machine identity stable across restarts of the same machine. */
6
9
  export function defaultMachineName(hostname = os.hostname()): string {
@@ -9,28 +12,41 @@ export function defaultMachineName(hostname = os.hostname()): string {
9
12
  }
10
13
 
11
14
  function readPort(raw: string): number | null {
12
- const port = Number.parseInt(raw, 10);
15
+ if (!/^\d+$/.test(raw)) return null;
16
+ const port = Number(raw);
13
17
  return Number.isInteger(port) && port > 0 && port <= 65535 ? port : null;
14
18
  }
15
19
 
20
+ function readEndpoint(address: string): { host: string; port: number } | null {
21
+ const separator = address.lastIndexOf(":");
22
+ if (separator <= 0) return null;
23
+ let host = address.slice(0, separator).trim();
24
+ const port = readPort(address.slice(separator + 1).trim());
25
+ if (host.startsWith("[")) {
26
+ if (!host.endsWith("]")) return null;
27
+ host = host.slice(1, -1);
28
+ if (isIP(host) !== 6) return null;
29
+ } else if (host.includes(":")) {
30
+ if (isIP(host) !== 6) return null;
31
+ }
32
+ if (!host || /[\s/\\[\]?#@]/.test(host) || port === null) return null;
33
+ return { host, port };
34
+ }
35
+
16
36
  /**
17
- * Read the Desk Link configuration. An unconfigured machine reports nothing:
37
+ * Read the Open DeskOS Link configuration. An unconfigured machine reports nothing:
18
38
  * a missing address or token is silent, never a partially configured link.
19
39
  */
20
- export function readDeskLinkConfig(env: NodeJS.ProcessEnv = process.env): DeskLinkConfig | null {
21
- const address = (env.ODK_DESK_LINK_ADDRESS ?? "").trim();
22
- const token = (env.ODK_DESK_LINK_TOKEN ?? "").trim();
40
+ export function readOdkLinkConfig(env: NodeJS.ProcessEnv = process.env): OdkLinkConfig | null {
41
+ const address = (env.ODK_LINK_ADDRESS ?? "").trim();
42
+ const token = (env.ODK_LINK_TOKEN ?? "").trim();
23
43
  if (address.length === 0 || token.length === 0) return null;
24
- const separator = address.lastIndexOf(":");
25
- if (separator <= 0) return null;
26
- const host = address.slice(0, separator).trim();
27
- const port = readPort(address.slice(separator + 1).trim());
28
- if (host.length === 0 || port === null) return null;
29
- const controlToken = (env.ODK_DESK_LINK_CONTROL_TOKEN ?? "").trim();
44
+ const endpoint = readEndpoint(address);
45
+ if (endpoint === null) return null;
46
+ const controlToken = (env.ODK_LINK_CONTROL_TOKEN ?? "").trim();
30
47
  return {
31
- machine: (env.ODK_DESK_LINK_MACHINE ?? "").trim() || defaultMachineName(),
32
- host,
33
- port,
48
+ machine: (env.ODK_LINK_MACHINE ?? "").trim() || defaultMachineName(),
49
+ ...endpoint,
34
50
  token,
35
51
  ...(controlToken.length > 0 ? { controlToken } : {}),
36
52
  };
@@ -40,3 +56,107 @@ export function readDeskLinkConfig(env: NodeJS.ProcessEnv = process.env): DeskLi
40
56
  export function tokenDigest(token: string): string {
41
57
  return createHash("sha256").update(token).digest("hex").slice(0, 8);
42
58
  }
59
+
60
+ /** Where a machine that reports to more than one desk states them. */
61
+ export const DEFAULT_DESKS_FILE = path.join(os.homedir(), ".config", "odk", "desks.json");
62
+
63
+ export interface OdkLinkConfigSet {
64
+ configs: OdkLinkConfig[];
65
+ /** Entries a desks file stated that this machine will not use, with the reason. */
66
+ refusals: string[];
67
+ source: "desks-file" | "environment" | "none";
68
+ }
69
+
70
+ /** One entry of a desks file, or the reason it is not usable. */
71
+ function configFromEntry(entry: unknown, machine: string): OdkLinkConfig | string {
72
+ if (entry === null || typeof entry !== "object") return "an entry is not an object";
73
+ const record = entry as Record<string, unknown>;
74
+ const address = typeof record.address === "string" ? record.address.trim() : "";
75
+ const token = typeof record.token === "string" ? record.token.trim() : "";
76
+ if (address.length === 0) return "an entry states no address";
77
+ if (token.length === 0) return `the entry for ${address} states no token`;
78
+ const endpoint = readEndpoint(address);
79
+ if (endpoint === null) return `${address} is not host:port`;
80
+ const controlToken = typeof record.controlToken === "string" ? record.controlToken.trim() : "";
81
+ const entryMachine = typeof record.machine === "string" && record.machine.trim().length > 0 ? record.machine.trim() : machine;
82
+ return {
83
+ machine: entryMachine,
84
+ ...endpoint,
85
+ token,
86
+ ...(controlToken.length > 0 ? { controlToken } : {}),
87
+ };
88
+ }
89
+
90
+ function entriesFrom(text: string): unknown[] | null {
91
+ let parsed: unknown;
92
+ try {
93
+ parsed = JSON.parse(text);
94
+ } catch {
95
+ return null;
96
+ }
97
+ if (Array.isArray(parsed)) return parsed;
98
+ const stated = (parsed as { desks?: unknown } | null)?.desks;
99
+ return Array.isArray(stated) ? stated : null;
100
+ }
101
+
102
+ /**
103
+ * Every desk this machine reports to.
104
+ *
105
+ * One disk file is the source of truth when there is one, because a machine that
106
+ * reports to several desks needs a token per desk and an environment variable
107
+ * cannot state that unambiguously. The single-desk environment form keeps
108
+ * working for a machine that reports to one. An operator who names a file and
109
+ * then has it not used is told, because silence there would look like a link
110
+ * that simply never connects.
111
+ */
112
+ export function readOdkLinkConfigs(
113
+ env: NodeJS.ProcessEnv = process.env,
114
+ options: { readFile?: (file: string) => string } = {},
115
+ ): OdkLinkConfigSet {
116
+ const readFile = options.readFile ?? ((file: string) => readFileSync(file, "utf8"));
117
+ const explicit = (env.ODK_LINK_DESKS_FILE ?? "").trim();
118
+ const machine = (env.ODK_LINK_MACHINE ?? "").trim() || defaultMachineName();
119
+ const file = explicit.length > 0 ? explicit : DEFAULT_DESKS_FILE;
120
+ const refusals: string[] = [];
121
+
122
+ let text: string | null = null;
123
+ try {
124
+ text = readFile(file);
125
+ } catch (error) {
126
+ const code = (error as { code?: string } | null)?.code;
127
+ if (code !== "ENOENT" || explicit.length > 0) {
128
+ refusals.push(`refusing ${file}: it could not be read${code === undefined ? "" : ` (${code})`}`);
129
+ return { configs: [], refusals, source: "desks-file" };
130
+ }
131
+ }
132
+
133
+ if (text !== null) {
134
+ const entries = entriesFrom(text);
135
+ if (entries === null) {
136
+ refusals.push(`refusing ${file}: it states no list of desks`);
137
+ return { configs: [], refusals, source: "desks-file" };
138
+ }
139
+ const configs: OdkLinkConfig[] = [];
140
+ const seen = new Set<string>();
141
+ for (const entry of entries) {
142
+ const result = configFromEntry(entry, machine);
143
+ if (typeof result === "string") {
144
+ refusals.push(`refusing ${file}: ${result}`);
145
+ continue;
146
+ }
147
+ const endpoint = `${result.host}:${result.port}`;
148
+ if (seen.has(endpoint)) {
149
+ refusals.push(`${endpoint} is listed more than once in ${file}; the first entry is used`);
150
+ continue;
151
+ }
152
+ seen.add(endpoint);
153
+ configs.push(result);
154
+ }
155
+ return { configs, refusals, source: "desks-file" };
156
+ }
157
+
158
+ const single = readOdkLinkConfig(env);
159
+ return single === null
160
+ ? { configs: [], refusals, source: "none" }
161
+ : { configs: [single], refusals, source: "environment" };
162
+ }
@@ -1,16 +1,16 @@
1
1
  import { createHmac, randomUUID } from "node:crypto";
2
- import { DESK_LINK_CONTROL_PROTOCOL, type ControlTransport } from "./control-transport.ts";
3
- import type { DeskLinkConfig, HistoryPage, HostedPiSession, PositionedSessionEvent } from "./types.ts";
2
+ import { ODK_LINK_CONTROL_PROTOCOL, type ControlTransport } from "./control-transport.ts";
3
+ import type { OdkLinkConfig, HistoryPage, HostedPiSession, PositionedSessionEvent } from "./types.ts";
4
4
 
5
5
  const CONTROL_TIMEOUT_MS = 5000;
6
- const CONTROL_DOMAIN = "open-deskos-control-v2";
6
+ const CONTROL_DOMAIN = "odk-control-v2";
7
7
  const MAX_ATTACH_BUFFER_EVENTS = 2048;
8
8
  const MAX_ATTACH_BUFFER_BYTES = 1024 * 1024;
9
9
 
10
10
  interface ConsoleClientOptions {
11
- config: DeskLinkConfig;
11
+ config: OdkLinkConfig;
12
12
  consoleSessionId: string;
13
- createTransport: (config: DeskLinkConfig) => ControlTransport;
13
+ createTransport: (config: OdkLinkConfig) => ControlTransport;
14
14
  timeoutMs?: number;
15
15
  }
16
16
 
@@ -47,8 +47,8 @@ function reasonFrom(record: Record<string, unknown>, fallback = "control request
47
47
 
48
48
  function versionError(record: Record<string, unknown>): Error {
49
49
  const received = String(record.received ?? record.v ?? "unknown");
50
- const accepted = Array.isArray(record.accepted) ? record.accepted.join(", ") : String(DESK_LINK_CONTROL_PROTOCOL);
51
- return new Error(`Desk Link control protocol version ${received} refused; accepted ${accepted}`);
50
+ const accepted = Array.isArray(record.accepted) ? record.accepted.join(", ") : String(ODK_LINK_CONTROL_PROTOCOL);
51
+ return new Error(`Open DeskOS Link control protocol version ${received} refused; accepted ${accepted}`);
52
52
  }
53
53
 
54
54
  function unwrapReply(record: Record<string, unknown>): Record<string, unknown> {
@@ -57,7 +57,7 @@ function unwrapReply(record: Record<string, unknown>): Record<string, unknown> {
57
57
  }
58
58
 
59
59
  export function controlTranscript(machine: string, sessionId: string, nonce: string): string {
60
- return `${CONTROL_DOMAIN}\n${DESK_LINK_CONTROL_PROTOCOL}\n${nonce}\n${machine}\n${sessionId}`;
60
+ return `${CONTROL_DOMAIN}\n${ODK_LINK_CONTROL_PROTOCOL}\n${nonce}\n${machine}\n${sessionId}`;
61
61
  }
62
62
 
63
63
  export function createControlProof(controlCredential: string, machine: string, sessionId: string, nonce: string): string {
@@ -86,15 +86,17 @@ class ControlConnection {
86
86
  readonly #transport: ControlTransport;
87
87
  readonly #pending = new Map<string, PendingRequest>();
88
88
  readonly #onUnsolicited: (record: Record<string, unknown>) => void;
89
+ readonly #onClosed: (error: Error) => void;
89
90
  #ready: Promise<void>;
90
91
  #readyResolve!: () => void;
91
92
  #readyReject!: (error: Error) => void;
92
93
  #closed = false;
93
94
  #transportClosed = false;
94
95
 
95
- constructor(options: ConsoleClientOptions, onUnsolicited: (record: Record<string, unknown>) => void = () => {}) {
96
+ constructor(options: ConsoleClientOptions, onUnsolicited: (record: Record<string, unknown>) => void = () => {}, onClosed: (error: Error) => void = () => {}) {
96
97
  this.#options = options;
97
98
  this.#onUnsolicited = onUnsolicited;
99
+ this.#onClosed = onClosed;
98
100
  this.#ready = new Promise<void>((resolve, reject) => {
99
101
  this.#readyResolve = resolve;
100
102
  this.#readyReject = reject;
@@ -104,7 +106,7 @@ class ControlConnection {
104
106
  readyTimer.unref?.();
105
107
  this.#ready.then(() => clearTimeout(readyTimer), () => clearTimeout(readyTimer));
106
108
  this.#transport.onOpen(() => this.#transport.send({
107
- v: DESK_LINK_CONTROL_PROTOCOL,
109
+ v: ODK_LINK_CONTROL_PROTOCOL,
108
110
  type: "control-hello",
109
111
  token: options.config.token,
110
112
  machine: options.config.machine,
@@ -129,7 +131,16 @@ class ControlConnection {
129
131
  timer.unref?.();
130
132
  this.#pending.set(requestId, { resolve, reject, timer, mutationId, sessionId, project });
131
133
  });
132
- this.#transport.send({ v: DESK_LINK_CONTROL_PROTOCOL, type, requestId, ...fields });
134
+ try {
135
+ this.#transport.send({ ...fields, v: ODK_LINK_CONTROL_PROTOCOL, type, requestId });
136
+ } catch (error) {
137
+ const pending = this.#pending.get(requestId);
138
+ if (pending) {
139
+ clearTimeout(pending.timer);
140
+ this.#pending.delete(requestId);
141
+ pending.reject(new ControlRequestError(error instanceof Error ? error.message : "control write failed", { requestId, mutationId, sessionId, project }));
142
+ }
143
+ }
133
144
  return response;
134
145
  }
135
146
 
@@ -138,9 +149,10 @@ class ControlConnection {
138
149
  }
139
150
 
140
151
  #receive(value: unknown): void {
152
+ if (this.#closed) return;
141
153
  const record = asRecord(value);
142
154
  if (!record) return;
143
- if (record.type === "version-mismatch" || (record.v !== undefined && record.v !== DESK_LINK_CONTROL_PROTOCOL)) {
155
+ if (record.type === "version-mismatch" || (record.v !== undefined && record.v !== ODK_LINK_CONTROL_PROTOCOL)) {
144
156
  this.#fail(versionError(record), true);
145
157
  return;
146
158
  }
@@ -149,7 +161,7 @@ class ControlConnection {
149
161
  const algorithm = record.algorithm;
150
162
  const credential = this.#options.config.controlToken ?? "";
151
163
  if (!nonce || (algorithm !== undefined && algorithm !== "hmac-sha256")) {
152
- this.#fail(new Error("invalid Desk Link control challenge"), true);
164
+ this.#fail(new Error("invalid Open DeskOS Link control challenge"), true);
153
165
  return;
154
166
  }
155
167
  if (!credential) {
@@ -157,7 +169,7 @@ class ControlConnection {
157
169
  return;
158
170
  }
159
171
  this.#transport.send({
160
- v: DESK_LINK_CONTROL_PROTOCOL,
172
+ v: ODK_LINK_CONTROL_PROTOCOL,
161
173
  type: "control-proof",
162
174
  proof: createControlProof(credential, this.#options.config.machine, this.#options.consoleSessionId, nonce),
163
175
  });
@@ -196,6 +208,7 @@ class ControlConnection {
196
208
  pending.reject(new ControlRequestError(error.message, { requestId, mutationId: pending.mutationId, sessionId: pending.sessionId, project: pending.project }));
197
209
  }
198
210
  this.#pending.clear();
211
+ this.#onClosed(error);
199
212
  }
200
213
  if (closeTransport && !this.#transportClosed) {
201
214
  this.#transportClosed = true;
@@ -219,6 +232,7 @@ export interface ConsoleAttachment {
219
232
  export class DeskConsoleClient {
220
233
  readonly #options: ConsoleClientOptions;
221
234
  #attachment: ConsoleAttachment | null = null;
235
+ #attaching: ControlConnection | null = null;
222
236
 
223
237
  constructor(options: ConsoleClientOptions) {
224
238
  this.#options = options;
@@ -246,7 +260,7 @@ export class DeskConsoleClient {
246
260
  }
247
261
 
248
262
  async attach(options: AttachOptions): Promise<ConsoleAttachment> {
249
- this.#attachment?.close();
263
+ this.closeAttachment();
250
264
  const attachmentId = randomUUID();
251
265
  let lastApplied = options.position ?? 0;
252
266
  let streamError: string | undefined;
@@ -314,12 +328,22 @@ export class DeskConsoleClient {
314
328
  ...(typeof record.response === "string" ? { response: record.response } : {}),
315
329
  });
316
330
  }
317
- });
318
- const attached = await connection.request("attach", {
319
- sessionId: options.sessionId,
320
- attachmentId,
321
- after: options.position ?? null,
322
- });
331
+ }, (error) => { streamError ??= error.message; });
332
+ this.#attaching = connection;
333
+ let attached: Record<string, unknown>;
334
+ try {
335
+ attached = await connection.request("attach", {
336
+ sessionId: options.sessionId,
337
+ attachmentId,
338
+ after: options.position ?? null,
339
+ });
340
+ if (this.#attaching !== connection) throw new Error("control link closed");
341
+ } catch (error) {
342
+ connection.close();
343
+ throw error;
344
+ } finally {
345
+ if (this.#attaching === connection) this.#attaching = null;
346
+ }
323
347
  acknowledged = true;
324
348
  drainQueued();
325
349
  const boundary = asPosition(attached.boundary);
@@ -363,6 +387,8 @@ export class DeskConsoleClient {
363
387
 
364
388
  closeAttachment(): void {
365
389
  this.#attachment?.close();
390
+ this.#attaching?.close();
391
+ this.#attaching = null;
366
392
  }
367
393
 
368
394
  async #oneShot(type: string, fields: Record<string, unknown> = {}): Promise<Record<string, unknown>> {