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..69d2414 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 @@ -393,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 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