fix: harden native recorder review paths
This commit is contained in:
@@ -69,6 +69,19 @@ function isPathAllowed(filePath: string): boolean {
|
||||
return getAllowedReadDirs().some((dir) => isPathWithinDir(resolved, dir));
|
||||
}
|
||||
|
||||
function resolveApprovedVideoPath(videoPath?: string | null): string | null {
|
||||
const normalizedPath = normalizeVideoSourcePath(videoPath);
|
||||
if (!normalizedPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!hasAllowedImportVideoExtension(normalizedPath) || !isPathAllowed(normalizedPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalizedPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to build dialog options with a parent window only when it's valid.
|
||||
* This prevents passing stale or destroyed BrowserWindow references to dialog calls.
|
||||
@@ -1363,7 +1376,7 @@ export function registerIpcHandlers(
|
||||
});
|
||||
|
||||
ipcMain.handle("get-cursor-telemetry", async (_, videoPath?: string) => {
|
||||
const targetVideoPath = normalizeVideoSourcePath(
|
||||
const targetVideoPath = resolveApprovedVideoPath(
|
||||
videoPath ?? currentRecordingSession?.screenVideoPath,
|
||||
);
|
||||
if (!targetVideoPath) {
|
||||
@@ -1494,11 +1507,18 @@ export function registerIpcHandlers(
|
||||
return { success: false, canceled: true };
|
||||
}
|
||||
|
||||
approveFilePath(result.filePaths[0]);
|
||||
const normalizedPath = await approveReadableVideoPath(result.filePaths[0]);
|
||||
if (!normalizedPath) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Selected file is not a supported readable video file",
|
||||
};
|
||||
}
|
||||
|
||||
currentProjectPath = null;
|
||||
return {
|
||||
success: true,
|
||||
path: result.filePaths[0],
|
||||
path: normalizedPath,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to open file picker:", error);
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cwctype>
|
||||
#include <exception>
|
||||
#include <iostream>
|
||||
|
||||
namespace {
|
||||
@@ -43,115 +43,6 @@ bool succeeded(HRESULT hr, const char* label) {
|
||||
return false;
|
||||
}
|
||||
|
||||
std::wstring readPropertyString(IPropertyBag* bag, LPCOLESTR key) {
|
||||
VARIANT value;
|
||||
VariantInit(&value);
|
||||
if (FAILED(bag->Read(key, &value, nullptr)) || value.vt != VT_BSTR || !value.bstrVal) {
|
||||
VariantClear(&value);
|
||||
return {};
|
||||
}
|
||||
|
||||
std::wstring result(value.bstrVal);
|
||||
VariantClear(&value);
|
||||
return result;
|
||||
}
|
||||
|
||||
bool containsInsensitive(const std::wstring& haystack, const std::wstring& needle) {
|
||||
if (haystack.empty() || needle.empty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
std::wstring lowerHaystack = haystack;
|
||||
std::wstring lowerNeedle = needle;
|
||||
std::transform(lowerHaystack.begin(), lowerHaystack.end(), lowerHaystack.begin(), ::towlower);
|
||||
std::transform(lowerNeedle.begin(), lowerNeedle.end(), lowerNeedle.begin(), ::towlower);
|
||||
return lowerHaystack.find(lowerNeedle) != std::wstring::npos ||
|
||||
lowerNeedle.find(lowerHaystack) != std::wstring::npos;
|
||||
}
|
||||
|
||||
std::wstring normalizeDeviceName(const std::wstring& value) {
|
||||
std::wstring normalized;
|
||||
normalized.reserve(value.size());
|
||||
bool lastWasSpace = true;
|
||||
for (const wchar_t ch : value) {
|
||||
if (std::iswalnum(ch)) {
|
||||
normalized.push_back(static_cast<wchar_t>(std::towlower(ch)));
|
||||
lastWasSpace = false;
|
||||
continue;
|
||||
}
|
||||
if (!lastWasSpace) {
|
||||
normalized.push_back(L' ');
|
||||
lastWasSpace = true;
|
||||
}
|
||||
}
|
||||
while (!normalized.empty() && normalized.back() == L' ') {
|
||||
normalized.pop_back();
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
std::vector<std::wstring> splitWords(const std::wstring& value) {
|
||||
std::vector<std::wstring> words;
|
||||
size_t start = 0;
|
||||
while (start < value.size()) {
|
||||
const size_t end = value.find(L' ', start);
|
||||
const auto word = value.substr(start, end == std::wstring::npos ? std::wstring::npos : end - start);
|
||||
if (word.size() > 1 && word != L"camera" && word != L"webcam" && word != L"video" && word != L"input") {
|
||||
words.push_back(word);
|
||||
}
|
||||
if (end == std::wstring::npos) {
|
||||
break;
|
||||
}
|
||||
start = end + 1;
|
||||
}
|
||||
return words;
|
||||
}
|
||||
|
||||
int deviceMatchScore(
|
||||
const std::wstring& candidateName,
|
||||
const std::wstring& candidatePath,
|
||||
const std::wstring& requestedName,
|
||||
const std::wstring& requestedId) {
|
||||
int score = 0;
|
||||
const auto normalizedName = normalizeDeviceName(candidateName);
|
||||
const auto normalizedPath = normalizeDeviceName(candidatePath);
|
||||
const auto normalizedRequestedName = normalizeDeviceName(requestedName);
|
||||
const auto normalizedRequestedId = normalizeDeviceName(requestedId);
|
||||
|
||||
if (!normalizedRequestedName.empty()) {
|
||||
if (normalizedName == normalizedRequestedName) {
|
||||
score = std::max(score, 1000);
|
||||
}
|
||||
if (containsInsensitive(normalizedName, normalizedRequestedName)) {
|
||||
score = std::max(score, 900);
|
||||
}
|
||||
if (containsInsensitive(normalizedPath, normalizedRequestedName)) {
|
||||
score = std::max(score, 800);
|
||||
}
|
||||
|
||||
int wordScore = 0;
|
||||
for (const auto& word : splitWords(normalizedRequestedName)) {
|
||||
if (normalizedName.find(word) != std::wstring::npos) {
|
||||
wordScore += 100;
|
||||
} else if (normalizedPath.find(word) != std::wstring::npos) {
|
||||
wordScore += 50;
|
||||
}
|
||||
}
|
||||
score = std::max(score, wordScore);
|
||||
}
|
||||
|
||||
if (!normalizedRequestedId.empty()) {
|
||||
if (containsInsensitive(normalizedPath, normalizedRequestedId)) {
|
||||
score = std::max(score, 700);
|
||||
}
|
||||
if (containsInsensitive(normalizedName, normalizedRequestedId)) {
|
||||
score = std::max(score, 600);
|
||||
}
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
void freeMediaType(AM_MEDIA_TYPE& type) {
|
||||
if (type.cbFormat != 0) {
|
||||
CoTaskMemFree(type.pbFormat);
|
||||
@@ -164,70 +55,6 @@ void freeMediaType(AM_MEDIA_TYPE& type) {
|
||||
}
|
||||
}
|
||||
|
||||
bool readRegistryString(HKEY key, const wchar_t* valueName, std::wstring& value) {
|
||||
DWORD type = 0;
|
||||
DWORD size = 0;
|
||||
if (RegGetValueW(key, nullptr, valueName, RRF_RT_REG_SZ, &type, nullptr, &size) != ERROR_SUCCESS || size == 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
std::wstring buffer(size / sizeof(wchar_t), L'\0');
|
||||
if (RegGetValueW(key, nullptr, valueName, RRF_RT_REG_SZ, &type, buffer.data(), &size) != ERROR_SUCCESS) {
|
||||
return false;
|
||||
}
|
||||
while (!buffer.empty() && buffer.back() == L'\0') {
|
||||
buffer.pop_back();
|
||||
}
|
||||
value = buffer;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool findRegisteredVideoInput(
|
||||
const std::wstring& deviceId,
|
||||
const std::wstring& deviceName,
|
||||
CLSID& selectedClsid,
|
||||
std::wstring& selectedName,
|
||||
int& bestScore) {
|
||||
HKEY instanceKey = nullptr;
|
||||
if (RegOpenKeyExW(
|
||||
HKEY_CLASSES_ROOT,
|
||||
L"CLSID\\{860BB310-5D01-11D0-BD3B-00A0C911CE86}\\Instance",
|
||||
0,
|
||||
KEY_READ,
|
||||
&instanceKey) != ERROR_SUCCESS) {
|
||||
return false;
|
||||
}
|
||||
|
||||
DWORD index = 0;
|
||||
wchar_t subkeyName[128];
|
||||
DWORD subkeyNameLength = ARRAYSIZE(subkeyName);
|
||||
bool found = false;
|
||||
while (RegEnumKeyExW(instanceKey, index, subkeyName, &subkeyNameLength, nullptr, nullptr, nullptr, nullptr) == ERROR_SUCCESS) {
|
||||
HKEY filterKey = nullptr;
|
||||
if (RegOpenKeyExW(instanceKey, subkeyName, 0, KEY_READ, &filterKey) == ERROR_SUCCESS) {
|
||||
std::wstring friendlyName;
|
||||
std::wstring clsidText;
|
||||
readRegistryString(filterKey, L"FriendlyName", friendlyName);
|
||||
readRegistryString(filterKey, L"CLSID", clsidText);
|
||||
const int score = deviceMatchScore(friendlyName, clsidText, deviceName, deviceId);
|
||||
std::wcerr << L"INFO: Registered DirectShow webcam candidate name=\"" << friendlyName << L"\" score=" << score << std::endl;
|
||||
CLSID clsid{};
|
||||
if (!clsidText.empty() && SUCCEEDED(CLSIDFromString(clsidText.c_str(), &clsid)) && (!found || score > bestScore)) {
|
||||
selectedClsid = clsid;
|
||||
selectedName = friendlyName;
|
||||
bestScore = score;
|
||||
found = true;
|
||||
}
|
||||
RegCloseKey(filterKey);
|
||||
}
|
||||
index += 1;
|
||||
subkeyNameLength = ARRAYSIZE(subkeyName);
|
||||
}
|
||||
|
||||
RegCloseKey(instanceKey);
|
||||
return found;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
struct DirectShowWebcamCapture::Impl {
|
||||
@@ -254,6 +81,7 @@ bool DirectShowWebcamCapture::initialize(
|
||||
int requestedWidth,
|
||||
int requestedHeight,
|
||||
int requestedFps) {
|
||||
(void)deviceId;
|
||||
stop();
|
||||
delete impl_;
|
||||
impl_ = nullptr;
|
||||
@@ -371,9 +199,21 @@ bool DirectShowWebcamCapture::start() {
|
||||
if (!succeeded(hr, "Run(DirectShow webcam)")) {
|
||||
return false;
|
||||
}
|
||||
impl_->running = true;
|
||||
stopRequested_ = false;
|
||||
thread_ = std::thread(&DirectShowWebcamCapture::captureLoop, this);
|
||||
try {
|
||||
thread_ = std::thread(&DirectShowWebcamCapture::captureLoop, this);
|
||||
} catch (const std::exception& error) {
|
||||
stopRequested_ = true;
|
||||
impl_->mediaControl->Stop();
|
||||
std::cerr << "ERROR: Failed to start DirectShow webcam capture thread: " << error.what() << std::endl;
|
||||
return false;
|
||||
} catch (...) {
|
||||
stopRequested_ = true;
|
||||
impl_->mediaControl->Stop();
|
||||
std::cerr << "ERROR: Failed to start DirectShow webcam capture thread" << std::endl;
|
||||
return false;
|
||||
}
|
||||
impl_->running = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -402,24 +402,30 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
}
|
||||
|
||||
activeNativeRecording.finalizing = true;
|
||||
nativeWindowsRecording.current = null;
|
||||
setRecording(false);
|
||||
setPaused(false);
|
||||
setElapsedSeconds(0);
|
||||
accumulatedDurationMs.current = 0;
|
||||
segmentStartedAt.current = null;
|
||||
|
||||
const clearNativeRecordingState = () => {
|
||||
nativeWindowsRecording.current = null;
|
||||
setRecording(false);
|
||||
setPaused(false);
|
||||
setElapsedSeconds(0);
|
||||
accumulatedDurationMs.current = 0;
|
||||
segmentStartedAt.current = null;
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.stopNativeWindowsRecording(discard);
|
||||
if (discard || result.discarded) {
|
||||
clearNativeRecordingState();
|
||||
return true;
|
||||
}
|
||||
if (!result.success) {
|
||||
console.error("Failed to stop native Windows recording:", result.error);
|
||||
toast.error(result.error ?? "Failed to stop native Windows recording");
|
||||
activeNativeRecording.finalizing = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
clearNativeRecordingState();
|
||||
if (result.session) {
|
||||
await window.electronAPI.setCurrentRecordingSession(result.session);
|
||||
} else if (result.path) {
|
||||
@@ -433,6 +439,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to save native Windows recording",
|
||||
);
|
||||
activeNativeRecording.finalizing = false;
|
||||
return true;
|
||||
} finally {
|
||||
if (discardRecordingId.current === activeNativeRecording.recordingId) {
|
||||
@@ -582,6 +589,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
|
||||
const availability = await window.electronAPI.isNativeWindowsCaptureAvailable();
|
||||
if (!availability.success || !availability.available) {
|
||||
if (availability.reason === "unsupported-os") {
|
||||
return false;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
availability.reason === "missing-helper"
|
||||
? "Native Windows capture helper is not available."
|
||||
|
||||
Reference in New Issue
Block a user