diff --git a/CMakeLists.txt b/CMakeLists.txt index 18f5c2baa..a459ec0f8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1570,6 +1570,7 @@ endif() # macOS app bundle if(APPLE) + target_sources(AetherSDR PRIVATE src/MacStartupAbortGuard.cpp) set_target_properties(AetherSDR PROPERTIES MACOSX_BUNDLE TRUE MACOSX_BUNDLE_GUI_IDENTIFIER "com.aethersdr.AetherSDR" @@ -2875,6 +2876,16 @@ add_executable(radio_status_ownership_test target_include_directories(radio_status_ownership_test PRIVATE src) target_link_libraries(radio_status_ownership_test PRIVATE Qt6::Core) enable_testing() + +if(APPLE) + add_executable(mac_startup_abort_guard_test + tests/mac_startup_abort_guard_test.cpp + src/MacStartupAbortGuard.cpp + ) + target_include_directories(mac_startup_abort_guard_test PRIVATE src) + add_test(NAME mac_startup_abort_guard_test COMMAND mac_startup_abort_guard_test) +endif() + add_test(NAME radio_status_ownership_test COMMAND radio_status_ownership_test) # Approved V12 cross-needle meter construction: versioned face resource, diff --git a/src/MacStartupAbortGuard.cpp b/src/MacStartupAbortGuard.cpp new file mode 100644 index 000000000..195c112d6 --- /dev/null +++ b/src/MacStartupAbortGuard.cpp @@ -0,0 +1,55 @@ +#include "MacStartupAbortGuard.h" + +#include + +namespace { + +constexpr char kStartupAbortMessage[] = + "AetherSDR startup error: macOS aborted GUI initialization. " + "GUI services may be unavailable (for example, in a restricted sandbox). " + "Launch AetherSDR from Finder or a normal Terminal session.\n"; + +void handleStartupAbort(int) +{ + // QApplication construction can abort from inside AppKit/HIServices before + // Qt logging exists. Only async-signal-safe operations are valid here. + const ssize_t ignored = write(STDERR_FILENO, + kStartupAbortMessage, + sizeof(kStartupAbortMessage) - 1); + (void)ignored; + _exit(AetherSDR::MacStartupAbortGuard::kFailureExitCode); +} + +} // namespace + +namespace AetherSDR { + +MacStartupAbortGuard::MacStartupAbortGuard() +{ + struct sigaction action {}; + action.sa_handler = handleStartupAbort; + sigemptyset(&action.sa_mask); + + m_armed = sigaction(SIGABRT, &action, &m_previousAction) == 0; +} + +MacStartupAbortGuard::~MacStartupAbortGuard() +{ + (void)disarm(); +} + +bool MacStartupAbortGuard::disarm() +{ + if (!m_armed) { + return true; + } + + if (sigaction(SIGABRT, &m_previousAction, nullptr) != 0) { + return false; + } + + m_armed = false; + return true; +} + +} // namespace AetherSDR diff --git a/src/MacStartupAbortGuard.h b/src/MacStartupAbortGuard.h new file mode 100644 index 000000000..3c1e3b0c5 --- /dev/null +++ b/src/MacStartupAbortGuard.h @@ -0,0 +1,29 @@ +#pragma once + +#include + +namespace AetherSDR { + +// Converts a macOS abort during QApplication construction into a normal +// startup failure. The guard must be disarmed as soon as construction succeeds +// so aborts from the running application retain their normal disposition. +class MacStartupAbortGuard final +{ +public: + static constexpr int kFailureExitCode = 1; + + MacStartupAbortGuard(); + ~MacStartupAbortGuard(); + + MacStartupAbortGuard(const MacStartupAbortGuard&) = delete; + MacStartupAbortGuard& operator=(const MacStartupAbortGuard&) = delete; + + bool isArmed() const { return m_armed; } + bool disarm(); + +private: + struct sigaction m_previousAction {}; + bool m_armed{false}; +}; + +} // namespace AetherSDR diff --git a/src/main.cpp b/src/main.cpp index c718172a2..7c5184aa2 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -8,6 +8,10 @@ #include "core/MacMicPermission.h" #include "core/AutomationServer.h" +#ifdef Q_OS_MAC +#include "MacStartupAbortGuard.h" +#endif + #include #include #include @@ -20,6 +24,8 @@ #include #include #include +#include +#include #ifdef _WIN32 #include @@ -177,8 +183,30 @@ int main(int argc, char* argv[]) // Together with WA_DontCreateNativeAncestors on those leaves, this avoids // redundant window-sized Core Animation backing stores (#4339). QApplication::setAttribute(Qt::AA_DontCreateNativeWidgetSiblings); + + // A restricted launcher can deny QCocoa access to macOS GUI services. + // AppKit then calls abort() from HIServices while QApplication is being + // constructed. Scope the handler to this constructor only: runtime aborts + // must keep their normal crash semantics. + AetherSDR::MacStartupAbortGuard startupAbortGuard; + if (!startupAbortGuard.isArmed()) { + std::fputs("AetherSDR startup error: could not install the macOS GUI " + "initialization guard; refusing to start.\n", + stderr); + return EXIT_FAILURE; + } #endif QApplication app(argc, argv); + +#ifdef Q_OS_MAC + if (!startupAbortGuard.disarm()) { + std::fputs("AetherSDR startup error: could not restore the SIGABRT " + "handler after GUI initialization; refusing to continue.\n", + stderr); + return EXIT_FAILURE; + } +#endif + app.setApplicationName("AetherSDR"); app.setApplicationVersion(AETHERSDR_VERSION); app.setOrganizationName("AetherSDR"); diff --git a/tests/mac_startup_abort_guard_test.cpp b/tests/mac_startup_abort_guard_test.cpp new file mode 100644 index 000000000..84abd24b7 --- /dev/null +++ b/tests/mac_startup_abort_guard_test.cpp @@ -0,0 +1,116 @@ +#include "MacStartupAbortGuard.h" + +#include +#include +#include +#include + +#include +#include + +namespace { + +volatile sig_atomic_t g_previousHandlerCalled = 0; + +void previousAbortHandler(int) +{ + g_previousHandlerCalled = 1; +} + +int fail(const char* message) +{ + std::fprintf(stderr, "FAIL: %s\n", message); + return 1; +} + +} // namespace + +int main() +{ + int pipeFds[2] {}; + if (pipe(pipeFds) != 0) { + return fail("could not create stderr capture pipe"); + } + + const pid_t child = fork(); + if (child < 0) { + close(pipeFds[0]); + close(pipeFds[1]); + return fail("could not fork guarded child"); + } + + if (child == 0) { + close(pipeFds[0]); + if (dup2(pipeFds[1], STDERR_FILENO) < 0) { + _exit(90); + } + close(pipeFds[1]); + + AetherSDR::MacStartupAbortGuard guard; + if (!guard.isArmed()) { + _exit(91); + } + std::abort(); + _exit(92); + } + + close(pipeFds[1]); + std::string errorText; + char buffer[512]; + ssize_t bytesRead = 0; + while ((bytesRead = read(pipeFds[0], buffer, sizeof(buffer))) > 0) { + errorText.append(buffer, static_cast(bytesRead)); + } + close(pipeFds[0]); + + int childStatus = 0; + if (waitpid(child, &childStatus, 0) != child) { + return fail("could not wait for guarded child"); + } + if (!WIFEXITED(childStatus) + || WEXITSTATUS(childStatus) != AetherSDR::MacStartupAbortGuard::kFailureExitCode) { + return fail("guarded SIGABRT did not become exit code 1"); + } + if (errorText.find("AetherSDR startup error: macOS aborted GUI initialization") + == std::string::npos) { + return fail("guarded SIGABRT did not emit the startup error"); + } + + struct sigaction originalAction {}; + if (sigaction(SIGABRT, nullptr, &originalAction) != 0) { + return fail("could not read the original SIGABRT action"); + } + + struct sigaction previousAction {}; + previousAction.sa_handler = previousAbortHandler; + sigemptyset(&previousAction.sa_mask); + if (sigaction(SIGABRT, &previousAction, nullptr) != 0) { + return fail("could not install the previous SIGABRT action"); + } + + int result = 0; + { + AetherSDR::MacStartupAbortGuard guard; + if (!guard.isArmed()) { + result = fail("startup guard did not arm"); + } else if (!guard.disarm()) { + result = fail("startup guard did not disarm"); + } + } + + if (result == 0) { + raise(SIGABRT); + if (g_previousHandlerCalled != 1) { + result = fail("disarm did not restore the previous SIGABRT action"); + } + } + + if (sigaction(SIGABRT, &originalAction, nullptr) != 0 && result == 0) { + result = fail("could not restore the original SIGABRT action"); + } + + if (result == 0) { + std::puts("macOS startup abort guard: PASS"); + } + return result; +}