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 000000000..49b90d2ef Binary files /dev/null and b/src/updater/Updater.icns differ diff --git a/src/updater/cocoa_main.mm b/src/updater/cocoa_main.mm new file mode 100644 index 000000000..b4f57b90a --- /dev/null +++ b/src/updater/cocoa_main.mm @@ -0,0 +1,135 @@ +// 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 "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;