@moxxy/plugin-channel-signal 0.27.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/LICENSE +21 -0
- package/dist/channel/chunker.d.ts +69 -0
- package/dist/channel/chunker.d.ts.map +1 -0
- package/dist/channel/chunker.js +133 -0
- package/dist/channel/chunker.js.map +1 -0
- package/dist/channel/turn-runner.d.ts +33 -0
- package/dist/channel/turn-runner.d.ts.map +1 -0
- package/dist/channel/turn-runner.js +70 -0
- package/dist/channel/turn-runner.js.map +1 -0
- package/dist/channel/voice.d.ts +37 -0
- package/dist/channel/voice.d.ts.map +1 -0
- package/dist/channel/voice.js +88 -0
- package/dist/channel/voice.js.map +1 -0
- package/dist/channel.d.ts +154 -0
- package/dist/channel.d.ts.map +1 -0
- package/dist/channel.js +468 -0
- package/dist/channel.js.map +1 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +215 -0
- package/dist/index.js.map +1 -0
- package/dist/jsonrpc.d.ts +53 -0
- package/dist/jsonrpc.d.ts.map +1 -0
- package/dist/jsonrpc.js +167 -0
- package/dist/jsonrpc.js.map +1 -0
- package/dist/keys.d.ts +37 -0
- package/dist/keys.d.ts.map +1 -0
- package/dist/keys.js +55 -0
- package/dist/keys.js.map +1 -0
- package/dist/pair-flow.d.ts +21 -0
- package/dist/pair-flow.d.ts.map +1 -0
- package/dist/pair-flow.js +136 -0
- package/dist/pair-flow.js.map +1 -0
- package/dist/permission.d.ts +21 -0
- package/dist/permission.d.ts.map +1 -0
- package/dist/permission.js +27 -0
- package/dist/permission.js.map +1 -0
- package/dist/schema.d.ts +4295 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +84 -0
- package/dist/schema.js.map +1 -0
- package/dist/setup-wizard.d.ts +14 -0
- package/dist/setup-wizard.d.ts.map +1 -0
- package/dist/setup-wizard.js +85 -0
- package/dist/setup-wizard.js.map +1 -0
- package/dist/sidecar.d.ts +137 -0
- package/dist/sidecar.d.ts.map +1 -0
- package/dist/sidecar.js +421 -0
- package/dist/sidecar.js.map +1 -0
- package/package.json +89 -0
- package/src/channel/chunker.test.ts +135 -0
- package/src/channel/chunker.ts +147 -0
- package/src/channel/turn-runner.ts +97 -0
- package/src/channel/voice.test.ts +136 -0
- package/src/channel/voice.ts +118 -0
- package/src/channel.test.ts +364 -0
- package/src/channel.ts +607 -0
- package/src/index.ts +289 -0
- package/src/jsonrpc.test.ts +128 -0
- package/src/jsonrpc.ts +198 -0
- package/src/keys.test.ts +45 -0
- package/src/keys.ts +56 -0
- package/src/pair-flow.ts +161 -0
- package/src/permission.ts +36 -0
- package/src/schema.ts +98 -0
- package/src/setup-wizard.ts +98 -0
- package/src/sidecar.test.ts +276 -0
- package/src/sidecar.ts +511 -0
- package/src/subcommands.test.ts +206 -0
package/src/sidecar.ts
ADDED
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as net from 'node:net';
|
|
4
|
+
import * as os from 'node:os';
|
|
5
|
+
import * as path from 'node:path';
|
|
6
|
+
import { sleepWithAbort } from '@moxxy/sdk';
|
|
7
|
+
import { SignalRpcClient, type RpcStream } from './jsonrpc.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* signal-cli sidecar lifecycle — the plugin owns the external `signal-cli`
|
|
11
|
+
* process the way `@moxxy/plugin-browser` owns its Playwright sidecar:
|
|
12
|
+
* spawn on channel `start()`, health-check before use, SIGTERM→SIGKILL grace
|
|
13
|
+
* on stop (via the sdk's `sleepWithAbort`, the runner-supervisor pattern).
|
|
14
|
+
*
|
|
15
|
+
* Transport: `signal-cli -a <account> daemon --socket <path>` — JSON-RPC over
|
|
16
|
+
* a UNIX socket. Chosen over `--tcp`/`--http` because a filesystem socket is
|
|
17
|
+
* never remotely reachable and needs no port/auth story; the socket lives in a
|
|
18
|
+
* fresh per-process temp path so two channels can't collide.
|
|
19
|
+
*
|
|
20
|
+
* signal-cli keeps the actual Signal protocol store (identity keys, sessions,
|
|
21
|
+
* message queue) in ITS OWN data dir — `$XDG_DATA_HOME/signal-cli/`
|
|
22
|
+
* (`~/.local/share/signal-cli/` by default). moxxy never touches those files;
|
|
23
|
+
* attachments land in `<dataDir>/attachments/<id>`.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export interface SidecarLogger {
|
|
27
|
+
info?(msg: string, meta?: Record<string, unknown>): void;
|
|
28
|
+
warn?(msg: string, meta?: Record<string, unknown>): void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The slice of `child_process.ChildProcess` the sidecar manager needs. */
|
|
32
|
+
export interface SpawnedProcess {
|
|
33
|
+
readonly pid?: number | undefined;
|
|
34
|
+
kill(signal?: NodeJS.Signals): boolean;
|
|
35
|
+
once(event: 'exit', listener: (code: number | null) => void): unknown;
|
|
36
|
+
once(event: 'error', listener: (err: Error) => void): unknown;
|
|
37
|
+
readonly stderr?: { on(event: 'data', listener: (chunk: Buffer | string) => void): unknown } | null;
|
|
38
|
+
readonly stdout?: { on(event: 'data', listener: (chunk: Buffer | string) => void): unknown } | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type SpawnFn = (command: string, args: ReadonlyArray<string>) => SpawnedProcess;
|
|
42
|
+
|
|
43
|
+
export type ConnectFn = (socketPath: string) => Promise<RpcStream>;
|
|
44
|
+
|
|
45
|
+
const defaultSpawn: SpawnFn = (command, args) =>
|
|
46
|
+
spawn(command, [...args], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
47
|
+
|
|
48
|
+
const defaultConnect: ConnectFn = (socketPath) =>
|
|
49
|
+
new Promise<RpcStream>((resolve, reject) => {
|
|
50
|
+
const socket = net.createConnection(socketPath);
|
|
51
|
+
socket.once('connect', () => resolve(socket));
|
|
52
|
+
socket.once('error', (err) => reject(err));
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
/** `$XDG_DATA_HOME/signal-cli` (defaults to `~/.local/share/signal-cli`). */
|
|
56
|
+
export function signalCliDataDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
57
|
+
const xdg = env['XDG_DATA_HOME']?.trim();
|
|
58
|
+
const base = xdg && xdg.length > 0 ? xdg : path.join(os.homedir(), '.local', 'share');
|
|
59
|
+
return path.join(base, 'signal-cli');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Where the daemon writes received attachments (voice notes live here). */
|
|
63
|
+
export function signalCliAttachmentsDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
64
|
+
return path.join(signalCliDataDir(env), 'attachments');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Cheap PATH probe for the `signal-cli` binary — a directory scan with an
|
|
69
|
+
* exec-bit check, NO process spawn (signal-cli is a JVM app; spawning it takes
|
|
70
|
+
* seconds and `isAvailable` runs on every `moxxy channels list` / `doctor`).
|
|
71
|
+
* Returns the resolved path or null. Never throws: a missing/odd PATH must
|
|
72
|
+
* not crash discovery.
|
|
73
|
+
*/
|
|
74
|
+
export function findSignalCliOnPath(env: NodeJS.ProcessEnv = process.env): string | null {
|
|
75
|
+
try {
|
|
76
|
+
const raw = env['PATH'] ?? '';
|
|
77
|
+
const names = process.platform === 'win32' ? ['signal-cli.bat', 'signal-cli.cmd', 'signal-cli.exe', 'signal-cli'] : ['signal-cli'];
|
|
78
|
+
for (const dir of raw.split(path.delimiter)) {
|
|
79
|
+
if (!dir) continue;
|
|
80
|
+
for (const name of names) {
|
|
81
|
+
const candidate = path.join(dir, name);
|
|
82
|
+
try {
|
|
83
|
+
fs.accessSync(candidate, fs.constants.X_OK);
|
|
84
|
+
if (fs.statSync(candidate).isFile()) return candidate;
|
|
85
|
+
} catch {
|
|
86
|
+
/* not here — keep scanning */
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
} catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** One-line install guidance surfaced when the binary is missing. */
|
|
97
|
+
export const SIGNAL_CLI_INSTALL_HINT =
|
|
98
|
+
'signal-cli not found on PATH. Install it: `brew install signal-cli` (macOS), ' +
|
|
99
|
+
'or see https://github.com/AsamK/signal-cli/wiki/Quickstart for Linux/Windows packages.';
|
|
100
|
+
|
|
101
|
+
export interface SignalSidecarOptions {
|
|
102
|
+
/** E.164 account the daemon serves (`-a <account>`). */
|
|
103
|
+
readonly account: string;
|
|
104
|
+
/** Binary path/name. Default `signal-cli` (resolved by the OS from PATH). */
|
|
105
|
+
readonly binary?: string;
|
|
106
|
+
/** Socket path override (tests). Default: fresh path under the OS tmpdir. */
|
|
107
|
+
readonly socketPath?: string;
|
|
108
|
+
/** How long to wait for the daemon socket + health check. Default 45s —
|
|
109
|
+
* signal-cli is a JVM app and cold-starts slowly. */
|
|
110
|
+
readonly bootTimeoutMs?: number;
|
|
111
|
+
/** SIGTERM grace before SIGKILL. Default 5s. */
|
|
112
|
+
readonly killGraceMs?: number;
|
|
113
|
+
readonly logger?: SidecarLogger;
|
|
114
|
+
readonly spawnFn?: SpawnFn;
|
|
115
|
+
readonly connectFn?: ConnectFn;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Poll cadence while waiting for the daemon socket to accept connections. */
|
|
119
|
+
const CONNECT_POLL_MS = 250;
|
|
120
|
+
|
|
121
|
+
export class SignalSidecar {
|
|
122
|
+
private readonly opts: SignalSidecarOptions;
|
|
123
|
+
private child: SpawnedProcess | null = null;
|
|
124
|
+
private exited = false;
|
|
125
|
+
private exitCode: number | null = null;
|
|
126
|
+
private client: SignalRpcClient | null = null;
|
|
127
|
+
private readonly recentStderr: string[] = [];
|
|
128
|
+
private readonly exitListeners = new Set<(code: number | null) => void>();
|
|
129
|
+
readonly socketPath: string;
|
|
130
|
+
|
|
131
|
+
constructor(opts: SignalSidecarOptions) {
|
|
132
|
+
this.opts = opts;
|
|
133
|
+
this.socketPath =
|
|
134
|
+
opts.socketPath ?? path.join(os.tmpdir(), `moxxy-signal-${process.pid}-${Date.now().toString(36)}.sock`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Fires when the daemon exits (clean stop AND crash). */
|
|
138
|
+
onExit(listener: (code: number | null) => void): () => void {
|
|
139
|
+
this.exitListeners.add(listener);
|
|
140
|
+
return () => this.exitListeners.delete(listener);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
get rpc(): SignalRpcClient | null {
|
|
144
|
+
return this.client;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Spawn the daemon and wait until it is actually serving: the socket accepts
|
|
149
|
+
* a connection AND a `version` JSON-RPC round-trips. Rejects (and reaps the
|
|
150
|
+
* child) when the child dies early or the boot deadline passes, carrying the
|
|
151
|
+
* daemon's stderr tail so the CAUSE ("User +… is not registered") surfaces
|
|
152
|
+
* instead of a bare timeout.
|
|
153
|
+
*/
|
|
154
|
+
async start(): Promise<SignalRpcClient> {
|
|
155
|
+
if (this.client) return this.client;
|
|
156
|
+
const binary = this.opts.binary ?? 'signal-cli';
|
|
157
|
+
const spawnFn = this.opts.spawnFn ?? defaultSpawn;
|
|
158
|
+
const connectFn = this.opts.connectFn ?? defaultConnect;
|
|
159
|
+
const bootTimeoutMs = this.opts.bootTimeoutMs ?? 45_000;
|
|
160
|
+
|
|
161
|
+
// A stale socket file from a crashed previous run would make the daemon
|
|
162
|
+
// fail to bind; the path is per-process+time so this is belt-and-braces.
|
|
163
|
+
try {
|
|
164
|
+
fs.rmSync(this.socketPath, { force: true });
|
|
165
|
+
} catch {
|
|
166
|
+
/* best-effort */
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const args = ['-a', this.opts.account, 'daemon', '--socket', this.socketPath, '--receive-mode', 'on-start'];
|
|
170
|
+
this.opts.logger?.info?.('signal: spawning signal-cli daemon', {
|
|
171
|
+
binary,
|
|
172
|
+
socket: this.socketPath,
|
|
173
|
+
});
|
|
174
|
+
let child: SpawnedProcess;
|
|
175
|
+
try {
|
|
176
|
+
child = spawnFn(binary, args);
|
|
177
|
+
} catch (err) {
|
|
178
|
+
throw new Error(`failed to spawn ${binary}: ${err instanceof Error ? err.message : String(err)}`);
|
|
179
|
+
}
|
|
180
|
+
this.child = child;
|
|
181
|
+
this.exited = false;
|
|
182
|
+
this.watchStderr(child);
|
|
183
|
+
child.once('error', (err) => {
|
|
184
|
+
// spawn-level failure (ENOENT etc.) — recorded like an exit so the boot
|
|
185
|
+
// loop below stops waiting on a process that never started.
|
|
186
|
+
this.recentStderr.push(`spawn error: ${err.message}`);
|
|
187
|
+
this.markExited(null);
|
|
188
|
+
});
|
|
189
|
+
child.once('exit', (code) => this.markExited(code));
|
|
190
|
+
|
|
191
|
+
const deadline = Date.now() + bootTimeoutMs;
|
|
192
|
+
let stream: RpcStream | null = null;
|
|
193
|
+
while (Date.now() < deadline) {
|
|
194
|
+
if (this.exited) {
|
|
195
|
+
throw new Error(this.describeEarlyExit());
|
|
196
|
+
}
|
|
197
|
+
try {
|
|
198
|
+
stream = await connectFn(this.socketPath);
|
|
199
|
+
break;
|
|
200
|
+
} catch {
|
|
201
|
+
await sleepWithAbort(CONNECT_POLL_MS);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (!stream) {
|
|
205
|
+
await this.stop();
|
|
206
|
+
throw new Error(
|
|
207
|
+
`signal-cli daemon did not open its socket within ${bootTimeoutMs}ms` + this.stderrTailSuffix(),
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const client = new SignalRpcClient({
|
|
212
|
+
stream,
|
|
213
|
+
...(this.opts.logger ? { logger: this.opts.logger } : {}),
|
|
214
|
+
});
|
|
215
|
+
// Health check: a cheap RPC proves the JSON-RPC dispatcher is live (the
|
|
216
|
+
// socket can accept before the daemon is ready to serve).
|
|
217
|
+
try {
|
|
218
|
+
await client.request('version', {});
|
|
219
|
+
} catch (err) {
|
|
220
|
+
client.close();
|
|
221
|
+
await this.stop();
|
|
222
|
+
throw new Error(
|
|
223
|
+
`signal-cli daemon failed its health check: ${err instanceof Error ? err.message : String(err)}` +
|
|
224
|
+
this.stderrTailSuffix(),
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
this.client = client;
|
|
228
|
+
this.opts.logger?.info?.('signal: daemon healthy', { pid: child.pid ?? null });
|
|
229
|
+
return client;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Graceful shutdown: close the RPC stream, SIGTERM, wait `killGraceMs` for a
|
|
234
|
+
* clean exit (aborting the wait the moment `exit` fires — the sdk's
|
|
235
|
+
* `sleepWithAbort` pattern from the runner supervisor), then SIGKILL a
|
|
236
|
+
* holdout so a wedged JVM can never outlive the channel as an orphan.
|
|
237
|
+
*/
|
|
238
|
+
async stop(): Promise<void> {
|
|
239
|
+
const child = this.child;
|
|
240
|
+
this.client?.close();
|
|
241
|
+
this.client = null;
|
|
242
|
+
if (!child || this.exited) {
|
|
243
|
+
this.child = null;
|
|
244
|
+
this.removeSocketFile();
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
try {
|
|
248
|
+
child.kill('SIGTERM');
|
|
249
|
+
} catch {
|
|
250
|
+
/* already gone */
|
|
251
|
+
}
|
|
252
|
+
const graceMs = this.opts.killGraceMs ?? 5_000;
|
|
253
|
+
if (!(await this.waitForExit(child, graceMs))) {
|
|
254
|
+
this.opts.logger?.warn?.('signal: daemon ignored SIGTERM; escalating to SIGKILL');
|
|
255
|
+
try {
|
|
256
|
+
child.kill('SIGKILL');
|
|
257
|
+
} catch {
|
|
258
|
+
/* already gone */
|
|
259
|
+
}
|
|
260
|
+
await this.waitForExit(child, 2_000);
|
|
261
|
+
}
|
|
262
|
+
this.child = null;
|
|
263
|
+
this.removeSocketFile();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Resolves true when the child exits within `ms`, false on timeout. */
|
|
267
|
+
private waitForExit(child: SpawnedProcess, ms: number): Promise<boolean> {
|
|
268
|
+
if (this.exited) return Promise.resolve(true);
|
|
269
|
+
return new Promise<boolean>((resolve) => {
|
|
270
|
+
const ac = new AbortController();
|
|
271
|
+
child.once('exit', () => ac.abort());
|
|
272
|
+
if (this.exited) {
|
|
273
|
+
resolve(true);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
sleepWithAbort(ms, ac.signal).then(
|
|
277
|
+
() => resolve(false), // timer ran out — still alive
|
|
278
|
+
() => resolve(true), // aborted — the child exited
|
|
279
|
+
);
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
private markExited(code: number | null): void {
|
|
284
|
+
if (this.exited) return;
|
|
285
|
+
this.exited = true;
|
|
286
|
+
this.exitCode = code;
|
|
287
|
+
this.client?.close();
|
|
288
|
+
this.client = null;
|
|
289
|
+
for (const listener of this.exitListeners) {
|
|
290
|
+
try {
|
|
291
|
+
listener(code);
|
|
292
|
+
} catch {
|
|
293
|
+
/* listener errors must not break teardown */
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
private watchStderr(child: SpawnedProcess): void {
|
|
299
|
+
let buf = '';
|
|
300
|
+
child.stderr?.on('data', (chunk: Buffer | string) => {
|
|
301
|
+
buf += typeof chunk === 'string' ? chunk : chunk.toString('utf8');
|
|
302
|
+
let nl: number;
|
|
303
|
+
while ((nl = buf.indexOf('\n')) !== -1) {
|
|
304
|
+
const line = buf.slice(0, nl).trim();
|
|
305
|
+
buf = buf.slice(nl + 1);
|
|
306
|
+
if (line) {
|
|
307
|
+
this.recentStderr.push(line);
|
|
308
|
+
if (this.recentStderr.length > 24) this.recentStderr.shift();
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
if (buf.length > 64 * 1024) buf = buf.slice(-64 * 1024);
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
private describeEarlyExit(): string {
|
|
316
|
+
return (
|
|
317
|
+
`signal-cli daemon exited during startup (code=${this.exitCode ?? 'null'})` + this.stderrTailSuffix()
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
private stderrTailSuffix(): string {
|
|
322
|
+
const tail = this.recentStderr.slice(-6).join('\n').trim();
|
|
323
|
+
return tail ? `:\n${tail}` : '';
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private removeSocketFile(): void {
|
|
327
|
+
try {
|
|
328
|
+
fs.rmSync(this.socketPath, { force: true });
|
|
329
|
+
} catch {
|
|
330
|
+
/* best-effort */
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ---------------------------------------------------------------------------
|
|
336
|
+
// One-shot signal-cli invocations (link flow, account listing)
|
|
337
|
+
// ---------------------------------------------------------------------------
|
|
338
|
+
|
|
339
|
+
export interface LinkProcessHandle {
|
|
340
|
+
/** Resolves with the `sgnl://linkdevice…` URI to render as a QR. */
|
|
341
|
+
readonly uri: Promise<string>;
|
|
342
|
+
/** Resolves when the phone completed the link (process exited 0). Carries the
|
|
343
|
+
* linked account number when signal-cli printed it. Rejects on failure. */
|
|
344
|
+
readonly completed: Promise<{ account: string | null }>;
|
|
345
|
+
cancel(): void;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Matches both the current `sgnl://linkdevice?...` URI and the legacy `tsdevice:/?...` form. */
|
|
349
|
+
const LINK_URI_RE = /(sgnl:\/\/linkdevice\S+|tsdevice:\/?\?\S+)/;
|
|
350
|
+
/** signal-cli prints `Associated with: +49… (device id: N)` on success. */
|
|
351
|
+
const ASSOCIATED_RE = /Associated with:\s*(\+\d{7,15})/;
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Run `signal-cli link -n <deviceName>`: it prints the linking URI, then blocks
|
|
355
|
+
* until the phone scans it (or the QR expires, at which point it exits
|
|
356
|
+
* non-zero). Separate from the daemon — linking happens BEFORE an account
|
|
357
|
+
* exists locally, so the daemon (which requires one) can't do it.
|
|
358
|
+
*/
|
|
359
|
+
export function startLinkProcess(opts: {
|
|
360
|
+
readonly deviceName: string;
|
|
361
|
+
readonly binary?: string;
|
|
362
|
+
readonly spawnFn?: SpawnFn;
|
|
363
|
+
readonly logger?: SidecarLogger;
|
|
364
|
+
}): LinkProcessHandle {
|
|
365
|
+
const spawnFn = opts.spawnFn ?? defaultSpawn;
|
|
366
|
+
const binary = opts.binary ?? 'signal-cli';
|
|
367
|
+
let child: SpawnedProcess;
|
|
368
|
+
let output = '';
|
|
369
|
+
|
|
370
|
+
let resolveUri: (uri: string) => void;
|
|
371
|
+
let rejectUri: (err: Error) => void;
|
|
372
|
+
const uri = new Promise<string>((resolve, reject) => {
|
|
373
|
+
resolveUri = resolve;
|
|
374
|
+
rejectUri = reject;
|
|
375
|
+
});
|
|
376
|
+
let resolveCompleted: (r: { account: string | null }) => void;
|
|
377
|
+
let rejectCompleted: (err: Error) => void;
|
|
378
|
+
const completed = new Promise<{ account: string | null }>((resolve, reject) => {
|
|
379
|
+
resolveCompleted = resolve;
|
|
380
|
+
rejectCompleted = reject;
|
|
381
|
+
});
|
|
382
|
+
// The two promises settle independently of consumption order (a caller may
|
|
383
|
+
// only await `uri` and cancel later) — pre-attach no-op catches so an
|
|
384
|
+
// unobserved rejection is never an unhandledRejection.
|
|
385
|
+
uri.catch(() => undefined);
|
|
386
|
+
completed.catch(() => undefined);
|
|
387
|
+
|
|
388
|
+
try {
|
|
389
|
+
child = spawnFn(binary, ['link', '-n', opts.deviceName]);
|
|
390
|
+
} catch (err) {
|
|
391
|
+
const e = new Error(`failed to spawn ${binary} link: ${err instanceof Error ? err.message : String(err)}`);
|
|
392
|
+
rejectUri!(e);
|
|
393
|
+
rejectCompleted!(e);
|
|
394
|
+
return { uri, completed, cancel: () => undefined };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
let uriSettled = false;
|
|
398
|
+
const scan = (chunk: Buffer | string): void => {
|
|
399
|
+
output += typeof chunk === 'string' ? chunk : chunk.toString('utf8');
|
|
400
|
+
if (output.length > 256 * 1024) output = output.slice(-256 * 1024);
|
|
401
|
+
if (!uriSettled) {
|
|
402
|
+
const m = LINK_URI_RE.exec(output);
|
|
403
|
+
if (m?.[1]) {
|
|
404
|
+
uriSettled = true;
|
|
405
|
+
resolveUri!(m[1]);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
};
|
|
409
|
+
child.stdout?.on('data', scan);
|
|
410
|
+
child.stderr?.on('data', scan);
|
|
411
|
+
child.once('error', (err) => {
|
|
412
|
+
const e = new Error(`signal-cli link failed to start: ${err.message}`);
|
|
413
|
+
if (!uriSettled) {
|
|
414
|
+
uriSettled = true;
|
|
415
|
+
rejectUri!(e);
|
|
416
|
+
}
|
|
417
|
+
rejectCompleted!(e);
|
|
418
|
+
});
|
|
419
|
+
child.once('exit', (code) => {
|
|
420
|
+
if (code === 0) {
|
|
421
|
+
const account = ASSOCIATED_RE.exec(output)?.[1] ?? null;
|
|
422
|
+
resolveCompleted!({ account });
|
|
423
|
+
} else {
|
|
424
|
+
const tail = output.split('\n').slice(-4).join('\n').trim();
|
|
425
|
+
const e = new Error(
|
|
426
|
+
`signal-cli link exited with code ${code ?? 'null'} (QR expired or linking rejected)` +
|
|
427
|
+
(tail ? `:\n${tail}` : ''),
|
|
428
|
+
);
|
|
429
|
+
if (!uriSettled) {
|
|
430
|
+
uriSettled = true;
|
|
431
|
+
rejectUri!(e);
|
|
432
|
+
}
|
|
433
|
+
rejectCompleted!(e);
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
return {
|
|
438
|
+
uri,
|
|
439
|
+
completed,
|
|
440
|
+
cancel: () => {
|
|
441
|
+
try {
|
|
442
|
+
child.kill('SIGTERM');
|
|
443
|
+
} catch {
|
|
444
|
+
/* already gone */
|
|
445
|
+
}
|
|
446
|
+
},
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* List the accounts registered in the local signal-cli store (a one-shot
|
|
452
|
+
* `signal-cli --output=json listAccounts` spawn). Used at channel start to
|
|
453
|
+
* decide "linked already?" — NOT in `isAvailable` (JVM spawns are too slow for
|
|
454
|
+
* a discovery probe). Parses JSON output and falls back to scraping `+<digits>`
|
|
455
|
+
* so an older plain-text signal-cli still works.
|
|
456
|
+
*/
|
|
457
|
+
export async function listSignalAccounts(opts: {
|
|
458
|
+
readonly binary?: string;
|
|
459
|
+
readonly spawnFn?: SpawnFn;
|
|
460
|
+
readonly timeoutMs?: number;
|
|
461
|
+
} = {}): Promise<string[]> {
|
|
462
|
+
const spawnFn = opts.spawnFn ?? defaultSpawn;
|
|
463
|
+
const binary = opts.binary ?? 'signal-cli';
|
|
464
|
+
const timeoutMs = opts.timeoutMs ?? 30_000;
|
|
465
|
+
let child: SpawnedProcess;
|
|
466
|
+
try {
|
|
467
|
+
child = spawnFn(binary, ['--output=json', 'listAccounts']);
|
|
468
|
+
} catch (err) {
|
|
469
|
+
throw new Error(`failed to spawn ${binary}: ${err instanceof Error ? err.message : String(err)}`);
|
|
470
|
+
}
|
|
471
|
+
let out = '';
|
|
472
|
+
child.stdout?.on('data', (chunk: Buffer | string) => {
|
|
473
|
+
out += typeof chunk === 'string' ? chunk : chunk.toString('utf8');
|
|
474
|
+
if (out.length > 1024 * 1024) out = out.slice(-1024 * 1024);
|
|
475
|
+
});
|
|
476
|
+
const exited = await new Promise<boolean>((resolve) => {
|
|
477
|
+
const ac = new AbortController();
|
|
478
|
+
child.once('exit', () => ac.abort());
|
|
479
|
+
child.once('error', () => ac.abort());
|
|
480
|
+
sleepWithAbort(timeoutMs, ac.signal).then(
|
|
481
|
+
() => resolve(false),
|
|
482
|
+
() => resolve(true),
|
|
483
|
+
);
|
|
484
|
+
});
|
|
485
|
+
if (!exited) {
|
|
486
|
+
try {
|
|
487
|
+
child.kill('SIGKILL');
|
|
488
|
+
} catch {
|
|
489
|
+
/* ignore */
|
|
490
|
+
}
|
|
491
|
+
throw new Error(`signal-cli listAccounts timed out after ${timeoutMs}ms`);
|
|
492
|
+
}
|
|
493
|
+
const accounts = new Set<string>();
|
|
494
|
+
for (const line of out.split('\n')) {
|
|
495
|
+
const trimmed = line.trim();
|
|
496
|
+
if (!trimmed) continue;
|
|
497
|
+
try {
|
|
498
|
+
const parsed: unknown = JSON.parse(trimmed);
|
|
499
|
+
const collect = (entry: unknown): void => {
|
|
500
|
+
const number = (entry as { number?: unknown })?.number;
|
|
501
|
+
if (typeof number === 'string' && number.startsWith('+')) accounts.add(number);
|
|
502
|
+
};
|
|
503
|
+
if (Array.isArray(parsed)) parsed.forEach(collect);
|
|
504
|
+
else collect(parsed);
|
|
505
|
+
} catch {
|
|
506
|
+
const m = /(\+\d{7,15})/.exec(trimmed);
|
|
507
|
+
if (m?.[1]) accounts.add(m[1]);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
return [...accounts];
|
|
511
|
+
}
|