Skip to content

Commit 81d9bcd

Browse files
ozgesolidkeyclaude
andcommitted
Add unified Live panel with Android logcat streaming and wall clock timestamps
Unify serial port and logcat into a single "Live" panel with source dropdown. Add LogcatHandler (adb logcat child process), prepend [HH:MM:SS.mmm] wall clock timestamps to all streamed lines for time-gap analysis, and rename serial UI to live throughout. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d739df3 commit 81d9bcd

10 files changed

Lines changed: 1067 additions & 256 deletions

File tree

src/main/index.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { analyzerRegistry, AnalyzerOptions } from './analyzers';
1111
import { loadDatadogConfig, saveDatadogConfig, clearDatadogConfig, fetchDatadogLogs, DatadogConfig, DatadogFetchParams } from './datadogClient';
1212
import { startApiServer, stopApiServer, ApiContext } from './api-server';
1313
import { SerialHandler } from './serialHandler';
14+
import { LogcatHandler } from './logcatHandler';
1415

1516
let mainWindow: BrowserWindow | null = null;
1617
let searchSignal: { cancelled: boolean } = { cancelled: false };
@@ -20,6 +21,9 @@ let currentFilePath: string | null = null;
2021
// Serial port handler
2122
const serialHandler = new SerialHandler();
2223

24+
// Logcat handler
25+
const logcatHandler = new LogcatHandler();
26+
2327
// Filter state - maps file path to array of visible line indices
2428
const filterState = new Map<string, number[] | null>();
2529

@@ -693,6 +697,7 @@ app.on('window-all-closed', () => {
693697

694698
app.on('will-quit', () => {
695699
serialHandler.cleanupTempFile();
700+
logcatHandler.cleanupTempFile();
696701
stopApiServer();
697702
});
698703

@@ -815,6 +820,102 @@ ipcMain.handle(IPC.SERIAL_SAVE_SESSION, async () => {
815820
}
816821
});
817822

823+
// === Logcat ===
824+
825+
ipcMain.handle(IPC.LOGCAT_LIST_DEVICES, async () => {
826+
try {
827+
const devices = await logcatHandler.listDevices();
828+
return { success: true, devices };
829+
} catch (error) {
830+
return { success: false, error: String(error) };
831+
}
832+
});
833+
834+
ipcMain.handle(IPC.LOGCAT_CONNECT, async (_, config: { device?: string; filter?: string }) => {
835+
try {
836+
const tempFilePath = await logcatHandler.connect(config);
837+
838+
// Open temp file with FileHandler
839+
const fileHandler = new FileHandler();
840+
const info = await fileHandler.open(tempFilePath, () => {});
841+
addToCache(tempFilePath, fileHandler);
842+
currentFilePath = tempFilePath;
843+
844+
// Forward events to renderer
845+
const onLinesAdded = (count: number) => {
846+
const handler = fileHandlerCache.get(tempFilePath);
847+
if (handler) {
848+
const newLines = handler.indexNewLines();
849+
if (newLines > 0) {
850+
mainWindow?.webContents.send(IPC.LOGCAT_LINES_ADDED, {
851+
totalLines: handler.getTotalLines(),
852+
newLines,
853+
});
854+
}
855+
}
856+
};
857+
858+
const onError = (message: string) => {
859+
mainWindow?.webContents.send(IPC.LOGCAT_ERROR, message);
860+
};
861+
862+
const onDisconnected = () => {
863+
mainWindow?.webContents.send(IPC.LOGCAT_DISCONNECTED);
864+
logcatHandler.removeListener('lines-added', onLinesAdded);
865+
logcatHandler.removeListener('error', onError);
866+
logcatHandler.removeListener('disconnected', onDisconnected);
867+
};
868+
869+
logcatHandler.on('lines-added', onLinesAdded);
870+
logcatHandler.on('error', onError);
871+
logcatHandler.on('disconnected', onDisconnected);
872+
873+
return { success: true, info, tempFilePath };
874+
} catch (error) {
875+
return { success: false, error: String(error) };
876+
}
877+
});
878+
879+
ipcMain.handle(IPC.LOGCAT_DISCONNECT, async () => {
880+
try {
881+
logcatHandler.disconnect();
882+
return { success: true };
883+
} catch (error) {
884+
return { success: false, error: String(error) };
885+
}
886+
});
887+
888+
ipcMain.handle(IPC.LOGCAT_STATUS, async () => {
889+
return logcatHandler.getStatus();
890+
});
891+
892+
ipcMain.handle(IPC.LOGCAT_SAVE_SESSION, async () => {
893+
const tempPath = logcatHandler.getTempFilePath();
894+
if (!tempPath || !fs.existsSync(tempPath)) {
895+
return { success: false, error: 'No logcat session data' };
896+
}
897+
898+
const result = await dialog.showSaveDialog(mainWindow!, {
899+
title: 'Save Logcat Session',
900+
defaultPath: path.basename(tempPath),
901+
filters: [
902+
{ name: 'Log Files', extensions: ['log', 'txt'] },
903+
{ name: 'All Files', extensions: ['*'] },
904+
],
905+
});
906+
907+
if (result.canceled || !result.filePath) {
908+
return { success: false, error: 'Cancelled' };
909+
}
910+
911+
try {
912+
fs.copyFileSync(tempPath, result.filePath);
913+
return { success: true, filePath: result.filePath };
914+
} catch (error) {
915+
return { success: false, error: String(error) };
916+
}
917+
});
918+
818919
// === File Operations ===
819920

820921
ipcMain.handle(IPC.OPEN_FILE_DIALOG, async () => {

src/main/logcatHandler.ts

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
import { EventEmitter } from 'events';
2+
import { ChildProcess, spawn, execFile } from 'child_process';
3+
import * as fs from 'fs';
4+
import * as path from 'path';
5+
import * as os from 'os';
6+
import { LogcatConfig, LogcatDeviceInfo, LogcatStatus } from '../shared/types';
7+
import { wallClockPrefix } from './serialHandler';
8+
9+
export class LogcatHandler extends EventEmitter {
10+
private process: ChildProcess | null = null;
11+
private tempFilePath: string | null = null;
12+
private fd: number | null = null;
13+
private lineBuffer: string = '';
14+
private linesReceived: number = 0;
15+
private connectedSince: number | null = null;
16+
private config: LogcatConfig | null = null;
17+
18+
async listDevices(): Promise<LogcatDeviceInfo[]> {
19+
return new Promise((resolve, reject) => {
20+
execFile('adb', ['devices', '-l'], { timeout: 5000 }, (err, stdout, stderr) => {
21+
if (err) {
22+
reject(new Error(`adb not found or failed: ${err.message}`));
23+
return;
24+
}
25+
resolve(parseAdbDevices(stdout));
26+
});
27+
});
28+
}
29+
30+
async connect(config: LogcatConfig): Promise<string> {
31+
if (this.process) {
32+
throw new Error('Already connected. Disconnect first.');
33+
}
34+
35+
// Create temp directory
36+
const tempDir = path.join(os.tmpdir(), 'logan-logcat');
37+
if (!fs.existsSync(tempDir)) {
38+
fs.mkdirSync(tempDir, { recursive: true });
39+
}
40+
41+
// Create temp file
42+
const safeDevice = (config.device || 'default').replace(/[^a-zA-Z0-9]/g, '_');
43+
const timestamp = Date.now();
44+
this.tempFilePath = path.join(tempDir, `logcat_${safeDevice}_${timestamp}.log`);
45+
fs.writeFileSync(this.tempFilePath, '');
46+
this.fd = fs.openSync(this.tempFilePath, 'a');
47+
48+
this.config = config;
49+
this.lineBuffer = '';
50+
this.linesReceived = 0;
51+
this.connectedSince = Date.now();
52+
53+
// Build adb logcat command args
54+
const args: string[] = [];
55+
if (config.device) {
56+
args.push('-s', config.device);
57+
}
58+
args.push('logcat');
59+
if (config.filter) {
60+
args.push(config.filter);
61+
}
62+
63+
this.process = spawn('adb', args, { stdio: ['ignore', 'pipe', 'pipe'] });
64+
65+
this.process.stdout?.on('data', (chunk: Buffer) => {
66+
this.handleData(chunk);
67+
});
68+
69+
this.process.stderr?.on('data', (chunk: Buffer) => {
70+
const msg = chunk.toString('utf-8').trim();
71+
if (msg) {
72+
this.emit('error', msg);
73+
}
74+
});
75+
76+
this.process.on('error', (err: Error) => {
77+
this.emit('error', err.message);
78+
this.cleanup();
79+
this.emit('disconnected');
80+
});
81+
82+
this.process.on('close', (code) => {
83+
this.process = null;
84+
this.config = null;
85+
this.connectedSince = null;
86+
if (this.fd !== null) {
87+
// Flush partial line
88+
if (this.lineBuffer.length > 0) {
89+
fs.writeSync(this.fd, wallClockPrefix() + ' ' + this.lineBuffer + '\n');
90+
this.linesReceived++;
91+
this.lineBuffer = '';
92+
this.emit('lines-added', 1);
93+
}
94+
fs.closeSync(this.fd);
95+
this.fd = null;
96+
}
97+
this.emit('disconnected');
98+
});
99+
100+
return this.tempFilePath;
101+
}
102+
103+
private handleData(chunk: Buffer): void {
104+
if (!this.fd) return;
105+
106+
const text = chunk.toString('utf-8');
107+
this.lineBuffer += text;
108+
109+
// Split on any line ending: \r\n, \n, \r
110+
const lines: string[] = [];
111+
let i = 0;
112+
let lineStart = 0;
113+
114+
while (i < this.lineBuffer.length) {
115+
const ch = this.lineBuffer[i];
116+
if (ch === '\n') {
117+
lines.push(this.lineBuffer.substring(lineStart, i));
118+
lineStart = i + 1;
119+
} else if (ch === '\r') {
120+
lines.push(this.lineBuffer.substring(lineStart, i));
121+
if (i + 1 < this.lineBuffer.length && this.lineBuffer[i + 1] === '\n') {
122+
i++;
123+
}
124+
lineStart = i + 1;
125+
}
126+
i++;
127+
}
128+
129+
// Keep remainder in buffer (partial line)
130+
this.lineBuffer = this.lineBuffer.substring(lineStart);
131+
132+
if (lines.length > 0) {
133+
// Write complete lines to temp file with wall clock timestamp
134+
const ts = wallClockPrefix();
135+
const data = lines.map(l => ts + ' ' + l + '\n').join('');
136+
fs.writeSync(this.fd, data);
137+
this.linesReceived += lines.length;
138+
this.emit('lines-added', lines.length);
139+
}
140+
}
141+
142+
disconnect(): void {
143+
if (!this.process) return;
144+
145+
try {
146+
this.process.kill('SIGTERM');
147+
} catch {
148+
// Process may already be dead
149+
}
150+
// The 'close' event handler will do the rest
151+
}
152+
153+
getStatus(): LogcatStatus {
154+
return {
155+
connected: this.process !== null && !this.process.killed,
156+
deviceId: this.config?.device || null,
157+
filter: this.config?.filter || null,
158+
linesReceived: this.linesReceived,
159+
connectedSince: this.connectedSince,
160+
tempFilePath: this.tempFilePath,
161+
};
162+
}
163+
164+
getTempFilePath(): string | null {
165+
return this.tempFilePath;
166+
}
167+
168+
cleanupTempFile(): void {
169+
this.disconnect();
170+
// Wait a tick for the close handler to flush
171+
if (this.fd !== null) {
172+
try { fs.closeSync(this.fd); } catch {}
173+
this.fd = null;
174+
}
175+
if (this.tempFilePath && fs.existsSync(this.tempFilePath)) {
176+
try {
177+
fs.unlinkSync(this.tempFilePath);
178+
} catch {
179+
// Best effort
180+
}
181+
}
182+
this.tempFilePath = null;
183+
}
184+
185+
private cleanup(): void {
186+
if (this.fd !== null) {
187+
fs.closeSync(this.fd);
188+
this.fd = null;
189+
}
190+
if (this.tempFilePath && fs.existsSync(this.tempFilePath)) {
191+
try {
192+
fs.unlinkSync(this.tempFilePath);
193+
} catch {
194+
// Best effort
195+
}
196+
}
197+
this.tempFilePath = null;
198+
this.config = null;
199+
this.connectedSince = null;
200+
}
201+
}
202+
203+
/** Parse `adb devices -l` output into LogcatDeviceInfo[] */
204+
export function parseAdbDevices(output: string): LogcatDeviceInfo[] {
205+
const devices: LogcatDeviceInfo[] = [];
206+
const lines = output.split('\n');
207+
208+
for (const line of lines) {
209+
// Skip header and empty lines
210+
const trimmed = line.trim();
211+
if (!trimmed || trimmed.startsWith('List of devices') || trimmed.startsWith('*')) {
212+
continue;
213+
}
214+
215+
// Format: <serial> <state> [usb:<...>] [product:<...>] [model:<...>] [device:<...>] [transport_id:<...>]
216+
const parts = trimmed.split(/\s+/);
217+
if (parts.length < 2) continue;
218+
219+
const id = parts[0];
220+
const state = parts[1];
221+
222+
// Extract model from key:value pairs
223+
let model: string | undefined;
224+
for (const part of parts.slice(2)) {
225+
if (part.startsWith('model:')) {
226+
model = part.substring(6).replace(/_/g, ' ');
227+
break;
228+
}
229+
}
230+
231+
devices.push({ id, state, model });
232+
}
233+
234+
return devices;
235+
}

0 commit comments

Comments
 (0)