Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 18 additions & 15 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
Comment on lines +1 to +5
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Revert CRLF line endings to LF (Unix-style).

The AI summary indicates these lines were changed to use Windows-style CRLF line endings. This is problematic for a cross-platform project, especially one addressing macOS bugs. .gitignore files should use LF (Unix) line endings to ensure consistent behavior across macOS, Linux, and Windows (Git automatically handles line ending conversions for repository files).

CRLF line endings in .gitignore can cause Git to malfunction on Unix-like systems, potentially failing to ignore specified files and directories.

Additionally, these formatting changes appear unrelated to the PR's stated objectives (fixing macOS video recording and cursor telemetry bugs).

🔧 Recommended action

Ensure the entire .gitignore file uses LF line endings. Most editors and Git tools can automatically convert line endings. If you're using Git, you can configure:

git config core.autocrlf input

Then re-stage the file to normalize line endings.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.gitignore around lines 1 - 5, The .gitignore file contains Windows-style
CRLF line endings; revert them to LF (Unix-style) so Git ignores work
consistently across macOS/Linux; open the .gitignore, convert all CRLF to LF (or
run git normalization with core.autocrlf=input), save and re-stage the
.gitignore so the commit only changes line endings back to LF and remove any
unrelated whitespace edits.

yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
Expand All @@ -29,13 +29,16 @@ release/**
.tmp/
.history/
*.tsbuildinfo
vite.config.js
vite.config.d.ts

# Native capture build artifacts
electron/native/wgc-capture/build/
electron/native/cursor-monitor/build/

# Local debug helpers
tmp-*.ps1
.tmp-*.ps1
vite.config.js
vite.config.d.ts

# Native capture build artifacts
electron/native/wgc-capture/build/
electron/native/cursor-monitor/build/

# Local debug helpers
tmp-*.ps1
.tmp-*.ps1

# Claude Code local settings
.claude/
88 changes: 56 additions & 32 deletions electron/ipc/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2605,17 +2605,16 @@ function snapshotCursorTelemetryForPersistence() {
}

async function finalizeStoredVideo(videoPath: string) {
let validation: { fileSizeBytes: number; durationSeconds: number | null } | null = null
try {
validation = await validateRecordedVideo(videoPath)
} catch (error) {
console.warn('Video validation failed (proceeding anyway):', error)
}

// Persist cursor telemetry before validation so it's saved even if validation fails.
snapshotCursorTelemetryForPersistence()
await persistPendingCursorTelemetry(videoPath).catch((err) => {
console.warn('[finalizeStoredVideo] Failed to persist cursor telemetry:', err)
})

const validation = await validateRecordedVideo(videoPath)

currentVideoPath = videoPath
currentProjectPath = null
await persistPendingCursorTelemetry(videoPath)
if (isAutoRecordingPath(videoPath)) {
await pruneAutoRecordings([videoPath])
}
Expand All @@ -2637,16 +2636,16 @@ async function finalizeStoredVideo(videoPath: string) {
supported: lastNativeCaptureDiagnostics.supported,
helperExists: lastNativeCaptureDiagnostics.helperExists,
processOutput: lastNativeCaptureDiagnostics.processOutput,
fileSizeBytes: validation?.fileSizeBytes ?? null,
fileSizeBytes: validation.fileSizeBytes,
})
}

return {
success: true,
path: videoPath,
message: validation?.durationSeconds !== null && validation !== null
message: validation.durationSeconds !== null
? `Video stored successfully (${validation.fileSizeBytes} bytes, ${validation.durationSeconds.toFixed(2)}s)`
: `Video stored successfully`
: `Video stored successfully (${validation.fileSizeBytes} bytes)`
}
}

Expand Down Expand Up @@ -2815,9 +2814,6 @@ export function registerIpcHandlers(
? 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(
Expand Down Expand Up @@ -2950,13 +2946,15 @@ export function registerIpcHandlers(
sourceType: 'window' as const,
}
})
.filter((source) => Boolean(source.thumbnail))
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Apply the usable-thumbnail check before converting to a data URL.

Boolean(source.thumbnail) only checks that a string exists after toDataURL(). It does not enforce the hasUsableSourceThumbnail() gate you already use in the fallback path, so unusable Electron thumbnails can still survive the merged macOS path.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@electron/ipc/handlers.ts` at line 2949, Replace the current filter predicate
that only checks Boolean(source.thumbnail) with the usable-thumbnail gate so
unusable Electron thumbnails are excluded before data-URL conversion;
specifically, change the filter on the array (currently .filter((source) =>
Boolean(source.thumbnail))) to use hasUsableSourceThumbnail(source) or run
hasUsableSourceThumbnail(source) prior to calling source.toDataURL() (the place
where toDataURL() is invoked), ensuring only sources passing
hasUsableSourceThumbnail() are mapped to data URLs.


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) => hasUsableSourceThumbnail(source.thumbnail))
.filter((source) => {
const normalizedName = normalizeDesktopSourceName(source.name)
if (!normalizedName) {
Expand Down Expand Up @@ -3006,16 +3004,41 @@ export function registerIpcHandlers(
const isWindow = source.id?.startsWith('window:')
const windowId = isWindow ? parseWindowId(source.id) : null

// ── 1. Bring window to front ──
// ── 1. Bring window to front & get its bounds via AppleScript ──
let asBounds: { x: number; y: number; width: number; height: number } | null = null

if (isWindow && process.platform === 'darwin') {
const appName = source.appName || source.name?.split(' — ')[0]?.trim()
if (appName) {
// Single AppleScript: activate AND return window bounds
try {
await execFileAsync('osascript', ['-e',
`tell application "${appName}" to activate`
], { timeout: 2000 })
await new Promise((resolve) => setTimeout(resolve, 350))
} catch { /* ignore */ }
const { stdout } = await execFileAsync('osascript', ['-e',
`tell application "${appName}"\n` +
` activate\n` +
`end tell\n` +
`delay 0.3\n` +
`tell application "System Events"\n` +
` tell process "${appName}"\n` +
` set frontWindow to front window\n` +
` set {x1, y1} to position of frontWindow\n` +
` set {w1, h1} to size of frontWindow\n` +
` return (x1 as text) & "," & (y1 as text) & "," & (w1 as text) & "," & (h1 as text)\n` +
` end tell\n` +
`end tell`
], { timeout: 4000 })
const parts = stdout.trim().split(',').map(Number)
if (parts.length === 4 && parts.every(n => Number.isFinite(n))) {
asBounds = { x: parts[0], y: parts[1], width: parts[2], height: parts[3] }
}
} catch {
// Fallback: just activate without bounds
try {
await execFileAsync('osascript', ['-e',
`tell application "${appName}" to activate`
], { timeout: 2000 })
Comment on lines +3015 to +3038
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

cd /root && find . -name "handlers.ts" -type f | head -20

Repository: webadderall/Recordly

Length of output: 119


🏁 Script executed:

# Check if the file exists and its size
wc -l electron/ipc/handlers.ts 2>/dev/null || find . -name "handlers.ts" -path "*/ipc/*" -type f

Repository: webadderall/Recordly

Length of output: 93


🏁 Script executed:

# Read the specific section mentioned
sed -n '3000,3050p' electron/ipc/handlers.ts

Repository: webadderall/Recordly

Length of output: 2356


🏁 Script executed:

# Also search for the appName variable definition and context around those lines
sed -n '2950,3100p' electron/ipc/handlers.ts

Repository: webadderall/Recordly

Length of output: 5555


🏁 Script executed:

# Search for other osascript calls in the file to see if there are similar patterns
rg -n "execFileAsync.*osascript" electron/ipc/handlers.ts -A 3

Repository: webadderall/Recordly

Length of output: 508


🏁 Script executed:

# Also check if there are other vulnerable string interpolation patterns with execFileAsync and shell commands
rg -n "execFileAsync.*\$\{.*\}" electron/ipc/handlers.ts | head -20

Repository: webadderall/Recordly

Length of output: 46


🏁 Script executed:

# Verify the AppleScript on run argv pattern is valid by checking if there are any existing uses in the codebase
rg -n "on run argv" . --type typescript --type javascript --type applescript

Repository: webadderall/Recordly

Length of output: 98


Fix AppleScript code injection vulnerability via appName parameter.

Both osascript calls (lines 3015 and 3036) interpolate appName directly into AppleScript without escaping. A quote or newline character in the app name can break out of the string literal and execute arbitrary AppleScript code. Pass the value via on run argv instead.

🔒 Safer pattern
-            const { stdout } = await execFileAsync('osascript', ['-e',
-              `tell application "${appName}"\n` +
-              `  activate\n` +
-              `end tell\n` +
-              `delay 0.3\n` +
-              `tell application "System Events"\n` +
-              `  tell process "${appName}"\n` +
-              `    set frontWindow to front window\n` +
-              `    set {x1, y1} to position of frontWindow\n` +
-              `    set {w1, h1} to size of frontWindow\n` +
-              `    return (x1 as text) & "," & (y1 as text) & "," & (w1 as text) & "," & (h1 as text)\n` +
-              `  end tell\n` +
-              `end tell`
-            ], { timeout: 4000 })
+            const script = `
+on run argv
+  set appName to item 1 of argv
+  tell application appName to activate
+  delay 0.3
+  tell application "System Events"
+    tell process appName
+      set frontWindow to front window
+      set {x1, y1} to position of frontWindow
+      set {w1, h1} to size of frontWindow
+      return (x1 as text) & "," & (y1 as text) & "," & (w1 as text) & "," & (h1 as text)
+    end tell
+  end tell
+end run`
+            const { stdout } = await execFileAsync('osascript', ['-e', script, appName], { timeout: 4000 })

Also apply the same fix to the fallback call at line 3036.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@electron/ipc/handlers.ts` around lines 3015 - 3038, The AppleScript
invocations in the execFileAsync calls interpolate appName directly and are
vulnerable to code injection; change both usages to pass appName via osascript's
argv (use an 'on run argv' wrapper that assigns the first argv to a local
variable) and remove any direct string interpolation of appName. Specifically,
update the main execFileAsync (the block that returns x,y,w,h and assigns
asBounds) and the fallback execFileAsync (the 'activate' fallback) to use an
AppleScript script that starts with "on run argv" / "set appName to item 1 of
argv" / ... / "end run" and then pass [appName] as the argv argument to
execFileAsync, keeping the same timeouts and the surrounding logic
(execFileAsync, stdout parsing, asBounds assignment) unchanged.

await new Promise((resolve) => setTimeout(resolve, 350))
} catch { /* ignore */ }
}
}
} else if (windowId && process.platform === 'linux') {
try {
Expand All @@ -3029,15 +3052,17 @@ export function registerIpcHandlers(
}

// ── 2. Resolve bounds ──
let bounds: { x: number; y: number; width: number; height: number } | null = null
let bounds = asBounds

if (source.id?.startsWith('screen:')) {
bounds = getDisplayBoundsForSource(source)
} else if (isWindow) {
if (process.platform === 'darwin') {
bounds = await resolveMacWindowBounds(source)
} else if (process.platform === 'linux') {
bounds = await resolveLinuxWindowBounds(source)
if (!bounds) {
if (source.id?.startsWith('screen:')) {
bounds = getDisplayBoundsForSource(source)
} else if (isWindow) {
if (process.platform === 'darwin') {
bounds = await resolveMacWindowBounds(source)
} else if (process.platform === 'linux') {
bounds = await resolveLinuxWindowBounds(source)
}
}
Comment on lines +3055 to 3066
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Prefer the selected window's bounds over the app's front window.

asBounds is for whichever window becomes frontmost after activate, not necessarily the windowId the user selected. In multi-window apps this can highlight the wrong window. Use resolveMacWindowBounds(source) first and fall back to asBounds only if that lookup fails.

🎯 Suggested fix
-      let bounds = asBounds
-
-      if (!bounds) {
-        if (source.id?.startsWith('screen:')) {
-          bounds = getDisplayBoundsForSource(source)
-        } else if (isWindow) {
-          if (process.platform === 'darwin') {
-            bounds = await resolveMacWindowBounds(source)
-          } else if (process.platform === 'linux') {
-            bounds = await resolveLinuxWindowBounds(source)
-          }
-        }
-      }
+      let bounds: WindowBounds | null = null
+
+      if (source.id?.startsWith('screen:')) {
+        bounds = getDisplayBoundsForSource(source)
+      } else if (isWindow) {
+        if (process.platform === 'darwin') {
+          bounds = await resolveMacWindowBounds(source) ?? asBounds
+        } else if (process.platform === 'linux') {
+          bounds = await resolveLinuxWindowBounds(source)
+        }
+      }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let bounds = asBounds
if (source.id?.startsWith('screen:')) {
bounds = getDisplayBoundsForSource(source)
} else if (isWindow) {
if (process.platform === 'darwin') {
bounds = await resolveMacWindowBounds(source)
} else if (process.platform === 'linux') {
bounds = await resolveLinuxWindowBounds(source)
if (!bounds) {
if (source.id?.startsWith('screen:')) {
bounds = getDisplayBoundsForSource(source)
} else if (isWindow) {
if (process.platform === 'darwin') {
bounds = await resolveMacWindowBounds(source)
} else if (process.platform === 'linux') {
bounds = await resolveLinuxWindowBounds(source)
}
}
let bounds: WindowBounds | null = null
if (source.id?.startsWith('screen:')) {
bounds = getDisplayBoundsForSource(source)
} else if (isWindow) {
if (process.platform === 'darwin') {
bounds = await resolveMacWindowBounds(source) ?? asBounds
} else if (process.platform === 'linux') {
bounds = await resolveLinuxWindowBounds(source)
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@electron/ipc/handlers.ts` around lines 3055 - 3066, The current logic prefers
asBounds then falls back to platform-specific resolution which can pick the
frontmost app window rather than the user-selected window; change the branch so
for window sources you call resolveMacWindowBounds(source) (on darwin) or
resolveLinuxWindowBounds(source) (on linux) first and set bounds from that
result only if it returns a valid value, and only then fall back to asBounds;
keep the screen source path using getDisplayBoundsForSource(source) unchanged
and ensure you still check isWindow before the platform-specific resolution.

}

Expand Down Expand Up @@ -3353,15 +3378,14 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh}
}

const helperPath = await ensureNativeCaptureHelperBinary()
const timestamp = Date.now()
const outputPath = path.join(recordingsDir, `recording-${timestamp}.mp4`)
const outputPath = path.join(recordingsDir, `recording-${Date.now()}.mp4`)
const capturesSystemAudio = Boolean(options?.capturesSystemAudio)
const capturesMicrophone = Boolean(options?.capturesMicrophone)
const systemAudioOutputPath = capturesSystemAudio
? path.join(recordingsDir, `recording-${timestamp}.system.m4a`)
? path.join(recordingsDir, `recording-${Date.now()}.system.m4a`)
: null
const microphoneOutputPath = capturesMicrophone
? path.join(recordingsDir, `recording-${timestamp}.mic.m4a`)
? path.join(recordingsDir, `recording-${Date.now()}.mic.m4a`)
: null
const config: Record<string, unknown> = {
fps: 60,
Expand Down
6 changes: 5 additions & 1 deletion electron/native/ScreenCaptureKitRecorder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,11 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
}
}

assetWriter?.endSession(atSourceTime: lastSampleBuffer?.presentationTimeStamp ?? .zero)
let sessionEndTime: CMTime = {
guard frameCount > 0, let lastBuffer = lastSampleBuffer else { return .zero }
return lastVideoPresentationTime + frameDuration(for: lastBuffer)
}()
assetWriter?.endSession(atSourceTime: sessionEndTime)
videoInput?.markAsFinished()
await assetWriter?.finishWriting()

Expand Down