deckide 3.5.32 → 3.5.34

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.
@@ -0,0 +1,381 @@
1
+ import { spawn, execFile } from 'node:child_process';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { promisify } from 'node:util';
5
+ import { WebSocket } from 'ws';
6
+ const execFileAsync = promisify(execFile);
7
+ const AUDIO_CLIENT_BUFFER_LIMIT = 2 * 1024 * 1024;
8
+ async function pathExists(filePath) {
9
+ try {
10
+ await fs.access(filePath);
11
+ return true;
12
+ }
13
+ catch {
14
+ return false;
15
+ }
16
+ }
17
+ async function findInPath(names) {
18
+ const pathValue = process.env.PATH || '';
19
+ for (const dir of pathValue.split(path.delimiter)) {
20
+ if (!dir)
21
+ continue;
22
+ for (const name of names) {
23
+ const candidate = path.join(dir, name);
24
+ if (await pathExists(candidate)) {
25
+ return candidate;
26
+ }
27
+ }
28
+ }
29
+ return null;
30
+ }
31
+ async function resolveExecutable(envName, names) {
32
+ const configured = process.env[envName];
33
+ if (configured) {
34
+ if (await pathExists(configured)) {
35
+ return configured;
36
+ }
37
+ throw new Error(`${envName} does not exist: ${configured}`);
38
+ }
39
+ return findInPath(names);
40
+ }
41
+ async function runText(command, args, timeoutMs = 2000) {
42
+ try {
43
+ const { stdout } = await execFileAsync(command, args, {
44
+ timeout: timeoutMs,
45
+ maxBuffer: 128 * 1024,
46
+ });
47
+ return stdout.trim();
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ }
53
+ async function resolvePulseSource(pactlPath) {
54
+ if (process.env.AGENT_BROWSER_AUDIO_SOURCE) {
55
+ return process.env.AGENT_BROWSER_AUDIO_SOURCE;
56
+ }
57
+ if (!pactlPath) {
58
+ return null;
59
+ }
60
+ const defaultSink = await runText(pactlPath, ['get-default-sink']);
61
+ const sourceList = await runText(pactlPath, ['list', 'short', 'sources']);
62
+ if (!sourceList) {
63
+ return defaultSink ? `${defaultSink}.monitor` : null;
64
+ }
65
+ const sources = sourceList
66
+ .split('\n')
67
+ .map((line) => line.split(/\s+/)[1])
68
+ .filter(Boolean);
69
+ if (defaultSink) {
70
+ const defaultMonitor = `${defaultSink}.monitor`;
71
+ if (sources.includes(defaultMonitor)) {
72
+ return defaultMonitor;
73
+ }
74
+ }
75
+ return sources.find((source) => source.endsWith('.monitor')) ?? null;
76
+ }
77
+ function isOpen(socket) {
78
+ return socket.readyState === WebSocket.OPEN;
79
+ }
80
+ export class BrowserAudioRelay {
81
+ clients = new Set();
82
+ captureProcess = null;
83
+ starting = null;
84
+ lastError;
85
+ ffmpegPath = null;
86
+ audioSource = null;
87
+ async getStatus() {
88
+ const ffmpegPath = await resolveExecutable('AGENT_BROWSER_FFMPEG', ['ffmpeg']).catch((error) => {
89
+ this.lastError = error instanceof Error ? error.message : String(error);
90
+ return null;
91
+ });
92
+ const pactlPath = await resolveExecutable('AGENT_BROWSER_PACTL', ['pactl']).catch(() => null);
93
+ const source = await resolvePulseSource(pactlPath);
94
+ let available = Boolean(ffmpegPath && source);
95
+ let reason;
96
+ if (!ffmpegPath) {
97
+ available = false;
98
+ reason = 'ffmpeg is not installed or AGENT_BROWSER_FFMPEG is not set';
99
+ }
100
+ else if (!source) {
101
+ available = false;
102
+ reason = 'PulseAudio monitor source was not found. Set AGENT_BROWSER_AUDIO_SOURCE if needed';
103
+ }
104
+ return {
105
+ available,
106
+ running: this.isRunning(),
107
+ clients: this.clients.size,
108
+ source,
109
+ ffmpegPath,
110
+ mimeType: 'audio/webm; codecs="opus"',
111
+ reason,
112
+ error: this.lastError,
113
+ };
114
+ }
115
+ attachWebSocket(socket) {
116
+ this.clients.add(socket);
117
+ socket.on('close', () => {
118
+ this.clients.delete(socket);
119
+ if (this.clients.size === 0) {
120
+ void this.stop();
121
+ }
122
+ });
123
+ socket.on('error', () => {
124
+ this.clients.delete(socket);
125
+ if (this.clients.size === 0) {
126
+ void this.stop();
127
+ }
128
+ });
129
+ void this.openForClient(socket);
130
+ }
131
+ async stop() {
132
+ const proc = this.captureProcess;
133
+ this.captureProcess = null;
134
+ if (proc && proc.exitCode == null && proc.signalCode == null) {
135
+ await new Promise((resolve) => {
136
+ const killTimer = setTimeout(() => {
137
+ try {
138
+ proc.kill('SIGKILL');
139
+ }
140
+ catch {
141
+ // ignore
142
+ }
143
+ }, 2000);
144
+ killTimer.unref?.();
145
+ proc.once('exit', () => {
146
+ clearTimeout(killTimer);
147
+ resolve();
148
+ });
149
+ try {
150
+ proc.kill('SIGTERM');
151
+ }
152
+ catch {
153
+ clearTimeout(killTimer);
154
+ resolve();
155
+ }
156
+ });
157
+ }
158
+ await this.broadcastStatus();
159
+ }
160
+ async openForClient(socket) {
161
+ await this.sendStatus(socket);
162
+ try {
163
+ await this.start();
164
+ await this.sendStatus(socket);
165
+ }
166
+ catch (error) {
167
+ const message = error instanceof Error ? error.message : String(error);
168
+ this.lastError = message;
169
+ this.send(socket, { type: 'audio-error', error: message });
170
+ await this.broadcastStatus();
171
+ }
172
+ }
173
+ async start() {
174
+ if (this.isRunning()) {
175
+ return;
176
+ }
177
+ if (this.starting) {
178
+ return this.starting;
179
+ }
180
+ this.starting = this.startCapture();
181
+ try {
182
+ await this.starting;
183
+ }
184
+ finally {
185
+ this.starting = null;
186
+ }
187
+ }
188
+ async startCapture() {
189
+ this.lastError = undefined;
190
+ this.ffmpegPath = await resolveExecutable('AGENT_BROWSER_FFMPEG', ['ffmpeg']);
191
+ if (!this.ffmpegPath) {
192
+ throw new Error('ffmpeg is required for browser audio streaming');
193
+ }
194
+ const pactlPath = await resolveExecutable('AGENT_BROWSER_PACTL', ['pactl']).catch(() => null);
195
+ this.audioSource = await resolvePulseSource(pactlPath);
196
+ if (!this.audioSource) {
197
+ throw new Error('PulseAudio monitor source was not found. Set AGENT_BROWSER_AUDIO_SOURCE to a monitor source');
198
+ }
199
+ const proc = spawn(this.ffmpegPath, [
200
+ '-hide_banner',
201
+ '-loglevel',
202
+ 'warning',
203
+ '-nostdin',
204
+ '-f',
205
+ 'pulse',
206
+ '-i',
207
+ this.audioSource,
208
+ '-vn',
209
+ '-ac',
210
+ '2',
211
+ '-ar',
212
+ '48000',
213
+ '-c:a',
214
+ 'libopus',
215
+ '-b:a',
216
+ '96k',
217
+ '-application',
218
+ 'audio',
219
+ '-f',
220
+ 'webm',
221
+ 'pipe:1',
222
+ ], {
223
+ stdio: ['ignore', 'pipe', 'pipe'],
224
+ env: process.env,
225
+ });
226
+ this.captureProcess = proc;
227
+ let stderrTail = '';
228
+ const stderrChunks = [];
229
+ proc.stderr.setEncoding('utf8');
230
+ proc.stderr.on('data', (chunk) => {
231
+ stderrChunks.push(chunk);
232
+ stderrTail = stderrChunks.join('').slice(-2000);
233
+ });
234
+ proc.stdout.on('data', (chunk) => {
235
+ this.broadcastAudio(chunk);
236
+ });
237
+ proc.once('exit', (code, signal) => {
238
+ if (this.captureProcess === proc) {
239
+ this.captureProcess = null;
240
+ }
241
+ if (this.clients.size > 0) {
242
+ this.lastError = `Audio capture exited (${signal ?? code ?? 'unknown'}): ${stderrTail.trim()}`;
243
+ this.broadcast({
244
+ type: 'audio-error',
245
+ error: this.lastError,
246
+ });
247
+ void this.broadcastStatus();
248
+ }
249
+ });
250
+ proc.once('error', (error) => {
251
+ this.lastError = error.message;
252
+ if (this.captureProcess === proc) {
253
+ this.captureProcess = null;
254
+ }
255
+ this.broadcast({ type: 'audio-error', error: error.message });
256
+ void this.broadcastStatus();
257
+ });
258
+ try {
259
+ await this.waitForFirstChunk(proc);
260
+ }
261
+ catch (error) {
262
+ if (this.captureProcess === proc) {
263
+ this.captureProcess = null;
264
+ }
265
+ try {
266
+ proc.kill('SIGTERM');
267
+ }
268
+ catch {
269
+ // ignore
270
+ }
271
+ throw error;
272
+ }
273
+ await this.broadcastStatus();
274
+ }
275
+ waitForFirstChunk(proc) {
276
+ return new Promise((resolve, reject) => {
277
+ let done = false;
278
+ const timer = setTimeout(() => {
279
+ if (done)
280
+ return;
281
+ done = true;
282
+ cleanup();
283
+ reject(new Error('Timed out waiting for browser audio stream'));
284
+ }, 5000);
285
+ timer.unref?.();
286
+ const cleanup = () => {
287
+ clearTimeout(timer);
288
+ proc.stdout.off('data', onData);
289
+ proc.off('exit', onExit);
290
+ proc.off('error', onError);
291
+ };
292
+ const onData = () => {
293
+ if (done)
294
+ return;
295
+ done = true;
296
+ cleanup();
297
+ resolve();
298
+ };
299
+ const onExit = (code, signal) => {
300
+ if (done)
301
+ return;
302
+ done = true;
303
+ cleanup();
304
+ reject(new Error(`Audio capture exited before streaming (${signal ?? code ?? 'unknown'})`));
305
+ };
306
+ const onError = (error) => {
307
+ if (done)
308
+ return;
309
+ done = true;
310
+ cleanup();
311
+ reject(error);
312
+ };
313
+ proc.stdout.once('data', onData);
314
+ proc.once('exit', onExit);
315
+ proc.once('error', onError);
316
+ });
317
+ }
318
+ isRunning() {
319
+ const proc = this.captureProcess;
320
+ return Boolean(proc && proc.exitCode == null && proc.signalCode == null);
321
+ }
322
+ broadcastAudio(chunk) {
323
+ for (const socket of this.clients) {
324
+ if (!isOpen(socket)) {
325
+ continue;
326
+ }
327
+ if (socket.bufferedAmount > AUDIO_CLIENT_BUFFER_LIMIT) {
328
+ try {
329
+ socket.close(1009, 'Browser audio overflow');
330
+ }
331
+ catch {
332
+ // ignore
333
+ }
334
+ continue;
335
+ }
336
+ try {
337
+ socket.send(chunk, { binary: true });
338
+ }
339
+ catch {
340
+ try {
341
+ socket.close(1011, 'Browser audio send failed');
342
+ }
343
+ catch {
344
+ // ignore
345
+ }
346
+ }
347
+ }
348
+ }
349
+ async sendStatus(socket) {
350
+ this.send(socket, { type: 'audio-status', status: await this.getStatus() });
351
+ }
352
+ async broadcastStatus() {
353
+ const status = await this.getStatus();
354
+ this.broadcast({ type: 'audio-status', status });
355
+ }
356
+ broadcast(payload) {
357
+ const text = JSON.stringify(payload);
358
+ for (const socket of this.clients) {
359
+ this.sendRaw(socket, text);
360
+ }
361
+ }
362
+ send(socket, payload) {
363
+ this.sendRaw(socket, JSON.stringify(payload));
364
+ }
365
+ sendRaw(socket, payload) {
366
+ if (!isOpen(socket)) {
367
+ return;
368
+ }
369
+ try {
370
+ socket.send(payload);
371
+ }
372
+ catch {
373
+ try {
374
+ socket.close(1011, 'Browser audio control send failed');
375
+ }
376
+ catch {
377
+ // ignore
378
+ }
379
+ }
380
+ }
381
+ }
@@ -45,16 +45,6 @@ export function initializeDatabase(db) {
45
45
  workspace_id TEXT NOT NULL,
46
46
  created_at TEXT NOT NULL
47
47
  );
48
- `);
49
- db.exec(`
50
- CREATE TABLE IF NOT EXISTS terminals (
51
- id TEXT PRIMARY KEY,
52
- deck_id TEXT NOT NULL,
53
- title TEXT NOT NULL,
54
- command TEXT,
55
- buffer TEXT NOT NULL DEFAULT '',
56
- created_at TEXT NOT NULL
57
- );
58
48
  `);
59
49
  // Migration: add sort_order column to decks
60
50
  try {
@@ -65,13 +55,6 @@ export function initializeDatabase(db) {
65
55
  }
66
56
  // Create indexes for better query performance
67
57
  db.exec(`CREATE INDEX IF NOT EXISTS idx_decks_workspace_id ON decks(workspace_id);`);
68
- db.exec(`CREATE INDEX IF NOT EXISTS idx_terminals_deck_id ON terminals(deck_id);`);
69
- }
70
- export function clearOrphanedTerminals(db) {
71
- try {
72
- db.exec('DELETE FROM terminals');
73
- }
74
- catch { /* table may not exist yet */ }
75
58
  }
76
59
  export function loadPersistedState(db, workspaces, workspacePathIndex, decks) {
77
60
  const workspaceRows = db
@@ -108,26 +91,3 @@ export function loadPersistedState(db, workspaces, workspacePathIndex, decks) {
108
91
  decks.set(deck.id, deck);
109
92
  });
110
93
  }
111
- export function saveTerminal(db, id, deckId, title, command, createdAt) {
112
- const stmt = db.prepare('INSERT OR REPLACE INTO terminals (id, deck_id, title, command, buffer, created_at) VALUES (?, ?, ?, ?, ?, ?)');
113
- stmt.run(id, deckId, title, command, '', createdAt);
114
- }
115
- export function updateTerminalBuffer(db, id, buffer) {
116
- const stmt = db.prepare('UPDATE terminals SET buffer = ? WHERE id = ?');
117
- stmt.run(buffer, id);
118
- }
119
- export function loadTerminals(db) {
120
- const rows = db.prepare('SELECT id, deck_id, title, command, buffer, created_at FROM terminals ORDER BY created_at ASC').all();
121
- return rows.map((row) => ({
122
- id: String(row.id),
123
- deckId: String(row.deck_id),
124
- title: String(row.title),
125
- command: row.command ? String(row.command) : null,
126
- buffer: String(row.buffer ?? ''),
127
- createdAt: String(row.created_at),
128
- }));
129
- }
130
- export function deleteTerminal(db, id) {
131
- const stmt = db.prepare('DELETE FROM terminals WHERE id = ?');
132
- stmt.run(id);
133
- }
@@ -1,4 +1,38 @@
1
+ import { execSync } from 'node:child_process';
2
+ import path from 'node:path';
1
3
  export function getDefaultShell() {
2
4
  return (process.env.SHELL ||
3
5
  (process.platform === 'win32' ? 'powershell.exe' : 'bash'));
4
6
  }
7
+ // `locale -a` の結果はプロセス内で変わらないのでモジュールスコープでキャッシュする。
8
+ let cachedAvailableLocales = null;
9
+ /**
10
+ * システムで利用可能なロケール一覧を返す(非Windowsのみ)。
11
+ * `locale -a` を実行できない環境では空配列を返す。
12
+ */
13
+ export function getAvailableLocales() {
14
+ if (process.platform === 'win32') {
15
+ return [];
16
+ }
17
+ if (cachedAvailableLocales !== null) {
18
+ return cachedAvailableLocales;
19
+ }
20
+ try {
21
+ const output = execSync('locale -a', { encoding: 'utf8' });
22
+ cachedAvailableLocales = output
23
+ .split('\n')
24
+ .map((line) => line.trim())
25
+ .filter((line) => line.length > 0);
26
+ }
27
+ catch {
28
+ cachedAvailableLocales = [];
29
+ }
30
+ return cachedAvailableLocales;
31
+ }
32
+ /**
33
+ * シェルのベース名を小文字で返す(例: "/bin/zsh" -> "zsh", "powershell.exe" -> "powershell")。
34
+ */
35
+ export function getShellBasename(shell) {
36
+ const base = path.basename(shell).toLowerCase();
37
+ return base.replace(/\.exe$/, '');
38
+ }
package/dist/websocket.js CHANGED
@@ -143,6 +143,19 @@ function sendControl(socket, message) {
143
143
  return false;
144
144
  }
145
145
  }
146
+ // 制御権(resizeOwner = 主クライアント)を付け替え、新旧クライアントへ
147
+ // primary 状態を通知する。主クライアントだけが端末クエリへ応答する。
148
+ function assignResizeOwner(session, socket) {
149
+ const previous = session.resizeOwner;
150
+ if (previous === socket) {
151
+ return;
152
+ }
153
+ session.resizeOwner = socket;
154
+ if (previous) {
155
+ sendControl(previous, { type: 'primary', value: false });
156
+ }
157
+ sendControl(socket, { type: 'primary', value: true });
158
+ }
146
159
  function readBufferedRange(session, startOffset, endOffset) {
147
160
  const relativeStart = Math.max(0, startOffset - session.bufferBase);
148
161
  const relativeEnd = Math.max(relativeStart, Math.min(session.bufferLength, endOffset - session.bufferBase));
@@ -183,7 +196,7 @@ function readBufferedRange(session, startOffset, endOffset) {
183
196
  }
184
197
  return Buffer.from(raw.subarray(alignedStart, alignedEnd));
185
198
  }
186
- export function setupWebSocketServer(server, terminals) {
199
+ export function setupWebSocketServer(server, terminals, browserService) {
187
200
  const wss = new WebSocketServer({ server, maxPayload: MAX_MESSAGE_SIZE });
188
201
  const heartbeatState = new WeakMap();
189
202
  const heartbeatInterval = setInterval(() => {
@@ -259,6 +272,32 @@ export function setupWebSocketServer(server, terminals) {
259
272
  return;
260
273
  }
261
274
  const url = new URL(req.url || '', `http://localhost:${PORT}`);
275
+ if (url.pathname === '/api/browser/ws') {
276
+ if (!browserService) {
277
+ untrackConnection(clientIP, socket);
278
+ socket.close(1011, 'Browser service unavailable');
279
+ return;
280
+ }
281
+ socket.on('close', () => {
282
+ heartbeatState.delete(socket);
283
+ untrackConnection(clientIP, socket);
284
+ });
285
+ browserService.attachWebSocket(socket);
286
+ return;
287
+ }
288
+ if (url.pathname === '/api/browser/audio/ws') {
289
+ if (!browserService) {
290
+ untrackConnection(clientIP, socket);
291
+ socket.close(1011, 'Browser service unavailable');
292
+ return;
293
+ }
294
+ socket.on('close', () => {
295
+ heartbeatState.delete(socket);
296
+ untrackConnection(clientIP, socket);
297
+ });
298
+ browserService.attachAudioWebSocket(socket);
299
+ return;
300
+ }
262
301
  const match = url.pathname.match(/\/api\/terminals\/(.+)/);
263
302
  if (!match) {
264
303
  untrackConnection(clientIP, socket);
@@ -321,6 +360,8 @@ export function setupWebSocketServer(server, terminals) {
321
360
  }
322
361
  }
323
362
  sendControl(socket, { type: 'ready' });
363
+ // この接続が現在の主クライアントかを通知(既定では先頭接続が主)。
364
+ sendControl(socket, { type: 'primary', value: session.resizeOwner === socket });
324
365
  socket.on('message', (data, isBinary) => {
325
366
  try {
326
367
  const messageSize = rawDataByteLength(data);
@@ -347,14 +388,14 @@ export function setupWebSocketServer(server, terminals) {
347
388
  control = null;
348
389
  }
349
390
  if (control?.type === 'claim') {
350
- session.resizeOwner = socket;
391
+ assignResizeOwner(session, socket);
351
392
  return;
352
393
  }
353
394
  if (control?.type === 'resize') {
354
395
  if (session.resizeOwner && session.resizeOwner !== socket) {
355
396
  return;
356
397
  }
357
- session.resizeOwner = socket;
398
+ assignResizeOwner(session, socket);
358
399
  const cols = validateTerminalSize(control.cols);
359
400
  const rows = validateTerminalSize(control.rows);
360
401
  try {
@@ -390,6 +431,12 @@ export function setupWebSocketServer(server, terminals) {
390
431
  session.sockets.delete(socket);
391
432
  if (session.resizeOwner === socket) {
392
433
  session.resizeOwner = null;
434
+ // 主クライアントが去ったら、残っている接続のいずれかを主に昇格させ、
435
+ // 端末クエリへ応答するクライアントが居なくならないようにする。
436
+ const next = session.sockets.values().next().value;
437
+ if (next) {
438
+ assignResizeOwner(session, next);
439
+ }
393
440
  }
394
441
  session.lastActive = Date.now();
395
442
  heartbeatState.delete(socket);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deckide",
3
- "version": "3.5.32",
3
+ "version": "3.5.34",
4
4
  "description": "Deck IDE - Browser-based IDE with terminal, file explorer, and git integration",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,6 +22,11 @@
22
22
  },
23
23
  "dependencies": {
24
24
  "@hono/node-server": "^1.12.2",
25
+ "@xterm/addon-fit": "^0.11.0",
26
+ "@xterm/addon-unicode11": "^0.9.0",
27
+ "@xterm/addon-web-links": "^0.12.0",
28
+ "@xterm/addon-webgl": "^0.19.0",
29
+ "@xterm/xterm": "^5.5.0",
25
30
  "hono": "^4.5.10",
26
31
  "node-pty": "^1.0.0",
27
32
  "simple-git": "^3.27.0",
@@ -39,13 +44,12 @@
39
44
  "react": "^18.3.1",
40
45
  "react-dom": "^18.3.1",
41
46
  "tailwindcss": "^4.2.1",
42
- "tsx": "^4.19.2",
47
+ "tsx": "^4.22.4",
43
48
  "typescript": "^5.6.3",
44
- "vite": "^5.2.0",
45
- "xterm": "^5.3.0",
46
- "xterm-addon-fit": "^0.8.0",
47
- "xterm-addon-unicode11": "^0.6.0",
48
- "xterm-addon-web-links": "^0.9.0"
49
+ "vite": "^6.4.3"
50
+ },
51
+ "overrides": {
52
+ "dompurify": "3.4.11"
49
53
  },
50
54
  "engines": {
51
55
  "node": ">=22.5.0"