Files
openscreen/electron/ipc/handlers.ts
T
2026-03-01 21:13:19 -08:00

496 lines
15 KiB
TypeScript

import { ipcMain, desktopCapturer, BrowserWindow, shell, app, dialog, screen } from 'electron'
import fs from 'node:fs/promises'
import path from 'node:path'
import { RECORDINGS_DIR } from '../main'
const PROJECT_FILE_EXTENSION = 'openscreen'
const SHORTCUTS_FILE = path.join(app.getPath('userData'), 'shortcuts.json')
type SelectedSource = {
name: string
[key: string]: unknown
}
let selectedSource: SelectedSource | null = null
let currentVideoPath: string | null = null
let currentProjectPath: string | null = null
function normalizePath(filePath: string) {
return path.resolve(filePath)
}
function isTrustedProjectPath(filePath?: string | null) {
if (!filePath || !currentProjectPath) {
return false
}
return normalizePath(filePath) === normalizePath(currentProjectPath)
}
const CURSOR_TELEMETRY_VERSION = 1
const CURSOR_SAMPLE_INTERVAL_MS = 100
const MAX_CURSOR_SAMPLES = 60 * 60 * 10 // 1 hour @ 10Hz
interface CursorTelemetryPoint {
timeMs: number
cx: number
cy: number
}
let cursorCaptureInterval: NodeJS.Timeout | null = null
let cursorCaptureStartTimeMs = 0
let activeCursorSamples: CursorTelemetryPoint[] = []
let pendingCursorSamples: CursorTelemetryPoint[] = []
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value))
}
function stopCursorCapture() {
if (cursorCaptureInterval) {
clearInterval(cursorCaptureInterval)
cursorCaptureInterval = null
}
}
function sampleCursorPoint() {
const cursor = screen.getCursorScreenPoint()
const sourceDisplayId = Number(selectedSource?.display_id)
const sourceDisplay = Number.isFinite(sourceDisplayId)
? screen.getAllDisplays().find((display) => display.id === sourceDisplayId) ?? null
: null
const display = sourceDisplay ?? screen.getDisplayNearestPoint(cursor)
const bounds = display.bounds
const width = Math.max(1, bounds.width)
const height = Math.max(1, bounds.height)
const cx = clamp((cursor.x - bounds.x) / width, 0, 1)
const cy = clamp((cursor.y - bounds.y) / height, 0, 1)
activeCursorSamples.push({
timeMs: Math.max(0, Date.now() - cursorCaptureStartTimeMs),
cx,
cy,
})
if (activeCursorSamples.length > MAX_CURSOR_SAMPLES) {
activeCursorSamples.shift()
}
}
export function registerIpcHandlers(
createEditorWindow: () => void,
createSourceSelectorWindow: () => BrowserWindow,
getMainWindow: () => BrowserWindow | null,
getSourceSelectorWindow: () => BrowserWindow | null,
onRecordingStateChange?: (recording: boolean, sourceName: string) => void
) {
ipcMain.handle('get-sources', async (_, opts) => {
const sources = await desktopCapturer.getSources(opts)
return sources.map(source => ({
id: source.id,
name: source.name,
display_id: source.display_id,
thumbnail: source.thumbnail ? source.thumbnail.toDataURL() : null,
appIcon: source.appIcon ? source.appIcon.toDataURL() : null
}))
})
ipcMain.handle('select-source', (_, source: SelectedSource) => {
selectedSource = source
const sourceSelectorWin = getSourceSelectorWindow()
if (sourceSelectorWin) {
sourceSelectorWin.close()
}
return selectedSource
})
ipcMain.handle('get-selected-source', () => {
return selectedSource
})
ipcMain.handle('open-source-selector', () => {
const sourceSelectorWin = getSourceSelectorWindow()
if (sourceSelectorWin) {
sourceSelectorWin.focus()
return
}
createSourceSelectorWindow()
})
ipcMain.handle('switch-to-editor', () => {
const mainWin = getMainWindow()
if (mainWin) {
mainWin.close()
}
createEditorWindow()
})
ipcMain.handle('store-recorded-video', async (_, videoData: ArrayBuffer, fileName: string) => {
try {
const videoPath = path.join(RECORDINGS_DIR, fileName)
await fs.writeFile(videoPath, Buffer.from(videoData))
currentVideoPath = videoPath;
currentProjectPath = null
const telemetryPath = `${videoPath}.cursor.json`
if (pendingCursorSamples.length > 0) {
await fs.writeFile(
telemetryPath,
JSON.stringify({ version: CURSOR_TELEMETRY_VERSION, samples: pendingCursorSamples }, null, 2),
'utf-8'
)
}
pendingCursorSamples = []
return {
success: true,
path: videoPath,
message: 'Video stored successfully'
}
} catch (error) {
console.error('Failed to store video:', error)
return {
success: false,
message: 'Failed to store video',
error: String(error)
}
}
})
ipcMain.handle('get-recorded-video-path', async () => {
try {
const files = await fs.readdir(RECORDINGS_DIR)
const videoFiles = files.filter(file => file.endsWith('.webm'))
if (videoFiles.length === 0) {
return { success: false, message: 'No recorded video found' }
}
const latestVideo = videoFiles.sort().reverse()[0]
const videoPath = path.join(RECORDINGS_DIR, latestVideo)
return { success: true, path: videoPath }
} catch (error) {
console.error('Failed to get video path:', error)
return { success: false, message: 'Failed to get video path', error: String(error) }
}
})
ipcMain.handle('set-recording-state', (_, recording: boolean) => {
if (recording) {
stopCursorCapture()
activeCursorSamples = []
pendingCursorSamples = []
cursorCaptureStartTimeMs = Date.now()
sampleCursorPoint()
cursorCaptureInterval = setInterval(sampleCursorPoint, CURSOR_SAMPLE_INTERVAL_MS)
} else {
stopCursorCapture()
pendingCursorSamples = [...activeCursorSamples]
activeCursorSamples = []
}
const source = selectedSource || { name: 'Screen' }
if (onRecordingStateChange) {
onRecordingStateChange(recording, source.name)
}
})
ipcMain.handle('get-cursor-telemetry', async (_, videoPath?: string) => {
const targetVideoPath = videoPath ?? currentVideoPath
if (!targetVideoPath) {
return { success: true, samples: [] }
}
const telemetryPath = `${targetVideoPath}.cursor.json`
try {
const content = await fs.readFile(telemetryPath, 'utf-8')
const parsed = JSON.parse(content)
const rawSamples = Array.isArray(parsed)
? parsed
: (Array.isArray(parsed?.samples) ? parsed.samples : [])
const samples: CursorTelemetryPoint[] = rawSamples
.filter((sample: unknown) => Boolean(sample && typeof sample === 'object'))
.map((sample: unknown) => {
const point = sample as Partial<CursorTelemetryPoint>
return {
timeMs: typeof point.timeMs === 'number' && Number.isFinite(point.timeMs) ? Math.max(0, point.timeMs) : 0,
cx: typeof point.cx === 'number' && Number.isFinite(point.cx) ? clamp(point.cx, 0, 1) : 0.5,
cy: typeof point.cy === 'number' && Number.isFinite(point.cy) ? clamp(point.cy, 0, 1) : 0.5,
}
})
.sort((a: CursorTelemetryPoint, b: CursorTelemetryPoint) => a.timeMs - b.timeMs)
return { success: true, samples }
} catch (error) {
const nodeError = error as NodeJS.ErrnoException
if (nodeError.code === 'ENOENT') {
return { success: true, samples: [] }
}
console.error('Failed to load cursor telemetry:', error)
return { success: false, message: 'Failed to load cursor telemetry', error: String(error), samples: [] }
}
})
ipcMain.handle('open-external-url', async (_, url: string) => {
try {
await shell.openExternal(url)
return { success: true }
} catch (error) {
console.error('Failed to open URL:', error)
return { success: false, error: String(error) }
}
})
// Return base path for assets so renderer can resolve file:// paths in production
ipcMain.handle('get-asset-base-path', () => {
try {
if (app.isPackaged) {
return path.join(process.resourcesPath, 'assets')
}
return path.join(app.getAppPath(), 'public', 'assets')
} catch (err) {
console.error('Failed to resolve asset base path:', err)
return null
}
})
ipcMain.handle('save-exported-video', async (_, videoData: ArrayBuffer, fileName: string) => {
try {
// Determine file type from extension
const isGif = fileName.toLowerCase().endsWith('.gif');
const filters = isGif
? [{ name: 'GIF Image', extensions: ['gif'] }]
: [{ name: 'MP4 Video', extensions: ['mp4'] }];
const result = await dialog.showSaveDialog({
title: isGif ? 'Save Exported GIF' : 'Save Exported Video',
defaultPath: path.join(app.getPath('downloads'), fileName),
filters,
properties: ['createDirectory', 'showOverwriteConfirmation']
});
if (result.canceled || !result.filePath) {
return {
success: false,
canceled: true,
message: 'Export canceled'
};
}
await fs.writeFile(result.filePath, Buffer.from(videoData));
return {
success: true,
path: result.filePath,
message: 'Video exported successfully'
};
} catch (error) {
console.error('Failed to save exported video:', error)
return {
success: false,
message: 'Failed to save exported video',
error: String(error)
}
}
})
ipcMain.handle('open-video-file-picker', async () => {
try {
const result = await dialog.showOpenDialog({
title: 'Select Video File',
defaultPath: RECORDINGS_DIR,
filters: [
{ name: 'Video Files', extensions: ['webm', 'mp4', 'mov', 'avi', 'mkv'] },
{ name: 'All Files', extensions: ['*'] }
],
properties: ['openFile']
});
if (result.canceled || result.filePaths.length === 0) {
return { success: false, canceled: true };
}
currentProjectPath = null
return {
success: true,
path: result.filePaths[0]
};
} catch (error) {
console.error('Failed to open file picker:', error);
return {
success: false,
message: 'Failed to open file picker',
error: String(error)
};
}
});
ipcMain.handle('save-project-file', async (_, projectData: unknown, suggestedName?: string, existingProjectPath?: string) => {
try {
const trustedExistingProjectPath = isTrustedProjectPath(existingProjectPath)
? existingProjectPath
: null
if (trustedExistingProjectPath) {
await fs.writeFile(trustedExistingProjectPath, JSON.stringify(projectData, null, 2), 'utf-8')
currentProjectPath = trustedExistingProjectPath
return {
success: true,
path: trustedExistingProjectPath,
message: 'Project saved successfully'
}
}
const safeName = (suggestedName || `project-${Date.now()}`).replace(/[^a-zA-Z0-9-_]/g, '_')
const defaultName = safeName.endsWith(`.${PROJECT_FILE_EXTENSION}`)
? safeName
: `${safeName}.${PROJECT_FILE_EXTENSION}`
const result = await dialog.showSaveDialog({
title: 'Save OpenScreen Project',
defaultPath: path.join(RECORDINGS_DIR, defaultName),
filters: [
{ name: 'OpenScreen Project', extensions: [PROJECT_FILE_EXTENSION] },
{ name: 'JSON', extensions: ['json'] }
],
properties: ['createDirectory', 'showOverwriteConfirmation']
})
if (result.canceled || !result.filePath) {
return {
success: false,
canceled: true,
message: 'Save project canceled'
}
}
await fs.writeFile(result.filePath, JSON.stringify(projectData, null, 2), 'utf-8')
currentProjectPath = result.filePath
return {
success: true,
path: result.filePath,
message: 'Project saved successfully'
}
} catch (error) {
console.error('Failed to save project file:', error)
return {
success: false,
message: 'Failed to save project file',
error: String(error)
}
}
})
ipcMain.handle('load-project-file', async () => {
try {
const result = await dialog.showOpenDialog({
title: 'Open OpenScreen Project',
defaultPath: RECORDINGS_DIR,
filters: [
{ name: 'OpenScreen Project', extensions: [PROJECT_FILE_EXTENSION] },
{ name: 'JSON', extensions: ['json'] },
{ name: 'All Files', extensions: ['*'] }
],
properties: ['openFile']
})
if (result.canceled || result.filePaths.length === 0) {
return { success: false, canceled: true, message: 'Open project canceled' }
}
const filePath = result.filePaths[0]
const content = await fs.readFile(filePath, 'utf-8')
const project = JSON.parse(content)
currentProjectPath = filePath
if (project && typeof project === 'object' && typeof project.videoPath === 'string') {
currentVideoPath = project.videoPath
}
return {
success: true,
path: filePath,
project
}
} catch (error) {
console.error('Failed to load project file:', error)
return {
success: false,
message: 'Failed to load project file',
error: String(error)
}
}
})
ipcMain.handle('load-current-project-file', async () => {
try {
if (!currentProjectPath) {
return { success: false, message: 'No active project' }
}
const content = await fs.readFile(currentProjectPath, 'utf-8')
const project = JSON.parse(content)
if (project && typeof project === 'object' && typeof project.videoPath === 'string') {
currentVideoPath = project.videoPath
}
return {
success: true,
path: currentProjectPath,
project,
}
} catch (error) {
console.error('Failed to load current project file:', error)
return {
success: false,
message: 'Failed to load current project file',
error: String(error),
}
}
})
ipcMain.handle('set-current-video-path', (_, path: string) => {
currentVideoPath = path;
currentProjectPath = null
return { success: true };
});
ipcMain.handle('get-current-video-path', () => {
return currentVideoPath ? { success: true, path: currentVideoPath } : { success: false };
});
ipcMain.handle('clear-current-video-path', () => {
currentVideoPath = null;
return { success: true };
});
ipcMain.handle('get-platform', () => {
return process.platform;
});
ipcMain.handle('get-shortcuts', async () => {
try {
const data = await fs.readFile(SHORTCUTS_FILE, 'utf-8');
return JSON.parse(data);
} catch {
return null;
}
});
ipcMain.handle('save-shortcuts', async (_, shortcuts: unknown) => {
try {
await fs.writeFile(SHORTCUTS_FILE, JSON.stringify(shortcuts, null, 2), 'utf-8');
return { success: true };
} catch (error) {
console.error('Failed to save shortcuts:', error);
return { success: false, error: String(error) };
}
});
}