claude-code-session-manager 0.2.7 → 0.3.1
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/dist/assets/{cssMode-BSA8IXdh.js → cssMode-CKIxmaLj.js} +1 -1
- package/dist/assets/{editor.main-DNyTQ8C2.js → editor.main-B3LWGdFG.js} +3 -3
- package/dist/assets/{freemarker2-Dic49d2Z.js → freemarker2-BzFx3VJF.js} +1 -1
- package/dist/assets/{handlebars-B0ttxNtX.js → handlebars-r19byd5F.js} +1 -1
- package/dist/assets/{html-BglndHCa.js → html-BFGOpdkL.js} +1 -1
- package/dist/assets/{htmlMode-DQDBWc2B.js → htmlMode-BdwkFXKG.js} +1 -1
- package/dist/assets/index-BzbwWnyF.css +32 -0
- package/dist/assets/index-C5FWtOH2.js +2971 -0
- package/dist/assets/{javascript-mHLCLV2P.js → javascript-B4jC2pwA.js} +1 -1
- package/dist/assets/{jsonMode-BjtyoJTt.js → jsonMode-Dwuw5t_L.js} +1 -1
- package/dist/assets/{liquid-BOVepZ_L.js → liquid-DzhrgdoQ.js} +1 -1
- package/dist/assets/{lspLanguageFeatures-CsKny4JJ.js → lspLanguageFeatures-BRdjkXT_.js} +1 -1
- package/dist/assets/{mdx-BTgcnA78.js → mdx-CqU6wRQ2.js} +1 -1
- package/dist/assets/{python-ZXDfZDk7.js → python-u7IU_Vcp.js} +1 -1
- package/dist/assets/{razor-CnSo5CZS.js → razor-C2yREyex.js} +1 -1
- package/dist/assets/{tsMode-BggS4HL2.js → tsMode-BQqGO9nP.js} +1 -1
- package/dist/assets/{typescript-B4jtvSCe.js → typescript-DMFxh4fR.js} +1 -1
- package/dist/assets/{whisperWorker-BhltUYOx.js → whisperWorker-HvcbMQn6.js} +17 -17
- package/dist/assets/{xml-CoM1qQrw.js → xml-F2eG5pB_.js} +1 -1
- package/dist/assets/{yaml-CSgZykpA.js → yaml-C7sflBn2.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/main/index.cjs +58 -1
- package/src/main/lib/voice-hotkey-log.cjs +69 -0
- package/src/main/logs.cjs +82 -0
- package/src/main/voiceHotkey.cjs +349 -0
- package/src/main/voiceSettings.cjs +337 -0
- package/src/main/voiceWizard.cjs +54 -0
- package/src/preload/api.d.ts +86 -0
- package/src/preload/index.cjs +33 -0
- package/dist/assets/index-BYHTuGIu.css +0 -32
- package/dist/assets/index-CBPfy0j1.js +0 -2971
|
@@ -1 +1 @@
|
|
|
1
|
-
import{l as e}from"./editor.main-
|
|
1
|
+
import{l as e}from"./editor.main-B3LWGdFG.js";import"./index-C5FWtOH2.js";const o={comments:{blockComment:["<!--","-->"]},brackets:[["<",">"]],autoClosingPairs:[{open:"<",close:">"},{open:"'",close:"'"},{open:'"',close:'"'}],surroundingPairs:[{open:"<",close:">"},{open:"'",close:"'"},{open:'"',close:'"'}],onEnterRules:[{beforeText:new RegExp("<([_:\\w][_:\\w-.\\d]*)([^/>]*(?!/)>)[^<]*$","i"),afterText:/^<\/([_:\w][_:\w-.\d]*)\s*>$/i,action:{indentAction:e.IndentAction.IndentOutdent}},{beforeText:new RegExp("<(\\w[\\w\\d]*)([^/>]*(?!/)>)[^<]*$","i"),action:{indentAction:e.IndentAction.Indent}}]},i={defaultToken:"",tokenPostfix:".xml",ignoreCase:!0,qualifiedName:/(?:[\w\.\-]+:)?[\w\.\-]+/,tokenizer:{root:[[/[^<&]+/,""],{include:"@whitespace"},[/(<)(@qualifiedName)/,[{token:"delimiter"},{token:"tag",next:"@tag"}]],[/(<\/)(@qualifiedName)(\s*)(>)/,[{token:"delimiter"},{token:"tag"},"",{token:"delimiter"}]],[/(<\?)(@qualifiedName)/,[{token:"delimiter"},{token:"metatag",next:"@tag"}]],[/(<\!)(@qualifiedName)/,[{token:"delimiter"},{token:"metatag",next:"@tag"}]],[/<\!\[CDATA\[/,{token:"delimiter.cdata",next:"@cdata"}],[/&\w+;/,"string.escape"]],cdata:[[/[^\]]+/,""],[/\]\]>/,{token:"delimiter.cdata",next:"@pop"}],[/\]/,""]],tag:[[/[ \t\r\n]+/,""],[/(@qualifiedName)(\s*=\s*)("[^"]*"|'[^']*')/,["attribute.name","","attribute.value"]],[/(@qualifiedName)(\s*=\s*)("[^">?\/]*|'[^'>?\/]*)(?=[\?\/]\>)/,["attribute.name","","attribute.value"]],[/(@qualifiedName)(\s*=\s*)("[^">]*|'[^'>]*)/,["attribute.name","","attribute.value"]],[/@qualifiedName/,"attribute.name"],[/\?>/,{token:"delimiter",next:"@pop"}],[/(\/)(>)/,[{token:"tag"},{token:"delimiter",next:"@pop"}]],[/>/,{token:"delimiter",next:"@pop"}]],whitespace:[[/[ \t\r\n]+/,""],[/<!--/,{token:"comment",next:"@comment"}]],comment:[[/[^<\-]+/,"comment.content"],[/-->/,{token:"comment",next:"@pop"}],[/<!--/,"comment.content.invalid"],[/[<\-]/,"comment.content"]]}};export{o as conf,i as language};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{l as e}from"./editor.main-
|
|
1
|
+
import{l as e}from"./editor.main-B3LWGdFG.js";import"./index-C5FWtOH2.js";const o={comments:{lineComment:"#"},brackets:[["{","}"],["[","]"],["(",")"]],autoClosingPairs:[{open:"{",close:"}"},{open:"[",close:"]"},{open:"(",close:")"},{open:'"',close:'"'},{open:"'",close:"'"}],surroundingPairs:[{open:"{",close:"}"},{open:"[",close:"]"},{open:"(",close:")"},{open:'"',close:'"'},{open:"'",close:"'"}],folding:{offSide:!0},onEnterRules:[{beforeText:/:\s*$/,action:{indentAction:e.IndentAction.Indent}}]},r={tokenPostfix:".yaml",brackets:[{token:"delimiter.bracket",open:"{",close:"}"},{token:"delimiter.square",open:"[",close:"]"}],keywords:["true","True","TRUE","false","False","FALSE","null","Null","Null","~"],numberInteger:/(?:0|[+-]?[0-9]+)/,numberFloat:/(?:0|[+-]?[0-9]+)(?:\.[0-9]+)?(?:e[-+][1-9][0-9]*)?/,numberOctal:/0o[0-7]+/,numberHex:/0x[0-9a-fA-F]+/,numberInfinity:/[+-]?\.(?:inf|Inf|INF)/,numberNaN:/\.(?:nan|Nan|NAN)/,numberDate:/\d{4}-\d\d-\d\d([Tt ]\d\d:\d\d:\d\d(\.\d+)?(( ?[+-]\d\d?(:\d\d)?)|Z)?)?/,escapes:/\\(?:[btnfr\\"']|[0-7][0-7]?|[0-3][0-7]{2})/,tokenizer:{root:[{include:"@whitespace"},{include:"@comment"},[/%[^ ]+.*$/,"meta.directive"],[/---/,"operators.directivesEnd"],[/\.{3}/,"operators.documentEnd"],[/[-?:](?= )/,"operators"],{include:"@anchor"},{include:"@tagHandle"},{include:"@flowCollections"},{include:"@blockStyle"},[/@numberInteger(?![ \t]*\S+)/,"number"],[/@numberFloat(?![ \t]*\S+)/,"number.float"],[/@numberOctal(?![ \t]*\S+)/,"number.octal"],[/@numberHex(?![ \t]*\S+)/,"number.hex"],[/@numberInfinity(?![ \t]*\S+)/,"number.infinity"],[/@numberNaN(?![ \t]*\S+)/,"number.nan"],[/@numberDate(?![ \t]*\S+)/,"number.date"],[/(".*?"|'.*?'|[^#'"]*?)([ \t]*)(:)( |$)/,["type","white","operators","white"]],{include:"@flowScalars"},[/.+?(?=(\s+#|$))/,{cases:{"@keywords":"keyword","@default":"string"}}]],object:[{include:"@whitespace"},{include:"@comment"},[/\}/,"@brackets","@pop"],[/,/,"delimiter.comma"],[/:(?= )/,"operators"],[/(?:".*?"|'.*?'|[^,\{\[]+?)(?=: )/,"type"],{include:"@flowCollections"},{include:"@flowScalars"},{include:"@tagHandle"},{include:"@anchor"},{include:"@flowNumber"},[/[^\},]+/,{cases:{"@keywords":"keyword","@default":"string"}}]],array:[{include:"@whitespace"},{include:"@comment"},[/\]/,"@brackets","@pop"],[/,/,"delimiter.comma"],{include:"@flowCollections"},{include:"@flowScalars"},{include:"@tagHandle"},{include:"@anchor"},{include:"@flowNumber"},[/[^\],]+/,{cases:{"@keywords":"keyword","@default":"string"}}]],multiString:[[/^( +).+$/,"string","@multiStringContinued.$1"]],multiStringContinued:[[/^( *).+$/,{cases:{"$1==$S2":"string","@default":{token:"@rematch",next:"@popall"}}}]],whitespace:[[/[ \t\r\n]+/,"white"]],comment:[[/#.*$/,"comment"]],flowCollections:[[/\[/,"@brackets","@array"],[/\{/,"@brackets","@object"]],flowScalars:[[/"([^"\\]|\\.)*$/,"string.invalid"],[/'([^'\\]|\\.)*$/,"string.invalid"],[/'[^']*'/,"string"],[/"/,"string","@doubleQuotedString"]],doubleQuotedString:[[/[^\\"]+/,"string"],[/@escapes/,"string.escape"],[/\\./,"string.escape.invalid"],[/"/,"string","@pop"]],blockStyle:[[/[>|][0-9]*[+-]?$/,"operators","@multiString"]],flowNumber:[[/@numberInteger(?=[ \t]*[,\]\}])/,"number"],[/@numberFloat(?=[ \t]*[,\]\}])/,"number.float"],[/@numberOctal(?=[ \t]*[,\]\}])/,"number.octal"],[/@numberHex(?=[ \t]*[,\]\}])/,"number.hex"],[/@numberInfinity(?=[ \t]*[,\]\}])/,"number.infinity"],[/@numberNaN(?=[ \t]*[,\]\}])/,"number.nan"],[/@numberDate(?=[ \t]*[,\]\}])/,"number.date"]],tagHandle:[[/\![^ ]*/,"tag"]],anchor:[[/[&*][^ ]+/,"namespace"]]}};export{o as conf,r as language};
|
package/dist/index.html
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>Claude Session Manager</title>
|
|
7
|
-
<script type="module" crossorigin src="./assets/index-
|
|
8
|
-
<link rel="stylesheet" crossorigin href="./assets/index-
|
|
7
|
+
<script type="module" crossorigin src="./assets/index-C5FWtOH2.js"></script>
|
|
8
|
+
<link rel="stylesheet" crossorigin href="./assets/index-BzbwWnyF.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body class="bg-bg text-fg font-mono antialiased">
|
|
11
11
|
<div id="root"></div>
|
package/package.json
CHANGED
package/src/main/index.cjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const { app, BrowserWindow, ipcMain, dialog, Menu, session, systemPreferences } = require('electron');
|
|
1
|
+
const { app, BrowserWindow, ipcMain, dialog, Menu, session, systemPreferences, globalShortcut } = require('electron');
|
|
2
2
|
const { spawn, execFileSync } = require('node:child_process');
|
|
3
3
|
const path = require('node:path');
|
|
4
4
|
const fs = require('node:fs');
|
|
@@ -8,6 +8,9 @@ const configMgr = require('./config.cjs');
|
|
|
8
8
|
const transcripts = require('./transcripts.cjs');
|
|
9
9
|
const sessionsStore = require('./sessionsStore.cjs');
|
|
10
10
|
const billing = require('./usage.cjs');
|
|
11
|
+
const logs = require('./logs.cjs');
|
|
12
|
+
const voiceHotkey = require('./voiceHotkey.cjs');
|
|
13
|
+
const voiceWizard = require('./voiceWizard.cjs');
|
|
11
14
|
|
|
12
15
|
let mainWindow = null;
|
|
13
16
|
let rebooting = false;
|
|
@@ -90,6 +93,9 @@ async function rebootApp() {
|
|
|
90
93
|
ptyManager.attachWindow(mainWindow);
|
|
91
94
|
configMgr.attachWindow(mainWindow);
|
|
92
95
|
transcripts.attachWindow(mainWindow);
|
|
96
|
+
voiceHotkey.init(mainWindow).catch((e) => {
|
|
97
|
+
logs.writeLine({ scope: 'voice-hotkey', level: 'error', message: 'reinit failed', meta: { error: e?.message } });
|
|
98
|
+
});
|
|
93
99
|
rebooting = false;
|
|
94
100
|
return;
|
|
95
101
|
}
|
|
@@ -184,10 +190,52 @@ configMgr.registerConfigHandlers();
|
|
|
184
190
|
transcripts.registerTranscriptHandlers();
|
|
185
191
|
sessionsStore.registerSessionsHandlers();
|
|
186
192
|
billing.registerBillingHandlers();
|
|
193
|
+
logs.registerLogHandlers();
|
|
194
|
+
voiceHotkey.registerHotkeyHandlers();
|
|
195
|
+
voiceWizard.registerWizardHandlers();
|
|
187
196
|
|
|
188
197
|
// --- App lifecycle ---
|
|
189
198
|
|
|
199
|
+
// E2E under xvfb-run has no working GPU; the GPU process exits ~15 seconds
|
|
200
|
+
// in and Chromium responds with a renderer reload that races our test
|
|
201
|
+
// fixtures. Disabling hardware accel sidesteps the entire crash chain.
|
|
202
|
+
// Must run before app.whenReady() — Electron rejects late changes.
|
|
203
|
+
if (process.env.SM_E2E === '1') {
|
|
204
|
+
try { app.disableHardwareAcceleration(); } catch { /* */ }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Single-instance lock (PRD F1 v2 §requestSingleInstanceLock).
|
|
208
|
+
// In dev mode we skip the lock so two-dev-instance workflows still work.
|
|
209
|
+
// E2E tests also skip so playwright.electron.launch can run multiple specs
|
|
210
|
+
// back-to-back without the prior app's lock causing the next launch to quit.
|
|
211
|
+
const isDev = process.env.SM_DEV === '1' || process.env.SM_E2E === '1';
|
|
212
|
+
if (!isDev) {
|
|
213
|
+
const gotLock = app.requestSingleInstanceLock();
|
|
214
|
+
if (!gotLock) {
|
|
215
|
+
app.quit();
|
|
216
|
+
} else {
|
|
217
|
+
app.on('second-instance', () => {
|
|
218
|
+
if (mainWindow && !mainWindow.isDestroyed()) {
|
|
219
|
+
if (mainWindow.isMinimized()) mainWindow.restore();
|
|
220
|
+
mainWindow.show();
|
|
221
|
+
mainWindow.focus();
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
190
227
|
app.whenReady().then(async () => {
|
|
228
|
+
logs.pruneOld();
|
|
229
|
+
logs.writeLine({ scope: 'main', level: 'info', message: 'app start', meta: { version: app.getVersion(), platform: process.platform } });
|
|
230
|
+
|
|
231
|
+
process.on('uncaughtException', (err) => {
|
|
232
|
+
logs.writeLine({ scope: 'main', level: 'error', message: 'uncaughtException', meta: { error: err?.message, stack: err?.stack } });
|
|
233
|
+
});
|
|
234
|
+
process.on('unhandledRejection', (reason) => {
|
|
235
|
+
const r = reason instanceof Error ? { error: reason.message, stack: reason.stack } : { reason: String(reason) };
|
|
236
|
+
logs.writeLine({ scope: 'main', level: 'error', message: 'unhandledRejection', meta: r });
|
|
237
|
+
});
|
|
238
|
+
|
|
191
239
|
// Grant microphone / media permissions so the renderer's getUserMedia
|
|
192
240
|
// (Whisper voice-to-text) resolves instead of being silently denied.
|
|
193
241
|
const MEDIA_PERMS = new Set(['media', 'audioCapture', 'microphone']);
|
|
@@ -260,6 +308,15 @@ app.whenReady().then(async () => {
|
|
|
260
308
|
ptyManager.attachWindow(mainWindow);
|
|
261
309
|
configMgr.attachWindow(mainWindow);
|
|
262
310
|
transcripts.attachWindow(mainWindow);
|
|
311
|
+
voiceHotkey.init(mainWindow).catch((e) => {
|
|
312
|
+
logs.writeLine({ scope: 'voice-hotkey', level: 'error', message: 'init failed', meta: { error: e?.message } });
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
app.on('will-quit', () => {
|
|
317
|
+
// PRD F1 v2 §IPC plumbing: must unregisterAll on will-quit.
|
|
318
|
+
try { globalShortcut.unregisterAll(); } catch { /* */ }
|
|
319
|
+
voiceHotkey.disposeOnQuit();
|
|
263
320
|
});
|
|
264
321
|
|
|
265
322
|
app.on('window-all-closed', () => {
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* voice-hotkey-log — sanitizing logger wrapper for the voice-hotkey scope.
|
|
3
|
+
*
|
|
4
|
+
* Enforces the F1 PRD §Security no-transcript rule: any meta key whose name
|
|
5
|
+
* matches /transcript|interim|final|text/i is dropped before the line hits
|
|
6
|
+
* disk. In dev (`!app.isPackaged`), if the dropped value was non-empty we
|
|
7
|
+
* throw so tests catch a leak immediately.
|
|
8
|
+
*
|
|
9
|
+
* This module is intentionally tiny and dependency-free apart from logs.cjs
|
|
10
|
+
* and electron's `app` for the dev check.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const { app } = require('electron');
|
|
14
|
+
const logs = require('../logs.cjs');
|
|
15
|
+
|
|
16
|
+
const SENSITIVE_KEY_RE = /transcript|interim|final|text/i;
|
|
17
|
+
|
|
18
|
+
function isNonEmpty(value) {
|
|
19
|
+
if (value === null || value === undefined) return false;
|
|
20
|
+
if (typeof value === 'string') return value.length > 0;
|
|
21
|
+
if (Array.isArray(value)) return value.length > 0;
|
|
22
|
+
if (typeof value === 'object') return Object.keys(value).length > 0;
|
|
23
|
+
// numbers, booleans → "non-empty" if truthy enough that a leak would matter
|
|
24
|
+
return Boolean(value);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function sanitize(meta) {
|
|
28
|
+
if (!meta || typeof meta !== 'object' || Array.isArray(meta)) return meta;
|
|
29
|
+
const out = {};
|
|
30
|
+
let leaked = null;
|
|
31
|
+
for (const [k, v] of Object.entries(meta)) {
|
|
32
|
+
if (SENSITIVE_KEY_RE.test(k)) {
|
|
33
|
+
if (isNonEmpty(v) && leaked === null) leaked = k;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
out[k] = v;
|
|
37
|
+
}
|
|
38
|
+
return { sanitized: out, leakedKey: leaked };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function voiceHotkeyLog(level, event, meta) {
|
|
42
|
+
let safeMeta = meta;
|
|
43
|
+
let leakedKey = null;
|
|
44
|
+
if (meta && typeof meta === 'object' && !Array.isArray(meta)) {
|
|
45
|
+
const result = sanitize(meta);
|
|
46
|
+
safeMeta = result.sanitized;
|
|
47
|
+
leakedKey = result.leakedKey;
|
|
48
|
+
}
|
|
49
|
+
// Write the (sanitized) line FIRST so the leak event itself is recorded
|
|
50
|
+
// — otherwise a dev-mode throw destroys the trail. Then throw in dev so
|
|
51
|
+
// tests fail loudly. Critique fix: was previously throw-then-log.
|
|
52
|
+
logs.writeLine({ scope: 'voice-hotkey', level, message: event, meta: safeMeta });
|
|
53
|
+
if (leakedKey !== null) {
|
|
54
|
+
logs.writeLine({
|
|
55
|
+
scope: 'voice-hotkey',
|
|
56
|
+
level: 'error',
|
|
57
|
+
message: 'sanitizer.leak',
|
|
58
|
+
meta: { leakedKey, event },
|
|
59
|
+
});
|
|
60
|
+
const isDev = (() => {
|
|
61
|
+
try { return !app.isPackaged; } catch { return true; }
|
|
62
|
+
})();
|
|
63
|
+
if (isDev) {
|
|
64
|
+
throw new Error(`voice-hotkey log leaked sensitive key: ${leakedKey}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = { voiceHotkeyLog, SENSITIVE_KEY_RE };
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
const { app, ipcMain } = require('electron');
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
|
|
5
|
+
// Rolling daily files under <userData>/logs/. Anything mtime'd older than
|
|
6
|
+
// RETENTION_MS is pruned at boot; we never compact mid-session, since the
|
|
7
|
+
// app is short-lived and a once-per-launch sweep is enough.
|
|
8
|
+
const RETENTION_MS = 3 * 24 * 60 * 60 * 1000;
|
|
9
|
+
const LOG_PREFIX = 'session-manager-';
|
|
10
|
+
const LOG_SUFFIX = '.log';
|
|
11
|
+
|
|
12
|
+
function logDir() {
|
|
13
|
+
return path.join(app.getPath('userData'), 'logs');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function todayPath() {
|
|
17
|
+
const d = new Date();
|
|
18
|
+
const yyyy = d.getFullYear();
|
|
19
|
+
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
|
20
|
+
const dd = String(d.getDate()).padStart(2, '0');
|
|
21
|
+
return path.join(logDir(), `${LOG_PREFIX}${yyyy}-${mm}-${dd}${LOG_SUFFIX}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function ensureDir() {
|
|
25
|
+
try { fs.mkdirSync(logDir(), { recursive: true }); } catch { /* best-effort */ }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function pruneOld() {
|
|
29
|
+
ensureDir();
|
|
30
|
+
let entries;
|
|
31
|
+
try { entries = fs.readdirSync(logDir()); } catch { return; }
|
|
32
|
+
const cutoff = Date.now() - RETENTION_MS;
|
|
33
|
+
for (const name of entries) {
|
|
34
|
+
if (!name.startsWith(LOG_PREFIX) || !name.endsWith(LOG_SUFFIX)) continue;
|
|
35
|
+
const full = path.join(logDir(), name);
|
|
36
|
+
try {
|
|
37
|
+
const st = fs.statSync(full);
|
|
38
|
+
if (st.mtimeMs < cutoff) fs.unlinkSync(full);
|
|
39
|
+
} catch { /* skip unreadable entries */ }
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Sanitize meta keys that look like they might carry transcript or user
|
|
44
|
+
// content. Voice features go to logs and the hotkey wrapper already strips
|
|
45
|
+
// these in main, but renderer-side log calls go through this writer too.
|
|
46
|
+
// Defense in depth: drop and replace with `[redacted]` so leaks are obvious.
|
|
47
|
+
const REDACT_KEY = /^(transcript|interim|final|text|content|partial|userText|message)$/i;
|
|
48
|
+
function sanitizeMeta(meta) {
|
|
49
|
+
if (!meta || typeof meta !== 'object') return meta;
|
|
50
|
+
if (Array.isArray(meta)) return meta;
|
|
51
|
+
const out = {};
|
|
52
|
+
for (const [k, v] of Object.entries(meta)) {
|
|
53
|
+
out[k] = REDACT_KEY.test(k) ? '[redacted]' : v;
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function writeLine(payload) {
|
|
59
|
+
if (!payload || typeof payload !== 'object') return;
|
|
60
|
+
ensureDir();
|
|
61
|
+
const ts = new Date().toISOString();
|
|
62
|
+
const lvl = String(payload.level || 'info').toUpperCase();
|
|
63
|
+
const scope = String(payload.scope || '-');
|
|
64
|
+
const message = String(payload.message ?? '');
|
|
65
|
+
let line = `[${ts}] [${lvl}] [${scope}] ${message}`;
|
|
66
|
+
if (payload.meta !== undefined) {
|
|
67
|
+
try { line += ' ' + JSON.stringify(sanitizeMeta(payload.meta)); } catch { line += ' [unserializable meta]'; }
|
|
68
|
+
}
|
|
69
|
+
// Open with 0o600 so logs don't leak to other local users; a session
|
|
70
|
+
// transcript could include device labels or recently-typed terminal text.
|
|
71
|
+
try {
|
|
72
|
+
const fd = fs.openSync(todayPath(), 'a', 0o600);
|
|
73
|
+
try { fs.writeSync(fd, line + '\n'); } finally { fs.closeSync(fd); }
|
|
74
|
+
} catch { /* best-effort */ }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function registerLogHandlers() {
|
|
78
|
+
ipcMain.on('log:write', (_e, payload) => writeLine(payload));
|
|
79
|
+
ipcMain.handle('log:dir', () => logDir());
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = { pruneOld, writeLine, registerLogHandlers, logDir };
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* voiceHotkey — F1 push-to-talk hotkey wiring (main process).
|
|
3
|
+
*
|
|
4
|
+
* Two layers per F1 v2 PRD:
|
|
5
|
+
* 1. Window-local: mainWindow.webContents.on('before-input-event', …) —
|
|
6
|
+
* always on, fires before xterm, blocks the terminal via preventDefault.
|
|
7
|
+
* 2. Global: globalShortcut.register(...) — opt-in via voice.json.global,
|
|
8
|
+
* always toggle-only (globalShortcut has no keyup signal).
|
|
9
|
+
*
|
|
10
|
+
* Precedence: when window is focused, globalShortcut is unregistered so the
|
|
11
|
+
* window-local layer owns the chord. On blur we re-register if global=true.
|
|
12
|
+
*
|
|
13
|
+
* Wayland: globalShortcut is broken on Wayland in Electron 32–40 (see
|
|
14
|
+
* electron#45607, #49806). For v1 we just disable global mode if
|
|
15
|
+
* XDG_SESSION_TYPE=wayland and log a warn. The DBus portal probe is deferred.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const { app, globalShortcut, ipcMain } = require('electron');
|
|
19
|
+
const voiceSettings = require('./voiceSettings.cjs');
|
|
20
|
+
const { voiceHotkeyLog } = require('./lib/voice-hotkey-log.cjs');
|
|
21
|
+
|
|
22
|
+
let mainWindow = null;
|
|
23
|
+
let currentConfig = null;
|
|
24
|
+
let beforeInputHandler = null;
|
|
25
|
+
// Per-press state for hold mode: we only care that *some* chord-member is
|
|
26
|
+
// held, then any chord-member keyup ends the press (PRD F1 v2 §Edge Cases #13).
|
|
27
|
+
let holdActive = false;
|
|
28
|
+
let globalRegisteredAccel = null;
|
|
29
|
+
// Suppress the first global callback within 500ms of registration to dodge
|
|
30
|
+
// the "key held during cold start" edge case (PRD §Edge Cases #4).
|
|
31
|
+
let globalRegisteredAt = 0;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Map an Electron Accelerator string to a token set we can compare against
|
|
35
|
+
* before-input-event Input. Handles CommandOrControl/Cmd/Ctrl/Alt/Option/
|
|
36
|
+
* Shift/Super and a key (letter, F-key, or named key like Space).
|
|
37
|
+
*
|
|
38
|
+
* Complexity: O(p) per call where p = number of '+' segments (≤ 5 in practice).
|
|
39
|
+
*/
|
|
40
|
+
function parseAccelerator(accel) {
|
|
41
|
+
if (typeof accel !== 'string' || !accel) return null;
|
|
42
|
+
const parts = accel.split('+').map((s) => s.trim()).filter(Boolean);
|
|
43
|
+
if (parts.length < 2) return null; // PRD requires ≥1 modifier + 1 key
|
|
44
|
+
const mods = { ctrl: false, meta: false, alt: false, shift: false };
|
|
45
|
+
let key = null;
|
|
46
|
+
for (const raw of parts) {
|
|
47
|
+
const p = raw.toLowerCase();
|
|
48
|
+
if (p === 'commandorcontrol') {
|
|
49
|
+
if (process.platform === 'darwin') mods.meta = true;
|
|
50
|
+
else mods.ctrl = true;
|
|
51
|
+
} else if (p === 'cmd' || p === 'command' || p === 'super') {
|
|
52
|
+
mods.meta = true;
|
|
53
|
+
} else if (p === 'ctrl' || p === 'control') {
|
|
54
|
+
mods.ctrl = true;
|
|
55
|
+
} else if (p === 'alt' || p === 'option') {
|
|
56
|
+
mods.alt = true;
|
|
57
|
+
} else if (p === 'shift') {
|
|
58
|
+
mods.shift = true;
|
|
59
|
+
} else {
|
|
60
|
+
// Normalize the key part so a hand-edited voice.json with `cmd+option+v`
|
|
61
|
+
// still parses despite the schema regex requiring `[A-Z]`. Uppercase
|
|
62
|
+
// single letters; leave F-keys / Space / digits as the user wrote them.
|
|
63
|
+
key = raw.length === 1 ? raw.toUpperCase() : raw;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (!key) return null;
|
|
67
|
+
return { mods, key };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** True if the modifier flags on `input` exactly match `mods`. */
|
|
71
|
+
function modsMatch(input, mods) {
|
|
72
|
+
return (
|
|
73
|
+
!!input.control === mods.ctrl &&
|
|
74
|
+
!!input.meta === mods.meta &&
|
|
75
|
+
!!input.alt === mods.alt &&
|
|
76
|
+
!!input.shift === mods.shift
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** True if the input.key matches an accelerator key token. */
|
|
81
|
+
function keyMatches(input, key) {
|
|
82
|
+
const ik = String(input.key || '').toLowerCase();
|
|
83
|
+
const ic = String(input.code || '').toLowerCase();
|
|
84
|
+
const kk = String(key).toLowerCase();
|
|
85
|
+
if (ik === kk) return true;
|
|
86
|
+
// Space: input.key may be ' ' (literal space) or 'space'.
|
|
87
|
+
if (kk === 'space' && (ik === ' ' || ik === 'space' || ic === 'space')) return true;
|
|
88
|
+
// Letters: input.code is 'KeyV' for V key. This is robust to dead-key
|
|
89
|
+
// modifier substitutions on macOS (e.g. Option+V → '√' in input.key).
|
|
90
|
+
if (kk.length === 1 && /[a-z0-9]/.test(kk) && ic === `key${kk}`) return true;
|
|
91
|
+
// Digit row: input.code is 'Digit1' for '1'.
|
|
92
|
+
if (/^[0-9]$/.test(kk) && ic === `digit${kk}`) return true;
|
|
93
|
+
// F-keys: input.code matches 'F1'..'F24', case differs.
|
|
94
|
+
if (/^f([1-9]|1[0-9]|2[0-4])$/.test(kk) && ic === kk) return true;
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** True if `input.key` is one of the chord's modifier keys (for hold-end). */
|
|
99
|
+
function isChordMemberRelease(input, parsed) {
|
|
100
|
+
const k = String(input.key || '').toLowerCase();
|
|
101
|
+
if (parsed.mods.ctrl && (k === 'control')) return true;
|
|
102
|
+
if (parsed.mods.meta && (k === 'meta' || k === 'cmd' || k === 'command' || k === 'os' || k === 'super')) return true;
|
|
103
|
+
if (parsed.mods.alt && (k === 'alt' || k === 'option')) return true;
|
|
104
|
+
if (parsed.mods.shift && (k === 'shift')) return true;
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function detachBeforeInput() {
|
|
109
|
+
if (mainWindow && !mainWindow.isDestroyed() && beforeInputHandler) {
|
|
110
|
+
try { mainWindow.webContents.removeListener('before-input-event', beforeInputHandler); } catch { /* */ }
|
|
111
|
+
}
|
|
112
|
+
beforeInputHandler = null;
|
|
113
|
+
holdActive = false;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function attachBeforeInput() {
|
|
117
|
+
if (!mainWindow || mainWindow.isDestroyed()) return;
|
|
118
|
+
detachBeforeInput();
|
|
119
|
+
const cfg = currentConfig;
|
|
120
|
+
if (!cfg) return;
|
|
121
|
+
const parsed = parseAccelerator(cfg.accelerator);
|
|
122
|
+
if (!parsed) {
|
|
123
|
+
voiceHotkeyLog('error', 'register-failed', {
|
|
124
|
+
accelerator: cfg.accelerator,
|
|
125
|
+
platform: process.platform,
|
|
126
|
+
sessionType: process.env.XDG_SESSION_TYPE || null,
|
|
127
|
+
reason: 'parse',
|
|
128
|
+
});
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const handler = (event, input) => {
|
|
133
|
+
if (input.type !== 'keyDown' && input.type !== 'keyUp') return;
|
|
134
|
+
// PRD F1 v2 §Auto-repeat: discard isAutoRepeat in main.
|
|
135
|
+
if (input.isAutoRepeat) return;
|
|
136
|
+
|
|
137
|
+
if (input.type === 'keyDown') {
|
|
138
|
+
// For keyDown, both modifiers and the key must match exactly.
|
|
139
|
+
if (!modsMatch(input, parsed.mods)) return;
|
|
140
|
+
if (!keyMatches(input, parsed.key)) return;
|
|
141
|
+
event.preventDefault();
|
|
142
|
+
if (cfg.mode === 'hold') {
|
|
143
|
+
if (holdActive) return; // ignore re-press while held
|
|
144
|
+
holdActive = true;
|
|
145
|
+
emitHotkey('down', 'window');
|
|
146
|
+
} else {
|
|
147
|
+
// Toggle: every fresh keyDown emits 'down'. Renderer state machine
|
|
148
|
+
// decides whether that means start or stop.
|
|
149
|
+
emitHotkey('down', 'window');
|
|
150
|
+
}
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// keyUp: in hold mode, any chord-member release ends the press
|
|
155
|
+
// (PRD §Edge Cases #13 — modifier release timing).
|
|
156
|
+
if (cfg.mode === 'hold' && holdActive) {
|
|
157
|
+
const releaseEndsHold = keyMatches(input, parsed.key) || isChordMemberRelease(input, parsed);
|
|
158
|
+
if (releaseEndsHold) {
|
|
159
|
+
event.preventDefault();
|
|
160
|
+
holdActive = false;
|
|
161
|
+
emitHotkey('up', 'window');
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
mainWindow.webContents.on('before-input-event', handler);
|
|
167
|
+
beforeInputHandler = handler;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function emitHotkey(phase, source) {
|
|
171
|
+
if (!mainWindow || mainWindow.isDestroyed()) return;
|
|
172
|
+
mainWindow.webContents.send('voice:hotkey', { phase, source });
|
|
173
|
+
voiceHotkeyLog('info', phase === 'down' ? 'down' : 'up', { source });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function unregisterGlobal() {
|
|
177
|
+
if (globalRegisteredAccel) {
|
|
178
|
+
try { globalShortcut.unregister(globalRegisteredAccel); } catch { /* */ }
|
|
179
|
+
globalRegisteredAccel = null;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function registerGlobalIfWanted() {
|
|
184
|
+
unregisterGlobal();
|
|
185
|
+
const cfg = currentConfig;
|
|
186
|
+
if (!cfg || !cfg.global) return;
|
|
187
|
+
|
|
188
|
+
// Wayland: disable global for v1 (PRD §Wayland is experimental).
|
|
189
|
+
if (process.platform === 'linux' && process.env.XDG_SESSION_TYPE === 'wayland') {
|
|
190
|
+
voiceHotkeyLog('warn', 'global-disabled-wayland', {
|
|
191
|
+
accelerator: cfg.accelerator,
|
|
192
|
+
sessionType: 'wayland',
|
|
193
|
+
});
|
|
194
|
+
return;
|
|
195
|
+
// TODO(F1-followup): DBus probe org.freedesktop.portal.GlobalShortcuts
|
|
196
|
+
// and enable global mode when present (xdg-desktop-portal ≥ 1.17).
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
let ok = false;
|
|
200
|
+
try {
|
|
201
|
+
ok = globalShortcut.register(cfg.accelerator, () => {
|
|
202
|
+
// Suppress callbacks within 500ms of registration (PRD §Edge Cases #4).
|
|
203
|
+
if (Date.now() - globalRegisteredAt < 500) return;
|
|
204
|
+
// Global is toggle-only (no keyup). Renderer state machine handles
|
|
205
|
+
// arm/disarm regardless of configured mode.
|
|
206
|
+
emitHotkey('down', 'global');
|
|
207
|
+
});
|
|
208
|
+
} catch (err) {
|
|
209
|
+
voiceHotkeyLog('error', 'register-failed', {
|
|
210
|
+
accelerator: cfg.accelerator,
|
|
211
|
+
platform: process.platform,
|
|
212
|
+
sessionType: process.env.XDG_SESSION_TYPE || null,
|
|
213
|
+
reason: err && err.message ? err.message : 'unknown',
|
|
214
|
+
});
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (ok) {
|
|
219
|
+
globalRegisteredAccel = cfg.accelerator;
|
|
220
|
+
globalRegisteredAt = Date.now();
|
|
221
|
+
voiceHotkeyLog('info', 'register', {
|
|
222
|
+
accelerator: cfg.accelerator,
|
|
223
|
+
global: true,
|
|
224
|
+
mode: cfg.mode,
|
|
225
|
+
ok: true,
|
|
226
|
+
sessionType: process.env.XDG_SESSION_TYPE || null,
|
|
227
|
+
});
|
|
228
|
+
} else {
|
|
229
|
+
voiceHotkeyLog('warn', 'collision', {
|
|
230
|
+
accelerator: cfg.accelerator,
|
|
231
|
+
platform: process.platform,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function applyConfig(cfg) {
|
|
237
|
+
currentConfig = cfg;
|
|
238
|
+
attachBeforeInput();
|
|
239
|
+
// If a focused window owns the chord, keep global unregistered until blur.
|
|
240
|
+
const focused = mainWindow && !mainWindow.isDestroyed() && mainWindow.isFocused();
|
|
241
|
+
if (focused) {
|
|
242
|
+
unregisterGlobal();
|
|
243
|
+
} else {
|
|
244
|
+
registerGlobalIfWanted();
|
|
245
|
+
}
|
|
246
|
+
// Always log the window-local registration so the e2e test can see it
|
|
247
|
+
// even if global is off / suppressed.
|
|
248
|
+
voiceHotkeyLog('info', 'register', {
|
|
249
|
+
accelerator: cfg.accelerator,
|
|
250
|
+
global: !!cfg.global,
|
|
251
|
+
mode: cfg.mode,
|
|
252
|
+
ok: true,
|
|
253
|
+
sessionType: process.env.XDG_SESSION_TYPE || null,
|
|
254
|
+
layer: 'window',
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function attachWindow(win) {
|
|
259
|
+
mainWindow = win;
|
|
260
|
+
if (!mainWindow) return;
|
|
261
|
+
attachBeforeInput();
|
|
262
|
+
|
|
263
|
+
// Precedence: window focus → unregister global; blur → re-register.
|
|
264
|
+
// Debounce focus 50ms so second-instance show()→focus() doesn't race
|
|
265
|
+
// with a held chord. (PRD §requestSingleInstanceLock).
|
|
266
|
+
// BrowserWindow emits `focus`/`blur` (not `browser-window-focus`/`-blur`,
|
|
267
|
+
// which are app-level events). The legacy listener was a no-op.
|
|
268
|
+
let focusDebounce = null;
|
|
269
|
+
const onFocus = () => {
|
|
270
|
+
if (focusDebounce) clearTimeout(focusDebounce);
|
|
271
|
+
focusDebounce = setTimeout(() => unregisterGlobal(), 50);
|
|
272
|
+
};
|
|
273
|
+
const onBlur = () => {
|
|
274
|
+
if (focusDebounce) { clearTimeout(focusDebounce); focusDebounce = null; }
|
|
275
|
+
registerGlobalIfWanted();
|
|
276
|
+
};
|
|
277
|
+
mainWindow.on('focus', onFocus);
|
|
278
|
+
mainWindow.on('blur', onBlur);
|
|
279
|
+
mainWindow.on('closed', () => {
|
|
280
|
+
if (focusDebounce) { clearTimeout(focusDebounce); focusDebounce = null; }
|
|
281
|
+
try { mainWindow.removeListener('focus', onFocus); } catch { /* */ }
|
|
282
|
+
try { mainWindow.removeListener('blur', onBlur); } catch { /* */ }
|
|
283
|
+
detachBeforeInput();
|
|
284
|
+
mainWindow = null;
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function init(win) {
|
|
289
|
+
attachWindow(win);
|
|
290
|
+
currentConfig = await voiceSettings.load();
|
|
291
|
+
applyConfig(currentConfig);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function registerHotkeyHandlers() {
|
|
295
|
+
ipcMain.handle('voice:get-hotkey-config', async () => {
|
|
296
|
+
if (!currentConfig) currentConfig = await voiceSettings.load();
|
|
297
|
+
return currentConfig;
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
ipcMain.handle('voice:set-hotkey', async (_e, cfg) => {
|
|
301
|
+
if (!voiceSettings.isValidConfig(cfg)) {
|
|
302
|
+
throw new Error('Invalid voice hotkey config');
|
|
303
|
+
}
|
|
304
|
+
await voiceSettings.save(cfg);
|
|
305
|
+
currentConfig = await voiceSettings.load();
|
|
306
|
+
applyConfig(currentConfig);
|
|
307
|
+
if (mainWindow && !mainWindow.isDestroyed()) {
|
|
308
|
+
mainWindow.webContents.send('voice:hotkey-changed', currentConfig);
|
|
309
|
+
}
|
|
310
|
+
return { ok: true, config: currentConfig };
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
ipcMain.handle('voice:get-hotkey-config-path', () => voiceSettings.storePath());
|
|
314
|
+
|
|
315
|
+
// F5 — device picker preference (additive subtree on voice.json).
|
|
316
|
+
ipcMain.handle('voice:get-device-pref', async () => {
|
|
317
|
+
return await voiceSettings.loadDevice();
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
ipcMain.handle('voice:set-device-pref', async (_e, pref) => {
|
|
321
|
+
if (!voiceSettings.isValidDevicePref(pref)) {
|
|
322
|
+
throw new Error('Invalid device pref payload');
|
|
323
|
+
}
|
|
324
|
+
await voiceSettings.saveDevice(pref);
|
|
325
|
+
return { ok: true };
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
// Renderer pings this when isRecording flips so we can prefix the window
|
|
329
|
+
// title with `● REC — ` (PRD F1 v2 §Security: privacy invariant).
|
|
330
|
+
ipcMain.on('voice:set-recording', (_e, recording) => {
|
|
331
|
+
if (!mainWindow || mainWindow.isDestroyed()) return;
|
|
332
|
+
const baseTitle = 'Claude Session Manager';
|
|
333
|
+
const next = recording ? `● REC — ${baseTitle}` : baseTitle;
|
|
334
|
+
try { mainWindow.setTitle(next); } catch { /* */ }
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function disposeOnQuit() {
|
|
339
|
+
unregisterGlobal();
|
|
340
|
+
detachBeforeInput();
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
module.exports = {
|
|
344
|
+
init,
|
|
345
|
+
registerHotkeyHandlers,
|
|
346
|
+
disposeOnQuit,
|
|
347
|
+
// exported for tests
|
|
348
|
+
parseAccelerator,
|
|
349
|
+
};
|