From d1ac86e43c615c2ccb21f3adbc18fe692e38bd3e Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Fri, 3 Jul 2026 17:43:44 -0400 Subject: [PATCH 1/2] feat: offer to move the app to /Applications on macOS Sparkle cannot update an app running from Downloads, a mounted DMG, or an App Translocation path. Prompt once at startup, copy the bundle to /Applications (falling back to ~/Applications), strip quarantine so Gatekeeper does not translocate the copy, trash the old copy when deletable, and relaunch from the new location. --- .gitignore | 1 + CMakeLists.txt | 15 ++++--- src/app/MoveToApplications.mm | 85 +++++++++++++++++++++++++++++++++++ src/main.cpp | 11 +++++ 4 files changed, 106 insertions(+), 6 deletions(-) create mode 100644 src/app/MoveToApplications.mm diff --git a/.gitignore b/.gitignore index 199167e..f0745c1 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ build/ # clangd compile database (symlink into build/) compile_commands.json +.playwright-mcp/ diff --git a/CMakeLists.txt b/CMakeLists.txt index b4ffeee..f20d913 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -378,14 +378,17 @@ if(APPLE) FetchContent_Declare(sparkle URL https://github.com/sparkle-project/Sparkle/releases/download/2.6.4/Sparkle-2.6.4.tar.xz) FetchContent_MakeAvailable(sparkle) - target_sources(${PROJECT_NAME} PRIVATE src/app/AutoUpdaterSparkle.mm) - # the C++ PCH is built with Objective-C disabled; reusing it for this - # Objective-C++ source fails ("Objective-C was disabled in precompiled - # file ... but is currently enabled"), so skip the PCH for it - set_source_files_properties(src/app/AutoUpdaterSparkle.mm + target_sources(${PROJECT_NAME} PRIVATE + src/app/AutoUpdaterSparkle.mm + src/app/MoveToApplications.mm) + # the C++ PCH is built with Objective-C disabled; reusing it for these + # Objective-C++ sources fails ("Objective-C was disabled in precompiled + # file ... but is currently enabled"), so skip the PCH for them + set_source_files_properties(src/app/AutoUpdaterSparkle.mm src/app/MoveToApplications.mm PROPERTIES SKIP_PRECOMPILE_HEADERS ON) target_compile_options(${PROJECT_NAME} PRIVATE -F${sparkle_SOURCE_DIR}) - target_link_libraries(${PROJECT_NAME} PRIVATE "-F${sparkle_SOURCE_DIR}" "-framework Sparkle") + target_link_libraries(${PROJECT_NAME} PRIVATE "-F${sparkle_SOURCE_DIR}" "-framework Sparkle" + "-framework AppKit") configure_file(${CMAKE_SOURCE_DIR}/packaging/Info.plist.in ${CMAKE_BINARY_DIR}/Info.plist @ONLY) set_target_properties(${PROJECT_NAME} PROPERTIES diff --git a/src/app/MoveToApplications.mm b/src/app/MoveToApplications.mm new file mode 100644 index 0000000..ba2759c --- /dev/null +++ b/src/app/MoveToApplications.mm @@ -0,0 +1,85 @@ +// Offers to move the app into /Applications on launch when it is running from +// its download location (Downloads, a mounted DMG, or an App Translocation +// path). Sparkle refuses to update from any of those, so without the move the +// user hits "OpenMix can't be updated if it's running from the location it was +// downloaded to". Compiled only on macOS. + +#import +#import + +static BOOL inApplicationsFolder(NSString* path) { + NSArray* dirs = + NSSearchPathForDirectoriesInDomains(NSApplicationDirectory, NSAllDomainsMask, YES); + for (NSString* dir in dirs) { + if ([path hasPrefix:[dir stringByAppendingString:@"/"]]) + return YES; + } + return [path hasPrefix:@"/Applications/"]; +} + +static void showMoveError(NSError* error) { + NSAlert* alert = [[NSAlert alloc] init]; + alert.alertStyle = NSAlertStyleWarning; + alert.messageText = @"Could not move OpenMix"; + alert.informativeText = [NSString + stringWithFormat:@"%@\n\nYou can move OpenMix.app to the Applications folder manually.", + error.localizedDescription]; + [alert addButtonWithTitle:@"OK"]; + [alert runModal]; +} + +extern "C" void openmix_offer_move_to_applications() { + NSString* bundlePath = [[NSBundle mainBundle] bundlePath]; + if (![bundlePath hasSuffix:@".app"]) + return; // bare dev binary, not an app bundle + if (inApplicationsFolder(bundlePath)) + return; + + NSUserDefaults* defaults = [NSUserDefaults standardUserDefaults]; + if ([defaults boolForKey:@"OMMoveToApplicationsDeclined"]) + return; + + NSAlert* alert = [[NSAlert alloc] init]; + alert.messageText = @"Move OpenMix to the Applications folder?"; + alert.informativeText = @"OpenMix is running from its download location. " + @"Automatic updates only work from the Applications folder."; + [alert addButtonWithTitle:@"Move to Applications"]; + [alert addButtonWithTitle:@"Don't Move"]; + if ([alert runModal] != NSAlertFirstButtonReturn) { + [defaults setBool:YES forKey:@"OMMoveToApplicationsDeclined"]; + return; + } + + NSFileManager* fm = [NSFileManager defaultManager]; + NSString* appsDir = @"/Applications"; + if (![fm isWritableFileAtPath:appsDir]) { + appsDir = [NSHomeDirectory() stringByAppendingPathComponent:@"Applications"]; + [fm createDirectoryAtPath:appsDir withIntermediateDirectories:YES attributes:nil error:nil]; + } + NSString* dest = [appsDir stringByAppendingPathComponent:[bundlePath lastPathComponent]]; + + NSError* error = nil; + if ([fm fileExistsAtPath:dest] && ![fm removeItemAtPath:dest error:&error]) { + showMoveError(error); + return; + } + if (![fm copyItemAtPath:bundlePath toPath:dest error:&error]) { + showMoveError(error); + return; + } + + // strip quarantine so Gatekeeper does not translocate the copy on launch + NSTask* xattr = [NSTask launchedTaskWithLaunchPath:@"/usr/bin/xattr" + arguments:@[ @"-dr", @"com.apple.quarantine", dest ]]; + [xattr waitUntilExit]; + + // tidy up the old copy when it is deletable (it is not on a mounted DMG or + // under an App Translocation mount; the stale copy is harmless there) + [fm trashItemAtURL:[NSURL fileURLWithPath:bundlePath] resultingItemURL:nil error:nil]; + + // relaunch from the new location once this process has exited + NSString* script = + [NSString stringWithFormat:@"sleep 0.5; /usr/bin/open -n \"%@\"", dest]; + [NSTask launchedTaskWithLaunchPath:@"/bin/sh" arguments:@[ @"-c", script ]]; + exit(0); +} diff --git a/src/main.cpp b/src/main.cpp index 272ad7d..57e9c8b 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -29,6 +29,11 @@ class FastTooltipStyle : public QProxyStyle { }; } // namespace OpenMix +#if defined(Q_OS_MACOS) +// implemented in MoveToApplications.mm; may relaunch the app and not return +extern "C" void openmix_offer_move_to_applications(); +#endif + int main(int argc, char* argv[]) { QApplication qtApp(argc, argv); @@ -38,6 +43,12 @@ int main(int argc, char* argv[]) { QApplication::setOrganizationName("OpenMix"); QApplication::setWindowIcon(QIcon(":/icons/openmix.png")); +#if defined(Q_OS_MACOS) + // before any real startup work: Sparkle cannot update an app that runs + // from Downloads, a DMG, or a translocation path + openmix_offer_move_to_applications(); +#endif + oclero::qlementine::icons::initializeIconTheme(); // Fusion base, but show tooltips faster than the platform default From 7a4a42ff3b8469eaa28b6ccfa3e07c2ec4f123c6 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Fri, 3 Jul 2026 17:49:33 -0400 Subject: [PATCH 2/2] fix: preserve execute bits when installing Sparkle.framework install(DIRECTORY) defaults every file to 644, which stripped the execute bits from Sparkle's Autoupdate, Updater.app, and XPC helpers. launchd then refused to run them (errno 13) and every macOS update died with Sparkle's generic updater error. --- CMakeLists.txt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index f20d913..69d2414 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -396,8 +396,12 @@ if(APPLE) MACOSX_BUNDLE_INFO_PLIST ${CMAKE_BINARY_DIR}/Info.plist BUILD_RPATH "@executable_path/../Frameworks" INSTALL_RPATH "@executable_path/../Frameworks") + # USE_SOURCE_PERMISSIONS: without it every file installs as 644 and + # Sparkle's Autoupdate/Updater/XPC helpers lose their execute bits, so + # launchd refuses to run them and every update fails install(DIRECTORY ${sparkle_SOURCE_DIR}/Sparkle.framework - DESTINATION ${PROJECT_NAME}.app/Contents/Frameworks) + DESTINATION ${PROJECT_NAME}.app/Contents/Frameworks + USE_SOURCE_PERMISSIONS) endif() target_precompile_headers(${PROJECT_NAME} PRIVATE