diff --git a/.gemini/settings.json b/.gemini/settings.json index 25a4a3b272..38707a8a49 100644 --- a/.gemini/settings.json +++ b/.gemini/settings.json @@ -4,5 +4,8 @@ "enabled": true }, "plan": true + }, + "general": { + "devtools": true } } diff --git a/docs/get-started/configuration.md b/docs/get-started/configuration.md index c17dc656cc..28578ae364 100644 --- a/docs/get-started/configuration.md +++ b/docs/get-started/configuration.md @@ -106,6 +106,10 @@ their corresponding top-level category object in your `settings.json` file. - **Description:** Enable Vim keybindings - **Default:** `false` +- **`general.devtools`** (boolean): + - **Description:** Enable DevTools inspector on launch. + - **Default:** `false` + - **`general.enableAutoUpdate`** (boolean): - **Description:** Enable automatic updates. - **Default:** `true` diff --git a/esbuild.config.js b/esbuild.config.js index 3fa6cae543..b2d33770cc 100644 --- a/esbuild.config.js +++ b/esbuild.config.js @@ -63,6 +63,7 @@ const external = [ '@lydell/node-pty-win32-arm64', '@lydell/node-pty-win32-x64', 'keytar', + 'gemini-cli-devtools', ]; const baseConfig = { diff --git a/package-lock.json b/package-lock.json index 882e0e55b1..682dbf2777 100644 --- a/package-lock.json +++ b/package-lock.json @@ -76,6 +76,7 @@ "@lydell/node-pty-linux-x64": "1.1.0", "@lydell/node-pty-win32-arm64": "1.1.0", "@lydell/node-pty-win32-x64": "1.1.0", + "gemini-cli-devtools": "^0.2.1", "keytar": "^7.9.0", "node-pty": "^1.0.0" } @@ -9605,6 +9606,18 @@ "node": ">=14" } }, + "node_modules/gemini-cli-devtools": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/gemini-cli-devtools/-/gemini-cli-devtools-0.2.1.tgz", + "integrity": "sha512-PcqPL9ZZjgjsp3oYhcXnUc6yNeLvdZuU/UQp0aT+DA8pt3BZzPzXthlOmIrRRqHBdLjMLPwN5GD29zR5bASXtQ==", + "optional": true, + "dependencies": { + "ws": "^8.16.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/gemini-cli-vscode-ide-companion": { "resolved": "packages/vscode-ide-companion", "link": true diff --git a/package.json b/package.json index 2a38846245..77c34b14f5 100644 --- a/package.json +++ b/package.json @@ -138,6 +138,7 @@ "@lydell/node-pty-linux-x64": "1.1.0", "@lydell/node-pty-win32-arm64": "1.1.0", "@lydell/node-pty-win32-x64": "1.1.0", + "gemini-cli-devtools": "^0.2.1", "keytar": "^7.9.0", "node-pty": "^1.0.0" }, diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 5798caa29d..2e53997a5d 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -179,6 +179,15 @@ const SETTINGS_SCHEMA = { description: 'Enable Vim keybindings', showInDialog: true, }, + devtools: { + type: 'boolean', + label: 'DevTools', + category: 'General', + requiresRestart: false, + default: false, + description: 'Enable DevTools inspector on launch.', + showInDialog: false, + }, enableAutoUpdate: { type: 'boolean', label: 'Enable Auto Update', diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 1887c8796e..fcbe183032 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -518,11 +518,11 @@ export async function main() { adminControlsListner.setConfig(config); - if (config.isInteractive() && config.getDebugMode()) { + if (config.isInteractive() && settings.merged.general.devtools) { const { registerActivityLogger } = await import( - './utils/activityLogger.js' + './utils/devtoolsService.js' ); - registerActivityLogger(config); + await registerActivityLogger(config); } // Register config for telemetry shutdown diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index 0824788503..886bfd3587 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -39,7 +39,7 @@ import type { LoadedSettings } from './config/settings.js'; vi.mock('./ui/hooks/atCommandProcessor.js'); const mockRegisterActivityLogger = vi.hoisted(() => vi.fn()); -vi.mock('./utils/activityLogger.js', () => ({ +vi.mock('./utils/devtoolsService.js', () => ({ registerActivityLogger: mockRegisterActivityLogger, })); diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index eca75ac739..dfe3e0274f 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -73,9 +73,9 @@ export async function runNonInteractive({ if (process.env['GEMINI_CLI_ACTIVITY_LOG_TARGET']) { const { registerActivityLogger } = await import( - './utils/activityLogger.js' + './utils/devtoolsService.js' ); - registerActivityLogger(config); + await registerActivityLogger(config); } const { stdout: workingStdout } = createWorkingStdio(); diff --git a/packages/cli/src/utils/activityLogger.ts b/packages/cli/src/utils/activityLogger.ts index fb35cd881c..4e88dd5c60 100644 --- a/packages/cli/src/utils/activityLogger.ts +++ b/packages/cli/src/utils/activityLogger.ts @@ -21,29 +21,6 @@ import WebSocket from 'ws'; const ACTIVITY_ID_HEADER = 'x-activity-request-id'; const MAX_BUFFER_SIZE = 100; -/** - * Parse a host:port string into its components. - * Uses the URL constructor for robust handling of IPv4, IPv6, and hostnames. - * Returns null for file paths or values without a valid port. - */ -function parseHostPort(value: string): { host: string; port: number } | null { - if (value.startsWith('/') || value.startsWith('.')) return null; - - try { - const url = new URL(`ws://${value}`); - if (!url.port) return null; - - const port = parseInt(url.port, 10); - if (url.hostname && !isNaN(port) && port > 0 && port <= 65535) { - return { host: url.hostname, port }; - } - } catch { - // Not a valid host:port - } - - return null; -} - export interface NetworkLog { id: string; timestamp: number; @@ -494,12 +471,15 @@ function setupNetworkLogging( host: string, port: number, config: Config, + onReconnectFailed?: () => void, ) { const buffer: Array> = []; let ws: WebSocket | null = null; let reconnectTimer: NodeJS.Timeout | null = null; let sessionId: string | null = null; let pingInterval: NodeJS.Timeout | null = null; + let reconnectAttempts = 0; + const MAX_RECONNECT_ATTEMPTS = 2; const connect = () => { try { @@ -507,6 +487,7 @@ function setupNetworkLogging( ws.on('open', () => { debugLogger.debug(`WebSocket connected to ${host}:${port}`); + reconnectAttempts = 0; // Register with CLI's session ID sendMessage({ type: 'register', @@ -620,11 +601,20 @@ function setupNetworkLogging( const scheduleReconnect = () => { if (reconnectTimer) return; + reconnectAttempts++; + if (reconnectAttempts > MAX_RECONNECT_ATTEMPTS && onReconnectFailed) { + debugLogger.debug( + `WebSocket reconnect failed after ${MAX_RECONNECT_ATTEMPTS} attempts, promoting to server...`, + ); + onReconnectFailed(); + return; + } + reconnectTimer = setTimeout(() => { reconnectTimer = null; debugLogger.debug('Reconnecting WebSocket...'); connect(); - }, 5000); + }, 1000); }; // Initial connection @@ -645,41 +635,65 @@ function setupNetworkLogging( }); } +let bridgeAttached = false; + /** - * Registers the activity logger if debug mode and interactive session are enabled. - * Captures network and console logs to a session-specific JSONL file or sends to network. - * - * Environment variable GEMINI_CLI_ACTIVITY_LOG_TARGET controls the output: - * - host:port format (e.g., "localhost:25417") → network mode (auto-enabled) - * - file path (e.g., "/tmp/logs.jsonl") → file mode (immediate) - * - not set → uses default file location in project temp logs dir - * - * @param config The CLI configuration + * Bridge coreEvents to the ActivityLogger singleton (guarded — only once). */ -export function registerActivityLogger(config: Config) { - const target = process.env['GEMINI_CLI_ACTIVITY_LOG_TARGET']; - const hostPort = target ? parseHostPort(target) : null; - - // Network mode doesn't need storage; file mode does - if (!hostPort && !config.storage) { - return; - } - - const capture = ActivityLogger.getInstance(); - capture.enable(); - - if (hostPort) { - // Network mode: send logs via WebSocket - setupNetworkLogging(capture, hostPort.host, hostPort.port, config); - // Auto-enable network logging when target is explicitly configured - capture.enableNetworkLogging(); - } else { - // File mode: write to JSONL file - setupFileLogging(capture, config, target); - } - - // Bridge CoreEvents to local capture +function bridgeCoreEvents(capture: ActivityLogger) { + if (bridgeAttached) return; + bridgeAttached = true; coreEvents.on(CoreEvent.ConsoleLog, (payload) => { capture.logConsole(payload); }); } + +/** + * Initialize the activity logger with a specific transport mode. + * + * @param config CLI configuration + * @param options Transport configuration: network (WebSocket) or file (JSONL) + */ +export function initActivityLogger( + config: Config, + options: + | { + mode: 'network'; + host: string; + port: number; + onReconnectFailed?: () => void; + } + | { mode: 'file'; filePath?: string }, +): void { + const capture = ActivityLogger.getInstance(); + capture.enable(); + + if (options.mode === 'network') { + setupNetworkLogging( + capture, + options.host, + options.port, + config, + options.onReconnectFailed, + ); + capture.enableNetworkLogging(); + } else { + setupFileLogging(capture, config, options.filePath); + } + + bridgeCoreEvents(capture); +} + +/** + * Add a network (WebSocket) transport to the existing ActivityLogger singleton. + * Used for promotion re-entry without re-bridging coreEvents. + */ +export function addNetworkTransport( + config: Config, + host: string, + port: number, + onReconnectFailed?: () => void, +): void { + const capture = ActivityLogger.getInstance(); + setupNetworkLogging(capture, host, port, config, onReconnectFailed); +} diff --git a/packages/cli/src/utils/devtoolsService.test.ts b/packages/cli/src/utils/devtoolsService.test.ts new file mode 100644 index 0000000000..2ac9cc9f9e --- /dev/null +++ b/packages/cli/src/utils/devtoolsService.test.ts @@ -0,0 +1,303 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import type { Config } from '@google/gemini-cli-core'; + +// --- Mocks (hoisted) --- + +const mockInitActivityLogger = vi.hoisted(() => vi.fn()); +const mockAddNetworkTransport = vi.hoisted(() => vi.fn()); + +type Listener = (...args: unknown[]) => void; + +const { MockWebSocket } = vi.hoisted(() => { + class MockWebSocket { + close = vi.fn(); + url: string; + static instances: MockWebSocket[] = []; + private listeners = new Map(); + + constructor(url: string) { + this.url = url; + MockWebSocket.instances.push(this); + } + + on(event: string, fn: Listener) { + const fns = this.listeners.get(event) || []; + fns.push(fn); + this.listeners.set(event, fns); + return this; + } + + emit(event: string, ...args: unknown[]) { + for (const fn of this.listeners.get(event) || []) { + fn(...args); + } + } + + simulateOpen() { + this.emit('open'); + } + + simulateError() { + this.emit('error', new Error('ECONNREFUSED')); + } + } + return { MockWebSocket }; +}); + +const mockDevToolsInstance = vi.hoisted(() => ({ + start: vi.fn(), + stop: vi.fn(), + getPort: vi.fn(), +})); + +vi.mock('./activityLogger.js', () => ({ + initActivityLogger: mockInitActivityLogger, + addNetworkTransport: mockAddNetworkTransport, +})); + +vi.mock('@google/gemini-cli-core', () => ({ + debugLogger: { + log: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock('ws', () => ({ + default: MockWebSocket, +})); + +vi.mock('gemini-cli-devtools', () => ({ + DevTools: { + getInstance: () => mockDevToolsInstance, + }, +})); + +// --- Import under test (after mocks) --- +import { registerActivityLogger, resetForTesting } from './devtoolsService.js'; + +function createMockConfig(overrides: Record = {}) { + return { + isInteractive: vi.fn().mockReturnValue(true), + getSessionId: vi.fn().mockReturnValue('test-session'), + getDebugMode: vi.fn().mockReturnValue(false), + storage: { getProjectTempLogsDir: vi.fn().mockReturnValue('/tmp/logs') }, + ...overrides, + } as unknown as Config; +} + +describe('devtoolsService', () => { + beforeEach(() => { + vi.clearAllMocks(); + MockWebSocket.instances = []; + resetForTesting(); + delete process.env['GEMINI_CLI_ACTIVITY_LOG_TARGET']; + }); + + describe('registerActivityLogger', () => { + it('connects to existing DevTools server when probe succeeds', async () => { + const config = createMockConfig(); + + // The probe WebSocket will succeed + const promise = registerActivityLogger(config); + + // Wait for WebSocket to be created + await vi.waitFor(() => { + expect(MockWebSocket.instances.length).toBe(1); + }); + + // Simulate probe success + MockWebSocket.instances[0].simulateOpen(); + + await promise; + + expect(mockInitActivityLogger).toHaveBeenCalledWith(config, { + mode: 'network', + host: '127.0.0.1', + port: 25417, + onReconnectFailed: expect.any(Function), + }); + }); + + it('starts new DevTools server when probe fails', async () => { + const config = createMockConfig(); + mockDevToolsInstance.start.mockResolvedValue('http://127.0.0.1:25417'); + mockDevToolsInstance.getPort.mockReturnValue(25417); + + const promise = registerActivityLogger(config); + + // Wait for probe WebSocket + await vi.waitFor(() => { + expect(MockWebSocket.instances.length).toBe(1); + }); + + // Simulate probe failure + MockWebSocket.instances[0].simulateError(); + + await promise; + + expect(mockDevToolsInstance.start).toHaveBeenCalled(); + expect(mockInitActivityLogger).toHaveBeenCalledWith(config, { + mode: 'network', + host: '127.0.0.1', + port: 25417, + onReconnectFailed: expect.any(Function), + }); + }); + + it('falls back to file mode when target env var is set', async () => { + process.env['GEMINI_CLI_ACTIVITY_LOG_TARGET'] = '/tmp/test.jsonl'; + const config = createMockConfig(); + + await registerActivityLogger(config); + + expect(mockInitActivityLogger).toHaveBeenCalledWith(config, { + mode: 'file', + filePath: '/tmp/test.jsonl', + }); + }); + + it('does nothing in file mode when config.storage is missing', async () => { + process.env['GEMINI_CLI_ACTIVITY_LOG_TARGET'] = '/tmp/test.jsonl'; + const config = createMockConfig({ storage: undefined }); + + await registerActivityLogger(config); + + expect(mockInitActivityLogger).not.toHaveBeenCalled(); + }); + + it('falls back to file logging when DevTools start fails', async () => { + const config = createMockConfig(); + mockDevToolsInstance.start.mockRejectedValue( + new Error('MODULE_NOT_FOUND'), + ); + + const promise = registerActivityLogger(config); + + // Wait for probe WebSocket + await vi.waitFor(() => { + expect(MockWebSocket.instances.length).toBe(1); + }); + + // Probe fails → tries to start server → server start fails → file fallback + MockWebSocket.instances[0].simulateError(); + + await promise; + + expect(mockInitActivityLogger).toHaveBeenCalledWith(config, { + mode: 'file', + filePath: undefined, + }); + }); + }); + + describe('startOrJoinDevTools (via registerActivityLogger)', () => { + it('stops own server and connects to existing when losing port race', async () => { + const config = createMockConfig(); + + // Server starts on a different port (lost the race) + mockDevToolsInstance.start.mockResolvedValue('http://127.0.0.1:25418'); + mockDevToolsInstance.getPort.mockReturnValue(25418); + + const promise = registerActivityLogger(config); + + // First: probe for existing server (fails) + await vi.waitFor(() => { + expect(MockWebSocket.instances.length).toBe(1); + }); + MockWebSocket.instances[0].simulateError(); + + // Second: after starting, probes the default port winner + await vi.waitFor(() => { + expect(MockWebSocket.instances.length).toBe(2); + }); + // Winner is alive + MockWebSocket.instances[1].simulateOpen(); + + await promise; + + expect(mockDevToolsInstance.stop).toHaveBeenCalled(); + expect(mockInitActivityLogger).toHaveBeenCalledWith( + config, + expect.objectContaining({ + mode: 'network', + host: '127.0.0.1', + port: 25417, // connected to winner's port + }), + ); + }); + + it('keeps own server when winner is not responding', async () => { + const config = createMockConfig(); + + mockDevToolsInstance.start.mockResolvedValue('http://127.0.0.1:25418'); + mockDevToolsInstance.getPort.mockReturnValue(25418); + + const promise = registerActivityLogger(config); + + // Probe for existing (fails) + await vi.waitFor(() => { + expect(MockWebSocket.instances.length).toBe(1); + }); + MockWebSocket.instances[0].simulateError(); + + // Probe the winner (also fails) + await vi.waitFor(() => { + expect(MockWebSocket.instances.length).toBe(2); + }); + MockWebSocket.instances[1].simulateError(); + + await promise; + + expect(mockDevToolsInstance.stop).not.toHaveBeenCalled(); + expect(mockInitActivityLogger).toHaveBeenCalledWith( + config, + expect.objectContaining({ + mode: 'network', + port: 25418, // kept own port + }), + ); + }); + }); + + describe('handlePromotion (via onReconnectFailed)', () => { + it('caps promotion attempts at MAX_PROMOTION_ATTEMPTS', async () => { + const config = createMockConfig(); + mockDevToolsInstance.start.mockResolvedValue('http://127.0.0.1:25417'); + mockDevToolsInstance.getPort.mockReturnValue(25417); + + // First: set up the logger so we can grab onReconnectFailed + const promise = registerActivityLogger(config); + + await vi.waitFor(() => { + expect(MockWebSocket.instances.length).toBe(1); + }); + MockWebSocket.instances[0].simulateError(); + + await promise; + + // Extract onReconnectFailed callback + const initCall = mockInitActivityLogger.mock.calls[0]; + const onReconnectFailed = initCall[1].onReconnectFailed; + expect(onReconnectFailed).toBeDefined(); + + // Trigger promotion MAX_PROMOTION_ATTEMPTS + 1 times + // Each call should succeed (addNetworkTransport called) until cap is hit + mockAddNetworkTransport.mockClear(); + + await onReconnectFailed(); // attempt 1 + await onReconnectFailed(); // attempt 2 + await onReconnectFailed(); // attempt 3 + await onReconnectFailed(); // attempt 4 — should be capped + + // Only 3 calls to addNetworkTransport (capped at MAX_PROMOTION_ATTEMPTS) + expect(mockAddNetworkTransport).toHaveBeenCalledTimes(3); + }); + }); +}); diff --git a/packages/cli/src/utils/devtoolsService.ts b/packages/cli/src/utils/devtoolsService.ts new file mode 100644 index 0000000000..661cd1c0a9 --- /dev/null +++ b/packages/cli/src/utils/devtoolsService.ts @@ -0,0 +1,179 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { debugLogger } from '@google/gemini-cli-core'; +import type { Config } from '@google/gemini-cli-core'; +import WebSocket from 'ws'; +import { initActivityLogger, addNetworkTransport } from './activityLogger.js'; + +interface IDevTools { + start(): Promise; + stop(): Promise; + getPort(): number; +} + +const DEVTOOLS_PKG = 'gemini-cli-devtools'; +const DEFAULT_DEVTOOLS_PORT = 25417; +const DEFAULT_DEVTOOLS_HOST = '127.0.0.1'; +const MAX_PROMOTION_ATTEMPTS = 3; +let promotionAttempts = 0; + +/** + * Probe whether a DevTools server is already listening on the given host:port. + * Returns true if a WebSocket handshake succeeds within a short timeout. + */ +function probeDevTools(host: string, port: number): Promise { + return new Promise((resolve) => { + const ws = new WebSocket(`ws://${host}:${port}/ws`); + const timer = setTimeout(() => { + ws.close(); + resolve(false); + }, 500); + + ws.on('open', () => { + clearTimeout(timer); + ws.close(); + resolve(true); + }); + + ws.on('error', () => { + clearTimeout(timer); + ws.close(); + resolve(false); + }); + }); +} + +/** + * Start a DevTools server, then check if we won the default port. + * If another instance grabbed it first (race), stop ours and connect as client. + * Returns { host, port } of the DevTools to connect to. + */ +async function startOrJoinDevTools( + defaultHost: string, + defaultPort: number, +): Promise<{ host: string; port: number }> { + const mod = await import(DEVTOOLS_PKG); + const devtools: IDevTools = mod.DevTools.getInstance(); + const url = await devtools.start(); + const actualPort = devtools.getPort(); + + if (actualPort === defaultPort) { + // We won the port — we are the server + debugLogger.log(`DevTools available at: ${url}`); + return { host: defaultHost, port: actualPort }; + } + + // Lost the race — someone else has the default port. + // Verify the winner is actually alive, then stop ours and connect to theirs. + const winnerAlive = await probeDevTools(defaultHost, defaultPort); + if (winnerAlive) { + await devtools.stop(); + debugLogger.log( + `DevTools (existing) at: http://${defaultHost}:${defaultPort}`, + ); + return { host: defaultHost, port: defaultPort }; + } + + // Winner isn't responding (maybe also racing and failed) — keep ours + debugLogger.log(`DevTools available at: ${url}`); + return { host: defaultHost, port: actualPort }; +} + +/** + * Handle promotion: when reconnect fails, start or join a DevTools server + * and add a new network transport for the logger. + */ +async function handlePromotion(config: Config) { + promotionAttempts++; + if (promotionAttempts > MAX_PROMOTION_ATTEMPTS) { + debugLogger.debug( + `Giving up on DevTools promotion after ${MAX_PROMOTION_ATTEMPTS} attempts`, + ); + return; + } + + try { + const result = await startOrJoinDevTools( + DEFAULT_DEVTOOLS_HOST, + DEFAULT_DEVTOOLS_PORT, + ); + addNetworkTransport(config, result.host, result.port, () => + handlePromotion(config), + ); + } catch (err) { + debugLogger.debug('Failed to promote to DevTools server:', err); + } +} + +/** + * Registers the activity logger. + * Captures network and console logs via DevTools WebSocket or to a file. + * + * Environment variable GEMINI_CLI_ACTIVITY_LOG_TARGET controls the output: + * - file path (e.g., "/tmp/logs.jsonl") → file mode + * - not set → auto-start DevTools (reuses existing instance if already running) + * + * @param config The CLI configuration + */ +export async function registerActivityLogger(config: Config) { + const target = process.env['GEMINI_CLI_ACTIVITY_LOG_TARGET']; + + if (!target) { + // No explicit target: try connecting to existing DevTools, then start new one + const onReconnectFailed = () => handlePromotion(config); + + // Probe for an existing DevTools server + const existing = await probeDevTools( + DEFAULT_DEVTOOLS_HOST, + DEFAULT_DEVTOOLS_PORT, + ); + if (existing) { + debugLogger.log( + `DevTools (existing) at: http://${DEFAULT_DEVTOOLS_HOST}:${DEFAULT_DEVTOOLS_PORT}`, + ); + initActivityLogger(config, { + mode: 'network', + host: DEFAULT_DEVTOOLS_HOST, + port: DEFAULT_DEVTOOLS_PORT, + onReconnectFailed, + }); + return; + } + + // No existing server — start (or join if we lose the race) + try { + const result = await startOrJoinDevTools( + DEFAULT_DEVTOOLS_HOST, + DEFAULT_DEVTOOLS_PORT, + ); + initActivityLogger(config, { + mode: 'network', + host: result.host, + port: result.port, + onReconnectFailed, + }); + return; + } catch (err) { + debugLogger.debug( + 'Failed to start DevTools, falling back to file logging:', + err, + ); + } + } + + // File mode fallback + if (!config.storage) { + return; + } + + initActivityLogger(config, { mode: 'file', filePath: target }); +} + +/** Reset module-level state — test only. */ +export function resetForTesting() { + promotionAttempts = 0; +} diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index bcbcabb101..80bc484a3b 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -42,6 +42,13 @@ "default": false, "type": "boolean" }, + "devtools": { + "title": "DevTools", + "description": "Enable DevTools inspector on launch.", + "markdownDescription": "Enable DevTools inspector on launch.\n\n- Category: `General`\n- Requires restart: `no`\n- Default: `false`", + "default": false, + "type": "boolean" + }, "enableAutoUpdate": { "title": "Enable Auto Update", "description": "Enable automatic updates.",