From 30fdffae03be85896238cd8a8b040eaa50dbcb53 Mon Sep 17 00:00:00 2001 From: Stenzek Date: Sat, 23 Sep 2023 15:43:12 +1000 Subject: [PATCH] Updater: Mac support --- .github/workflows/rolling-release.yml | 16 ++ README.md | 1 - src/CMakeLists.txt | 2 +- src/duckstation-qt/autoupdaterdialog.cpp | 85 +++++++- src/updater/CMakeLists.txt | 26 +++ src/updater/Info.plist.in | 24 +++ src/updater/Updater.icns | Bin 0 -> 8521 bytes src/updater/cocoa_main.mm | 135 +++++++++++++ src/updater/cocoa_progress_callback.h | 64 ++++++ src/updater/cocoa_progress_callback.mm | 244 +++++++++++++++++++++++ src/updater/updater.cpp | 118 +++++++++-- src/updater/updater.h | 10 +- src/updater/win32_main.cpp | 14 +- 13 files changed, 703 insertions(+), 36 deletions(-) create mode 100644 src/updater/Info.plist.in create mode 100644 src/updater/Updater.icns create mode 100644 src/updater/cocoa_main.mm create mode 100644 src/updater/cocoa_progress_callback.h create mode 100644 src/updater/cocoa_progress_callback.mm diff --git a/.github/workflows/rolling-release.yml b/.github/workflows/rolling-release.yml index 336f648b9..538ab5e12 100644 --- a/.github/workflows/rolling-release.yml +++ b/.github/workflows/rolling-release.yml @@ -334,6 +334,22 @@ jobs: if: steps.cache-deps-mac.outputs.cache-hit != 'true' run: scripts/build-dependencies-mac.sh + - name: Tag as preview build + if: github.ref == 'refs/heads/master' + run: | + echo '#pragma once' > src/scmversion/tag.h + echo '#define SCM_RELEASE_ASSET "duckstation-mac-release.zip"' >> src/scmversion/tag.h + echo '#define SCM_RELEASE_TAGS {"latest", "preview"}' >> src/scmversion/tag.h + echo '#define SCM_RELEASE_TAG "preview"' >> src/scmversion/tag.h + + - name: Tag as dev build + if: github.ref == 'refs/heads/dev' + run: | + echo '#pragma once' > src/scmversion/tag.h + echo '#define SCM_RELEASE_ASSET "duckstation-mac-release.zip"' >> src/scmversion/tag.h + echo '#define SCM_RELEASE_TAGS {"latest", "preview"}' >> src/scmversion/tag.h + echo '#define SCM_RELEASE_TAG "latest"' >> src/scmversion/tag.h + - name: Compile and zip .app shell: bash run: | diff --git a/README.md b/README.md index 5bbe19f22..c2c06cc9e 100644 --- a/README.md +++ b/README.md @@ -173,7 +173,6 @@ Requirements (Debian/Ubuntu package names): 5. Run the binary, located in the build directory under `bin/duckstation-qt`. ### macOS -**NOTE:** macOS is highly experimental and not tested by the developer. Use at your own risk; things may be horribly broken. Vulkan support may be unstable, so sticking to OpenGL or software renderer is recommended. Requirements: - CMake diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 4b130b827..879d46bc7 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -3,7 +3,7 @@ add_subdirectory(util) add_subdirectory(core) add_subdirectory(scmversion) -if(WIN32) +if(WIN32 OR APPLE) add_subdirectory(updater) endif() diff --git a/src/duckstation-qt/autoupdaterdialog.cpp b/src/duckstation-qt/autoupdaterdialog.cpp index 3d5e96356..04bb138a3 100644 --- a/src/duckstation-qt/autoupdaterdialog.cpp +++ b/src/duckstation-qt/autoupdaterdialog.cpp @@ -11,6 +11,7 @@ #include "common/file_system.h" #include "common/log.h" #include "common/minizip_helpers.h" +#include "common/path.h" #include "common/string_util.h" #include @@ -28,18 +29,18 @@ #include #include -Log_SetChannel(AutoUpdaterDialog); +#ifdef __APPLE__ +#include "common/cocoa_tools.h" +#endif // Logic to detect whether we can use the auto updater. -// Currently Windows and Linux-only, and requires that the channel be defined by the buildbot. -#if defined(_WIN32) || defined(__linux__) +// Requires that the channel be defined by the buildbot. #if defined(__has_include) && __has_include("scmversion/tag.h") #include "scmversion/tag.h" #ifdef SCM_RELEASE_TAGS #define AUTO_UPDATER_SUPPORTED #endif #endif -#endif #ifdef AUTO_UPDATER_SUPPORTED @@ -52,6 +53,8 @@ static const char* THIS_RELEASE_TAG = SCM_RELEASE_TAG; #endif +Log_SetChannel(AutoUpdaterDialog); + AutoUpdaterDialog::AutoUpdaterDialog(EmuThread* host_interface, QWidget* parent /* = nullptr */) : QDialog(parent), m_host_interface(host_interface) { @@ -81,7 +84,7 @@ bool AutoUpdaterDialog::isSupported() return true; #else - // Windows - always supported. + // Windows/Mac - always supported. return true; #endif #else @@ -582,6 +585,78 @@ void AutoUpdaterDialog::cleanupAfterUpdate() { } +#elif defined(__APPLE__) + +bool AutoUpdaterDialog::processUpdate(const QByteArray& update_data) +{ + std::optional bundle_path = CocoaTools::GetNonTranslocatedBundlePath(); + if (!bundle_path.has_value()) + { + reportError("Couldn't obtain non-translocated bundle path."); + return false; + } + + QFileInfo info(QString::fromStdString(bundle_path.value())); + if (!info.isBundle()) + { + reportError("Application %s isn't a bundle.", bundle_path->c_str()); + return false; + } + if (info.suffix() != QStringLiteral("app")) + { + reportError("Unexpected application suffix %s on %s.", info.suffix().toUtf8().constData(), bundle_path->c_str()); + return false; + } + + // Use the updater from this version to unpack the new version. + const std::string updater_app = Path::Combine(bundle_path.value(), "Contents/Resources/Updater.app"); + if (!FileSystem::DirectoryExists(updater_app.c_str())) + { + reportError("Failed to find updater at %s.", updater_app.c_str()); + return false; + } + + // We use the user data directory to temporarily store the update zip. + const std::string zip_path = Path::Combine(EmuFolders::DataRoot, "update.zip"); + const std::string staging_directory = Path::Combine(EmuFolders::DataRoot, "UPDATE_STAGING"); + if (FileSystem::FileExists(zip_path.c_str()) && !FileSystem::DeleteFile(zip_path.c_str())) + { + reportError("Failed to remove old update zip."); + return false; + } + + // Save update. + { + QFile zip_file(QString::fromStdString(zip_path)); + if (!zip_file.open(QIODevice::WriteOnly) || zip_file.write(update_data) != update_data.size()) + { + reportError("Writing update zip to '%s' failed", zip_path.c_str()); + return false; + } + zip_file.close(); + } + + Log_InfoFmt("Beginning update:\nUpdater path: {}\nZip path: {}\nStaging directory: {}\nOutput directory: {}", + updater_app, zip_path, staging_directory, bundle_path.value()); + + const std::string_view args[] = { + zip_path, + staging_directory, + bundle_path.value(), + }; + + // Kick off updater! + CocoaTools::DelayedLaunch(updater_app, args); + return true; +} + +void AutoUpdaterDialog::cleanupAfterUpdate() +{ + const QString zip_path = QString::fromStdString(Path::Combine(EmuFolders::DataRoot, "update.zip")); + if (QFile::exists(zip_path)) + QFile::remove(zip_path); +} + #elif defined(__linux__) bool AutoUpdaterDialog::processUpdate(const QByteArray& update_data) diff --git a/src/updater/CMakeLists.txt b/src/updater/CMakeLists.txt index 80af88967..438a9da1f 100644 --- a/src/updater/CMakeLists.txt +++ b/src/updater/CMakeLists.txt @@ -14,3 +14,29 @@ if(WIN32) target_link_libraries(updater PRIVATE "Comctl32.lib") set_target_properties(updater PROPERTIES WIN32_EXECUTABLE TRUE) endif() + +if(APPLE) + set(MAC_SOURCES + cocoa_main.mm + cocoa_progress_callback.mm + cocoa_progress_callback.h + ) + target_sources(updater PRIVATE ${MAC_SOURCES}) + set_source_files_properties(${MAC_SOURCES} PROPERTIES SKIP_PRECOMPILE_HEADERS TRUE) + find_library(COCOA_LIBRARY Cocoa REQUIRED) + target_link_libraries(updater PRIVATE ${COCOA_LIBRARY}) + + if(NOT CMAKE_GENERATOR MATCHES "Xcode" AND NOT SKIP_POSTPROCESS_BUNDLE) + set_target_properties(updater PROPERTIES OUTPUT_NAME "Updater") + set_target_properties(updater PROPERTIES + MACOSX_BUNDLE true + MACOSX_BUNDLE_INFO_PLIST ${CMAKE_CURRENT_SOURCE_DIR}/Info.plist.in + OUTPUT_NAME Updater + RUNTIME_OUTPUT_DIRECTORY ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/DuckStation.app/Contents/Resources + ) + + # Copy icon into the bundle + target_sources(updater PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/Updater.icns") + set_source_files_properties("${CMAKE_CURRENT_SOURCE_DIR}/Updater.icns" PROPERTIES MACOSX_PACKAGE_LOCATION Resources) + endif() +endif() diff --git a/src/updater/Info.plist.in b/src/updater/Info.plist.in new file mode 100644 index 000000000..3e0a98227 --- /dev/null +++ b/src/updater/Info.plist.in @@ -0,0 +1,24 @@ + + + + + CFBundleExecutable + Updater + CFBundleIconFile + Updater.icns + CFBundleIdentifier + com.github.stenzek.duckstation.updater + CFBundleDevelopmentRegion + English + CFBundlePackageType + APPL + NSHumanReadableCopyright + Licensed under GPL version 3 + LSMinimumSystemVersion + ${CMAKE_OSX_DEPLOYMENT_TARGET} + NSHighResolutionCapable + + CSResourcesFileMapped + + + diff --git a/src/updater/Updater.icns b/src/updater/Updater.icns new file mode 100644 index 0000000000000000000000000000000000000000..49b90d2ef269ef5cdbdba03b8cb14c65ecbde461 GIT binary patch literal 8521 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 q*Ug2fdi$#Xl-H$4(;ky1pQ7g3) +// SPDX-License-Identifier: (GPL-3.0 OR CC-BY-NC-ND-4.0) + +#include "cocoa_progress_callback.h" +#include "updater.h" + +#include "common/file_system.h" +#include "common/log.h" +#include "common/path.h" +#include "common/scoped_guard.h" +#include "common/string_util.h" +#include "common/timer.h" + +#include +#include + +static void LaunchApplication(const char* path) +{ + @autoreleasepool + { + NSTask* task = [[[NSTask alloc] init] autorelease]; + [task setLaunchPath:[NSString stringWithUTF8String:path]]; + [task launch]; + } +} + +int main(int argc, char* argv[]) +{ + [NSApplication sharedApplication]; + [NSApp setActivationPolicy:NSApplicationActivationPolicyRegular]; + + // Needed for keyboard in put. + const ProcessSerialNumber psn = {0, kCurrentProcess}; + TransformProcessType(&psn, kProcessTransformToForegroundApplication); + + Log::SetConsoleOutputParams(true, "", LOGLEVEL_DEBUG); + + CocoaProgressCallback progress; + + if (argc != 4) + { + progress.ModalError("Expected 3 arguments: update zip, staging directory, output directory.\n\nThis program is not " + "intended to be run manually, please use the Qt frontend and click Help->Check for Updates."); + return EXIT_FAILURE; + } + + std::string zip_path = argv[1]; + std::string staging_directory = argv[2]; + std::string destination_directory = argv[3]; + + if (zip_path.empty() || staging_directory.empty() || destination_directory.empty()) + { + progress.ModalError("One or more parameters is empty."); + return EXIT_FAILURE; + } + + if (const char* home_dir = getenv("HOME")) + { + static constexpr char log_file[] = "Library/Application Support/DuckStation/updater.log"; + std::string log_path = Path::Combine(home_dir, log_file); + Log::SetFileOutputParams(true, log_path.c_str()); + } + + std::string program_to_launch = Path::Combine(destination_directory, "Contents/MacOS/DuckStation"); + int result = EXIT_SUCCESS; + + std::thread worker([&progress, zip_path = std::move(zip_path), + destination_directory = std::move(destination_directory), + staging_directory = std::move(staging_directory), &result]() { + ScopedGuard app_stopper([]() { dispatch_async(dispatch_get_main_queue(), []() { [NSApp stop:nil]; }); }); + + Updater updater(&progress); + if (!updater.Initialize(std::move(staging_directory), std::move(destination_directory))) + { + progress.ModalError("Failed to initialize updater."); + result = EXIT_FAILURE; + return; + } + + if (!updater.OpenUpdateZip(zip_path.c_str())) + { + progress.DisplayFormattedModalError("Could not open update zip '%s'. Update not installed.", zip_path.c_str()); + result = EXIT_FAILURE; + return; + } + + if (!updater.PrepareStagingDirectory()) + { + progress.ModalError("Failed to prepare staging directory. Update not installed."); + result = EXIT_FAILURE; + return; + } + + if (!updater.StageUpdate()) + { + progress.ModalError("Failed to stage update. Update not installed."); + result = EXIT_FAILURE; + return; + } + + if (!updater.ClearDestinationDirectory()) + { + progress.ModalError("Failed to clear destination directory. Your installation may be corrupted, please " + "re-download a fresh version from GitHub."); + result = EXIT_FAILURE; + return; + } + + if (!updater.CommitUpdate()) + { + progress.ModalError( + "Failed to commit update. Your installation may be corrupted, please re-download a fresh version from GitHub."); + result = EXIT_FAILURE; + return; + } + + updater.CleanupStagingDirectory(); + + progress.ModalInformation("Update complete."); + + result = EXIT_SUCCESS; + }); + + [NSApp run]; + + worker.join(); + + if (result == EXIT_SUCCESS) + { + progress.DisplayFormattedInformation("Launching '%s'...", program_to_launch.c_str()); + LaunchApplication(program_to_launch.c_str()); + } + + return result; +} diff --git a/src/updater/cocoa_progress_callback.h b/src/updater/cocoa_progress_callback.h new file mode 100644 index 000000000..689c3045b --- /dev/null +++ b/src/updater/cocoa_progress_callback.h @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: 2019-2023 Connor McLaughlin +// SPDX-License-Identifier: (GPL-3.0 OR CC-BY-NC-ND-4.0) + +#pragma once + +#include "common/progress_callback.h" + +#include +#include + +#ifndef __OBJC__ +#error This file needs to be compiled with Objective C++. +#endif + +#if __has_feature(objc_arc) +#error ARC should not be enabled. +#endif + +class CocoaProgressCallback final : public BaseProgressCallback +{ +public: + CocoaProgressCallback(); + ~CocoaProgressCallback(); + + void PushState() override; + void PopState() override; + + void SetCancellable(bool cancellable) override; + void SetTitle(const char* title) override; + void SetStatusText(const char* text) override; + void SetProgressRange(u32 range) override; + void SetProgressValue(u32 value) override; + + void DisplayError(const char* message) override; + void DisplayWarning(const char* message) override; + void DisplayInformation(const char* message) override; + void DisplayDebugMessage(const char* message) override; + + void ModalError(const char* message) override; + bool ModalConfirmation(const char* message) override; + void ModalInformation(const char* message) override; + +private: + enum : int + { + WINDOW_WIDTH = 600, + WINDOW_HEIGHT = 300, + WINDOW_MARGIN = 20, + SUBWINDOW_PADDING = 10, + SUBWINDOW_WIDTH = WINDOW_WIDTH - WINDOW_MARGIN - WINDOW_MARGIN, + }; + + bool Create(); + void Destroy(); + void UpdateProgress(); + void AppendMessage(const char* message); + + NSWindow* m_window = nil; + NSView* m_view = nil; + NSTextField* m_status = nil; + NSProgressIndicator* m_progress = nil; + NSScrollView* m_text_scroll = nil; + NSTextView* m_text = nil; +}; diff --git a/src/updater/cocoa_progress_callback.mm b/src/updater/cocoa_progress_callback.mm new file mode 100644 index 000000000..a64a9ed96 --- /dev/null +++ b/src/updater/cocoa_progress_callback.mm @@ -0,0 +1,244 @@ +// SPDX-FileCopyrightText: 2019-2023 Connor McLaughlin +// SPDX-License-Identifier: (GPL-3.0 OR CC-BY-NC-ND-4.0) + +#include "cocoa_progress_callback.h" + +#include "common/log.h" + +Log_SetChannel(CocoaProgressCallback); + +CocoaProgressCallback::CocoaProgressCallback() : BaseProgressCallback() +{ + Create(); +} + +CocoaProgressCallback::~CocoaProgressCallback() +{ + Destroy(); +} + +void CocoaProgressCallback::PushState() +{ + BaseProgressCallback::PushState(); +} + +void CocoaProgressCallback::PopState() +{ + BaseProgressCallback::PopState(); + UpdateProgress(); +} + +void CocoaProgressCallback::SetCancellable(bool cancellable) +{ + BaseProgressCallback::SetCancellable(cancellable); +} + +void CocoaProgressCallback::SetTitle(const char* title) +{ + dispatch_async(dispatch_get_main_queue(), [this, title = [[NSString alloc] initWithUTF8String:title]]() { + [m_window setTitle:title]; + [title release]; + }); +} + +void CocoaProgressCallback::SetStatusText(const char* text) +{ + BaseProgressCallback::SetStatusText(text); + dispatch_async(dispatch_get_main_queue(), [this, title = [[NSString alloc] initWithUTF8String:text]]() { + [m_status setStringValue:title]; + [title release]; + }); +} + +void CocoaProgressCallback::SetProgressRange(u32 range) +{ + BaseProgressCallback::SetProgressRange(range); + UpdateProgress(); +} + +void CocoaProgressCallback::SetProgressValue(u32 value) +{ + BaseProgressCallback::SetProgressValue(value); + UpdateProgress(); +} + +bool CocoaProgressCallback::Create() +{ + @autoreleasepool + { + const NSRect window_rect = + NSMakeRect(0.0f, 0.0f, static_cast(WINDOW_WIDTH), static_cast(WINDOW_HEIGHT)); + constexpr NSWindowStyleMask style = NSWindowStyleMaskTitled; + m_window = [[NSWindow alloc] initWithContentRect:window_rect + styleMask:style + backing:NSBackingStoreBuffered + defer:NO]; + + NSView* m_view; + m_view = [[NSView alloc] init]; + [m_window setContentView:m_view]; + + int x = WINDOW_MARGIN; + int y = WINDOW_HEIGHT - WINDOW_MARGIN; + + y -= 16 + SUBWINDOW_PADDING; + m_status = [NSTextField labelWithString:@"Initializing..."]; + [m_status setFrame:NSMakeRect(x, y, SUBWINDOW_WIDTH, 16)]; + [m_view addSubview:m_status]; + + y -= 16 + SUBWINDOW_PADDING; + m_progress = [[NSProgressIndicator alloc] initWithFrame:NSMakeRect(x, y, SUBWINDOW_WIDTH, 16)]; + [m_progress setMinValue:0]; + [m_progress setMaxValue:100]; + [m_progress setDoubleValue:0]; + [m_progress setIndeterminate:NO]; + [m_view addSubview:m_progress]; + + y -= 170 + SUBWINDOW_PADDING; + m_text_scroll = [[NSScrollView alloc] initWithFrame:NSMakeRect(x, y, SUBWINDOW_WIDTH, 170)]; + [m_text_scroll setBorderType:NSBezelBorder]; + [m_text_scroll setHasVerticalScroller:YES]; + [m_text_scroll setHasHorizontalScroller:NO]; + + const NSSize content_size = [m_text_scroll contentSize]; + m_text = [[NSTextView alloc] initWithFrame:NSMakeRect(0, 0, content_size.width, content_size.height)]; + [m_text setMinSize:NSMakeSize(0, content_size.height)]; + [m_text setMaxSize:NSMakeSize(FLT_MAX, FLT_MAX)]; + [m_text setVerticallyResizable:YES]; + [m_text setHorizontallyResizable:NO]; + [m_text setAutoresizingMask:NSViewWidthSizable]; + [m_text setUsesAdaptiveColorMappingForDarkAppearance:YES]; + [[m_text textContainer] setContainerSize:NSMakeSize(content_size.width, FLT_MAX)]; + [[m_text textContainer] setWidthTracksTextView:YES]; + [m_text_scroll setDocumentView:m_text]; + [m_view addSubview:m_text_scroll]; + + [m_window center]; + [m_window setIsVisible:TRUE]; + [m_window makeKeyAndOrderFront:nil]; + [m_window setReleasedWhenClosed:NO]; + } + + return true; +} + +void CocoaProgressCallback::Destroy() +{ + if (m_window == nil) + return; + + [m_window close]; + + m_text = nil; + m_progress = nil; + m_status = nil; + + [m_view release]; + m_view = nil; + + [m_window release]; + m_window = nil; +} + +void CocoaProgressCallback::UpdateProgress() +{ + const float percent = (static_cast(m_progress_value) / static_cast(m_progress_range)) * 100.0f; + dispatch_async(dispatch_get_main_queue(), [this, percent]() { + [m_progress setDoubleValue:percent]; + }); +} + +void CocoaProgressCallback::DisplayError(const char* message) +{ + Log_ErrorPrint(message); + AppendMessage(message); +} + +void CocoaProgressCallback::DisplayWarning(const char* message) +{ + Log_WarningPrint(message); + AppendMessage(message); +} + +void CocoaProgressCallback::DisplayInformation(const char* message) +{ + Log_InfoPrint(message); + AppendMessage(message); +} + +void CocoaProgressCallback::AppendMessage(const char* message) +{ + @autoreleasepool + { + NSString* nsmessage = [[[NSString stringWithUTF8String:message] stringByAppendingString:@"\n"] retain]; + dispatch_async(dispatch_get_main_queue(), [this, nsmessage]() { + @autoreleasepool + { + NSAttributedString* attr = [[[NSAttributedString alloc] initWithString:nsmessage] autorelease]; + [[m_text textStorage] appendAttributedString:attr]; + [m_text scrollRangeToVisible:NSMakeRange([[m_text string] length], 0)]; + [nsmessage release]; + } + }); + } +} + +void CocoaProgressCallback::DisplayDebugMessage(const char* message) +{ + Log_DevPrint(message); +} + +void CocoaProgressCallback::ModalError(const char* message) +{ + if (![NSThread isMainThread]) + { + dispatch_sync(dispatch_get_main_queue(), [this, message]() { ModalError(message); }); + return; + } + + @autoreleasepool + { + NSAlert* alert = [[[NSAlert alloc] init] autorelease]; + [alert setMessageText:[NSString stringWithUTF8String:message]]; + [alert setAlertStyle:NSAlertStyleCritical]; + [alert runModal]; + } +} + +bool CocoaProgressCallback::ModalConfirmation(const char* message) +{ + if (![NSThread isMainThread]) + { + bool result; + dispatch_sync(dispatch_get_main_queue(), [this, message, &result]() { result = ModalConfirmation(message); }); + return result; + } + + bool result; + @autoreleasepool + { + NSAlert* alert = [[[NSAlert alloc] init] autorelease]; + [alert setMessageText:[NSString stringWithUTF8String:message]]; + [alert addButtonWithTitle:@"Yes"]; + [alert addButtonWithTitle:@"No"]; + result = ([alert runModal] == NSAlertFirstButtonReturn); + } + + return result; +} + +void CocoaProgressCallback::ModalInformation(const char* message) +{ + if (![NSThread isMainThread]) + { + dispatch_sync(dispatch_get_main_queue(), [this, message]() { ModalInformation(message); }); + return; + } + + @autoreleasepool + { + NSAlert* alert = [[[NSAlert alloc] init] autorelease]; + [alert setMessageText:[NSString stringWithUTF8String:message]]; + [alert runModal]; + } +} diff --git a/src/updater/updater.cpp b/src/updater/updater.cpp index 930f42858..ccb054ad9 100644 --- a/src/updater/updater.cpp +++ b/src/updater/updater.cpp @@ -1,12 +1,14 @@ -// SPDX-FileCopyrightText: 2019-2022 Connor McLaughlin +// SPDX-FileCopyrightText: 2019-2023 Connor McLaughlin // SPDX-License-Identifier: (GPL-3.0 OR CC-BY-NC-ND-4.0) #include "updater.h" -#include "win32_progress_callback.h" +#include "common/error.h" #include "common/file_system.h" #include "common/log.h" #include "common/minizip_helpers.h" +#include "common/path.h" +#include "common/progress_callback.h" #include "common/string_util.h" #include @@ -18,7 +20,14 @@ #include #ifdef _WIN32 +#include "common/windows_headers.h" #include +#else +#include +#endif + +#ifdef __APPLE__ +#include "common/cocoa_tools.h" #endif Updater::Updater(ProgressCallback* progress) : m_progress(progress) @@ -32,19 +41,12 @@ Updater::~Updater() unzClose(m_zf); } -bool Updater::Initialize(std::string destination_directory) +bool Updater::Initialize(std::string staging_directory, std::string destination_directory) { + m_staging_directory = std::move(staging_directory); m_destination_directory = std::move(destination_directory); - m_staging_directory = StringUtil::StdStringFromFormat("%s" FS_OSPATH_SEPARATOR_STR "%s", - m_destination_directory.c_str(), "UPDATE_STAGING"); m_progress->DisplayFormattedInformation("Destination directory: '%s'", m_destination_directory.c_str()); m_progress->DisplayFormattedInformation("Staging directory: '%s'", m_staging_directory.c_str()); - - // log everything to file as well - Log::SetFileOutputParams( - true, StringUtil::StdStringFromFormat("%s" FS_OSPATH_SEPARATOR_STR "updater.log", m_destination_directory.c_str()) - .c_str()); - return true; } @@ -58,9 +60,12 @@ bool Updater::OpenUpdateZip(const char* path) return ParseZip(); } -bool Updater::RecursiveDeleteDirectory(const char* path) +bool Updater::RecursiveDeleteDirectory(const char* path, bool remove_dir) { #ifdef _WIN32 + if (!remove_dir) + return false; + // making this safer on Win32... std::wstring wpath(StringUtil::UTF8StringToWideString(path)); wpath += L'\0'; @@ -72,7 +77,31 @@ bool Updater::RecursiveDeleteDirectory(const char* path) return (SHFileOperationW(&op) == 0 && !op.fAnyOperationsAborted); #else - return FileSystem::DeleteDirectory(path, true); + FileSystem::FindResultsArray results; + if (FileSystem::FindFiles(path, "*", FILESYSTEM_FIND_FILES | FILESYSTEM_FIND_FOLDERS | FILESYSTEM_FIND_HIDDEN_FILES, + &results)) + { + for (const FILESYSTEM_FIND_DATA& fd : results) + { + if (fd.Attributes & FILESYSTEM_FILE_ATTRIBUTE_DIRECTORY) + { + if (!RecursiveDeleteDirectory(fd.FileName.c_str(), true)) + return false; + } + else + { + m_progress->DisplayFormattedInformation("Removing directory '%s'.", fd.FileName.c_str()); + if (!FileSystem::DeleteFile(fd.FileName.c_str())) + return false; + } + } + } + + if (!remove_dir) + return true; + + m_progress->DisplayFormattedInformation("Removing directory '%s'.", path); + return FileSystem::DeleteDirectory(path); #endif } @@ -110,13 +139,33 @@ bool Updater::ParseZip() while (zip_filename_buffer[0] == FS_OSPATH_SEPARATOR_CHARACTER) std::memmove(&zip_filename_buffer[1], &zip_filename_buffer[0], --len); +#ifdef _WIN32 + entry.file_mode = 0; +#else + // Preserve permissions on Unix. + static constexpr u32 PERMISSION_MASK = (S_IRWXO | S_IRWXG | S_IRWXU); + entry.file_mode = + ((file_info.external_fa >> 16) & 0x01FFu) & PERMISSION_MASK; // https://stackoverflow.com/a/28753385 +#endif + // skip directories (we sort them out later) if (len > 0 && zip_filename_buffer[len - 1] != FS_OSPATH_SEPARATOR_CHARACTER) { + bool process_file = true; + const char* filename_to_add = zip_filename_buffer; +#ifdef _WIN32 // skip updater itself, since it was already pre-extracted. - if (StringUtil::Strcasecmp(zip_filename_buffer, "updater.exe") != 0) + process_file = process_file && (StringUtil::Strcasecmp(zip_filename_buffer, "updater.exe") != 0); +#elif defined(__APPLE__) + // on MacOS, we want to remove the DuckStation.app prefix. + static constexpr const char* PREFIX_PATH = "DuckStation.app/"; + const size_t prefix_length = std::strlen(PREFIX_PATH); + process_file = process_file && (std::strncmp(zip_filename_buffer, PREFIX_PATH, prefix_length) == 0); + filename_to_add += prefix_length; +#endif + if (process_file) { - entry.destination_filename = zip_filename_buffer; + entry.destination_filename = filename_to_add; m_progress->DisplayFormattedInformation("Found file in zip: '%s'", entry.destination_filename.c_str()); m_update_paths.push_back(std::move(entry)); } @@ -167,7 +216,7 @@ bool Updater::PrepareStagingDirectory() if (FileSystem::DirectoryExists(m_staging_directory.c_str())) { m_progress->DisplayFormattedWarning("Update staging directory already exists, removing"); - if (!RecursiveDeleteDirectory(m_staging_directory.c_str()) || + if (!RecursiveDeleteDirectory(m_staging_directory.c_str(), true) || FileSystem::DirectoryExists(m_staging_directory.c_str())) { m_progress->ModalError("Failed to remove old staging directory"); @@ -204,7 +253,8 @@ bool Updater::StageUpdate() for (const FileToUpdate& ftu : m_update_paths) { - m_progress->SetFormattedStatusText("Extracting '%s'...", ftu.original_zip_filename.c_str()); + m_progress->SetFormattedStatusText("Extracting '%s' (mode %o)...", ftu.original_zip_filename.c_str(), + ftu.file_mode); if (unzLocateFile(m_zf, ftu.original_zip_filename.c_str(), 0) != UNZ_OK) { @@ -258,6 +308,23 @@ bool Updater::StageUpdate() } } +#ifndef _WIN32 + if (ftu.file_mode != 0) + { + const int fd = fileno(fp); + const int res = (fd >= 0) ? fchmod(fd, ftu.file_mode) : -1; + if (res < 0) + { + m_progress->DisplayFormattedModalError("Failed to set mode for file '%s' (fd %d) to %u: errno %d", + destination_file.c_str(), fd, res, errno); + std::fclose(fp); + FileSystem::DeleteFile(destination_file.c_str()); + unzCloseCurrentFile(m_zf); + return false; + } + } +#endif + std::fclose(fp); unzCloseCurrentFile(m_zf); m_progress->IncrementProgressValue(); @@ -291,17 +358,23 @@ bool Updater::CommitUpdate() const std::string dest_file_name = StringUtil::StdStringFromFormat( "%s" FS_OSPATH_SEPARATOR_STR "%s", m_destination_directory.c_str(), ftu.destination_filename.c_str()); m_progress->DisplayFormattedInformation("Moving '%s' to '%s'", staging_file_name.c_str(), dest_file_name.c_str()); + + Error error; #ifdef _WIN32 const bool result = MoveFileExW(StringUtil::UTF8StringToWideString(staging_file_name).c_str(), StringUtil::UTF8StringToWideString(dest_file_name).c_str(), MOVEFILE_REPLACE_EXISTING); + if (!result) + error.SetWin32(GetLastError()); +#elif defined(__APPLE__) + const bool result = CocoaTools::MoveFile(staging_file_name.c_str(), dest_file_name.c_str(), &error); #else const bool result = (rename(staging_file_name.c_str(), dest_file_name.c_str()) == 0); #endif if (!result) { - m_progress->DisplayFormattedModalError("Failed to rename '%s' to '%s'", staging_file_name.c_str(), - dest_file_name.c_str()); + m_progress->DisplayFormattedModalError("Failed to rename '%s' to '%s': %s", staging_file_name.c_str(), + dest_file_name.c_str(), error.GetDescription().c_str()); return false; } } @@ -312,6 +385,11 @@ bool Updater::CommitUpdate() void Updater::CleanupStagingDirectory() { // remove staging directory itself - if (!RecursiveDeleteDirectory(m_staging_directory.c_str())) + if (!RecursiveDeleteDirectory(m_staging_directory.c_str(), true)) m_progress->DisplayFormattedError("Failed to remove staging directory '%s'", m_staging_directory.c_str()); } + +bool Updater::ClearDestinationDirectory() +{ + return RecursiveDeleteDirectory(m_destination_directory.c_str(), false); +} diff --git a/src/updater/updater.h b/src/updater/updater.h index a925ff70b..3aed0ac55 100644 --- a/src/updater/updater.h +++ b/src/updater/updater.h @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2019-2022 Connor McLaughlin +// SPDX-FileCopyrightText: 2019-2023 Connor McLaughlin // SPDX-License-Identifier: (GPL-3.0 OR CC-BY-NC-ND-4.0) #pragma once @@ -13,27 +13,29 @@ public: Updater(ProgressCallback* progress); ~Updater(); - bool Initialize(std::string destination_directory); + bool Initialize(std::string staging_directory, std::string destination_directory); bool OpenUpdateZip(const char* path); bool PrepareStagingDirectory(); bool StageUpdate(); bool CommitUpdate(); void CleanupStagingDirectory(); + bool ClearDestinationDirectory(); private: - static bool RecursiveDeleteDirectory(const char* path); + bool RecursiveDeleteDirectory(const char* path, bool remove_dir); struct FileToUpdate { std::string original_zip_filename; std::string destination_filename; + u32 file_mode; }; bool ParseZip(); - std::string m_destination_directory; std::string m_staging_directory; + std::string m_destination_directory; std::vector m_update_paths; std::vector m_update_directories; diff --git a/src/updater/win32_main.cpp b/src/updater/win32_main.cpp index c2a3ecbff..70009f25e 100644 --- a/src/updater/win32_main.cpp +++ b/src/updater/win32_main.cpp @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2019-2022 Connor McLaughlin +// SPDX-FileCopyrightText: 2019-2023 Connor McLaughlin // SPDX-License-Identifier: (GPL-3.0 OR CC-BY-NC-ND-4.0) #include "updater.h" @@ -6,6 +6,7 @@ #include "common/file_system.h" #include "common/log.h" +#include "common/path.h" #include "common/string_util.h" #include "common/windows_headers.h" @@ -42,9 +43,10 @@ int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPWSTR lpCmdLi } const int parent_process_id = StringUtil::FromChars(StringUtil::WideStringToUTF8String(argv[0])).value_or(0); - const std::string destination_directory = StringUtil::WideStringToUTF8String(argv[1]); - const std::string zip_path = StringUtil::WideStringToUTF8String(argv[2]); - const std::wstring program_to_launch(argv[3]); + std::string destination_directory = StringUtil::WideStringToUTF8String(argv[1]); + std::string staging_directory = Path::Combine(destination_directory, "UPDATE_STAGING"); + std::string zip_path = StringUtil::WideStringToUTF8String(argv[2]); + std::wstring program_to_launch(argv[3]); LocalFree(argv); if (parent_process_id <= 0 || destination_directory.empty() || zip_path.empty() || program_to_launch.empty()) @@ -53,11 +55,13 @@ int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPWSTR lpCmdLi return 1; } + Log::SetFileOutputParams(true, Path::Combine(destination_directory, "updater.log").c_str()); + progress.SetFormattedStatusText("Waiting for parent process %d to exit...", parent_process_id); WaitForProcessToExit(parent_process_id); Updater updater(&progress); - if (!updater.Initialize(destination_directory)) + if (!updater.Initialize(std::move(staging_directory), std::move(destination_directory))) { progress.ModalError("Failed to initialize updater."); return 1;