浏览代码

frontend: Add CrashHandler class

The CrashHandler class encapsulates all functionality around unsafe
shutdown detection as well as crash log discovery and log upload.

Each (functional) CrashHandler should be initialized with an app launch
UUID, which enables the handler to disambiguate a sentinel file for the
current app launch from those of prior app launches.
PatTheMav 6 月之前
父节点
当前提交
272825b46a

+ 4 - 1
frontend/cmake/os-freebsd.cmake

@@ -1,4 +1,7 @@
-target_sources(obs-studio PRIVATE utility/platform-x11.cpp utility/system-info-posix.cpp)
+target_sources(
+  obs-studio
+  PRIVATE utility/platform-x11.cpp utility/system-info-posix.cpp utility/CrashHandler_FreeBSD.cpp
+)
 target_compile_definitions(obs-studio PRIVATE OBS_INSTALL_PREFIX="${OBS_INSTALL_PREFIX}")
 target_compile_definitions(obs-studio PRIVATE OBS_INSTALL_PREFIX="${OBS_INSTALL_PREFIX}")
 target_link_libraries(obs-studio PRIVATE Qt::GuiPrivate Qt::DBus procstat)
 target_link_libraries(obs-studio PRIVATE Qt::GuiPrivate Qt::DBus procstat)
 
 

+ 1 - 1
frontend/cmake/os-linux.cmake

@@ -1,4 +1,4 @@
-target_sources(obs-studio PRIVATE utility/platform-x11.cpp utility/system-info-posix.cpp)
+target_sources(obs-studio PRIVATE utility/platform-x11.cpp utility/system-info-posix.cpp utility/CrashHandler_Linux.cpp)
 target_compile_definitions(
 target_compile_definitions(
   obs-studio
   obs-studio
   PRIVATE OBS_INSTALL_PREFIX="${OBS_INSTALL_PREFIX}" $<$<BOOL:${ENABLE_PORTABLE_CONFIG}>:ENABLE_PORTABLE_CONFIG>
   PRIVATE OBS_INSTALL_PREFIX="${OBS_INSTALL_PREFIX}" $<$<BOOL:${ENABLE_PORTABLE_CONFIG}>:ENABLE_PORTABLE_CONFIG>

+ 8 - 1
frontend/cmake/os-macos.cmake

@@ -6,12 +6,19 @@ target_sources(
     dialogs/OBSPermissions.cpp
     dialogs/OBSPermissions.cpp
     dialogs/OBSPermissions.hpp
     dialogs/OBSPermissions.hpp
     forms/OBSPermissions.ui
     forms/OBSPermissions.ui
+    utility/CrashHandler_MacOS.mm
     utility/platform-osx.mm
     utility/platform-osx.mm
     utility/system-info-macos.mm
     utility/system-info-macos.mm
 )
 )
 target_compile_options(obs-studio PRIVATE -Wno-quoted-include-in-framework-header -Wno-comma)
 target_compile_options(obs-studio PRIVATE -Wno-quoted-include-in-framework-header -Wno-comma)
 
 
-set_source_files_properties(platform-osx.mm PROPERTIES COMPILE_OPTIONS -fobjc-arc)
+list(APPEND _frontend_objcxx_compile_options -fobjc-arc -fmodules -fcxx-modules)
+
+set_property(
+  SOURCE utility/platform-osx.mm utility/CrashHandler_MacOS.mm
+  APPEND
+  PROPERTY COMPILE_OPTIONS ${_frontend_objcxx_compile_options}
+)
 
 
 if(CMAKE_C_COMPILER_VERSION VERSION_GREATER_EQUAL 14.0.3)
 if(CMAKE_C_COMPILER_VERSION VERSION_GREATER_EQUAL 14.0.3)
   target_compile_options(obs-studio PRIVATE -Wno-error=unqualified-std-cast-call)
   target_compile_options(obs-studio PRIVATE -Wno-error=unqualified-std-cast-call)

+ 1 - 0
frontend/cmake/os-windows.cmake

@@ -24,6 +24,7 @@ target_sources(
     obs.rc
     obs.rc
     utility/AutoUpdateThread.cpp
     utility/AutoUpdateThread.cpp
     utility/AutoUpdateThread.hpp
     utility/AutoUpdateThread.hpp
+    utility/CrashHandler_Windows.cpp
     utility/crypto-helpers-mbedtls.cpp
     utility/crypto-helpers-mbedtls.cpp
     utility/crypto-helpers.hpp
     utility/crypto-helpers.hpp
     utility/models/branches.hpp
     utility/models/branches.hpp

+ 2 - 0
frontend/cmake/ui-utility.cmake

@@ -8,6 +8,8 @@ target_sources(
     utility/BaseLexer.hpp
     utility/BaseLexer.hpp
     utility/BasicOutputHandler.cpp
     utility/BasicOutputHandler.cpp
     utility/BasicOutputHandler.hpp
     utility/BasicOutputHandler.hpp
+    utility/CrashHandler.cpp
+    utility/CrashHandler.hpp
     utility/display-helpers.hpp
     utility/display-helpers.hpp
     utility/FFmpegCodec.cpp
     utility/FFmpegCodec.cpp
     utility/FFmpegCodec.hpp
     utility/FFmpegCodec.hpp

+ 10 - 0
frontend/data/locale/en-US.ini

@@ -137,6 +137,16 @@ AutoSafeMode.LaunchNormal="Run Normally"
 SafeMode.Restart="Do you want to restart OBS in Safe Mode (third-party plugins, scripting, and WebSockets disabled)?"
 SafeMode.Restart="Do you want to restart OBS in Safe Mode (third-party plugins, scripting, and WebSockets disabled)?"
 SafeMode.RestartNormal="Do you want to restart OBS in Normal Mode?"
 SafeMode.RestartNormal="Do you want to restart OBS in Normal Mode?"
 
 
+# Prior Crash Detection
+CrashHandling.Dialog.Title="OBS Studio Crash Detected"
+CrashHandling.Labels.Text="OBS Studio did not properly shut down.\n\nRun in Safe Mode (third-party plugins, scripting, and WebSockets disabled)?"
+CrashHandling.Labels.PrivacyNotice="You can also choose to automatically upload the most recent crash report to the OBSProject.<br /><br />Please read the <a href='https://obsproject.com/privacy-policy'>Privacy Policy</a> before uploading any files and pay special attention to the parts regarding file uploads."
+CrashHandling.Checkbox.SendReport="I have read the privacy policy and consent to the upload."
+CrashHandling.Buttons.LaunchSafe="Run in Safe Mode"
+CrashHandling.Buttons.LaunchNormal="Run in Normal Mode"
+CrashHandling.Errors.UploadJSONError="An error occurred while trying to upload the most recent crash log. Please try again later."
+CrashHandling.Errors.Title="Crash Log Upload Error"
+
 ChromeOS.Title="Unsupported Platform"
 ChromeOS.Title="Unsupported Platform"
 ChromeOS.Text="OBS appears to be running inside a ChromeOS container. This platform is unsupported."
 ChromeOS.Text="OBS appears to be running inside a ChromeOS container. This platform is unsupported."
 
 

+ 378 - 0
frontend/utility/CrashHandler.cpp

@@ -0,0 +1,378 @@
+/******************************************************************************
+    Copyright (C) 2025 by Patrick Heyer <[email protected]>
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 2 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+******************************************************************************/
+
+#include "CrashHandler.hpp"
+#include <OBSApp.hpp>
+#include <qt-wrappers.hpp>
+
+#include <nlohmann/json.hpp>
+
+#include <fstream>
+#include <iostream>
+#include <sstream>
+#include <string_view>
+
+#include "moc_CrashHandler.cpp"
+
+using json = nlohmann::json;
+using CrashLogUpdateResult = OBS::CrashHandler::CrashLogUpdateResult;
+
+namespace {
+
+constexpr std::string_view crashSentinelPath = "obs-studio";
+constexpr std::string_view crashSentinelPrefix = "crash_sentinel_";
+constexpr std::string_view crashUploadURL = "https://obsproject.com/logs/upload";
+
+#ifndef NDEBUG
+constexpr bool isSentinelEnabled = false;
+#else
+constexpr bool isSentinelEnabled = true;
+#endif
+
+std::string getCrashLogFileContent(std::filesystem::path crashLogFile)
+{
+	std::string crashLogFileContent;
+
+	if (!std::filesystem::exists(crashLogFile)) {
+		return crashLogFileContent;
+	}
+
+	std::fstream filestream;
+	std::streampos crashLogFileSize = 0;
+	filestream.open(crashLogFile, std::ios::in | std::ios::binary);
+
+	if (filestream.is_open()) {
+		crashLogFileSize = filestream.tellg();
+		filestream.seekg(0, std::ios::end);
+		crashLogFileSize = filestream.tellg() - crashLogFileSize;
+		filestream.seekg(0);
+
+		crashLogFileContent.resize(crashLogFileSize);
+		crashLogFileContent.assign((std::istreambuf_iterator<char>(filestream)),
+					   (std::istreambuf_iterator<char>()));
+	}
+
+	return crashLogFileContent;
+}
+
+std::pair<OBS::TimePoint, std::string> buildCrashLogUploadContent(OBS::PlatformType platformType,
+								  std::string crashLogFileContent)
+{
+	OBS::TimePoint uploadTimePoint = OBS::Clock::now();
+	std::time_t uploadTimePoint_c = OBS::Clock::to_time_t(uploadTimePoint);
+	std::tm uploadTimeLocal = *std::localtime(&uploadTimePoint_c);
+
+	std::stringstream uploadLogMessage;
+
+	switch (platformType) {
+	case OBS::PlatformType::Windows:
+		uploadLogMessage << "OBS " << App()->GetVersionString(false) << " crash file uploaded at "
+				 << std::put_time(&uploadTimeLocal, "%Y-%m-%d, %X") << "\n\n"
+				 << crashLogFileContent;
+		break;
+	case OBS::PlatformType::macOS:
+		uploadLogMessage << crashLogFileContent;
+	default:
+		break;
+	}
+
+	return std::pair<OBS::TimePoint, std::string>(uploadTimePoint, uploadLogMessage.str());
+}
+} // namespace
+
+namespace OBS {
+
+static_assert(!crashSentinelPath.empty(), "Crash sentinel path name cannot be empty");
+static_assert(!crashSentinelPrefix.empty(), "Crash sentinel filename prefix cannot be empty");
+static_assert(!crashUploadURL.empty(), "Crash sentinel upload URL cannot be empty");
+
+CrashHandler::CrashHandler(QUuid appLaunchUUID) : appLaunchUUID_(appLaunchUUID)
+{
+	if (!isSentinelEnabled) {
+		return;
+	}
+
+	setupSentinel();
+	checkCrashState();
+}
+
+CrashHandler::~CrashHandler()
+{
+	if (isActiveCrashHandler_) {
+		applicationShutdownHandler();
+	}
+}
+
+CrashHandler::CrashHandler(CrashHandler &&other) noexcept
+{
+	crashLogUploadThread_ = std::exchange(other.crashLogUploadThread_, nullptr);
+	isActiveCrashHandler_ = true;
+	other.isActiveCrashHandler_ = false;
+}
+
+CrashHandler &CrashHandler::operator=(CrashHandler &&other) noexcept
+{
+	std::swap(crashLogUploadThread_, other.crashLogUploadThread_);
+	isActiveCrashHandler_ = true;
+	other.isActiveCrashHandler_ = false;
+	return *this;
+}
+
+bool CrashHandler::hasUncleanShutdown()
+{
+	return isSentinelEnabled && isSentinelPresent_;
+}
+
+bool CrashHandler::hasNewCrashLog()
+{
+	CrashLogUpdateResult result = updateLocalCrashLogState();
+
+	bool hasNewCrashLog = (result == CrashLogUpdateResult::Updated);
+	bool hasNoLogUrl = lastCrashLogURL_.empty();
+
+	return (hasNewCrashLog || hasNoLogUrl);
+}
+
+CrashLogUpdateResult CrashHandler::updateLocalCrashLogState()
+{
+	updateCrashLogFromConfig();
+
+	std::filesystem::path lastLocalCrashLogFile = findLastCrashLog();
+
+	if (lastLocalCrashLogFile != lastCrashLogFile_) {
+		lastCrashLogFile_ = std::move(lastLocalCrashLogFile);
+		lastCrashLogFileName_ = lastCrashLogFile_.filename().u8string();
+		lastCrashLogURL_.clear();
+
+		return CrashLogUpdateResult::Updated;
+	} else {
+		return CrashLogUpdateResult::NotUpdated;
+	}
+}
+
+void CrashHandler::uploadLastCrashLog()
+{
+	bool newCrashDetected = hasNewCrashLog();
+
+	if (newCrashDetected) {
+		uploadCrashLogToServer();
+	} else {
+		handleExistingCrashLogUpload();
+	}
+}
+
+void CrashHandler::checkCrashState()
+{
+	bool currentSentinelFound = false;
+
+	std::filesystem::path crashSentinelPath = crashSentinelFile_.parent_path();
+
+	if (!std::filesystem::exists(crashSentinelPath)) {
+		blog(LOG_ERROR, "Crash sentinel location '%s' does not exist", crashSentinelPath.u8string().c_str());
+		return;
+	}
+
+	for (const auto &entry : std::filesystem::directory_iterator(crashSentinelPath)) {
+		if (entry.is_directory()) {
+			continue;
+		}
+
+		std::string entryFileName = entry.path().filename().u8string();
+
+		if (entryFileName.rfind(crashSentinelPrefix.data(), 0) != 0) {
+			continue;
+		}
+
+		if (entry.path().filename() == crashSentinelFile_.filename()) {
+			currentSentinelFound = true;
+			continue;
+		}
+
+		isSentinelPresent_ = true;
+	}
+
+	if (!currentSentinelFound) {
+		std::fstream filestream;
+		filestream.open(crashSentinelFile_, std::ios::out);
+		filestream.close();
+	}
+}
+
+void CrashHandler::setupSentinel()
+{
+	BPtr crashSentinelPathString = GetAppConfigPathPtr(crashSentinelPath.data());
+	std::string appLaunchUUIDString = appLaunchUUID_.toString(QUuid::WithoutBraces).toStdString();
+
+	std::string crashSentinelFilePath = crashSentinelPathString.Get();
+	crashSentinelFilePath.reserve(crashSentinelFilePath.size() + crashSentinelPrefix.size() +
+				      appLaunchUUIDString.size() + 1);
+	crashSentinelFilePath.append("/").append(crashSentinelPrefix).append(appLaunchUUIDString);
+
+	crashSentinelFile_ = std::filesystem::u8path(crashSentinelFilePath);
+
+	isActiveCrashHandler_ = true;
+}
+
+void CrashHandler::updateCrashLogFromConfig()
+{
+	config_t *appConfig = App()->GetAppConfig();
+
+	if (!appConfig) {
+		return;
+	}
+
+	const char *last_crash_log_file = config_get_string(appConfig, "CrashHandler", "last_crash_log_file");
+	const char *last_crash_log_url = config_get_string(appConfig, "CrashHandler", "last_crash_log_url");
+
+	std::string lastCrashLogFilePath = last_crash_log_file ? last_crash_log_file : "";
+	int64_t lastCrashLogUploadTimestamp = config_get_int(appConfig, "CrashHandler", "last_crash_log_time");
+	std::string lastCrashLogUploadURL = last_crash_log_url ? last_crash_log_url : "";
+
+	OBS::Clock::duration durationSinceEpoch = std::chrono::seconds(lastCrashLogUploadTimestamp);
+	OBS::TimePoint lastCrashLogUploadTime = OBS::TimePoint(durationSinceEpoch);
+
+	lastCrashLogFile_ = std::filesystem::u8path(lastCrashLogFilePath);
+	lastCrashLogFileName_ = lastCrashLogFile_.filename().u8string();
+	lastCrashLogURL_ = lastCrashLogUploadURL;
+	lastCrashUploadTime_ = lastCrashLogUploadTime;
+}
+
+void CrashHandler::saveCrashLogToConfig()
+{
+	config_t *appConfig = App()->GetAppConfig();
+
+	if (!appConfig) {
+		return;
+	}
+
+	std::time_t uploadTimePoint_c = OBS::Clock::to_time_t(lastCrashUploadTime_);
+
+	config_set_string(appConfig, "CrashHandler", "last_crash_log_file", lastCrashLogFile_.u8string().c_str());
+	config_set_int(appConfig, "CrashHandler", "last_crash_log_time", uploadTimePoint_c);
+	config_set_string(appConfig, "CrashHandler", "last_crash_log_url", lastCrashLogURL_.c_str());
+	config_save_safe(appConfig, "tmp", nullptr);
+}
+
+void CrashHandler::uploadCrashLogToServer()
+{
+	std::string crashLogFileContent = getCrashLogFileContent(lastCrashLogFile_);
+
+	if (crashLogFileContent.empty()) {
+		blog(LOG_WARNING, "Most recent crash log file was empty or unavailable for reading");
+		return;
+	}
+
+	emit crashLogUploadStarted();
+
+	PlatformType platformType = getPlatformType();
+
+	auto crashLogUploadContent = buildCrashLogUploadContent(platformType, crashLogFileContent);
+
+	if (crashLogUploadThread_) {
+		crashLogUploadThread_->wait();
+	}
+
+	auto uploadThread =
+		std::make_unique<RemoteTextThread>(crashUploadURL.data(), "text/plain", crashLogUploadContent.second);
+
+	std::swap(crashLogUploadThread_, uploadThread);
+
+	connect(crashLogUploadThread_.get(), &RemoteTextThread::Result, this,
+		&CrashHandler::crashLogUploadResultHandler);
+
+	lastCrashUploadTime_ = crashLogUploadContent.first;
+
+	crashLogUploadThread_->start();
+}
+
+void CrashHandler::handleExistingCrashLogUpload()
+{
+	if (!lastCrashLogURL_.empty()) {
+		const QString crashLogUrlString = QString::fromStdString(lastCrashLogURL_);
+
+		emit crashLogUploadFinished(crashLogUrlString);
+	} else {
+		uploadCrashLogToServer();
+	}
+}
+
+void CrashHandler::crashLogUploadResultHandler(const QString &uploadResult, const QString &error)
+{
+	if (uploadResult.isEmpty()) {
+		emit crashLogUploadFailed(error);
+		return;
+	}
+
+	json uploadResultData = json::parse(uploadResult.toStdString());
+
+	try {
+		std::string crashLogUrl = uploadResultData["url"];
+
+		lastCrashLogURL_ = crashLogUrl;
+
+		saveCrashLogToConfig();
+
+		const QString crashLogUrlString = QString::fromStdString(crashLogUrl);
+
+		emit crashLogUploadFinished(crashLogUrlString);
+	} catch (const json::exception &error) {
+		blog(LOG_ERROR, "JSON error while parsing crash upload response:\n%s", error.what());
+
+		const QString jsonErrorMessage = QTStr("CrashHandling.Errors.UploadJSONError");
+		emit crashLogUploadFailed(jsonErrorMessage);
+	}
+}
+
+void CrashHandler::applicationShutdownHandler() noexcept
+{
+	if (!isSentinelEnabled) {
+		return;
+	}
+
+	if (crashSentinelFile_.empty()) {
+		blog(LOG_ERROR, "No crash sentinel location set for crash handler");
+		return;
+	}
+
+	const std::filesystem::path crashSentinelPath = crashSentinelFile_.parent_path();
+
+	if (!std::filesystem::exists(crashSentinelPath)) {
+		blog(LOG_ERROR, "Crash sentinel location '%s' does not exist", crashSentinelPath.u8string().c_str());
+		return;
+	}
+
+	for (const auto &entry : std::filesystem::directory_iterator(crashSentinelPath)) {
+		if (entry.is_directory()) {
+			continue;
+		}
+
+		std::string entryFileName = entry.path().filename().u8string();
+
+		if (entryFileName.rfind(crashSentinelPrefix.data(), 0) != 0) {
+			continue;
+		}
+
+		try {
+			std::filesystem::remove(entry.path());
+		} catch (const std::filesystem::filesystem_error &error) {
+			blog(LOG_ERROR, "Failed to delete crash sentinel file:\n%s", error.what());
+		}
+	}
+
+	isActiveCrashHandler_ = false;
+}
+} // namespace OBS

+ 97 - 0
frontend/utility/CrashHandler.hpp

@@ -0,0 +1,97 @@
+/******************************************************************************
+    Copyright (C) 2025 by Patrick Heyer <[email protected]>
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 2 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+******************************************************************************/
+
+#pragma once
+
+#include <utility/RemoteTextThread.hpp>
+
+#include <util/base.h>
+
+#include <QObject>
+#include <QThread>
+#include <QUuid>
+
+#include <chrono>
+#include <filesystem>
+
+namespace OBS {
+
+enum class PlatformType { InvalidPlatform, Windows, macOS, Linux, FreeBSD };
+
+using Clock = std::chrono::system_clock;
+using TimePoint = std::chrono::time_point<Clock>;
+
+class CrashHandler : public QObject {
+	Q_OBJECT
+
+private:
+	QUuid appLaunchUUID_;
+	std::filesystem::path crashSentinelFile_;
+
+	TimePoint lastCrashUploadTime_;
+	std::filesystem::path lastCrashLogFile_;
+	std::string lastCrashLogFileName_;
+	std::string lastCrashLogURL_;
+
+	bool isSentinelPresent_ = false;
+	bool isActiveCrashHandler_ = false;
+
+	std::unique_ptr<RemoteTextThread> crashLogUploadThread_;
+
+public:
+	CrashHandler() = default;
+	CrashHandler(QUuid appLaunchUUID);
+	CrashHandler(const CrashHandler &other) = delete;
+	CrashHandler &operator=(const CrashHandler &other) = delete;
+	CrashHandler(CrashHandler &&other) noexcept;
+	CrashHandler &operator=(CrashHandler &&other) noexcept;
+
+	~CrashHandler();
+
+	bool hasUncleanShutdown();
+	bool hasNewCrashLog();
+	std::filesystem::path getCrashLogDirectory() const;
+	void uploadLastCrashLog();
+
+	enum class CrashLogUpdateResult { InvalidResult, NotUpdated, Updated };
+
+private:
+	void checkCrashState();
+	void setupSentinel();
+	std::filesystem::path findLastCrashLog() const;
+
+	CrashLogUpdateResult updateLocalCrashLogState();
+	void updateCrashLogFromConfig();
+	void saveCrashLogToConfig();
+	void uploadCrashLogToServer();
+	void handleExistingCrashLogUpload();
+
+	PlatformType getPlatformType() const;
+
+private slots:
+	void crashLogUploadResultHandler(const QString &uploadResult, const QString &error);
+
+	// FIXME: Turn into private slot once OBSBasic does not handle application shutdown duties anymore.
+public slots:
+	void applicationShutdownHandler() noexcept;
+
+signals:
+	void crashLogUploadStarted() const;
+	void crashLogUploadFailed(const QString &errorMessage) const;
+	void crashLogUploadFinished(const QString &crashLogURL) const;
+};
+} // namespace OBS

+ 40 - 0
frontend/utility/CrashHandler_FreeBSD.cpp

@@ -0,0 +1,40 @@
+/******************************************************************************
+ Copyright (C) 2025 by Patrick Heyer <[email protected]>
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 2 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ ******************************************************************************/
+
+#include "CrashHandler.hpp"
+
+namespace OBS {
+
+PlatformType CrashHandler::getPlatformType() const
+{
+	return PlatformType::FreeBSD;
+}
+
+std::filesystem::path CrashHandler::findLastCrashLog() const
+{
+	std::filesystem::path lastCrashLogFile;
+
+	return lastCrashLogFile;
+}
+
+std::filesystem::path CrashHandler::getCrashLogDirectory() const
+{
+	std::filesystem::path crashLogDirectoryPath;
+
+	return crashLogDirectoryPath;
+}
+} // namespace OBS

+ 40 - 0
frontend/utility/CrashHandler_Linux.cpp

@@ -0,0 +1,40 @@
+/******************************************************************************
+ Copyright (C) 2025 by Patrick Heyer <[email protected]>
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 2 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ ******************************************************************************/
+
+#include "CrashHandler.hpp"
+
+namespace OBS {
+
+PlatformType CrashHandler::getPlatformType() const
+{
+	return PlatformType::Linux;
+}
+
+std::filesystem::path CrashHandler::findLastCrashLog() const
+{
+	std::filesystem::path lastCrashLogFile;
+
+	return lastCrashLogFile;
+}
+
+std::filesystem::path CrashHandler::getCrashLogDirectory() const
+{
+	std::filesystem::path crashLogDirectoryPath;
+
+	return crashLogDirectoryPath;
+}
+} // namespace OBS

+ 115 - 0
frontend/utility/CrashHandler_MacOS.mm

@@ -0,0 +1,115 @@
+/******************************************************************************
+ Copyright (C) 2025 by Patrick Heyer <[email protected]>
+ 
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 2 of the License, or
+ (at your option) any later version.
+ 
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ GNU General Public License for more details.
+ 
+ You should have received a copy of the GNU General Public License
+ along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ ******************************************************************************/
+
+@import Foundation;
+
+#import "CrashHandler.hpp"
+
+namespace {
+    NSURL *getDiagnosticReportsDirectory()
+    {
+        NSFileManager *fileManager = [NSFileManager defaultManager];
+
+        NSArray<NSURL *> *libraryURLs = [fileManager URLsForDirectory:NSLibraryDirectory inDomains:NSUserDomainMask];
+
+        if (libraryURLs.count == 0) {
+            blog(LOG_ERROR, "Unable to get macOS user library directory URL");
+            return nil;
+        }
+
+        NSURL *diagnosticsReportsURL = [libraryURLs[0] URLByAppendingPathComponent:@"logs/DiagnosticReports"];
+
+        return diagnosticsReportsURL;
+    }
+}  // namespace
+
+namespace OBS {
+    PlatformType CrashHandler::getPlatformType() const
+    {
+        return PlatformType::macOS;
+    }
+
+    std::filesystem::path CrashHandler::findLastCrashLog() const
+    {
+        std::filesystem::path crashLogDirectoryPath;
+
+        NSURL *diagnosticsReportsURL = getDiagnosticReportsDirectory();
+
+        if (!diagnosticsReportsURL) {
+            return crashLogDirectoryPath;
+        }
+
+        BOOL (^enumerationErrorHandler)(NSURL *_Nonnull, NSError *_Nonnull) =
+            ^BOOL(NSURL *_Nonnull url __unused, NSError *_Nonnull error) {
+                blog(LOG_ERROR, "Failed to enumerate diagnostics reports directory: %s",
+                     error.localizedDescription.UTF8String);
+
+                return NO;
+            };
+
+        NSFileManager *fileManager = [NSFileManager defaultManager];
+
+        NSDirectoryEnumerator *dirEnumerator = [fileManager
+                       enumeratorAtURL:diagnosticsReportsURL
+            includingPropertiesForKeys:@[NSURLNameKey, NSURLIsDirectoryKey]
+                               options:(NSDirectoryEnumerationSkipsHiddenFiles) errorHandler:enumerationErrorHandler];
+
+        NSMutableArray<NSURL *> *reportCandidates = [NSMutableArray array];
+
+        for (NSURL *entry in dirEnumerator) {
+            NSString *fileName = nil;
+            [entry getResourceValue:&fileName forKey:NSURLNameKey error:nil];
+
+            if ([fileName hasPrefix:@"OBS"] && [fileName hasSuffix:@".ips"]) {
+                [reportCandidates addObject:entry];
+            }
+        }
+
+        NSArray<NSURL *> *sortedCandidates = [reportCandidates
+            sortedArrayUsingComparator:^NSComparisonResult(NSURL *_Nonnull obj1, NSURL *_Nonnull obj2) {
+                NSDate *creationDateObj1 = nil;
+                NSDate *creationDateObj2 = nil;
+
+                [obj1 getResourceValue:&creationDateObj1 forKey:NSURLCreationDateKey error:nil];
+                [obj2 getResourceValue:&creationDateObj2 forKey:NSURLCreationDateKey error:nil];
+
+                NSComparisonResult result = [creationDateObj2 compare:creationDateObj1];
+
+                return result;
+            }];
+
+        if (sortedCandidates.count > 0) {
+            NSURL *lastDiagnosticsReport = sortedCandidates[0];
+            crashLogDirectoryPath = std::filesystem::u8path(lastDiagnosticsReport.path.UTF8String);
+        }
+
+        return crashLogDirectoryPath;
+    }
+
+    std::filesystem::path CrashHandler::getCrashLogDirectory() const
+    {
+        std::filesystem::path crashLogDirectoryPath;
+
+        NSURL *diagnosticsReportsURL = getDiagnosticReportsDirectory();
+
+        if (diagnosticsReportsURL) {
+            crashLogDirectoryPath = std::filesystem::u8path(diagnosticsReportsURL.path.UTF8String);
+        }
+
+        return crashLogDirectoryPath;
+    }
+}  // namespace OBS

+ 85 - 0
frontend/utility/CrashHandler_Windows.cpp

@@ -0,0 +1,85 @@
+/******************************************************************************
+ Copyright (C) 2025 by Patrick Heyer <[email protected]>
+ 
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 2 of the License, or
+ (at your option) any later version.
+ 
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ GNU General Public License for more details.
+ 
+ You should have received a copy of the GNU General Public License
+ along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ ******************************************************************************/
+
+#include "CrashHandler.hpp"
+#include <OBSApp.hpp>
+
+#include <util/util.hpp>
+
+#include <vector>
+
+namespace OBS {
+
+using CrashFileEntry = std::pair<std::filesystem::path, std::filesystem::file_time_type>;
+
+PlatformType CrashHandler::getPlatformType() const
+{
+	return PlatformType::Windows;
+}
+
+std::filesystem::path CrashHandler::findLastCrashLog() const
+{
+	std::filesystem::path lastCrashLogFile;
+
+	std::filesystem::path crashLogDirectory = getCrashLogDirectory();
+
+	if (!std::filesystem::exists(crashLogDirectory)) {
+		blog(LOG_ERROR, "Crash log directory '%s' does not exist", crashLogDirectory.u8string().c_str());
+
+		return lastCrashLogFile;
+	}
+
+	std::vector<CrashFileEntry> crashLogFiles;
+
+	for (const auto &entry : std::filesystem::directory_iterator(crashLogDirectory)) {
+		if (entry.is_directory()) {
+			continue;
+		}
+
+		std::string entryFileName = entry.path().filename().u8string();
+
+		if (entryFileName.rfind("Crash ", 0) != 0) {
+			continue;
+		}
+
+		CrashFileEntry crashLogFile =
+			CrashFileEntry(entry.path(), std::filesystem::last_write_time(entry.path()));
+
+		crashLogFiles.push_back(crashLogFile);
+	}
+
+	std::sort(crashLogFiles.begin(), crashLogFiles.end(),
+		  [](CrashFileEntry &lhs, CrashFileEntry &rhs) { return lhs.second > rhs.second; });
+
+	if (crashLogFiles.size() > 0) {
+		lastCrashLogFile = crashLogFiles.front().first;
+	}
+
+	return lastCrashLogFile;
+}
+
+std::filesystem::path CrashHandler::getCrashLogDirectory() const
+{
+	BPtr crashLogDirectory = GetAppConfigPathPtr("obs-studio/crashes");
+
+	std::string crashLogDirectoryString = crashLogDirectory.Get();
+
+	std::filesystem::path crashLogDirectoryPath = std::filesystem::u8path(crashLogDirectoryString);
+
+	return crashLogDirectoryPath;
+}
+} // namespace OBS