refactor: extract registerIpcHandlers into 8 focused register/ modules

handlers.ts reduced from 2930 → 65 lines (pure delegation).

New files under electron/ipc/register/:
- sources.ts    — get-sources, select-source, show-source-highlight, open-source-selector
- recording.ts  — start/stop/pause native + ffmpeg, mux, store, set-recording-state, get-cursor-telemetry
- permissions.ts — accessibility/screen permissions, open-external-url
- assets.ts     — wallpaper thumbnails, asset-base-path, list-asset-dir, read-local-file
- export.ts     — native-video-export-*, save-exported-video
- captions.ts   — whisper model, file pickers, generate-auto-captions
- project.ts    — project files, recordings dir, video/session state, delete-recording
- settings.ts   — shortcuts, recording prefs, countdown, platform info

Also moved shared helpers:
- getMacPrivacySettingsUrl, approveUserPath → utils.ts
- isTrustedProjectPath → project/manager.ts
This commit is contained in:
webadderall
2026-04-17 20:13:47 +10:00
parent 099ce2bbb5
commit 673dfdadd3
11 changed files with 3017 additions and 2882 deletions
+19 -2882
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -335,3 +335,9 @@ export async function loadProjectFromPath(projectPath: string) {
project,
};
}
export function isTrustedProjectPath(filePath?: string | null): boolean {
if (!filePath || !currentProjectPath) return false;
return normalizePath(filePath) === normalizePath(currentProjectPath);
}
+123
View File
@@ -0,0 +1,123 @@
import { existsSync } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { ipcMain } from "electron";
import { USER_DATA_PATH } from "../../appPaths";
import { normalizePath } from "../utils";
import { isAllowedLocalReadPath, getAssetRootPath } from "../project/manager";
export function registerAssetHandlers() {
// Generate a tiny thumbnail for a wallpaper image and cache it in userData.
// Returns the cached thumbnail as raw JPEG bytes for fast grid rendering.
// Serialized to prevent concurrent nativeImage operations from eating memory.
const THUMB_SIZE = 96
const thumbCacheDir = path.join(USER_DATA_PATH, 'wallpaper-thumbs')
let thumbGenerationQueue: Promise<void> = Promise.resolve()
ipcMain.handle('generate-wallpaper-thumbnail', async (_, filePath: string) => {
try {
const resolved = normalizePath(filePath)
const realResolved = await fs.realpath(resolved).catch(() => resolved)
if (!isAllowedLocalReadPath(resolved) && !isAllowedLocalReadPath(realResolved)) {
return { success: false, error: 'Access denied' }
}
// Deterministic cache key from file path + mtime
const stat = await fs.stat(resolved)
const cacheKey = Buffer.from(`${resolved}:${stat.mtimeMs}`).toString('base64url')
const thumbPath = path.join(thumbCacheDir, `${cacheKey}.jpg`)
// Return cached thumbnail if it exists (no queue needed)
if (existsSync(thumbPath)) {
const data = await fs.readFile(thumbPath)
return { success: true, data }
}
// Serialize nativeImage operations to avoid OOM from concurrent full-res decodes
let jpegData: Buffer
const generation = thumbGenerationQueue.then(async () => {
const { nativeImage } = await import('electron')
const img = nativeImage.createFromPath(resolved)
if (img.isEmpty()) {
throw new Error('Failed to load image')
}
const { width, height } = img.getSize()
const scale = THUMB_SIZE / Math.min(width, height)
const resized = img.resize({
width: Math.round(width * scale),
height: Math.round(height * scale),
quality: 'good',
})
jpegData = resized.toJPEG(70)
// Cache to disk
await fs.mkdir(thumbCacheDir, { recursive: true })
await fs.writeFile(thumbPath, jpegData)
})
// Keep the queue moving even if one fails
thumbGenerationQueue = generation.catch(() => {})
await generation
return { success: true, data: jpegData! }
} catch (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 {
const assetPath = getAssetRootPath()
return pathToFileURL(`${assetPath}${path.sep}`).toString()
} catch (err) {
console.error('Failed to resolve asset base path:', err)
return null
}
})
ipcMain.handle('list-asset-directory', async (_, relativeDir: string) => {
try {
const normalizedRelativeDir = String(relativeDir ?? '')
.replace(/\\/g, '/')
.replace(/^\/+/, '')
const assetRootPath = path.resolve(getAssetRootPath())
const targetDirPath = path.resolve(assetRootPath, normalizedRelativeDir)
if (targetDirPath !== assetRootPath && !targetDirPath.startsWith(`${assetRootPath}${path.sep}`)) {
return { success: false, error: 'Invalid asset directory' }
}
const entries = await fs.readdir(targetDirPath, { withFileTypes: true })
const files = entries
.filter((entry) => entry.isFile())
.map((entry) => entry.name)
.sort(new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare)
return { success: true, files }
} catch (error) {
console.error('Failed to list asset directory:', error)
return { success: false, error: String(error) }
}
})
ipcMain.handle('read-local-file', async (_, filePath: string) => {
try {
const resolved = normalizePath(filePath)
const realResolved = await fs.realpath(resolved).catch(() => resolved)
if (!isAllowedLocalReadPath(resolved) && !isAllowedLocalReadPath(realResolved)) {
console.warn(`[read-local-file] Blocked read outside allowed directories: ${resolved}`)
return { success: false, error: 'Access denied: path outside allowed directories' }
}
const data = await fs.readFile(resolved)
return { success: true, data }
} catch (error) {
console.error('Failed to read local file:', error)
return { success: false, error: String(error) }
}
})
}
+207
View File
@@ -0,0 +1,207 @@
import { dialog, ipcMain } from "electron";
import { setCurrentProjectPath } from "../state";
import {
getWhisperSmallModelStatus,
downloadWhisperSmallModel,
deleteWhisperSmallModel,
sendWhisperModelDownloadProgress,
} from "../captions/whisper";
import { generateAutoCaptionsFromVideo } from "../captions/generate";
import { approveUserPath, getRecordingsDir } from "../utils";
export function registerCaptionHandlers() {
ipcMain.handle('open-video-file-picker', async () => {
try {
const recordingsDir = await getRecordingsDir()
const result = await dialog.showOpenDialog({
title: 'Select Video File',
defaultPath: recordingsDir,
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 };
}
approveUserPath(result.filePaths[0])
setCurrentProjectPath(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('open-audio-file-picker', async () => {
try {
const result = await dialog.showOpenDialog({
title: 'Select Audio File',
filters: [
{ name: 'Audio Files', extensions: ['mp3', 'wav', 'aac', 'm4a', 'flac', 'ogg'] },
{ name: 'All Files', extensions: ['*'] }
],
properties: ['openFile']
});
if (result.canceled || result.filePaths.length === 0) {
return { success: false, canceled: true };
}
approveUserPath(result.filePaths[0])
return {
success: true,
path: result.filePaths[0]
};
} catch (error) {
console.error('Failed to open audio file picker:', error);
return {
success: false,
message: 'Failed to open audio file picker',
error: String(error)
};
}
});
ipcMain.handle('open-whisper-executable-picker', async () => {
try {
const result = await dialog.showOpenDialog({
title: 'Select Whisper Executable',
filters: [
{ name: 'Executables', extensions: process.platform === 'win32' ? ['exe', 'cmd', 'bat'] : ['*'] },
{ name: 'All Files', extensions: ['*'] },
],
properties: ['openFile'],
})
if (result.canceled || result.filePaths.length === 0) {
return { success: false, canceled: true }
}
approveUserPath(result.filePaths[0])
return { success: true, path: result.filePaths[0] }
} catch (error) {
console.error('Failed to open Whisper executable picker:', error)
return { success: false, error: String(error) }
}
})
ipcMain.handle('open-whisper-model-picker', async () => {
try {
const result = await dialog.showOpenDialog({
title: 'Select Whisper Model',
filters: [
{ name: 'Whisper Models', extensions: ['bin'] },
{ name: 'All Files', extensions: ['*'] },
],
properties: ['openFile'],
})
if (result.canceled || result.filePaths.length === 0) {
return { success: false, canceled: true }
}
approveUserPath(result.filePaths[0])
return { success: true, path: result.filePaths[0] }
} catch (error) {
console.error('Failed to open Whisper model picker:', error)
return { success: false, error: String(error) }
}
})
ipcMain.handle('get-whisper-small-model-status', async () => {
try {
return await getWhisperSmallModelStatus()
} catch (error) {
return { success: false, exists: false, path: null, error: String(error) }
}
})
ipcMain.handle('download-whisper-small-model', async (event) => {
try {
const existing = await getWhisperSmallModelStatus()
if (existing.exists) {
sendWhisperModelDownloadProgress(event.sender, {
status: 'downloaded',
progress: 100,
path: existing.path,
})
return { success: true, path: existing.path, alreadyDownloaded: true }
}
const modelPath = await downloadWhisperSmallModel(event.sender)
return { success: true, path: modelPath }
} catch (error) {
console.error('Failed to download Whisper small model:', error)
return { success: false, error: String(error) }
}
})
ipcMain.handle('delete-whisper-small-model', async (event) => {
try {
await deleteWhisperSmallModel()
sendWhisperModelDownloadProgress(event.sender, {
status: 'idle',
progress: 0,
path: null,
})
return { success: true }
} catch (error) {
console.error('Failed to delete Whisper small model:', error)
// Verify whether the file was actually removed despite the error
const status = await getWhisperSmallModelStatus()
if (!status.exists) {
// File is gone — treat as success
sendWhisperModelDownloadProgress(event.sender, {
status: 'idle',
progress: 0,
path: null,
})
return { success: true }
}
sendWhisperModelDownloadProgress(event.sender, {
status: 'error',
progress: 0,
path: null,
error: String(error),
})
return { success: false, error: String(error) }
}
})
ipcMain.handle('generate-auto-captions', async (_, options: {
videoPath: string
whisperExecutablePath: string
whisperModelPath: string
language?: string
}) => {
try {
const result = await generateAutoCaptionsFromVideo(options)
return {
success: true,
cues: result.cues,
message: result.audioSourceLabel === 'recording'
? `Generated ${result.cues.length} caption cues.`
: `Generated ${result.cues.length} caption cues from the ${result.audioSourceLabel}.`,
}
} catch (error) {
console.error('Failed to generate auto captions:', error)
return {
success: false,
error: String(error),
message: 'Failed to generate auto captions',
}
}
})
}
+383
View File
@@ -0,0 +1,383 @@
import type { ChildProcessByStdio } from "node:child_process";
import { spawn } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import type { Readable, Writable } from "node:stream";
import type { SaveDialogOptions } from "electron";
import { app, BrowserWindow, dialog, ipcMain } from "electron";
import { getFfmpegBinaryPath } from "../ffmpeg/binary";
import {
buildNativeH264StreamExportArgs,
buildNativeVideoExportArgs,
getNativeVideoInputByteSize,
type NativeExportEncodingMode,
type NativeVideoExportFinishOptions,
} from "../nativeVideoExport";
import {
nativeVideoExportSessions,
getNativeVideoExportMaxQueuedWriteBytes,
isHardwareAcceleratedVideoEncoder,
removeTemporaryExportFile,
getNativeVideoExportSessionError,
sendNativeVideoExportWriteFrameResult,
settleNativeVideoExportWriteFrameRequest,
flushNativeVideoExportPendingWriteRequests,
isIgnorableNativeVideoExportStreamError,
enqueueNativeVideoExportFrameWrite,
resolveNativeVideoEncoder,
muxNativeVideoExportAudio,
muxExportedVideoAudioBuffer,
type NativeVideoExportSession,
} from "../export/native-video";
export function registerExportHandlers() {
ipcMain.handle(
'native-video-export-start',
async (
event,
options: {
width: number
height: number
frameRate: number
bitrate: number
encodingMode: NativeExportEncodingMode
inputMode?: 'rawvideo' | 'h264-stream'
},
) => {
try {
if (options.width % 2 !== 0 || options.height % 2 !== 0) {
throw new Error('Native export requires even output dimensions')
}
const ffmpegPath = getFfmpegBinaryPath()
const inputMode = options.inputMode ?? 'rawvideo'
const sessionId = `recordly-export-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const outputPath = path.join(app.getPath('temp'), `${sessionId}.mp4`)
let encoderName: string
let ffmpegArgs: string[]
if (inputMode === 'h264-stream') {
// Pre-encoded H.264 Annex B from browser VideoEncoder — just stream-copy into MP4
encoderName = 'h264-stream-copy'
ffmpegArgs = buildNativeH264StreamExportArgs({ frameRate: options.frameRate, outputPath })
} else {
encoderName = await resolveNativeVideoEncoder(ffmpegPath, options.encodingMode)
ffmpegArgs = buildNativeVideoExportArgs(encoderName, options, outputPath)
}
const ffmpegProcess = spawn(ffmpegPath, ffmpegArgs, {
stdio: ['pipe', 'ignore', 'pipe'],
}) as ChildProcessByStdio<Writable, null, Readable>
// For rawvideo, frames are a fixed RGBA size. For h264-stream, chunks are variable.
const inputByteSize = inputMode === 'rawvideo' ? getNativeVideoInputByteSize(options.width, options.height) : 0
const session: NativeVideoExportSession = {
ffmpegProcess,
outputPath,
inputByteSize,
inputMode,
maxQueuedWriteBytes: inputMode === 'h264-stream' ? 8 * 1024 * 1024 : getNativeVideoExportMaxQueuedWriteBytes(inputByteSize),
stderrOutput: '',
encoderName,
processError: null,
stdinError: null,
terminating: false,
writeSequence: Promise.resolve(),
sender: event.sender,
pendingWriteRequestIds: new Set<number>(),
completionPromise: new Promise<void>((resolve, reject) => {
ffmpegProcess.once('error', (error) => {
const processError = error instanceof Error ? error : new Error(String(error))
if (session.terminating) {
resolve()
return
}
session.processError = processError
reject(processError)
})
ffmpegProcess.stdin.once('error', (error) => {
const stdinError = error instanceof Error ? error : new Error(String(error))
if (session.terminating && isIgnorableNativeVideoExportStreamError(stdinError)) {
return
}
session.stdinError = stdinError
})
ffmpegProcess.once('close', (code, signal) => {
if (session.terminating) {
resolve()
return
}
if (code === 0) {
resolve()
return
}
reject(
new Error(
getNativeVideoExportSessionError(
session,
`FFmpeg exited with code ${code ?? 'unknown'}${signal ? ` (signal ${signal})` : ''}`,
),
),
)
})
}),
}
void session.completionPromise.catch(() => undefined)
ffmpegProcess.stderr.on('data', (chunk: Buffer) => {
session.stderrOutput += chunk.toString()
})
nativeVideoExportSessions.set(sessionId, session)
console.log(
`[native-export] Started ${isHardwareAcceleratedVideoEncoder(encoderName) ? 'hardware' : 'software'} session ${sessionId} with ${encoderName}`,
)
return {
success: true,
sessionId,
encoderName,
}
} catch (error) {
console.error('[native-export] Failed to start native video export session:', error)
return {
success: false,
error: String(error),
}
}
},
)
ipcMain.on(
'native-video-export-write-frame-async',
(
event,
payload: {
sessionId: string
requestId: number
frameData: Uint8Array
},
) => {
const sessionId = payload?.sessionId
const requestId = payload?.requestId
const frameData = payload?.frameData
if (typeof sessionId !== 'string' || typeof requestId !== 'number' || !frameData) {
return
}
const session = nativeVideoExportSessions.get(sessionId)
if (!session) {
sendNativeVideoExportWriteFrameResult(event.sender, sessionId, requestId, {
success: false,
error: 'Invalid native export session',
})
return
}
session.sender = event.sender
session.pendingWriteRequestIds.add(requestId)
if (session.terminating) {
settleNativeVideoExportWriteFrameRequest(sessionId, session, requestId, {
success: false,
error: 'Native video export session was cancelled',
})
return
}
if (session.inputMode !== 'h264-stream' && frameData.byteLength !== session.inputByteSize) {
settleNativeVideoExportWriteFrameRequest(sessionId, session, requestId, {
success: false,
error: `Native video export expected ${session.inputByteSize} bytes per frame but received ${frameData.byteLength}`,
})
return
}
void enqueueNativeVideoExportFrameWrite(session, frameData)
.then(() => {
settleNativeVideoExportWriteFrameRequest(sessionId, session, requestId, {
success: true,
})
})
.catch((error) => {
session.stdinError = error instanceof Error ? error : new Error(String(error))
settleNativeVideoExportWriteFrameRequest(sessionId, session, requestId, {
success: false,
error: getNativeVideoExportSessionError(
session,
session.stdinError.message,
),
})
})
},
)
ipcMain.handle(
'native-video-export-finish',
async (_, sessionId: string, options?: NativeVideoExportFinishOptions) => {
const session = nativeVideoExportSessions.get(sessionId)
if (!session) {
return { success: false, error: 'Invalid native export session' }
}
try {
await session.writeSequence
if (!session.ffmpegProcess.stdin.destroyed && !session.ffmpegProcess.stdin.writableEnded) {
session.ffmpegProcess.stdin.end()
}
await session.completionPromise
const finalizedPath = await muxNativeVideoExportAudio(session.outputPath, options ?? {})
const data = await fs.readFile(finalizedPath)
nativeVideoExportSessions.delete(sessionId)
await removeTemporaryExportFile(finalizedPath)
return {
success: true,
data: new Uint8Array(data),
encoderName: session.encoderName,
}
} catch (error) {
flushNativeVideoExportPendingWriteRequests(
sessionId,
session,
String(error),
)
nativeVideoExportSessions.delete(sessionId)
await removeTemporaryExportFile(session.outputPath)
const finalizedSuffix = session.outputPath.replace(/\.mp4$/, '-final.mp4')
await removeTemporaryExportFile(finalizedSuffix)
return {
success: false,
error: String(error),
}
}
},
)
ipcMain.handle(
'mux-exported-video-audio',
async (_, videoData: ArrayBuffer, options?: NativeVideoExportFinishOptions) => {
try {
const data = await muxExportedVideoAudioBuffer(videoData, options ?? {})
return {
success: true,
data,
}
} catch (error) {
return {
success: false,
error: String(error),
}
}
},
)
ipcMain.handle('native-video-export-cancel', async (_, sessionId: string) => {
const session = nativeVideoExportSessions.get(sessionId)
if (!session) {
return { success: true }
}
session.terminating = true
nativeVideoExportSessions.delete(sessionId)
flushNativeVideoExportPendingWriteRequests(
sessionId,
session,
'Native video export session was cancelled',
)
try {
if (!session.ffmpegProcess.stdin.destroyed && !session.ffmpegProcess.stdin.writableEnded) {
session.ffmpegProcess.stdin.destroy()
}
} catch {
// Stream may already be closed.
}
try {
session.ffmpegProcess.kill('SIGKILL')
} catch {
// Process may already be closed.
}
await session.completionPromise.catch(() => undefined)
await removeTemporaryExportFile(session.outputPath)
return { success: true }
})
ipcMain.handle('save-exported-video', async (event, 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 parentWindow = BrowserWindow.fromWebContents(event.sender)
const saveDialogOptions: SaveDialogOptions = {
title: isGif ? 'Save Exported GIF' : 'Save Exported Video',
defaultPath: path.join(app.getPath('downloads'), fileName),
filters,
properties: ['createDirectory', 'showOverwriteConfirmation'],
}
const result = parentWindow
? await dialog.showSaveDialog(parentWindow, saveDialogOptions)
: await dialog.showSaveDialog(saveDialogOptions)
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('write-exported-video-to-path', async (_event, videoData: ArrayBuffer, outputPath: string) => {
try {
const resolvedPath = path.resolve(outputPath)
await fs.mkdir(path.dirname(resolvedPath), { recursive: true });
await fs.writeFile(resolvedPath, Buffer.from(videoData));
return {
success: true,
path: outputPath,
message: 'Video exported successfully',
canceled: false,
};
} catch (error) {
console.error('Failed to write exported video to path:', error)
return {
success: false,
message: 'Failed to write exported video',
canceled: false,
error: String(error)
}
}
})
}
+87
View File
@@ -0,0 +1,87 @@
import { ipcMain, shell, systemPreferences } from "electron";
import { getMacPrivacySettingsUrl } from "../utils";
export function registerPermissionHandlers() {
ipcMain.handle('open-external-url', async (_, url: string) => {
try {
// Security: only allow http/https URLs to prevent file:// or custom protocol abuse
const parsed = new URL(url)
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
return { success: false, error: `Blocked non-HTTP URL: ${parsed.protocol}` }
}
await shell.openExternal(url)
return { success: true }
} catch (error) {
console.error('Failed to open URL:', error)
return { success: false, error: String(error) }
}
})
ipcMain.handle('get-accessibility-permission-status', () => {
if (process.platform !== 'darwin') {
return { success: true, trusted: true, prompted: false }
}
return {
success: true,
trusted: systemPreferences.isTrustedAccessibilityClient(false),
prompted: false,
}
})
ipcMain.handle('request-accessibility-permission', () => {
if (process.platform !== 'darwin') {
return { success: true, trusted: true, prompted: false }
}
return {
success: true,
trusted: systemPreferences.isTrustedAccessibilityClient(true),
prompted: true,
}
})
ipcMain.handle('get-screen-recording-permission-status', () => {
if (process.platform !== 'darwin') {
return { success: true, status: 'granted' }
}
try {
return {
success: true,
status: systemPreferences.getMediaAccessStatus('screen'),
}
} catch (error) {
console.error('Failed to get screen recording permission status:', error)
return { success: false, status: 'unknown', error: String(error) }
}
})
ipcMain.handle('open-screen-recording-preferences', async () => {
if (process.platform !== 'darwin') {
return { success: true }
}
try {
await shell.openExternal(getMacPrivacySettingsUrl('screen'))
return { success: true }
} catch (error) {
console.error('Failed to open Screen Recording preferences:', error)
return { success: false, error: String(error) }
}
})
ipcMain.handle('open-accessibility-preferences', async () => {
if (process.platform !== 'darwin') {
return { success: true }
}
try {
await shell.openExternal(getMacPrivacySettingsUrl('accessibility'))
return { success: true }
} catch (error) {
console.error('Failed to open Accessibility preferences:', error)
return { success: false, error: String(error) }
}
})
}
+371
View File
@@ -0,0 +1,371 @@
import { constants as fsConstants } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { dialog, ipcMain, shell } from "electron";
import { RECORDINGS_DIR } from "../../appPaths";
import {
PROJECT_FILE_EXTENSION,
LEGACY_PROJECT_FILE_EXTENSIONS,
} from "../constants";
import {
currentProjectPath,
setCurrentProjectPath,
currentVideoPath,
setCurrentVideoPath,
currentRecordingSession,
setCurrentRecordingSession,
} from "../state";
import { normalizeVideoSourcePath } from "../utils";
import { replaceApprovedSessionLocalReadPaths } from "../project/manager";
import {
getTelemetryPathForVideo,
isAutoRecordingPath,
getRecordingsDir,
approveUserPath,
} from "../utils";
import {
getProjectsDir,
persistRecordingsDirectorySetting,
saveProjectThumbnail,
rememberRecentProject,
listProjectLibraryEntries,
loadProjectFromPath,
isTrustedProjectPath,
} from "../project/manager";
import { persistRecordingSessionManifest, resolveRecordingSession } from "../project/session";
function normalizeRecordingTimeOffsetMs(value: unknown): number {
return typeof value === "number" && Number.isFinite(value) ? Math.round(value) : 0;
}
export function registerProjectHandlers() {
ipcMain.handle('reveal-in-folder', async (_, filePath: string) => {
try {
// shell.showItemInFolder doesn't return a value, it throws on error
shell.showItemInFolder(filePath);
return { success: true };
} catch (error) {
console.error(`Error revealing item in folder: ${filePath}`, error);
// Fallback to open the directory if revealing the item fails
// This might happen if the file was moved or deleted after export,
// or if the path is somehow invalid for showItemInFolder
try {
const openPathResult = await shell.openPath(path.dirname(filePath));
if (openPathResult) {
// openPath returned an error message
return { success: false, error: openPathResult };
}
return { success: true, message: 'Could not reveal item, but opened directory.' };
} catch (openError) {
console.error(`Error opening directory: ${path.dirname(filePath)}`, openError);
return { success: false, error: String(error) };
}
}
});
ipcMain.handle('open-recordings-folder', async () => {
try {
const recordingsDir = await getRecordingsDir();
const openPathResult = await shell.openPath(recordingsDir);
if (openPathResult) {
return { success: false, error: openPathResult, message: 'Failed to open recordings folder.' };
}
return { success: true };
} catch (error) {
console.error('Failed to open recordings folder:', error);
return { success: false, error: String(error), message: 'Failed to open recordings folder.' };
}
});
ipcMain.handle('get-recordings-directory', async () => {
try {
const recordingsDir = await getRecordingsDir()
return {
success: true,
path: recordingsDir,
isDefault: recordingsDir === RECORDINGS_DIR,
}
} catch (error) {
return {
success: false,
path: RECORDINGS_DIR,
isDefault: true,
error: String(error),
}
}
})
ipcMain.handle('choose-recordings-directory', async () => {
try {
const current = await getRecordingsDir()
const result = await dialog.showOpenDialog({
title: 'Choose recordings folder',
defaultPath: current,
properties: ['openDirectory', 'createDirectory', 'promptToCreate'],
})
if (result.canceled || result.filePaths.length === 0) {
return { success: false, canceled: true, path: current }
}
const selectedPath = path.resolve(result.filePaths[0])
await fs.mkdir(selectedPath, { recursive: true })
await fs.access(selectedPath, fsConstants.W_OK)
await persistRecordingsDirectorySetting(selectedPath)
return { success: true, path: selectedPath, isDefault: selectedPath === RECORDINGS_DIR }
} catch (error) {
return { success: false, error: String(error), message: 'Failed to set recordings folder' }
}
})
ipcMain.handle('save-project-file', async (_, projectData: unknown, suggestedName?: string, existingProjectPath?: string, thumbnailDataUrl?: string | null) => {
try {
const projectsDir = await getProjectsDir()
const trustedExistingProjectPath = isTrustedProjectPath(existingProjectPath)
? existingProjectPath
: null
if (trustedExistingProjectPath) {
await fs.writeFile(trustedExistingProjectPath, JSON.stringify(projectData, null, 2), 'utf-8')
setCurrentProjectPath(trustedExistingProjectPath)
await saveProjectThumbnail(trustedExistingProjectPath, thumbnailDataUrl)
await rememberRecentProject(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 Recordly Project',
defaultPath: path.join(projectsDir, defaultName),
filters: [
{ name: 'Recordly 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')
setCurrentProjectPath(result.filePath)
await saveProjectThumbnail(result.filePath, thumbnailDataUrl)
await rememberRecentProject(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 projectsDir = await getProjectsDir()
const result = await dialog.showOpenDialog({
title: 'Open Recordly Project',
defaultPath: projectsDir,
filters: [
{ name: 'Recordly Project', extensions: [PROJECT_FILE_EXTENSION, ...LEGACY_PROJECT_FILE_EXTENSIONS] },
{ 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' }
}
return await loadProjectFromPath(result.filePaths[0])
} 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' }
}
return await loadProjectFromPath(currentProjectPath)
} 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('get-projects-directory', async () => {
try {
return {
success: true,
path: await getProjectsDir(),
}
} catch (error) {
return {
success: false,
error: String(error),
}
}
})
ipcMain.handle('list-project-files', async () => {
try {
const library = await listProjectLibraryEntries()
return {
success: true,
projectsDir: library.projectsDir,
entries: library.entries,
}
} catch (error) {
return {
success: false,
projectsDir: null,
entries: [],
error: String(error),
}
}
})
ipcMain.handle('open-project-file-at-path', async (_, filePath: string) => {
try {
return await loadProjectFromPath(filePath)
} catch (error) {
console.error('Failed to open project file at path:', error)
return {
success: false,
message: 'Failed to open project file',
error: String(error),
}
}
})
ipcMain.handle('open-projects-directory', async () => {
try {
const projectsDir = await getProjectsDir()
const openPathResult = await shell.openPath(projectsDir)
if (openPathResult) {
return { success: false, error: openPathResult, message: 'Failed to open projects folder.' }
}
return { success: true, path: projectsDir }
} catch (error) {
console.error('Failed to open projects folder:', error)
return { success: false, error: String(error), message: 'Failed to open projects folder.' }
}
})
ipcMain.handle('set-current-video-path', async (_, path: string) => {
setCurrentVideoPath(normalizeVideoSourcePath(path) ?? path)
approveUserPath(currentVideoPath)
const resolvedSession = await resolveRecordingSession(currentVideoPath)
?? {
videoPath: currentVideoPath!,
webcamPath: null,
timeOffsetMs: 0,
}
setCurrentRecordingSession(resolvedSession)
await replaceApprovedSessionLocalReadPaths([
resolvedSession.videoPath,
resolvedSession.webcamPath,
])
if (resolvedSession.webcamPath) {
await persistRecordingSessionManifest(resolvedSession)
}
setCurrentProjectPath(null)
return { success: true, webcamPath: resolvedSession.webcamPath ?? null }
})
ipcMain.handle('set-current-recording-session', async (_, session: { videoPath: string; webcamPath?: string | null; timeOffsetMs?: number }) => {
const normalizedVideoPath = normalizeVideoSourcePath(session.videoPath) ?? session.videoPath
setCurrentVideoPath(normalizedVideoPath)
setCurrentRecordingSession({
videoPath: normalizedVideoPath,
webcamPath: normalizeVideoSourcePath(session.webcamPath ?? null),
timeOffsetMs: normalizeRecordingTimeOffsetMs(session.timeOffsetMs),
});
await replaceApprovedSessionLocalReadPaths([
currentRecordingSession!.videoPath,
currentRecordingSession!.webcamPath,
])
setCurrentProjectPath(null)
await persistRecordingSessionManifest(currentRecordingSession!)
return { success: true }
})
ipcMain.handle('get-current-recording-session', () => {
if (!currentRecordingSession) {
return { success: false }
}
return {
success: true,
session: currentRecordingSession,
}
})
ipcMain.handle('get-current-video-path', () => {
return currentVideoPath ? { success: true, path: currentVideoPath } : { success: false };
});
ipcMain.handle('clear-current-video-path', () => {
setCurrentVideoPath(null);
setCurrentRecordingSession(null);
return { success: true };
});
ipcMain.handle('delete-recording-file', async (_, filePath: string) => {
try {
if (!filePath || !isAutoRecordingPath(filePath)) {
return { success: false, error: 'Only auto-generated recordings can be deleted' };
}
await fs.unlink(filePath);
// Also delete the cursor telemetry sidecar if it exists
const telemetryPath = getTelemetryPathForVideo(filePath);
await fs.unlink(telemetryPath).catch(() => {});
if (currentVideoPath === filePath) {
setCurrentVideoPath(null);
setCurrentRecordingSession(null);
}
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
}
});
}
File diff suppressed because it is too large Load Diff
+195
View File
@@ -0,0 +1,195 @@
import fs from "node:fs/promises";
import { app, ipcMain } from "electron";
import { hideCursor } from "../../cursorHider";
import { closeCountdownWindow, createCountdownWindow, getCountdownWindow } from "../../windows";
import {
SHORTCUTS_FILE,
RECORDINGS_SETTINGS_FILE,
COUNTDOWN_SETTINGS_FILE,
} from "../constants";
import {
countdownTimer,
setCountdownTimer,
countdownCancelled,
setCountdownCancelled,
countdownInProgress,
setCountdownInProgress,
countdownRemaining,
setCountdownRemaining,
} from "../state";
export function registerSettingsHandlers() {
ipcMain.handle('app:getVersion', () => {
return app.getVersion()
})
ipcMain.handle('get-platform', () => {
return process.platform;
});
// ---------------------------------------------------------------------------
// Cursor hiding for the browser-capture fallback.
// The IPC promise resolves only after the cursor hide attempt completes.
// ---------------------------------------------------------------------------
ipcMain.handle('hide-cursor', () => {
if (process.platform !== 'win32') {
return { success: true }
}
return { success: hideCursor() }
})
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) };
}
});
// ---------------------------------------------------------------------------
// Countdown timer before recording
// ---------------------------------------------------------------------------
ipcMain.handle('get-recording-preferences', async () => {
try {
const content = await fs.readFile(RECORDINGS_SETTINGS_FILE, 'utf-8')
const parsed = JSON.parse(content) as Record<string, unknown>
return {
success: true,
microphoneEnabled: parsed.microphoneEnabled === true,
microphoneDeviceId: typeof parsed.microphoneDeviceId === 'string' ? parsed.microphoneDeviceId : undefined,
systemAudioEnabled: parsed.systemAudioEnabled !== false,
}
} catch {
return { success: true, microphoneEnabled: false, microphoneDeviceId: undefined, systemAudioEnabled: true }
}
})
ipcMain.handle('set-recording-preferences', async (_, prefs: { microphoneEnabled?: boolean; microphoneDeviceId?: string; systemAudioEnabled?: boolean }) => {
try {
let existing: Record<string, unknown> = {}
try {
const content = await fs.readFile(RECORDINGS_SETTINGS_FILE, 'utf-8')
existing = JSON.parse(content) as Record<string, unknown>
} catch {
// file doesn't exist yet
}
const merged = { ...existing, ...prefs }
await fs.writeFile(RECORDINGS_SETTINGS_FILE, JSON.stringify(merged, null, 2), 'utf-8')
return { success: true }
} catch (error) {
console.error('Failed to save recording preferences:', error)
return { success: false, error: String(error) }
}
})
ipcMain.handle('get-countdown-delay', async () => {
try {
const content = await fs.readFile(COUNTDOWN_SETTINGS_FILE, 'utf-8')
const parsed = JSON.parse(content) as { delay?: number }
return { success: true, delay: parsed.delay ?? 3 }
} catch {
return { success: true, delay: 3 }
}
})
ipcMain.handle('set-countdown-delay', async (_, delay: number) => {
try {
await fs.writeFile(COUNTDOWN_SETTINGS_FILE, JSON.stringify({ delay }, null, 2), 'utf-8')
return { success: true }
} catch (error) {
console.error('Failed to save countdown delay:', error)
return { success: false, error: String(error) }
}
})
ipcMain.handle('start-countdown', async (_, seconds: number) => {
if (countdownInProgress) {
return { success: false, error: 'Countdown already in progress' }
}
setCountdownInProgress(true)
setCountdownCancelled(false)
setCountdownRemaining(seconds)
const countdownWin = createCountdownWindow()
if (countdownWin.webContents.isLoadingMainFrame()) {
await new Promise<void>((resolve) => {
countdownWin.webContents.once('did-finish-load', () => {
resolve()
})
})
}
return new Promise<{ success: boolean; cancelled?: boolean }>((resolve) => {
let remaining = seconds
setCountdownRemaining(remaining)
countdownWin.webContents.send('countdown-tick', remaining)
setCountdownTimer(setInterval(() => {
if (countdownCancelled) {
if (countdownTimer) {
clearInterval(countdownTimer)
setCountdownTimer(null)
}
closeCountdownWindow()
setCountdownInProgress(false)
setCountdownRemaining(null)
resolve({ success: false, cancelled: true })
return
}
remaining--
setCountdownRemaining(remaining)
if (remaining <= 0) {
if (countdownTimer) {
clearInterval(countdownTimer)
setCountdownTimer(null)
}
closeCountdownWindow()
setCountdownInProgress(false)
setCountdownRemaining(null)
resolve({ success: true })
} else {
const win = getCountdownWindow()
if (win && !win.isDestroyed()) {
win.webContents.send('countdown-tick', remaining)
}
}
}, 1000))
})
})
ipcMain.handle('cancel-countdown', () => {
setCountdownCancelled(true)
setCountdownInProgress(false)
setCountdownRemaining(null)
if (countdownTimer) {
clearInterval(countdownTimer)
setCountdownTimer(null)
}
closeCountdownWindow()
return { success: true }
})
ipcMain.handle('get-active-countdown', () => {
return {
success: true,
seconds: countdownInProgress ? countdownRemaining : null,
}
})
}
+448
View File
@@ -0,0 +1,448 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { app, BrowserWindow, desktopCapturer, ipcMain } from "electron";
import { ALLOW_RECORDLY_WINDOW_CAPTURE } from "../constants";
import { selectedSource, setSelectedSource } from "../state";
import type { SelectedSource } from "../types";
import { getScreen, parseWindowId } from "../utils";
import { getDisplayBoundsForSource } from "../recording/ffmpeg";
import {
getNativeMacWindowSources,
resolveMacWindowBounds,
resolveWindowsWindowBounds,
resolveLinuxWindowBounds,
stopWindowBoundsCapture,
} from "../cursor/bounds";
const execFileAsync = promisify(execFile);
function normalizeDesktopSourceName(value: string) {
return value.trim().replace(/\s+/g, " ").toLowerCase();
}
function hasUsableSourceThumbnail(
thumbnail:
| {
isEmpty: () => boolean;
getSize: () => { width: number; height: number };
}
| null
| undefined,
) {
if (!thumbnail || thumbnail.isEmpty()) return false;
const size = thumbnail.getSize();
return size.width > 1 && size.height > 1;
}
function broadcastSelectedSourceChange() {
for (const window of BrowserWindow.getAllWindows()) {
if (!window.isDestroyed()) {
window.webContents.send("selected-source-changed", selectedSource);
}
}
}
export function registerSourceHandlers({
createEditorWindow,
createSourceSelectorWindow,
getSourceSelectorWindow,
}: {
createEditorWindow: () => void;
createSourceSelectorWindow: () => BrowserWindow;
getSourceSelectorWindow: () => BrowserWindow | null;
}) {
ipcMain.handle("get-sources", async (_, opts) => {
const includeScreens = Array.isArray(opts?.types) ? opts.types.includes("screen") : true;
const includeWindows = Array.isArray(opts?.types) ? opts.types.includes("window") : true;
const electronTypes = [
...(includeScreens ? ["screen" as const] : []),
...(includeWindows ? ["window" as const] : []),
];
const electronSources =
electronTypes.length > 0
? await desktopCapturer
.getSources({
...opts,
types: electronTypes,
})
.catch((error) => {
console.warn(
"desktopCapturer.getSources failed (screen recording permission may be missing):",
error,
);
return [];
})
: [];
const ownWindowNames = new Set(
[
app.getName(),
"Recordly",
...BrowserWindow.getAllWindows().flatMap((win) => {
const title = win.getTitle().trim();
return title ? [title] : [];
}),
]
.map((name) => normalizeDesktopSourceName(name))
.filter(Boolean),
);
const ownAppName = normalizeDesktopSourceName(app.getName());
const displays = includeScreens
? [...getScreen().getAllDisplays()].sort(
(left, right) =>
left.bounds.x - right.bounds.x ||
left.bounds.y - right.bounds.y ||
left.id - right.id,
)
: [];
const primaryDisplayId = includeScreens ? String(getScreen().getPrimaryDisplay().id) : "";
const electronScreenSourcesByDisplayId = new Map(
electronSources
.filter((source) => source.id.startsWith("screen:"))
.map((source) => [String(source.display_id ?? ""), source] as const),
);
const screenSources = displays.map((display, index) => {
const displayId = String(display.id);
const matchedSource = electronScreenSourcesByDisplayId.get(displayId);
const displayName =
displayId === primaryDisplayId
? `Screen ${index + 1} (Primary)`
: `Screen ${index + 1}`;
return {
id: matchedSource?.id ?? `screen:fallback:${displayId}`,
name: displayName,
originalName: matchedSource?.name ?? displayName,
display_id: displayId,
thumbnail: matchedSource?.thumbnail ? matchedSource.thumbnail.toDataURL() : null,
appIcon: matchedSource?.appIcon ? matchedSource.appIcon.toDataURL() : null,
sourceType: "screen" as const,
};
});
if (process.platform !== "darwin" || !includeWindows) {
const windowSources = electronSources
.filter((source) => source.id.startsWith("window:"))
.filter((source) => hasUsableSourceThumbnail(source.thumbnail))
.filter((source) => {
const normalizedName = normalizeDesktopSourceName(source.name);
if (!normalizedName) {
return true;
}
if (ALLOW_RECORDLY_WINDOW_CAPTURE && normalizedName.includes("recordly")) {
return true;
}
for (const ownName of ownWindowNames) {
if (!ownName) continue;
if (normalizedName === ownName) {
return false;
}
}
return true;
})
.map((source) => ({
id: source.id,
name: source.name,
originalName: source.name,
display_id: source.display_id,
thumbnail: source.thumbnail ? source.thumbnail.toDataURL() : null,
appIcon: source.appIcon ? source.appIcon.toDataURL() : null,
sourceType: "window" as const,
}));
return [...screenSources, ...windowSources];
}
try {
const nativeWindowSources = await getNativeMacWindowSources();
const electronWindowSourceMap = new Map(
electronSources
.filter((source) => source.id.startsWith("window:"))
.map((source) => [source.id, source] as const),
);
const mergedWindowSources = nativeWindowSources
.filter((source) => {
const normalizedWindowName = normalizeDesktopSourceName(
source.windowTitle ?? source.name,
);
const normalizedAppName = normalizeDesktopSourceName(source.appName ?? "");
if (
!ALLOW_RECORDLY_WINDOW_CAPTURE &&
normalizedAppName &&
normalizedAppName === ownAppName
) {
return false;
}
if (
ALLOW_RECORDLY_WINDOW_CAPTURE &&
(normalizedAppName === "recordly" ||
normalizedWindowName?.includes("recordly"))
) {
return true;
}
if (!normalizedWindowName) {
return true;
}
for (const ownName of ownWindowNames) {
if (!ownName) continue;
if (normalizedWindowName === ownName) {
return false;
}
}
return true;
})
.map((source) => {
const electronWindowSource = electronWindowSourceMap.get(source.id);
return {
id: source.id,
name: source.name,
originalName: source.name,
display_id: source.display_id ?? electronWindowSource?.display_id ?? "",
thumbnail: electronWindowSource?.thumbnail
? electronWindowSource.thumbnail.toDataURL()
: null,
appIcon:
source.appIcon ??
(electronWindowSource?.appIcon
? electronWindowSource.appIcon.toDataURL()
: null),
appName: source.appName,
windowTitle: source.windowTitle,
sourceType: "window" as const,
};
});
return [...screenSources, ...mergedWindowSources];
} catch (error) {
console.warn("Falling back to Electron window enumeration on macOS:", error);
const windowSources = electronSources
.filter((source) => source.id.startsWith("window:"))
.filter((source) => {
const normalizedName = normalizeDesktopSourceName(source.name);
if (!normalizedName) {
return true;
}
if (ALLOW_RECORDLY_WINDOW_CAPTURE && normalizedName.includes("recordly")) {
return true;
}
for (const ownName of ownWindowNames) {
if (!ownName) continue;
if (
normalizedName === ownName ||
normalizedName.includes(ownName) ||
ownName.includes(normalizedName)
) {
return false;
}
}
return true;
})
.map((source) => ({
id: source.id,
name: source.name,
originalName: source.name,
display_id: source.display_id,
thumbnail: source.thumbnail ? source.thumbnail.toDataURL() : null,
appIcon: source.appIcon ? source.appIcon.toDataURL() : null,
sourceType: "window" as const,
}));
return [...screenSources, ...windowSources];
}
});
ipcMain.handle("select-source", (_, source: SelectedSource) => {
setSelectedSource(source);
broadcastSelectedSourceChange();
stopWindowBoundsCapture();
const sourceSelectorWin = getSourceSelectorWindow();
if (sourceSelectorWin) {
sourceSelectorWin.close();
}
return selectedSource;
});
ipcMain.handle("show-source-highlight", async (_, source: SelectedSource) => {
try {
const isWindow = source.id?.startsWith("window:");
const windowId = isWindow ? parseWindowId(source.id) : null;
// ── 1. Bring window to front ──
if (isWindow && process.platform === "darwin") {
const appName = source.appName || source.name?.split(" — ")[0]?.trim();
if (appName) {
try {
await execFileAsync(
"osascript",
["-e", `tell application "${appName}" to activate`],
{ timeout: 2000 },
);
await new Promise((resolve) => setTimeout(resolve, 350));
} catch {
/* ignore */
}
}
} else if (windowId && process.platform === "linux") {
try {
await execFileAsync("wmctrl", ["-i", "-a", `0x${windowId.toString(16)}`], {
timeout: 1500,
});
} catch {
try {
await execFileAsync("xdotool", ["windowactivate", String(windowId)], {
timeout: 1500,
});
} catch {
/* not available */
}
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
// ── 2. Resolve bounds ──
let bounds: { x: number; y: number; width: number; height: number } | null = null;
if (source.id?.startsWith("screen:")) {
bounds = getDisplayBoundsForSource(source);
} else if (isWindow) {
if (process.platform === "darwin") {
bounds = await resolveMacWindowBounds(source);
} else if (process.platform === "win32") {
bounds = await resolveWindowsWindowBounds(source);
} else if (process.platform === "linux") {
bounds = await resolveLinuxWindowBounds(source);
}
}
if (!bounds || bounds.width <= 0 || bounds.height <= 0) {
bounds = getDisplayBoundsForSource(source);
}
// ── 3. Show traveling wave highlight ──
const pad = 6;
const highlightWin = new BrowserWindow({
x: bounds.x - pad,
y: bounds.y - pad,
width: bounds.width + pad * 2,
height: bounds.height + pad * 2,
frame: false,
transparent: true,
alwaysOnTop: true,
skipTaskbar: true,
hasShadow: false,
resizable: false,
focusable: false,
webPreferences: { nodeIntegration: false, contextIsolation: true },
});
highlightWin.setIgnoreMouseEvents(true);
const html = `<!DOCTYPE html>
<html><head><style>
*{margin:0;padding:0;box-sizing:border-box}
body{background:transparent;overflow:hidden;width:100vw;height:100vh}
.border-wrap{
position:fixed;inset:0;border-radius:10px;padding:3px;
background:conic-gradient(from var(--angle,0deg),
transparent 0%,
transparent 60%,
rgba(99,96,245,.15) 70%,
rgba(99,96,245,.9) 80%,
rgba(123,120,255,1) 85%,
rgba(99,96,245,.9) 90%,
rgba(99,96,245,.15) 95%,
transparent 100%
);
-webkit-mask:linear-gradient(#fff 0 0) content-box,linear-gradient(#fff 0 0);
-webkit-mask-composite:xor;
mask-composite:exclude;
animation:spin 1.2s linear forwards, fadeAll 1.6s ease-out forwards;
}
.glow-wrap{
position:fixed;inset:-4px;border-radius:14px;padding:6px;
background:conic-gradient(from var(--angle,0deg),
transparent 0%,
transparent 65%,
rgba(99,96,245,.3) 78%,
rgba(123,120,255,.5) 85%,
rgba(99,96,245,.3) 92%,
transparent 100%
);
-webkit-mask:linear-gradient(#fff 0 0) content-box,linear-gradient(#fff 0 0);
-webkit-mask-composite:xor;
mask-composite:exclude;
filter:blur(8px);
animation:spin 1.2s linear forwards, fadeAll 1.6s ease-out forwards;
}
@property --angle{
syntax:'<angle>';
initial-value:0deg;
inherits:false;
}
@keyframes spin{
0%{--angle:0deg}
100%{--angle:360deg}
}
@keyframes fadeAll{
0%,60%{opacity:1}
100%{opacity:0}
}
</style></head><body>
<div class="glow-wrap"></div>
<div class="border-wrap"></div>
</body></html>`
await highlightWin.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`)
setTimeout(() => {
if (!highlightWin.isDestroyed()) highlightWin.close()
}, 1700)
return { success: true }
} catch (error) {
console.error('Failed to show source highlight:', error)
return { success: false }
}
})
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', () => {
console.log('[switch-to-editor] Opening editor window')
const sourceSelectorWin = getSourceSelectorWindow()
if (sourceSelectorWin && !sourceSelectorWin.isDestroyed()) {
sourceSelectorWin.close()
}
createEditorWindow()
})
}
+19
View File
@@ -6,6 +6,7 @@ import { app } from "electron";
import { RECORDINGS_DIR } from "../appPaths";
import { RECORDINGS_SETTINGS_FILE, AUTO_RECORDING_PREFIX } from "./constants";
import {
approvedLocalReadPaths,
customRecordingsDir,
setCustomRecordingsDir,
recordingsDirLoaded,
@@ -103,3 +104,21 @@ export async function getRecordingsDir() {
await fs.mkdir(targetDir, { recursive: true });
return targetDir;
}
export function getMacPrivacySettingsUrl(pane: "screen" | "accessibility" | "microphone"): string {
if (pane === "screen")
return "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture";
if (pane === "microphone")
return "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone";
return "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility";
}
export function approveUserPath(filePath: string | null | undefined): void {
if (!filePath) return;
try {
approvedLocalReadPaths.add(path.resolve(filePath));
} catch {
// Ignore invalid paths; later reads will surface the underlying error.
}
}