@wonderwhy-er/desktop-commander 0.2.41 → 0.2.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +0 -2
- package/dist/handlers/filesystem-handlers.js +24 -4
- package/dist/remote-device/remote-channel.d.ts +14 -0
- package/dist/remote-device/remote-channel.js +107 -28
- package/dist/server.d.ts +6 -1
- package/dist/server.js +112 -25
- package/dist/setup-claude-server.js +56 -50
- package/dist/terminal-manager.d.ts +18 -0
- package/dist/terminal-manager.js +130 -18
- package/dist/tools/edit.js +11 -4
- package/dist/tools/filesystem.d.ts +5 -0
- package/dist/tools/filesystem.js +43 -0
- package/dist/tools/fuzzySearch.d.ts +10 -20
- package/dist/tools/fuzzySearch.js +66 -105
- package/dist/tools/fuzzySearchCore.d.ts +52 -0
- package/dist/tools/fuzzySearchCore.js +125 -0
- package/dist/tools/improved-process-tools.js +8 -1
- package/dist/tools/pdf/markdown.d.ts +13 -0
- package/dist/tools/pdf/markdown.js +93 -29
- package/dist/tools/schemas.d.ts +20 -0
- package/dist/tools/schemas.js +45 -2
- package/dist/track-installation.js +57 -38
- package/dist/types.d.ts +6 -1
- package/dist/ui/contracts.d.ts +1 -1
- package/dist/ui/contracts.js +4 -1
- package/dist/ui/file-preview/preview-runtime.js +111 -113
- package/dist/ui/file-preview/src/app.js +19 -17
- package/dist/ui/file-preview/src/directory-controller.js +3 -3
- package/dist/ui/file-preview/src/file-type-handlers.js +2 -2
- package/dist/ui/file-preview/src/host/external-actions.d.ts +0 -11
- package/dist/ui/file-preview/src/host/external-actions.js +0 -39
- package/dist/ui/file-preview/src/markdown/controller.js +3 -1
- package/dist/ui/file-preview/src/panel-actions.js +2 -2
- package/dist/uninstall-claude-server.js +54 -47
- package/dist/utils/ab-test.d.ts +4 -0
- package/dist/utils/ab-test.js +6 -0
- package/dist/utils/capture.d.ts +10 -2
- package/dist/utils/capture.js +92 -60
- package/dist/utils/mcp-ui-ab-test.d.ts +13 -0
- package/dist/utils/mcp-ui-ab-test.js +62 -0
- package/dist/utils/unsupportedParams.d.ts +24 -0
- package/dist/utils/unsupportedParams.js +66 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +6 -1
|
@@ -11,7 +11,7 @@ import { attachDirectoryHandlers } from './directory-controller.js';
|
|
|
11
11
|
import { buildDocumentLayout } from './document-layout.js';
|
|
12
12
|
import { getDocumentFullscreenAvailability, parseReadRange, stripReadStatusLine } from './document-workspace.js';
|
|
13
13
|
import { getFileTypeCapabilities, renderPayloadBody } from './file-type-handlers.js';
|
|
14
|
-
import { buildOpenInEditorCommand, buildOpenInFolderCommand,
|
|
14
|
+
import { buildOpenInEditorCommand, buildOpenInFolderCommand, renderMarkdownEditorAppIcon } from './host/external-actions.js';
|
|
15
15
|
import { attachSelectionContext } from './host/selection-context.js';
|
|
16
16
|
import { createMarkdownController } from './markdown/controller.js';
|
|
17
17
|
import { createConflictDialogController, renderConflictDialogMarkup, } from './markdown/conflict-dialog.js';
|
|
@@ -40,7 +40,9 @@ let inlinePayloadBeforeFullscreen;
|
|
|
40
40
|
let directoryBackPayload;
|
|
41
41
|
let selectionAbortController = null;
|
|
42
42
|
const markdownEditorAppCache = new Map();
|
|
43
|
-
|
|
43
|
+
function getTelemetryToolName(payload) {
|
|
44
|
+
return typeof payload?.sourceTool === 'string' ? payload.sourceTool : 'read_file';
|
|
45
|
+
}
|
|
44
46
|
async function callToolIfReady(name, args) {
|
|
45
47
|
return rpcCallTool ? rpcCallTool(name, args) : undefined;
|
|
46
48
|
}
|
|
@@ -147,7 +149,10 @@ async function readAndResolvePayload(payload, onReady) {
|
|
|
147
149
|
try {
|
|
148
150
|
const freshPayload = await markdownController.readPayload(payload.filePath);
|
|
149
151
|
if (freshPayload) {
|
|
150
|
-
onReady(
|
|
152
|
+
onReady({
|
|
153
|
+
...freshPayload,
|
|
154
|
+
sourceTool: payload.sourceTool ?? freshPayload.sourceTool,
|
|
155
|
+
});
|
|
151
156
|
if (freshPayload.fileType === 'markdown') {
|
|
152
157
|
void markdownController.refreshFromDisk(freshPayload);
|
|
153
158
|
}
|
|
@@ -212,21 +217,15 @@ export function renderApp(container, payload, htmlMode = 'rendered', expandedSta
|
|
|
212
217
|
const canGoFullscreen = !isFullscreen && getDocumentFullscreenAvailability({
|
|
213
218
|
availableDisplayModes: getAvailableDisplayModes(),
|
|
214
219
|
}).canFullscreen;
|
|
220
|
+
if (payload.fileType === 'markdown' && payload.defaultEditorName) {
|
|
221
|
+
markdownEditorAppCache.set(payload.filePath, {
|
|
222
|
+
appName: payload.defaultEditorName,
|
|
223
|
+
appPath: payload.defaultEditorPath,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
215
226
|
const defaultMarkdownEditor = payload.fileType === 'markdown'
|
|
216
227
|
? markdownEditorAppCache.get(payload.filePath)
|
|
217
228
|
: undefined;
|
|
218
|
-
if (payload.fileType === 'markdown' && !defaultMarkdownEditor) {
|
|
219
|
-
void detectDefaultMarkdownEditor({
|
|
220
|
-
filePath: payload.filePath,
|
|
221
|
-
editorAppCache: markdownEditorAppCache,
|
|
222
|
-
editorAppPending: markdownEditorAppPending,
|
|
223
|
-
callTool: callToolIfReady,
|
|
224
|
-
extractToolText,
|
|
225
|
-
onDetected: () => {
|
|
226
|
-
rerenderCurrent?.();
|
|
227
|
-
},
|
|
228
|
-
});
|
|
229
|
-
}
|
|
230
229
|
const layout = buildDocumentLayout({
|
|
231
230
|
payload,
|
|
232
231
|
body,
|
|
@@ -415,9 +414,12 @@ export function bootstrapApp() {
|
|
|
415
414
|
const result = await app.requestDisplayMode({ mode });
|
|
416
415
|
return typeof result.mode === 'string' ? result.mode : null;
|
|
417
416
|
};
|
|
418
|
-
|
|
417
|
+
const filePreviewUiEvent = createUiEventTracker((name, args) => app.callServerTool({ name, arguments: args }), {
|
|
419
418
|
component: 'file_preview',
|
|
420
|
-
|
|
419
|
+
});
|
|
420
|
+
trackUiEvent = (event, params = {}) => filePreviewUiEvent(event, {
|
|
421
|
+
tool_name: getTelemetryToolName(currentPayload ?? hostPayload),
|
|
422
|
+
...params,
|
|
421
423
|
});
|
|
422
424
|
app.ontoolinput = (params) => {
|
|
423
425
|
const requestedPath = typeof params.arguments?.path === 'string' ? params.arguments.path : undefined;
|
|
@@ -146,7 +146,7 @@ export function attachDirectoryHandlers(options) {
|
|
|
146
146
|
loadMoreBtn.querySelector('.dir-warning-text').textContent = 'Loading…';
|
|
147
147
|
loadMoreBtn.disabled = true;
|
|
148
148
|
try {
|
|
149
|
-
const result = await options.callTool?.('list_directory', { path: loadPath, depth: 1 });
|
|
149
|
+
const result = await options.callTool?.('list_directory', { path: loadPath, depth: 1, origin: 'ui' });
|
|
150
150
|
const text = extractToolText(result) ?? '';
|
|
151
151
|
if (text) {
|
|
152
152
|
const parsed = parseDirectoryEntries(text);
|
|
@@ -194,7 +194,7 @@ export function attachDirectoryHandlers(options) {
|
|
|
194
194
|
if (chevron)
|
|
195
195
|
chevron.textContent = '⏳';
|
|
196
196
|
try {
|
|
197
|
-
const result = await options.callTool?.('list_directory', { path: fullPath, depth: 2 });
|
|
197
|
+
const result = await options.callTool?.('list_directory', { path: fullPath, depth: 2, origin: 'ui' });
|
|
198
198
|
const text = extractToolText(result) ?? '';
|
|
199
199
|
if (text) {
|
|
200
200
|
target.dataset.loaded = 'true';
|
|
@@ -222,7 +222,7 @@ export function attachDirectoryHandlers(options) {
|
|
|
222
222
|
if (target.classList.contains('dir-row-file')) {
|
|
223
223
|
target.classList.add('dir-loading');
|
|
224
224
|
try {
|
|
225
|
-
const result = await options.callTool?.('read_file', { path: fullPath });
|
|
225
|
+
const result = await options.callTool?.('read_file', { path: fullPath, origin: 'ui' });
|
|
226
226
|
if (!result || typeof result !== 'object' || result === null) {
|
|
227
227
|
return;
|
|
228
228
|
}
|
|
@@ -16,13 +16,13 @@ function renderImageBody(payload) {
|
|
|
16
16
|
html: '<div class="panel-content source-content"></div>',
|
|
17
17
|
};
|
|
18
18
|
}
|
|
19
|
-
if (!payload.
|
|
19
|
+
if (!payload.content || payload.content.trim().length === 0) {
|
|
20
20
|
return {
|
|
21
21
|
notice: 'Preview is unavailable because image data is missing.',
|
|
22
22
|
html: '<div class="panel-content source-content"></div>',
|
|
23
23
|
};
|
|
24
24
|
}
|
|
25
|
-
const src = `data:${mimeType};base64,${payload.
|
|
25
|
+
const src = `data:${mimeType};base64,${payload.content}`;
|
|
26
26
|
return {
|
|
27
27
|
html: `<div class="panel-content image-content"><div class="image-preview"><img src="${escapeHtml(src)}" alt="${escapeHtml(payload.fileName)}" loading="eager" decoding="async"></div></div>`,
|
|
28
28
|
};
|
|
@@ -5,15 +5,4 @@ export declare function buildOpenInEditorCommand(filePath: string, isLikelyUrl:
|
|
|
5
5
|
appName: string;
|
|
6
6
|
appPath?: string;
|
|
7
7
|
}>): string | undefined;
|
|
8
|
-
export declare function detectDefaultMarkdownEditor(options: {
|
|
9
|
-
filePath: string;
|
|
10
|
-
editorAppCache: Map<string, {
|
|
11
|
-
appName: string;
|
|
12
|
-
appPath?: string;
|
|
13
|
-
}>;
|
|
14
|
-
editorAppPending: Set<string>;
|
|
15
|
-
callTool?: (name: string, args: Record<string, unknown>) => Promise<unknown | undefined>;
|
|
16
|
-
extractToolText: (value: unknown) => string | undefined;
|
|
17
|
-
onDetected?: () => void;
|
|
18
|
-
}): Promise<void>;
|
|
19
8
|
export declare function renderMarkdownEditorAppIcon(): string;
|
|
@@ -50,45 +50,6 @@ export function buildOpenInEditorCommand(filePath, isLikelyUrl, editorAppCache)
|
|
|
50
50
|
}
|
|
51
51
|
return `xdg-open ${shellQuote(trimmedPath)}`;
|
|
52
52
|
}
|
|
53
|
-
export async function detectDefaultMarkdownEditor(options) {
|
|
54
|
-
const trimmedPath = options.filePath.trim();
|
|
55
|
-
if (!trimmedPath || options.editorAppCache.has(trimmedPath) || options.editorAppPending.has(trimmedPath)) {
|
|
56
|
-
return;
|
|
57
|
-
}
|
|
58
|
-
const userAgent = navigator.userAgent.toLowerCase();
|
|
59
|
-
if (!userAgent.includes('mac')) {
|
|
60
|
-
return;
|
|
61
|
-
}
|
|
62
|
-
options.editorAppPending.add(trimmedPath);
|
|
63
|
-
try {
|
|
64
|
-
const detectCommand = `osascript -e ${shellQuote(`set appAlias to default application of (info for POSIX file "${trimmedPath.replace(/"/g, '\\"')}")
|
|
65
|
-
return (name of (info for appAlias)) & linefeed & POSIX path of appAlias`)}`;
|
|
66
|
-
const detectResult = await options.callTool?.('start_process', {
|
|
67
|
-
command: detectCommand,
|
|
68
|
-
timeout_ms: 12000,
|
|
69
|
-
});
|
|
70
|
-
const text = options.extractToolText(detectResult) ?? '';
|
|
71
|
-
if (!text || text.toLowerCase().includes('error') || text.toLowerCase().includes('execution')) {
|
|
72
|
-
return;
|
|
73
|
-
}
|
|
74
|
-
const lines = text.split('\n').map((line) => line.trim()).filter(Boolean);
|
|
75
|
-
const appName = lines[lines.length - 2]?.replace(/\.app$/i, '') ?? '';
|
|
76
|
-
const appPath = lines[lines.length - 1] ?? '';
|
|
77
|
-
if (appName && appPath.startsWith('/')) {
|
|
78
|
-
options.editorAppCache.set(trimmedPath, {
|
|
79
|
-
appName,
|
|
80
|
-
appPath,
|
|
81
|
-
});
|
|
82
|
-
options.onDetected?.();
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
catch {
|
|
86
|
-
// Fall back to generic editor label.
|
|
87
|
-
}
|
|
88
|
-
finally {
|
|
89
|
-
options.editorAppPending.delete(trimmedPath);
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
53
|
export function renderMarkdownEditorAppIcon() {
|
|
93
54
|
return '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 1 1 3 3L7 19l-4 1 1-4Z"/></svg>';
|
|
94
55
|
}
|
|
@@ -281,6 +281,7 @@ export function createMarkdownController(dependencies) {
|
|
|
281
281
|
const rawResult = await dependencies.callTool?.('read_file', {
|
|
282
282
|
path: filePath,
|
|
283
283
|
...(typeof length === 'number' ? { offset: offset ?? 0, length } : {}),
|
|
284
|
+
origin: 'ui',
|
|
284
285
|
});
|
|
285
286
|
return { rawResult, payload: extractRenderPayload(rawResult) ?? null };
|
|
286
287
|
}
|
|
@@ -360,7 +361,7 @@ export function createMarkdownController(dependencies) {
|
|
|
360
361
|
const markers = new Set(['[DIR] .git', '[DIR] .obsidian', '[FILE] package.json', '[FILE] pnpm-workspace.yaml', '[FILE] turbo.json']);
|
|
361
362
|
for (const ancestor of ancestors) {
|
|
362
363
|
try {
|
|
363
|
-
const result = await dependencies.callTool?.('list_directory', { path: ancestor, depth: 1 });
|
|
364
|
+
const result = await dependencies.callTool?.('list_directory', { path: ancestor, depth: 1, origin: 'ui' });
|
|
364
365
|
const text = extractToolText(result) ?? '';
|
|
365
366
|
const entries = splitListingLines(text);
|
|
366
367
|
if (entries.some((entry) => markers.has(entry))) {
|
|
@@ -681,6 +682,7 @@ export function createMarkdownController(dependencies) {
|
|
|
681
682
|
old_string: block.old_string,
|
|
682
683
|
new_string: block.new_string,
|
|
683
684
|
expected_replacements: 1,
|
|
685
|
+
origin: 'ui',
|
|
684
686
|
});
|
|
685
687
|
assertSuccessfulEditBlockResult(editResult);
|
|
686
688
|
appliedCount++;
|
|
@@ -144,8 +144,8 @@ export function attachPanelActions(options) {
|
|
|
144
144
|
});
|
|
145
145
|
try {
|
|
146
146
|
const readArgs = direction === 'before'
|
|
147
|
-
? { path: options.payload.filePath, offset: 0, length: range.fromLine - 1 }
|
|
148
|
-
: { path: options.payload.filePath, offset: range.toLine };
|
|
147
|
+
? { path: options.payload.filePath, offset: 0, length: range.fromLine - 1, origin: 'ui' }
|
|
148
|
+
: { path: options.payload.filePath, offset: range.toLine, origin: 'ui' };
|
|
149
149
|
const result = await options.callTool?.('read_file', readArgs);
|
|
150
150
|
const newText = extractToolText(result);
|
|
151
151
|
if (newText && typeof newText === 'string') {
|
|
@@ -10,10 +10,9 @@ import { version as nodeVersion } from 'process';
|
|
|
10
10
|
import * as https from 'https';
|
|
11
11
|
import { randomUUID } from 'crypto';
|
|
12
12
|
|
|
13
|
-
//
|
|
14
|
-
const
|
|
15
|
-
const
|
|
16
|
-
const GA_BASE_URL = `https://www.google-analytics.com/mp/collect?measurement_id=${GA_MEASUREMENT_ID}&api_secret=${GA_API_SECRET}`;
|
|
13
|
+
// Telemetry proxy configuration
|
|
14
|
+
const TELEMETRY_PROXY_URL = 'https://telemetry.desktopcommander.app/mp/collect';
|
|
15
|
+
const TELEMETRY_PROXY_FALLBACK_URL = 'https://dc-telemetry-proxy-83847352264.europe-west1.run.app/mp/collect';
|
|
17
16
|
|
|
18
17
|
// Read clientId and telemetry settings from existing config
|
|
19
18
|
let uniqueUserId = 'unknown';
|
|
@@ -222,11 +221,6 @@ async function trackEvent(eventName, additionalProps = {}) {
|
|
|
222
221
|
return true; // Return success since this is expected behavior
|
|
223
222
|
}
|
|
224
223
|
|
|
225
|
-
if (!GA_MEASUREMENT_ID || !GA_API_SECRET) {
|
|
226
|
-
updateUninstallStep(trackingStep, 'skipped', new Error('GA not configured'));
|
|
227
|
-
return;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
224
|
const maxRetries = 2;
|
|
231
225
|
let attempt = 0;
|
|
232
226
|
let lastError = null;
|
|
@@ -257,43 +251,8 @@ async function trackEvent(eventName, additionalProps = {}) {
|
|
|
257
251
|
}
|
|
258
252
|
};
|
|
259
253
|
|
|
260
|
-
const result = await
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
const timeoutId = setTimeout(() => {
|
|
264
|
-
req.destroy();
|
|
265
|
-
reject(new Error('Request timeout'));
|
|
266
|
-
}, 5000);
|
|
267
|
-
|
|
268
|
-
req.on('error', (error) => {
|
|
269
|
-
clearTimeout(timeoutId);
|
|
270
|
-
reject(error);
|
|
271
|
-
});
|
|
272
|
-
|
|
273
|
-
req.on('response', (res) => {
|
|
274
|
-
clearTimeout(timeoutId);
|
|
275
|
-
let data = '';
|
|
276
|
-
|
|
277
|
-
res.on('data', (chunk) => {
|
|
278
|
-
data += chunk;
|
|
279
|
-
});
|
|
280
|
-
|
|
281
|
-
res.on('error', (error) => {
|
|
282
|
-
reject(error);
|
|
283
|
-
});
|
|
284
|
-
|
|
285
|
-
res.on('end', () => {
|
|
286
|
-
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
287
|
-
resolve({ success: true, data });
|
|
288
|
-
} else {
|
|
289
|
-
reject(new Error(`HTTP error ${res.statusCode}: ${data}`));
|
|
290
|
-
}
|
|
291
|
-
});
|
|
292
|
-
});
|
|
293
|
-
|
|
294
|
-
req.write(postData);
|
|
295
|
-
req.end();
|
|
296
|
-
});
|
|
254
|
+
const result = await postTelemetryPayload(postData, options);
|
|
255
|
+
if (!result.success) throw new Error('Telemetry proxy request failed');
|
|
297
256
|
|
|
298
257
|
updateUninstallStep(trackingStep, 'completed');
|
|
299
258
|
return result;
|
|
@@ -309,6 +268,54 @@ async function trackEvent(eventName, additionalProps = {}) {
|
|
|
309
268
|
updateUninstallStep(trackingStep, 'failed', lastError);
|
|
310
269
|
return false;
|
|
311
270
|
}
|
|
271
|
+
|
|
272
|
+
async function postTelemetryPayload(postData, options) {
|
|
273
|
+
for (const endpoint of [TELEMETRY_PROXY_URL, TELEMETRY_PROXY_FALLBACK_URL]) {
|
|
274
|
+
const result = await new Promise((resolve) => {
|
|
275
|
+
let settled = false;
|
|
276
|
+
let timeoutId;
|
|
277
|
+
const finish = (result) => {
|
|
278
|
+
if (settled) return;
|
|
279
|
+
settled = true;
|
|
280
|
+
clearTimeout(timeoutId);
|
|
281
|
+
resolve(result);
|
|
282
|
+
};
|
|
283
|
+
const req = https.request(endpoint, options);
|
|
284
|
+
|
|
285
|
+
timeoutId = setTimeout(() => {
|
|
286
|
+
req.destroy();
|
|
287
|
+
finish({ success: false, data: '' });
|
|
288
|
+
}, 5000);
|
|
289
|
+
|
|
290
|
+
req.on('error', () => {
|
|
291
|
+
finish({ success: false, data: '' });
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
req.on('response', (res) => {
|
|
295
|
+
let data = '';
|
|
296
|
+
res.on('data', (chunk) => {
|
|
297
|
+
data += chunk;
|
|
298
|
+
});
|
|
299
|
+
res.on('error', () => {
|
|
300
|
+
finish({ success: false, data: '' });
|
|
301
|
+
});
|
|
302
|
+
res.on('end', () => {
|
|
303
|
+
finish({ success: res.statusCode >= 200 && res.statusCode < 300, data });
|
|
304
|
+
});
|
|
305
|
+
res.on('close', () => {
|
|
306
|
+
finish({ success: false, data: '' });
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
req.write(postData);
|
|
311
|
+
req.end();
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
if (result.success) return result;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return { success: false, data: '' };
|
|
318
|
+
}
|
|
312
319
|
// Ensure tracking completes before process exits
|
|
313
320
|
async function ensureTrackingCompleted(eventName, additionalProps = {}, timeoutMs = 6000) {
|
|
314
321
|
return new Promise(async (resolve) => {
|
|
@@ -750,4 +757,4 @@ if (process.argv.length >= 2 && process.argv[1] === fileURLToPath(import.meta.ur
|
|
|
750
757
|
logToFile(`Fatal error: ${error}`, true);
|
|
751
758
|
process.exit(1);
|
|
752
759
|
});
|
|
753
|
-
}
|
|
760
|
+
}
|
package/dist/utils/ab-test.d.ts
CHANGED
package/dist/utils/ab-test.js
CHANGED
|
@@ -57,6 +57,12 @@ async function getVariant(experimentName) {
|
|
|
57
57
|
variantCache[experimentName] = variant;
|
|
58
58
|
return variant;
|
|
59
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* Get the exact assigned variant for a named experiment.
|
|
62
|
+
*/
|
|
63
|
+
export async function getABTestVariant(experimentName) {
|
|
64
|
+
return getVariant(experimentName);
|
|
65
|
+
}
|
|
60
66
|
/**
|
|
61
67
|
* Check if a feature (variant name) is enabled for current user
|
|
62
68
|
*/
|
package/dist/utils/capture.d.ts
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hard kill-switch for telemetry via environment variable.
|
|
3
|
+
*
|
|
4
|
+
* Independent of the persisted `telemetryEnabled` config so that tests, CI and
|
|
5
|
+
* one-off runs can suppress all analytics without mutating the user's config.
|
|
6
|
+
* Set DESKTOP_COMMANDER_DISABLE_TELEMETRY to 1/true/yes/on to disable.
|
|
7
|
+
*/
|
|
8
|
+
export declare function isTelemetryDisabledByEnv(): boolean;
|
|
1
9
|
/**
|
|
2
10
|
* Sanitizes error objects to remove potentially sensitive information like file paths
|
|
3
11
|
* @param error Error object or string to sanitize
|
|
@@ -8,13 +16,13 @@ export declare function sanitizeError(error: any): {
|
|
|
8
16
|
code?: string;
|
|
9
17
|
};
|
|
10
18
|
/**
|
|
11
|
-
* Send an event to
|
|
19
|
+
* Send an event to telemetry
|
|
12
20
|
* @param event Event name
|
|
13
21
|
* @param properties Optional event properties
|
|
14
22
|
*/
|
|
15
23
|
export declare const captureBase: (captureURL: string, event: string, properties?: any) => Promise<void>;
|
|
16
|
-
export declare const capture_call_tool: (event: string, properties?: any) => Promise<void>;
|
|
17
24
|
export declare const capture: (event: string, properties?: any) => Promise<void>;
|
|
25
|
+
export declare const capture_call_tool: (event: string, properties?: any) => Promise<void>;
|
|
18
26
|
export declare const capture_ui_event: (event: string, properties?: any) => Promise<void>;
|
|
19
27
|
/**
|
|
20
28
|
* Wrapper for capture() that automatically adds remote flag for remote-device telemetry
|
package/dist/utils/capture.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { platform } from 'os';
|
|
2
2
|
import * as https from 'https';
|
|
3
3
|
import { configManager, isTelemetryDisabledValue } from '../config-manager.js';
|
|
4
|
-
import { currentClient } from '../server.js';
|
|
4
|
+
import { currentClient, currentCallIsRemote, currentRemoteClient } from '../server.js';
|
|
5
5
|
let VERSION = 'unknown';
|
|
6
6
|
try {
|
|
7
7
|
const versionModule = await import('../version.js');
|
|
@@ -13,8 +13,26 @@ catch {
|
|
|
13
13
|
// Will be initialized when needed
|
|
14
14
|
let uniqueUserId = 'unknown';
|
|
15
15
|
// --- Telemetry Proxy (direct BigQuery ingestion) ---
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
// TODO: Move proxy endpoints, auth header setup, request retry/fallback, and
|
|
17
|
+
// transport code into a dedicated telemetry utility once this migration lands.
|
|
18
|
+
// TODO(security): bearer token was removed, so this endpoint is now unauthenticated.
|
|
19
|
+
// Confirm the proxy enforces rate limiting / payload validation server-side,
|
|
20
|
+
// otherwise anyone can POST arbitrary events straight into BigQuery ingestion.
|
|
21
|
+
const TELEMETRY_PROXY_URL = 'https://telemetry.desktopcommander.app/mp/collect';
|
|
22
|
+
const TELEMETRY_PROXY_FALLBACK_URL = 'https://dc-telemetry-proxy-83847352264.europe-west1.run.app/mp/collect';
|
|
23
|
+
/**
|
|
24
|
+
* Hard kill-switch for telemetry via environment variable.
|
|
25
|
+
*
|
|
26
|
+
* Independent of the persisted `telemetryEnabled` config so that tests, CI and
|
|
27
|
+
* one-off runs can suppress all analytics without mutating the user's config.
|
|
28
|
+
* Set DESKTOP_COMMANDER_DISABLE_TELEMETRY to 1/true/yes/on to disable.
|
|
29
|
+
*/
|
|
30
|
+
export function isTelemetryDisabledByEnv() {
|
|
31
|
+
const raw = process.env.DESKTOP_COMMANDER_DISABLE_TELEMETRY;
|
|
32
|
+
if (!raw)
|
|
33
|
+
return false;
|
|
34
|
+
return ['1', 'true', 'yes', 'on'].includes(raw.trim().toLowerCase());
|
|
35
|
+
}
|
|
18
36
|
/**
|
|
19
37
|
* Sanitizes error objects to remove potentially sensitive information like file paths
|
|
20
38
|
* @param error Error object or string to sanitize
|
|
@@ -47,12 +65,19 @@ export function sanitizeError(error) {
|
|
|
47
65
|
};
|
|
48
66
|
}
|
|
49
67
|
/**
|
|
50
|
-
* Send an event to
|
|
68
|
+
* Send an event to telemetry
|
|
51
69
|
* @param event Event name
|
|
52
70
|
* @param properties Optional event properties
|
|
53
71
|
*/
|
|
72
|
+
// TODO(cleanup): captureBase is now dead code — no caller remains after the GA
|
|
73
|
+
// removal (only referenced in a comment). It still carries the full GA4-flavored
|
|
74
|
+
// send path. Remove it, or repurpose it as the shared proxy transport.
|
|
54
75
|
export const captureBase = async (captureURL, event, properties) => {
|
|
55
76
|
try {
|
|
77
|
+
// Env kill-switch takes precedence over config (tests/CI).
|
|
78
|
+
if (isTelemetryDisabledByEnv()) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
56
81
|
// Check if telemetry is enabled in config (defaults to true if not set)
|
|
57
82
|
const telemetryEnabled = await configManager.getValue('telemetryEnabled');
|
|
58
83
|
// If telemetry is explicitly disabled or GA credentials are missing, don't send
|
|
@@ -64,11 +89,14 @@ export const captureBase = async (captureURL, event, properties) => {
|
|
|
64
89
|
uniqueUserId = await configManager.getOrCreateClientId();
|
|
65
90
|
}
|
|
66
91
|
// Get current client information for all events
|
|
92
|
+
// For remote calls, attribute to the originating remote client (carried on
|
|
93
|
+
// the tool call) instead of the device's local currentClient.
|
|
94
|
+
const effectiveClient = currentCallIsRemote && currentRemoteClient ? currentRemoteClient : currentClient;
|
|
67
95
|
let clientContext = {};
|
|
68
|
-
if (
|
|
96
|
+
if (effectiveClient) {
|
|
69
97
|
clientContext = {
|
|
70
|
-
client_name:
|
|
71
|
-
client_version:
|
|
98
|
+
client_name: effectiveClient.name,
|
|
99
|
+
client_version: effectiveClient.version,
|
|
72
100
|
};
|
|
73
101
|
}
|
|
74
102
|
// Track if user saw onboarding page
|
|
@@ -178,9 +206,13 @@ export const captureBase = async (captureURL, event, properties) => {
|
|
|
178
206
|
const eventProperties = {
|
|
179
207
|
...baseProperties,
|
|
180
208
|
...clientContext,
|
|
209
|
+
// Attribute events to the remote path when the in-flight tool call
|
|
210
|
+
// came from a remote device. Placed before sanitizedProperties so an
|
|
211
|
+
// explicit `remote` passed by the caller (e.g. captureRemote) wins.
|
|
212
|
+
...(currentCallIsRemote ? { remote: String(true) } : {}),
|
|
181
213
|
...sanitizedProperties
|
|
182
214
|
};
|
|
183
|
-
// Prepare
|
|
215
|
+
// Prepare telemetry payload
|
|
184
216
|
const payload = {
|
|
185
217
|
client_id: uniqueUserId,
|
|
186
218
|
non_personalized_ads: false,
|
|
@@ -190,7 +222,7 @@ export const captureBase = async (captureURL, event, properties) => {
|
|
|
190
222
|
params: eventProperties
|
|
191
223
|
}]
|
|
192
224
|
};
|
|
193
|
-
// Send data to
|
|
225
|
+
// Send data to telemetry endpoint
|
|
194
226
|
const postData = JSON.stringify(payload);
|
|
195
227
|
const options = {
|
|
196
228
|
method: 'POST',
|
|
@@ -209,7 +241,7 @@ export const captureBase = async (captureURL, event, properties) => {
|
|
|
209
241
|
const success = res.statusCode === 200 || res.statusCode === 204;
|
|
210
242
|
if (!success) {
|
|
211
243
|
// Optional debug logging
|
|
212
|
-
// console.debug(`
|
|
244
|
+
// console.debug(`Telemetry tracking error: ${res.statusCode} ${data}`);
|
|
213
245
|
}
|
|
214
246
|
});
|
|
215
247
|
});
|
|
@@ -229,18 +261,21 @@ export const captureBase = async (captureURL, event, properties) => {
|
|
|
229
261
|
}
|
|
230
262
|
};
|
|
231
263
|
/**
|
|
232
|
-
* Build the standard event properties used by
|
|
264
|
+
* Build the standard event properties used by the telemetry proxy.
|
|
233
265
|
* Extracted from captureBase so both paths get identical data.
|
|
234
266
|
*/
|
|
235
267
|
const buildEventProperties = async (properties) => {
|
|
236
268
|
if (uniqueUserId === 'unknown') {
|
|
237
269
|
uniqueUserId = await configManager.getOrCreateClientId();
|
|
238
270
|
}
|
|
271
|
+
// For remote calls, attribute to the originating remote client (carried on
|
|
272
|
+
// the tool call) instead of the device's local currentClient.
|
|
273
|
+
const effectiveClient = currentCallIsRemote && currentRemoteClient ? currentRemoteClient : currentClient;
|
|
239
274
|
let clientContext = {};
|
|
240
|
-
if (
|
|
275
|
+
if (effectiveClient) {
|
|
241
276
|
clientContext = {
|
|
242
|
-
client_name:
|
|
243
|
-
client_version:
|
|
277
|
+
client_name: effectiveClient.name,
|
|
278
|
+
client_version: effectiveClient.version,
|
|
244
279
|
};
|
|
245
280
|
}
|
|
246
281
|
const sawOnboardingPage = await configManager.getValue('sawOnboardingPage');
|
|
@@ -325,16 +360,21 @@ const buildEventProperties = async (properties) => {
|
|
|
325
360
|
app_version: VERSION,
|
|
326
361
|
engagement_time_msec: "100",
|
|
327
362
|
...clientContext,
|
|
363
|
+
// Attribute events to the remote path when the in-flight tool call
|
|
364
|
+
// came from a remote device. Placed before sanitizedProperties so an
|
|
365
|
+
// explicit `remote` passed by the caller (e.g. captureRemote) wins.
|
|
366
|
+
...(currentCallIsRemote ? { remote: String(true) } : {}),
|
|
328
367
|
...sanitizedProperties,
|
|
329
368
|
};
|
|
330
369
|
};
|
|
331
370
|
/**
|
|
332
371
|
* Send event to the telemetry proxy (direct BigQuery ingestion).
|
|
333
|
-
*
|
|
334
|
-
* the 1M/day GA4 BigQuery export limit.
|
|
372
|
+
* Uses the custom domain first and retries the generated Cloud Run URL on failure.
|
|
335
373
|
*/
|
|
336
374
|
const sendToTelemetryProxy = async (event, eventProperties) => {
|
|
337
375
|
try {
|
|
376
|
+
if (isTelemetryDisabledByEnv())
|
|
377
|
+
return;
|
|
338
378
|
const telemetryEnabled = await configManager.getValue('telemetryEnabled');
|
|
339
379
|
if (isTelemetryDisabledValue(telemetryEnabled))
|
|
340
380
|
return;
|
|
@@ -346,7 +386,18 @@ const sendToTelemetryProxy = async (event, eventProperties) => {
|
|
|
346
386
|
params: eventProperties
|
|
347
387
|
}]
|
|
348
388
|
});
|
|
349
|
-
const
|
|
389
|
+
const sent = await postTelemetryPayload(TELEMETRY_PROXY_URL, payload);
|
|
390
|
+
if (!sent) {
|
|
391
|
+
await postTelemetryPayload(TELEMETRY_PROXY_FALLBACK_URL, payload);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
// Silent fail — telemetry should never break functionality
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
const postTelemetryPayload = async (endpoint, payload) => {
|
|
399
|
+
return await new Promise((resolve) => {
|
|
400
|
+
const url = new URL(endpoint);
|
|
350
401
|
const options = {
|
|
351
402
|
hostname: url.hostname,
|
|
352
403
|
port: 443,
|
|
@@ -354,59 +405,40 @@ const sendToTelemetryProxy = async (event, eventProperties) => {
|
|
|
354
405
|
method: 'POST',
|
|
355
406
|
headers: {
|
|
356
407
|
'Content-Type': 'application/json',
|
|
357
|
-
'Authorization': `Bearer ${TELEMETRY_PROXY_TOKEN}`,
|
|
358
408
|
'Content-Length': Buffer.byteLength(payload)
|
|
359
409
|
}
|
|
360
410
|
};
|
|
361
411
|
const req = https.request(options, (res) => {
|
|
362
|
-
res.resume();
|
|
412
|
+
res.resume();
|
|
413
|
+
res.on('end', () => resolve(res.statusCode !== undefined && res.statusCode >= 200 && res.statusCode < 300));
|
|
414
|
+
});
|
|
415
|
+
req.on('error', () => resolve(false));
|
|
416
|
+
req.setTimeout(3000, () => {
|
|
417
|
+
req.destroy();
|
|
418
|
+
resolve(false);
|
|
363
419
|
});
|
|
364
|
-
req.on('error', () => { }); // silent fail
|
|
365
|
-
req.setTimeout(3000, () => req.destroy());
|
|
366
420
|
req.write(payload);
|
|
367
421
|
req.end();
|
|
368
|
-
}
|
|
369
|
-
catch {
|
|
370
|
-
// Silent fail — telemetry should never break functionality
|
|
371
|
-
}
|
|
372
|
-
};
|
|
373
|
-
export const capture_call_tool = async (event, properties) => {
|
|
374
|
-
// Old property (G-8L163XZ1CE) — keeps lower-volume tool events
|
|
375
|
-
const GA_OLD_ID = 'G-8L163XZ1CE';
|
|
376
|
-
const GA_OLD_SECRET = 'hNxh4TK2TnSy4oLZn4RwTA';
|
|
377
|
-
const GA_OLD_URL = `https://www.google-analytics.com/mp/collect?measurement_id=${GA_OLD_ID}&api_secret=${GA_OLD_SECRET}`;
|
|
378
|
-
// New property (dc_high_volume) — receives highest-volume tool events to avoid 1M/day BQ export limit
|
|
379
|
-
const GA_NEW_ID = 'G-ZDF1M5403Z';
|
|
380
|
-
const GA_NEW_SECRET = 'cUEilpa0SpWfc2UjblDtKQ';
|
|
381
|
-
const GA_NEW_URL = `https://www.google-analytics.com/mp/collect?measurement_id=${GA_NEW_ID}&api_secret=${GA_NEW_SECRET}`;
|
|
382
|
-
// Route highest-volume tools to new property, rest to old
|
|
383
|
-
const HIGH_VOLUME_TOOLS = ['start_process', 'track_ui_event'];
|
|
384
|
-
const toolName = properties?.tool_name ?? properties?.name;
|
|
385
|
-
const gaUrl = HIGH_VOLUME_TOOLS.includes(toolName) ? GA_NEW_URL : GA_OLD_URL;
|
|
386
|
-
// Build properties once, send to GA4 + telemetry proxy in parallel
|
|
387
|
-
const eventProperties = await buildEventProperties(properties);
|
|
388
|
-
await Promise.all([
|
|
389
|
-
captureBase(gaUrl, event, properties), // GA4 (routed by tool name)
|
|
390
|
-
sendToTelemetryProxy(event, eventProperties), // direct BigQuery (all events)
|
|
391
|
-
]);
|
|
422
|
+
});
|
|
392
423
|
};
|
|
424
|
+
// TODO(behavior): capture() is now fire-and-forget — every `await capture(...)`
|
|
425
|
+
// call site resolves before the network send completes. Fine for the long-running
|
|
426
|
+
// MCP server, but events fired right before process exit (e.g. opt-out, feedback)
|
|
427
|
+
// can be silently dropped. If we need delivery guarantees on short-lived paths,
|
|
428
|
+
// expose an awaitable variant or flush-before-exit hook.
|
|
393
429
|
export const capture = async (event, properties) => {
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
};
|
|
404
|
-
export const capture_ui_event = async (event, properties) => {
|
|
405
|
-
const GA_MEASUREMENT_ID = 'G-MPFSWEGQ0T';
|
|
406
|
-
const GA_API_SECRET = 'BeK3uyAOQ6-TK6wnaDG2Ww';
|
|
407
|
-
const GA_BASE_URL = `https://www.google-analytics.com/mp/collect?measurement_id=${GA_MEASUREMENT_ID}&api_secret=${GA_API_SECRET}`;
|
|
408
|
-
return await captureBase(GA_BASE_URL, event, properties);
|
|
430
|
+
void (async () => {
|
|
431
|
+
try {
|
|
432
|
+
const eventProperties = await buildEventProperties(properties);
|
|
433
|
+
await sendToTelemetryProxy(event, eventProperties);
|
|
434
|
+
}
|
|
435
|
+
catch {
|
|
436
|
+
// Silent fail — telemetry should never break functionality
|
|
437
|
+
}
|
|
438
|
+
})();
|
|
409
439
|
};
|
|
440
|
+
export const capture_call_tool = capture;
|
|
441
|
+
export const capture_ui_event = capture;
|
|
410
442
|
/**
|
|
411
443
|
* Wrapper for capture() that automatically adds remote flag for remote-device telemetry
|
|
412
444
|
* Also adds additional privacy filtering to remove sensitive identity information
|