mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-25 23:35:43 +00:00
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:
+19
-2882
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user