496 lines
15 KiB
TypeScript
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) };
|
|
}
|
|
});
|
|
}
|