File size: 6,805 Bytes
84aa3bf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
/**
 * @license
 * Copyright 2025 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */

import { debugLogger, type Config } from '@google/gemini-cli-core';
import WebSocket from 'ws';
import {
  initActivityLogger,
  addNetworkTransport,
  ActivityLogger,
} from './activityLogger.js';

interface IDevTools {
  start(): Promise<string>;
  stop(): Promise<void>;
  getPort(): number;
}

const DEFAULT_DEVTOOLS_PORT = 25417;
const DEFAULT_DEVTOOLS_HOST = '127.0.0.1';
const MAX_PROMOTION_ATTEMPTS = 3;
let promotionAttempts = 0;
let serverStartPromise: Promise<string> | null = null;
let connectedUrl: string | null = null;

/**
 * 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<boolean> {
  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('@google/gemini-cli-devtools');
  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);
  }
}

/**
 * Initializes the activity logger.
 * Interception starts immediately in buffering mode.
 * Transport is only attached when the user presses F12.
 */
export function setupInitialActivityLogger(config: Config) {
  const target = process.env['GEMINI_CLI_ACTIVITY_LOG_TARGET'];

  if (target) {
    if (!config.storage) return;
    initActivityLogger(config, { mode: 'file', filePath: target });
  } else {
    // Start in buffering mode — transport attached later on F12
    initActivityLogger(config, { mode: 'buffer' });
  }
}

/**
 * Starts the DevTools server and opens the UI in the browser.
 * Returns the URL to the DevTools UI.
 * Deduplicates concurrent calls — returns the same promise if already in flight.
 */
export function startDevToolsServer(config: Config): Promise<string> {
  if (connectedUrl) return Promise.resolve(connectedUrl);
  if (serverStartPromise) return serverStartPromise;
  serverStartPromise = startDevToolsServerImpl(config).catch((err) => {
    serverStartPromise = null;
    throw err;
  });
  return serverStartPromise;
}

async function startDevToolsServerImpl(config: Config): Promise<string> {
  const onReconnectFailed = () => handlePromotion(config);

  // Probe for an existing DevTools server
  const existing = await probeDevTools(
    DEFAULT_DEVTOOLS_HOST,
    DEFAULT_DEVTOOLS_PORT,
  );

  let host = DEFAULT_DEVTOOLS_HOST;
  let port = DEFAULT_DEVTOOLS_PORT;

  if (existing) {
    debugLogger.log(
      `DevTools (existing) at: http://${DEFAULT_DEVTOOLS_HOST}:${DEFAULT_DEVTOOLS_PORT}`,
    );
  } else {
    // No existing server — start (or join if we lose the race)
    try {
      const result = await startOrJoinDevTools(
        DEFAULT_DEVTOOLS_HOST,
        DEFAULT_DEVTOOLS_PORT,
      );
      host = result.host;
      port = result.port;
    } catch (err) {
      debugLogger.debug('Failed to start DevTools:', err);
      throw err;
    }
  }

  // Promote the activity logger to use the network transport
  addNetworkTransport(config, host, port, onReconnectFailed);
  const capture = ActivityLogger.getInstance();
  capture.enableNetworkLogging();

  const url = `http://localhost:${port}`;
  connectedUrl = url;
  return url;
}

/**
 * Handles the F12 key toggle for the DevTools panel.
 * Starts the DevTools server, attempts to open the browser.
 * If the panel is already open, it closes it.
 * If the panel is closed:
 * - Attempts to open the browser.
 * - If browser opening is successful, the panel remains closed.
 * - If browser opening fails or is not possible, the panel is opened.
 */
export async function toggleDevToolsPanel(
  config: Config,
  isOpen: boolean,
  toggle: () => void,
  setOpen: () => void,
): Promise<void> {
  if (isOpen) {
    toggle();
    return;
  }

  try {
    const { openBrowserSecurely, shouldLaunchBrowser } = await import(
      '@google/gemini-cli-core'
    );
    const url = await startDevToolsServer(config);
    if (shouldLaunchBrowser()) {
      try {
        await openBrowserSecurely(url);
        // Browser opened successfully, don't open drawer.
        return;
      } catch (e) {
        debugLogger.warn('Failed to open browser securely:', e);
      }
    }
    // If we can't launch browser or it failed, open drawer.
    setOpen();
  } catch (e) {
    setOpen();
    debugLogger.error('Failed to start DevTools server:', e);
  }
}

/** Reset module-level state — test only. */
export function resetForTesting() {
  promotionAttempts = 0;
  serverStartPromise = null;
  connectedUrl = null;
}