Skip to content

Commit 0c604bc

Browse files
committed
feat: recording service - capture, save, list, delete
- Implement desktop capturer for audio/screen - Save recordings to userData/recordings/ - Path traversal validation - Recording lifecycle (start, stop, list, delete, get-file) - No transcription yet (stub handler)
1 parent 0ef6fa4 commit 0c604bc

File tree

2 files changed

+214
-1
lines changed

2 files changed

+214
-1
lines changed

src/main/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { registerAgentIpc, type TaskController } from "./services/agent.js";
1313
import { registerFsIpc } from "./services/fs.js";
1414
import { registerOsIpc } from "./services/os.js";
1515
import { registerPosthogIpc } from "./services/posthog.js";
16-
import { registerRecordingIpc } from "./services/recording-stub.js";
16+
import { registerRecordingIpc } from "./services/recording-notranscribe.js";
1717

1818
const __filename = fileURLToPath(import.meta.url);
1919
const __dirname = path.dirname(__filename);
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
import fs from "node:fs";
2+
import path from "node:path";
3+
import { app, desktopCapturer, ipcMain } from "electron";
4+
import type { Recording } from "../../shared/types.js";
5+
6+
interface RecordingSession {
7+
id: string;
8+
startTime: Date;
9+
}
10+
11+
const activeRecordings = new Map<string, RecordingSession>();
12+
const recordingsDir = path.join(app.getPath("userData"), "recordings");
13+
14+
if (!fs.existsSync(recordingsDir)) {
15+
fs.mkdirSync(recordingsDir, { recursive: true });
16+
}
17+
18+
/**
19+
* Validates a recording ID to prevent path traversal attacks
20+
*/
21+
function validateRecordingId(recordingId: string): boolean {
22+
const safePattern = /^[a-zA-Z0-9._-]+$/;
23+
if (!safePattern.test(recordingId)) {
24+
return false;
25+
}
26+
27+
const resolvedPath = path.resolve(path.join(recordingsDir, recordingId));
28+
const recordingsDirResolved = path.resolve(recordingsDir);
29+
return resolvedPath.startsWith(recordingsDirResolved + path.sep);
30+
}
31+
32+
function safeLog(...args: unknown[]): void {
33+
try {
34+
console.log(...args);
35+
} catch {
36+
// Ignore logging errors
37+
}
38+
}
39+
40+
export function registerRecordingIpc(): void {
41+
ipcMain.handle(
42+
"desktop-capturer:get-sources",
43+
async (_event, options: { types: ("screen" | "window")[] }) => {
44+
const sources = await desktopCapturer.getSources(options);
45+
46+
const plainSources = sources.map((source) => {
47+
return {
48+
id: String(source.id),
49+
name: String(source.name),
50+
};
51+
});
52+
53+
safeLog(`[Desktop Capturer] Found ${plainSources.length} sources`);
54+
return plainSources;
55+
},
56+
);
57+
58+
ipcMain.handle("recording:start", async (_event) => {
59+
const recordingId = `recording-${Date.now()}`;
60+
const session: RecordingSession = {
61+
id: recordingId,
62+
startTime: new Date(),
63+
};
64+
65+
activeRecordings.set(recordingId, session);
66+
67+
return { recordingId, startTime: session.startTime.toISOString() };
68+
});
69+
70+
ipcMain.handle(
71+
"recording:stop",
72+
async (
73+
_event,
74+
recordingId: string,
75+
audioData: Uint8Array,
76+
duration: number,
77+
) => {
78+
const session = activeRecordings.get(recordingId);
79+
if (!session) {
80+
throw new Error("Recording session not found");
81+
}
82+
83+
const filename = `recording-${session.startTime.toISOString().replace(/[:.]/g, "-")}.webm`;
84+
const filePath = path.join(recordingsDir, filename);
85+
const metadataPath = path.join(recordingsDir, `${filename}.json`);
86+
87+
const buffer = Buffer.from(audioData);
88+
fs.writeFileSync(filePath, buffer);
89+
90+
const metadata = {
91+
duration,
92+
created_at: session.startTime.toISOString(),
93+
};
94+
fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2));
95+
96+
const recording: Recording = {
97+
id: filename,
98+
filename,
99+
duration,
100+
created_at: session.startTime.toISOString(),
101+
file_path: filePath,
102+
};
103+
104+
activeRecordings.delete(recordingId);
105+
106+
return recording;
107+
},
108+
);
109+
110+
ipcMain.handle("recording:list", async (_event) => {
111+
const recordings: Recording[] = [];
112+
113+
if (!fs.existsSync(recordingsDir)) {
114+
return recordings;
115+
}
116+
117+
const files = fs.readdirSync(recordingsDir);
118+
119+
for (const file of files) {
120+
if (!file.endsWith(".webm")) continue;
121+
122+
const filePath = path.join(recordingsDir, file);
123+
const metadataPath = path.join(recordingsDir, `${file}.json`);
124+
125+
let duration = 0;
126+
let createdAt = new Date().toISOString();
127+
let transcription: Recording["transcription"];
128+
129+
if (fs.existsSync(metadataPath)) {
130+
try {
131+
const metadataContent = fs.readFileSync(metadataPath, "utf-8");
132+
const metadata = JSON.parse(metadataContent);
133+
duration = metadata.duration || 0;
134+
createdAt = metadata.created_at || createdAt;
135+
transcription = metadata.transcription;
136+
} catch (err) {
137+
console.error("Failed to read metadata:", err);
138+
}
139+
}
140+
141+
recordings.push({
142+
id: file,
143+
filename: file,
144+
duration,
145+
created_at: createdAt,
146+
file_path: filePath,
147+
transcription,
148+
});
149+
}
150+
151+
return recordings.sort(
152+
(a, b) =>
153+
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
154+
);
155+
});
156+
157+
ipcMain.handle("recording:delete", async (_event, recordingId: string) => {
158+
if (!validateRecordingId(recordingId)) {
159+
throw new Error("Invalid recording ID");
160+
}
161+
162+
const filePath = path.join(recordingsDir, recordingId);
163+
164+
const resolvedPath = fs.realpathSync.native(filePath);
165+
const recordingsDirResolved = fs.realpathSync.native(recordingsDir);
166+
if (!resolvedPath.startsWith(recordingsDirResolved)) {
167+
throw new Error("Invalid recording path");
168+
}
169+
170+
const metadataPath = path.join(recordingsDir, `${recordingId}.json`);
171+
172+
let deleted = false;
173+
174+
if (fs.existsSync(filePath)) {
175+
fs.unlinkSync(filePath);
176+
deleted = true;
177+
}
178+
179+
if (fs.existsSync(metadataPath)) {
180+
fs.unlinkSync(metadataPath);
181+
}
182+
183+
return deleted;
184+
});
185+
186+
ipcMain.handle("recording:get-file", async (_event, recordingId: string) => {
187+
if (!validateRecordingId(recordingId)) {
188+
throw new Error("Invalid recording ID");
189+
}
190+
191+
const filePath = path.join(recordingsDir, recordingId);
192+
193+
if (!fs.existsSync(filePath)) {
194+
throw new Error("Recording file not found");
195+
}
196+
197+
const resolvedPath = fs.realpathSync.native(filePath);
198+
const recordingsDirResolved = fs.realpathSync.native(recordingsDir);
199+
if (!resolvedPath.startsWith(recordingsDirResolved)) {
200+
throw new Error("Invalid recording path");
201+
}
202+
203+
const buffer = fs.readFileSync(filePath);
204+
return buffer;
205+
});
206+
207+
ipcMain.handle(
208+
"recording:transcribe",
209+
async (_event, _recordingId: string, _openaiApiKey: string) => {
210+
throw new Error("Transcription not yet implemented");
211+
},
212+
);
213+
}

0 commit comments

Comments
 (0)