diff --git a/CMakeLists.txt b/CMakeLists.txt index c744b28c05..ad10182ceb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -587,6 +587,11 @@ else() set(PNG png) endif() +if (APPLE) + message(STATUS "Using ed25519 from Externals") + add_subdirectory(Externals/ed25519) +endif() + # Using static soundtouch from Externals # Unable to use system soundtouch library: We require shorts, not floats. add_subdirectory(Externals/soundtouch) diff --git a/Externals/ed25519/CMakeLists.txt b/Externals/ed25519/CMakeLists.txt new file mode 100644 index 0000000000..a3a68d536f --- /dev/null +++ b/Externals/ed25519/CMakeLists.txt @@ -0,0 +1,13 @@ +project(ed25519 C) + +add_library(ed25519 + add_scalar.c + ge.c + keypair.c + seed.c + sign.c + fe.c + key_exchange.c + sc.c + sha512.c + verify.c) diff --git a/Source/Core/CMakeLists.txt b/Source/Core/CMakeLists.txt index a004180694..1eb5f30a4b 100644 --- a/Source/Core/CMakeLists.txt +++ b/Source/Core/CMakeLists.txt @@ -11,3 +11,7 @@ add_subdirectory(VideoBackends) if(ENABLE_QT) add_subdirectory(DolphinQt) endif() + +if (APPLE) + add_subdirectory(MacUpdater) +endif() diff --git a/Source/Core/Common/FileUtil.cpp b/Source/Core/Common/FileUtil.cpp index fe622ac21a..a631aa3edc 100644 --- a/Source/Core/Common/FileUtil.cpp +++ b/Source/Core/Common/FileUtil.cpp @@ -42,6 +42,7 @@ #include #include #include +#include #include #endif @@ -683,6 +684,9 @@ std::string GetExePath() dolphin_path = TStrToUTF8(dolphin_exe_expanded_path); else dolphin_path = TStrToUTF8(dolphin_exe_path); +#elif defined(__APPLE__) + dolphin_path = GetBundleDirectory(); + dolphin_path = dolphin_path.substr(0, dolphin_path.find_last_of("Dolphin.app/Contents/MacOS")); #else char dolphin_exe_path[PATH_MAX]; ssize_t len = ::readlink("/proc/self/exe", dolphin_exe_path, sizeof(dolphin_exe_path)); diff --git a/Source/Core/MacUpdater/AppDelegate.h b/Source/Core/MacUpdater/AppDelegate.h new file mode 100644 index 0000000000..88c49bac39 --- /dev/null +++ b/Source/Core/MacUpdater/AppDelegate.h @@ -0,0 +1,9 @@ +// Copyright 2019 Dolphin Emulator Project +// Licensed under GPLv2+ +// Refer to the license.txt file included. + +#import + +@interface AppDelegate : NSObject + +@end diff --git a/Source/Core/MacUpdater/AppDelegate.mm b/Source/Core/MacUpdater/AppDelegate.mm new file mode 100644 index 0000000000..02a4992321 --- /dev/null +++ b/Source/Core/MacUpdater/AppDelegate.mm @@ -0,0 +1,794 @@ +// Copyright 2019 Dolphin Emulator Project +// Licensed under GPLv2+ +// Refer to the license.txt file included. + +#import "AppDelegate.h" + +#include "UICommon/AutoUpdate.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "Common/CommonPaths.h" +#include "Common/CommonTypes.h" +#include "Common/FileUtil.h" +#include "Common/HttpRequest.h" +#include "Common/StringUtil.h" + +#include "MacUpdater/UI.h" + +namespace +{ +// Public key used to verify update manifests. +const u8 UPDATE_PUB_KEY[] = {0x2a, 0xb3, 0xd1, 0xdc, 0x6e, 0xf5, 0x07, 0xf6, 0xa0, 0x6c, 0x7c, + 0x54, 0xdf, 0x54, 0xf4, 0x42, 0x80, 0xa6, 0x28, 0x8b, 0x6d, 0x70, + 0x14, 0xb5, 0x4c, 0x34, 0x95, 0x20, 0x4d, 0xd4, 0xd3, 0x5d}; + +const char UPDATE_TEMP_DIR[] = "TempUpdate"; + +// Where to log updater output. +FILE* log_fp = stderr; + +void FlushLog() +{ + fflush(log_fp); + fclose(log_fp); +} + +// Internal representation of options passed on the command-line. +struct Options +{ + std::string this_manifest_url; + std::string next_manifest_url; + std::string content_store_url; + std::string install_base_path; + std::optional binary_to_restart; + std::optional parent_pid; + std::optional log_file; +}; + +std::optional ParseCommandLine(std::vector& args) +{ + using optparse::OptionParser; + + OptionParser parser = + OptionParser().prog("Dolphin Updater").description("Dolphin Updater binary"); + + parser.add_option("--this-manifest-url") + .dest("this-manifest-url") + .help("URL to the update manifest for the currently installed version.") + .metavar("URL"); + parser.add_option("--next-manifest-url") + .dest("next-manifest-url") + .help("URL to the update manifest for the to-be-installed version.") + .metavar("URL"); + parser.add_option("--content-store-url") + .dest("content-store-url") + .help("Base URL of the content store where files to download are stored.") + .metavar("URL"); + parser.add_option("--install-base-path") + .dest("install-base-path") + .help("Base path of the Dolphin install to be updated.") + .metavar("PATH"); + parser.add_option("--binary-to-restart") + .dest("binary-to-restart") + .help("Binary to restart after the update is over.") + .metavar("PATH"); + parser.add_option("--log-file") + .dest("log-file") + .help("File where to log updater debug output.") + .metavar("PATH"); + parser.add_option("--parent-pid") + .dest("parent-pid") + .type("int") + .help("(optional) PID of the parent process. The updater will wait for this process to " + "complete before proceeding.") + .metavar("PID"); + + optparse::Values options = parser.parse_args(args); + + Options opts; + + // Required arguments. + std::vector required{"this-manifest-url", "next-manifest-url", "content-store-url", + "install-base-path"}; + for (const auto& req : required) + { + if (!options.is_set(req)) + { + parser.print_help(); + return {}; + } + } + opts.this_manifest_url = options["this-manifest-url"]; + opts.next_manifest_url = options["next-manifest-url"]; + opts.content_store_url = options["content-store-url"]; + opts.install_base_path = options["install-base-path"]; + + // Optional arguments. + if (options.is_set("binary-to-restart")) + opts.binary_to_restart = options["binary-to-restart"]; + if (options.is_set("parent-pid")) + opts.parent_pid = (pid_t)options.get("parent-pid"); + if (options.is_set("log-file")) + opts.log_file = options["log-file"]; + + return opts; +} + +std::optional GzipInflate(const std::string& data) +{ + z_stream zstrm; + zstrm.zalloc = nullptr; + zstrm.zfree = nullptr; + zstrm.opaque = nullptr; + zstrm.avail_in = static_cast(data.size()); + zstrm.next_in = reinterpret_cast(const_cast(data.data())); + + // 16 + MAX_WBITS means gzip. Don't ask me. + inflateInit2(&zstrm, 16 + MAX_WBITS); + + std::string out; + char buffer[4096]; + int ret; + + do + { + zstrm.avail_out = sizeof(buffer); + zstrm.next_out = reinterpret_cast(buffer); + + ret = inflate(&zstrm, 0); + out.append(buffer, sizeof(buffer) - zstrm.avail_out); + } while (ret == Z_OK); + + inflateEnd(&zstrm); + + if (ret != Z_STREAM_END) + { + fprintf(log_fp, "Could not read the data as gzip: error %d.\n", ret); + return {}; + } + + return out; +} + +bool VerifySignature(const std::string& data, const std::string& b64_signature) +{ + u8 signature[64]; // ed25519 sig size. + size_t sig_size; + + if (mbedtls_base64_decode(signature, sizeof(signature), &sig_size, + reinterpret_cast(b64_signature.data()), + b64_signature.size()) || + sig_size != sizeof(signature)) + { + fprintf(log_fp, "Invalid base64: %s\n", b64_signature.c_str()); + return false; + } + + return ed25519_verify(signature, reinterpret_cast(data.data()), data.size(), + UPDATE_PUB_KEY); +} + +struct Manifest +{ + using Filename = std::string; + using Hash = std::array; + std::map entries; +}; + +bool HexDecode(const std::string& hex, u8* buffer, size_t size) +{ + if (hex.size() != size * 2) + return false; + + auto DecodeNibble = [](char c) -> std::optional { + if (c >= '0' && c <= '9') + return static_cast(c - '0'); + else if (c >= 'a' && c <= 'f') + return static_cast(c - 'a' + 10); + else if (c >= 'A' && c <= 'F') + return static_cast(c - 'A' + 10); + else + return {}; + }; + for (size_t i = 0; i < size; ++i) + { + std::optional high = DecodeNibble(hex[2 * i]); + std::optional low = DecodeNibble(hex[2 * i + 1]); + + if (!high || !low) + return false; + + buffer[i] = (*high << 4) | *low; + } + + return true; +} + +std::string HexEncode(const u8* buffer, size_t size) +{ + std::string out(size * 2, '\0'); + + for (size_t i = 0; i < size; ++i) + { + out[2 * i] = "0123456789abcdef"[buffer[i] >> 4]; + out[2 * i + 1] = "0123456789abcdef"[buffer[i] & 0xF]; + } + + return out; +} + +std::optional ParseManifest(const std::string& manifest) +{ + Manifest parsed; + size_t pos = 0; + + while (pos < manifest.size()) + { + size_t filename_end_pos = manifest.find('\t', pos); + if (filename_end_pos == std::string::npos) + { + fprintf(log_fp, "Manifest entry %zu: could not find filename end.\n", parsed.entries.size()); + return {}; + } + size_t hash_end_pos = manifest.find('\n', filename_end_pos); + if (hash_end_pos == std::string::npos) + { + fprintf(log_fp, "Manifest entry %zu: could not find hash end.\n", parsed.entries.size()); + return {}; + } + + std::string filename = manifest.substr(pos, filename_end_pos - pos); + std::string hash = manifest.substr(filename_end_pos + 1, hash_end_pos - filename_end_pos - 1); + if (hash.size() != 32) + { + fprintf(log_fp, "Manifest entry %zu: invalid hash: \"%s\".\n", parsed.entries.size(), + hash.c_str()); + return {}; + } + + Manifest::Hash decoded_hash; + if (!HexDecode(hash, decoded_hash.data(), decoded_hash.size())) + { + fprintf(log_fp, "Manifest entry %zu: invalid hash: \"%s\".\n", parsed.entries.size(), + hash.c_str()); + return {}; + } + + parsed.entries[filename] = decoded_hash; + pos = hash_end_pos + 1; + } + + return parsed; +} + +// Not showing a progress bar here because this part is just too quick +std::optional FetchAndParseManifest(const std::string& url) +{ + Common::HttpRequest http; + + Common::HttpRequest::Response resp = http.Get(url); + if (!resp) + { + fprintf(log_fp, "Manifest download failed.\n"); + return {}; + } + + std::string contents(reinterpret_cast(resp->data()), resp->size()); + std::optional maybe_decompressed = GzipInflate(contents); + if (!maybe_decompressed) + return {}; + std::string decompressed = std::move(*maybe_decompressed); + + // Split into manifest and signature. + size_t boundary = decompressed.rfind("\n\n"); + if (boundary == std::string::npos) + { + fprintf(log_fp, "No signature was found in manifest.\n"); + return {}; + } + + std::string signature_block = decompressed.substr(boundary + 2); // 2 for "\n\n". + decompressed.resize(boundary + 1); // 1 to keep the final "\n". + + std::vector signatures = SplitString(signature_block, '\n'); + bool found_valid_signature = false; + for (const auto& signature : signatures) + { + if (VerifySignature(decompressed, signature)) + { + found_valid_signature = true; + break; + } + } + if (!found_valid_signature) + { + fprintf(log_fp, "Could not verify signature of the manifest.\n"); + return {}; + } + + return ParseManifest(decompressed); +} + +// Represent the operations to be performed by the updater. +struct TodoList +{ + struct DownloadOp + { + Manifest::Filename filename; + Manifest::Hash hash; + }; + std::vector to_download; + + struct UpdateOp + { + Manifest::Filename filename; + std::optional old_hash; + Manifest::Hash new_hash; + }; + std::vector to_update; + + struct DeleteOp + { + Manifest::Filename filename; + Manifest::Hash old_hash; + }; + std::vector to_delete; + + void Log() const + { + if (to_update.size()) + { + fprintf(log_fp, "Updating:\n"); + for (const auto& op : to_update) + { + std::string old_desc = + op.old_hash ? HexEncode(op.old_hash->data(), op.old_hash->size()) : "(new)"; + fprintf(log_fp, " - %s: %s -> %s\n", op.filename.c_str(), old_desc.c_str(), + HexEncode(op.new_hash.data(), op.new_hash.size()).c_str()); + } + } + if (to_delete.size()) + { + fprintf(log_fp, "Deleting:\n"); + for (const auto& op : to_delete) + { + fprintf(log_fp, " - %s (%s)\n", op.filename.c_str(), + HexEncode(op.old_hash.data(), op.old_hash.size()).c_str()); + } + } + } +}; + +TodoList ComputeActionsToDo(Manifest this_manifest, Manifest next_manifest) +{ + TodoList todo; + + // Delete if present in this manifest but not in next manifest. + for (const auto& entry : this_manifest.entries) + { + if (next_manifest.entries.find(entry.first) == next_manifest.entries.end()) + { + TodoList::DeleteOp del; + del.filename = entry.first; + del.old_hash = entry.second; + todo.to_delete.push_back(std::move(del)); + } + } + + // Download and update if present in next manifest with different hash from this manifest. + for (const auto& entry : next_manifest.entries) + { + std::optional old_hash; + + const auto& old_entry = this_manifest.entries.find(entry.first); + if (old_entry != this_manifest.entries.end()) + old_hash = old_entry->second; + + if (!old_hash || *old_hash != entry.second) + { + TodoList::DownloadOp download; + download.filename = entry.first; + download.hash = entry.second; + + todo.to_download.push_back(std::move(download)); + + TodoList::UpdateOp update; + update.filename = entry.first; + update.old_hash = old_hash; + update.new_hash = entry.second; + todo.to_update.push_back(std::move(update)); + } + } + + return todo; +} + +std::optional FindOrCreateTempDir(const std::string& base_path) +{ + std::string temp_path = base_path + DIR_SEP + UPDATE_TEMP_DIR; + int counter = 0; + + do + { + if (!File::Exists(temp_path)) + { + if (File::CreateDir(temp_path)) + { + return temp_path; + } + else + { + fprintf(log_fp, "Couldn't create temp directory.\n"); + return {}; + } + } + else if (File::IsDirectory(temp_path)) + { + return temp_path; + } + else + { + // Try again with a counter appended to the path. + std::string suffix = UPDATE_TEMP_DIR + std::to_string(counter); + temp_path = base_path + DIR_SEP + suffix; + } + } while (counter++ < 10); + + fprintf(log_fp, "Could not find an appropriate temp directory name. Giving up.\n"); + return {}; +} + +void CleanUpTempDir(const std::string& temp_dir, const TodoList& todo) +{ + // This is best-effort cleanup, we ignore most errors. + for (const auto& download : todo.to_download) + File::Delete(temp_dir + DIR_SEP + HexEncode(download.hash.data(), download.hash.size())); + File::DeleteDir(temp_dir); +} + +Manifest::Hash ComputeHash(const std::string& contents) +{ + std::array full; + mbedtls_sha256(reinterpret_cast(contents.data()), contents.size(), full.data(), false); + + Manifest::Hash out; + std::copy(full.begin(), full.begin() + 16, out.begin()); + return out; +} + +bool ProgressCallback(double total, double now, double, double) +{ + UI::SetCurrentProgress(static_cast(now), static_cast(total)); + return true; +} + +bool DownloadContent(const std::vector& to_download, + const std::string& content_base_url, const std::string& temp_path) +{ + Common::HttpRequest req(std::chrono::seconds(30), ProgressCallback); + + UI::SetTotalMarquee(false); + + for (size_t i = 0; i < to_download.size(); i++) + { + UI::SetTotalProgress(static_cast(i + 1), static_cast(to_download.size())); + + auto& download = to_download[i]; + + std::string hash_filename = HexEncode(download.hash.data(), download.hash.size()); + UI::SetDescription("Downloading " + download.filename + "... (File " + std::to_string(i + 1) + + " of " + std::to_string(to_download.size()) + ")"); + UI::SetCurrentMarquee(false); + + // Add slashes where needed. + std::string content_store_path = hash_filename; + content_store_path.insert(4, "/"); + content_store_path.insert(2, "/"); + + std::string url = content_base_url + content_store_path; + fprintf(log_fp, "Downloading %s ...\n", url.c_str()); + + auto resp = req.Get(url); + if (!resp) + return false; + + UI::SetCurrentMarquee(true); + UI::SetDescription("Verifying " + download.filename + "..."); + + std::string contents(reinterpret_cast(resp->data()), resp->size()); + std::optional maybe_decompressed = GzipInflate(contents); + if (!maybe_decompressed) + return false; + std::string decompressed = std::move(*maybe_decompressed); + + // Check that the downloaded contents have the right hash. + Manifest::Hash contents_hash = ComputeHash(decompressed); + if (contents_hash != download.hash) + { + fprintf(log_fp, "Wrong hash on downloaded content %s.\n", url.c_str()); + return false; + } + + std::string out = temp_path + DIR_SEP + hash_filename; + if (!File::WriteStringToFile(decompressed, out)) + { + fprintf(log_fp, "Could not write cache file %s.\n", out.c_str()); + return false; + } + } + return true; +} + +bool BackupFile(const std::string& path) +{ + std::string backup_path = path + ".bak"; + fprintf(log_fp, "Backing up unknown pre-existing %s to .bak.\n", path.c_str()); + if (!File::Rename(path, backup_path)) + { + fprintf(log_fp, "Cound not rename %s to %s for backup.\n", path.c_str(), backup_path.c_str()); + return false; + } + return true; +} + +bool UpdateFiles(const std::vector& to_update, + const std::string& install_base_path, const std::string& temp_path) +{ + for (const auto& op : to_update) + { + std::string path = install_base_path + DIR_SEP + op.filename; + if (!File::CreateFullPath(path)) + { + fprintf(log_fp, "Could not create directory structure for %s.\n", op.filename.c_str()); + return false; + } + + if (File::Exists(path)) + { + std::string contents; + if (!File::ReadFileToString(path, contents)) + { + fprintf(log_fp, "Could not read existing file %s.\n", op.filename.c_str()); + return false; + } + Manifest::Hash contents_hash = ComputeHash(contents); + if (contents_hash == op.new_hash) + { + fprintf(log_fp, "File %s was already up to date. Partial update?\n", op.filename.c_str()); + continue; + } + else if (!op.old_hash || contents_hash != *op.old_hash) + { + if (!BackupFile(path)) + return false; + } + } + + // Now we can safely move the new contents to the location. + std::string content_filename = HexEncode(op.new_hash.data(), op.new_hash.size()); + fprintf(log_fp, "Updating file %s from content %s...\n", op.filename.c_str(), + content_filename.c_str()); + if (!File::Copy(temp_path + DIR_SEP + content_filename, path)) + { + fprintf(log_fp, "Could not update file %s.\n", op.filename.c_str()); + return false; + } + } + return true; +} + +bool DeleteObsoleteFiles(const std::vector& to_delete, + const std::string& install_base_path) +{ + for (const auto& op : to_delete) + { + std::string path = install_base_path + DIR_SEP + op.filename; + + if (!File::Exists(path)) + { + fprintf(log_fp, "File %s is already missing.\n", op.filename.c_str()); + continue; + } + else + { + std::string contents; + if (!File::ReadFileToString(path, contents)) + { + fprintf(log_fp, "Could not read file planned for deletion: %s.\n", op.filename.c_str()); + return false; + } + Manifest::Hash contents_hash = ComputeHash(contents); + if (contents_hash != op.old_hash) + { + if (!BackupFile(path)) + return false; + } + + File::Delete(path); + } + } + return true; +} + +bool PerformUpdate(const TodoList& todo, const std::string& install_base_path, + const std::string& content_base_url, const std::string& temp_path) +{ + fprintf(log_fp, "Starting download step...\n"); + if (!DownloadContent(todo.to_download, content_base_url, temp_path)) + return false; + fprintf(log_fp, "Download step completed.\n"); + + fprintf(log_fp, "Starting update step...\n"); + if (!UpdateFiles(todo.to_update, install_base_path, temp_path)) + return false; + fprintf(log_fp, "Update step completed.\n"); + + fprintf(log_fp, "Starting deletion step...\n"); + if (!DeleteObsoleteFiles(todo.to_delete, install_base_path)) + return false; + fprintf(log_fp, "Deletion step completed.\n"); + + return true; +} + +void FatalError(const std::string& message) +{ + fprintf(log_fp, "%s\n", message.c_str()); + + UI::Error(message); +} +} // namespace + +@interface AppDelegate () + +@end + +@implementation AppDelegate + +- (void)applicationDidFinishLaunching:(NSNotification*)aNotification +{ + UI::SetVisible(false); + + NSArray* arguments = [[NSProcessInfo processInfo] arguments]; + + __block std::vector args; + [arguments + enumerateObjectsUsingBlock:^(NSString* _Nonnull obj, NSUInteger idx, BOOL* _Nonnull stop) { + args.push_back(std::string([obj UTF8String])); + }]; + + std::optional maybe_opts = ParseCommandLine(args); + + if (!maybe_opts) + { + [NSApp terminate:nil]; + return; + } + + Options opts = std::move(*maybe_opts); + + dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0ul); + dispatch_async(queue, ^{ + if (opts.log_file) + { + log_fp = fopen(opts.log_file.value().c_str(), "w"); + if (!log_fp) + log_fp = stderr; + else + atexit(FlushLog); + } + + fprintf(log_fp, "Updating from: %s\n", opts.this_manifest_url.c_str()); + fprintf(log_fp, "Updating to: %s\n", opts.next_manifest_url.c_str()); + fprintf(log_fp, "Install path: %s\n", opts.install_base_path.c_str()); + + if (!File::IsDirectory(opts.install_base_path)) + { + FatalError("Cannot find install base path, or not a directory."); + + [NSApp performSelector:@selector(terminate:) withObject:nil afterDelay:0.0]; + return; + } + + if (opts.parent_pid) + { + UI::SetDescription("Waiting for Dolphin to quit..."); + + fprintf(log_fp, "Waiting for parent PID %d to complete...\n", *opts.parent_pid); + + auto pid = opts.parent_pid.value(); + + for (int res = kill(pid, 0); res == 0 || (res < 0 && errno == EPERM); res = kill(pid, 0)) + { + sleep(1); + } + + fprintf(log_fp, "Completed! Proceeding with update.\n"); + } + + UI::SetVisible(true); + + UI::SetDescription("Fetching and parsing manifests..."); + + Manifest this_manifest, next_manifest; + { + std::optional maybe_manifest = FetchAndParseManifest(opts.this_manifest_url); + if (!maybe_manifest) + { + FatalError("Could not fetch current manifest. Aborting."); + return; + } + this_manifest = std::move(*maybe_manifest); + + maybe_manifest = FetchAndParseManifest(opts.next_manifest_url); + if (!maybe_manifest) + { + FatalError("Could not fetch next manifest. Aborting."); + [NSApp performSelector:@selector(terminate:) withObject:nil afterDelay:0.0]; + return; + } + next_manifest = std::move(*maybe_manifest); + } + + UI::SetDescription("Computing what to do..."); + + TodoList todo = ComputeActionsToDo(this_manifest, next_manifest); + todo.Log(); + + std::optional maybe_temp_dir = FindOrCreateTempDir(opts.install_base_path); + if (!maybe_temp_dir) + return; + std::string temp_dir = std::move(*maybe_temp_dir); + + UI::SetDescription("Performing Update..."); + + bool ok = PerformUpdate(todo, opts.install_base_path, opts.content_store_url, temp_dir); + if (!ok) + FatalError("Failed to apply the update."); + + CleanUpTempDir(temp_dir, todo); + + UI::ResetCurrentProgress(); + UI::ResetTotalProgress(); + UI::SetCurrentMarquee(false); + UI::SetTotalMarquee(false); + UI::SetCurrentProgress(1, 1); + UI::SetTotalProgress(1, 1); + UI::SetDescription("Done!"); + + // Let the user process that we are done. + [NSThread sleepForTimeInterval:1.0f]; + + if (opts.binary_to_restart) + { + [[NSWorkspace sharedWorkspace] + launchApplication:[NSString stringWithCString:opts.binary_to_restart.value().c_str() + encoding:[NSString defaultCStringEncoding]]]; + } + + dispatch_sync(dispatch_get_main_queue(), ^{ + [NSApp terminate:nil]; + }); + }); +} + +- (void)applicationWillTerminate:(NSNotification*)aNotification +{ +} + +@end diff --git a/Source/Core/MacUpdater/CMakeLists.txt b/Source/Core/MacUpdater/CMakeLists.txt new file mode 100644 index 0000000000..61d3489bfc --- /dev/null +++ b/Source/Core/MacUpdater/CMakeLists.txt @@ -0,0 +1,63 @@ +set(STORYBOARDS Main.storyboard) + +set(SOURCES + main.m + AppDelegate.h + AppDelegate.mm + ViewController.h + ViewController.m + UI.h + UI.mm + ${STORYBOARDS} +) + +add_executable(MacUpdater ${SOURCES}) + +set(MacUpdater_NAME "Dolphin Updater") + +set_target_properties(MacUpdater PROPERTIES + MACOSX_BUNDLE true + MACOSX_BUNDLE_INFO_PLIST ${CMAKE_CURRENT_SOURCE_DIR}/Info.plist.in + OUTPUT_NAME ${MacUpdater_NAME}) + +target_compile_options(MacUpdater PRIVATE -x objective-c++) + +# Copy icon into the bundle +target_sources(MacUpdater PRIVATE "${CMAKE_SOURCE_DIR}/Data/Dolphin.icns") +set_source_files_properties("${CMAKE_SOURCE_DIR}/Data/Dolphin.icns" PROPERTIES MACOSX_PACKAGE_LOCATION Resources) + +target_link_libraries(MacUpdater PRIVATE + "-framework Cocoa" + "-framework AppKit" + "-framework CoreData" + "-framework Foundation" + uicommon + mbedtls + z + ed25519 +) + +# Compile storyboards (Adapted from https://gitlab.kitware.com/cmake/community/wikis/doc/tutorials/OSX-InterfaceBuilderFiles) + +# Make sure we can find the 'ibtool' program. If we can NOT find it we +# skip generation of this project +find_program(IBTOOL ibtool HINTS "/usr/bin" "${OSX_DEVELOPER_ROOT}/usr/bin") +if (${IBTOOL} STREQUAL "IBTOOL-NOTFOUND") + message(SEND_ERROR "ibtool can not be found and is needed to compile the .storyboard files. It should have been installed with + the Apple developer tools. The default system paths were searched in addition to ${OSX_DEVELOPER_ROOT}/usr/bin") +endif() + +foreach(sb ${STORYBOARDS}) + set(MacUpdater_BIN_DIR ${CMAKE_BINARY_DIR}/Binaries) + + if (CMAKE_GENERATOR STREQUAL Xcode) + string(APPEND MacUpdater_BIN_DIR "/\${CONFIGURATION}") + endif() + + add_custom_command(TARGET MacUpdater POST_BUILD + COMMAND ${IBTOOL} --errors --warnings --notices --output-format human-readable-text + --compile ${MacUpdater_BIN_DIR}/${MacUpdater_NAME}.app/Contents/Resources/${sb}c + ${CMAKE_CURRENT_SOURCE_DIR}/${sb} + COMMENT "Compiling Storyboard ${sb}...") +endforeach() + diff --git a/Source/Core/MacUpdater/Info.plist.in b/Source/Core/MacUpdater/Info.plist.in new file mode 100644 index 0000000000..3382688ba9 --- /dev/null +++ b/Source/Core/MacUpdater/Info.plist.in @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + English + CFBundleExecutable + ${MacUpdater_NAME} + CFBundleIconFile + Dolphin.icns + CFBundleIdentifier + com.dolphinteam.dolphin-updater + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Updater + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + 10.9 + NSHumanReadableCopyright + Licensed under GPL version 2 or later (GPLv2+) + NSMainStoryboardFile + Main + NSPrincipalClass + NSApplication + + diff --git a/Source/Core/MacUpdater/Main.storyboard b/Source/Core/MacUpdater/Main.storyboard new file mode 100644 index 0000000000..fe5e280ada --- /dev/null +++ b/Source/Core/MacUpdater/Main.storyboard @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Source/Core/MacUpdater/UI.h b/Source/Core/MacUpdater/UI.h new file mode 100644 index 0000000000..9a963c91e7 --- /dev/null +++ b/Source/Core/MacUpdater/UI.h @@ -0,0 +1,24 @@ +// Copyright 2019 Dolphin Emulator Project +// Licensed under GPLv2+ +// Refer to the license.txt file included. + +#pragma once + +#include + +namespace UI +{ +void Error(const std::string& text); + +void SetVisible(bool visible); + +void SetDescription(const std::string& text); + +void SetTotalMarquee(bool marquee); +void ResetTotalProgress(); +void SetTotalProgress(int current, int total); + +void SetCurrentMarquee(bool marquee); +void ResetCurrentProgress(); +void SetCurrentProgress(int current, int total); +} // namespace UI diff --git a/Source/Core/MacUpdater/UI.mm b/Source/Core/MacUpdater/UI.mm new file mode 100644 index 0000000000..0deb85db93 --- /dev/null +++ b/Source/Core/MacUpdater/UI.mm @@ -0,0 +1,107 @@ +// Copyright 2019 Dolphin Emulator Project +// Licensed under GPLv2+ +// Refer to the license.txt file included. + +#include "MacUpdater/UI.h" +#include "MacUpdater/ViewController.h" + +#include + +#include + +// When we call from the main thread, we are not allowed to use +// dispatch_sync(dispatch_get_main_queue() as it will cause crashes) To prevent this check if we're +// already on the main thread first +void run_on_main(std::function fnc) +{ + if (![NSThread isMainThread]) + { + dispatch_sync(dispatch_get_main_queue(), ^{ + fnc(); + }); + } + else + { + fnc(); + } +} + +NSWindow* GetWindow() +{ + return [[[NSApplication sharedApplication] windows] objectAtIndex:0]; +} + +ViewController* GetView() +{ + return (ViewController*)GetWindow().contentViewController; +} + +void UI::Error(const std::string& text) +{ + run_on_main([&] { + NSAlert* alert = [[[NSAlert alloc] init] autorelease]; + + [alert setMessageText:@"Fatal error"]; + [alert + setInformativeText:[NSString stringWithCString:text.c_str() encoding:NSUTF8StringEncoding]]; + [alert setAlertStyle:NSAlertStyleCritical]; + + [alert beginSheetModalForWindow:GetWindow() + completionHandler:^(NSModalResponse) { + [NSApp performSelector:@selector(terminate:) withObject:nil afterDelay:0.0]; + }]; + }); +} + +void UI::SetVisible(bool visible) +{ + run_on_main([&] { + if (visible) + { + [NSApp unhide:nil]; + [NSApp activateIgnoringOtherApps:YES]; + } + else + { + [NSApp hide:nil]; + } + }); +} + +void UI::SetDescription(const std::string& text) +{ + run_on_main([&] { + [GetView() + SetDescription:[NSString stringWithCString:text.c_str() encoding:NSUTF8StringEncoding]]; + }); +} + +void UI::SetTotalMarquee(bool marquee) +{ + run_on_main([marquee] { [GetView() SetTotalMarquee:marquee]; }); +} + +void UI::SetCurrentMarquee(bool marquee) +{ + run_on_main([&] { [GetView() SetCurrentMarquee:marquee]; }); +} + +void UI::ResetTotalProgress() +{ + run_on_main([] { SetTotalProgress(0, 1); }); +} + +void UI::ResetCurrentProgress() +{ + run_on_main([] { SetCurrentProgress(0, 1); }); +} + +void UI::SetCurrentProgress(int current, int total) +{ + run_on_main([&] { [GetView() SetCurrentProgress:(double)current total:(double)total]; }); +} + +void UI::SetTotalProgress(int current, int total) +{ + run_on_main([&] { [GetView() SetTotalProgress:(double)current total:(double)total]; }); +} diff --git a/Source/Core/MacUpdater/ViewController.h b/Source/Core/MacUpdater/ViewController.h new file mode 100644 index 0000000000..572f00b607 --- /dev/null +++ b/Source/Core/MacUpdater/ViewController.h @@ -0,0 +1,21 @@ +// Copyright 2019 Dolphin Emulator Project +// Licensed under GPLv2+ +// Refer to the license.txt file included. + +#import + +@interface ViewController : NSViewController + +@property(assign) IBOutlet NSProgressIndicator* progressCurrent; +@property(assign) IBOutlet NSProgressIndicator* progressTotal; +@property(assign) IBOutlet NSTextField* labelProgress; + +- (void)SetDescription:(NSString*)string; + +- (void)SetTotalMarquee:(bool)marquee; +- (void)SetCurrentMarquee:(bool)marquee; + +- (void)SetTotalProgress:(double)current total:(double)total; +- (void)SetCurrentProgress:(double)current total:(double)total; + +@end diff --git a/Source/Core/MacUpdater/ViewController.m b/Source/Core/MacUpdater/ViewController.m new file mode 100644 index 0000000000..ba35f61967 --- /dev/null +++ b/Source/Core/MacUpdater/ViewController.m @@ -0,0 +1,52 @@ +// Copyright 2019 Dolphin Emulator Project +// Licensed under GPLv2+ +// Refer to the license.txt file included. + +#import "ViewController.h" + +@implementation ViewController + +- (void)viewDidLoad +{ + [super viewDidLoad]; + NSWindow* window = [[self view] window]; + + [window setLevel:kCGMainMenuWindowLevel - 1]; + [window setCollectionBehavior:NSWindowCollectionBehaviorStationary | + NSWindowCollectionBehaviorCanJoinAllSpaces | + NSWindowCollectionBehaviorFullScreenAuxiliary]; +} + +- (void)SetDescription:(NSString*)string +{ + [_labelProgress setStringValue:string]; +} + +- (void)SetTotalMarquee:(bool)marquee +{ + [_progressTotal setIndeterminate:marquee]; +} + +- (void)SetCurrentMarquee:(bool)marquee +{ + [_progressCurrent setIndeterminate:marquee]; +} + +- (void)SetTotalProgress:(double)current total:(double)total +{ + [_progressTotal setMaxValue:total]; + [_progressTotal setDoubleValue:current]; +} + +- (void)SetCurrentProgress:(double)current total:(double)total +{ + [_progressCurrent setMaxValue:total]; + [_progressCurrent setDoubleValue:current]; +} + +- (void)setRepresentedObject:(id)representedObject +{ + [super setRepresentedObject:representedObject]; +} + +@end diff --git a/Source/Core/MacUpdater/main.m b/Source/Core/MacUpdater/main.m new file mode 100644 index 0000000000..b2e372139f --- /dev/null +++ b/Source/Core/MacUpdater/main.m @@ -0,0 +1,22 @@ +// Copyright 2019 Dolphin Emulator Project +// Licensed under GPLv2+ +// Refer to the license.txt file included. + +#include + +int main(int argc, const char** argv) +{ + if (argc == 1) + { + NSAlert* alert = [[[NSAlert alloc] init] autorelease]; + + [alert setMessageText:@"This updater is not meant to be launched directly."]; + [alert setAlertStyle:NSAlertStyleWarning]; + [alert setInformativeText:@"Configure Auto-Update in Dolphin's settings instead."]; + [alert runModal]; + + return 1; + } + + return NSApplicationMain(argc, argv); +} diff --git a/Source/Core/UICommon/AutoUpdate.cpp b/Source/Core/UICommon/AutoUpdate.cpp index 003135683a..00256ee174 100644 --- a/Source/Core/UICommon/AutoUpdate.cpp +++ b/Source/Core/UICommon/AutoUpdate.cpp @@ -19,23 +19,50 @@ #include #endif +#ifdef __APPLE__ +#include +#include +#include +#endif + +#if defined _WIN32 || defined __APPLE__ +#define OS_SUPPORTS_UPDATER +#endif + namespace { #ifdef _WIN32 const char UPDATER_FILENAME[] = "Updater.exe"; const char UPDATER_RELOC_FILENAME[] = "Updater.2.exe"; + +#elif defined(__APPLE__) + +const char UPDATER_FILENAME[] = "Dolphin Updater.app"; +const char UPDATER_RELOC_FILENAME[] = ".Dolphin Updater.2.app"; + +#endif + const char UPDATER_LOG_FILE[] = "Updater.log"; -std::wstring MakeUpdaterCommandLine(const std::map& flags) +#ifdef OS_SUPPORTS_UPDATER +std::string MakeUpdaterCommandLine(const std::map& flags) { - std::wstring cmdline = UTF8ToUTF16(UPDATER_FILENAME) + L" "; // Start with a fake argv[0]. +#ifdef __APPLE__ + std::string cmdline = "\"" + File::GetExeDirectory() + DIR_SEP + UPDATER_RELOC_FILENAME + + "/Contents/MacOS/Dolphin Updater\""; +#else + std::string cmdline = File::GetExeDirectory() + DIR_SEP + UPDATER_RELOC_FILENAME; +#endif + + cmdline += " "; + for (const auto& pair : flags) { std::string value = "--" + pair.first + "=" + pair.second; value = ReplaceAll(value, "\"", "\\\""); // Escape double quotes. value = "\"" + value + "\" "; - cmdline += UTF8ToUTF16(value); + cmdline += value; } return cmdline; } @@ -44,7 +71,12 @@ std::wstring MakeUpdaterCommandLine(const std::map& fl void CleanupFromPreviousUpdate() { std::string reloc_updater_path = File::GetExeDirectory() + DIR_SEP + UPDATER_RELOC_FILENAME; + +#ifdef __APPLE__ + File::DeleteDirRecursively(reloc_updater_path); +#else File::Delete(reloc_updater_path); +#endif } #endif @@ -93,28 +125,40 @@ std::string GenerateChangelog(const picojson::array& versions) bool AutoUpdateChecker::SystemSupportsAutoUpdates() { -#ifdef _WIN32 +#if defined _WIN32 || defined __APPLE__ return true; #else return false; #endif } +static std::string GetPlatformID() +{ +#if defined _WIN32 + return "win"; +#elif defined __APPLE__ + return "macos"; +#else + return "unknown"; +#endif +} + void AutoUpdateChecker::CheckForUpdate() { // Don't bother checking if updates are not supported or not enabled. if (!SystemSupportsAutoUpdates() || SConfig::GetInstance().m_auto_update_track.empty()) return; -#ifdef _WIN32 +#ifdef OS_SUPPORTS_UPDATER CleanupFromPreviousUpdate(); #endif std::string version_hash = SConfig::GetInstance().m_auto_update_hash_override.empty() ? SCM_REV_STR : SConfig::GetInstance().m_auto_update_hash_override; - std::string url = "https://dolphin-emu.org/update/check/v0/" + - SConfig::GetInstance().m_auto_update_track + "/" + version_hash; + std::string url = "https://dolphin-emu.org/update/check/v1/" + + SConfig::GetInstance().m_auto_update_track + "/" + version_hash + "/" + + GetPlatformID(); Common::HttpRequest req{std::chrono::seconds{10}}; auto resp = req.Get(url); @@ -157,12 +201,16 @@ void AutoUpdateChecker::CheckForUpdate() void AutoUpdateChecker::TriggerUpdate(const AutoUpdateChecker::NewVersionInformation& info, AutoUpdateChecker::RestartMode restart_mode) { -#ifdef _WIN32 +#ifdef OS_SUPPORTS_UPDATER std::map updater_flags; updater_flags["this-manifest-url"] = info.this_manifest_url; updater_flags["next-manifest-url"] = info.next_manifest_url; updater_flags["content-store-url"] = info.content_store_url; +#ifdef _WIN32 updater_flags["parent-pid"] = std::to_string(GetCurrentProcessId()); +#else + updater_flags["parent-pid"] = std::to_string(getpid()); +#endif updater_flags["install-base-path"] = File::GetExeDirectory(); updater_flags["log-file"] = File::GetExeDirectory() + DIR_SEP + UPDATER_LOG_FILE; @@ -172,19 +220,35 @@ void AutoUpdateChecker::TriggerUpdate(const AutoUpdateChecker::NewVersionInforma // Copy the updater so it can update itself if needed. std::string updater_path = File::GetExeDirectory() + DIR_SEP + UPDATER_FILENAME; std::string reloc_updater_path = File::GetExeDirectory() + DIR_SEP + UPDATER_RELOC_FILENAME; + +#ifdef __APPLE__ + File::CopyDir(updater_path, reloc_updater_path); + chmod((reloc_updater_path + "/Contents/MacOS/Dolphin Updater").c_str(), 0700); +#else File::Copy(updater_path, reloc_updater_path); +#endif // Run the updater! - std::wstring command_line = MakeUpdaterCommandLine(updater_flags); + std::string command_line = MakeUpdaterCommandLine(updater_flags); + + INFO_LOG(COMMON, "Updater command line: %s", command_line.c_str()); + +#ifdef _WIN32 STARTUPINFO sinfo = {sizeof(info)}; sinfo.dwFlags = STARTF_FORCEOFFFEEDBACK; // No hourglass cursor after starting the process. PROCESS_INFORMATION pinfo; - INFO_LOG(COMMON, "Updater command line: %s", UTF16ToUTF8(command_line).c_str()); if (!CreateProcessW(UTF8ToUTF16(reloc_updater_path).c_str(), - const_cast(command_line.c_str()), nullptr, nullptr, FALSE, 0, - nullptr, nullptr, &sinfo, &pinfo)) + const_cast(UTF8ToUTF16(command_line).c_str()), nullptr, nullptr, + FALSE, 0, nullptr, nullptr, &sinfo, &pinfo)) { ERROR_LOG(COMMON, "Could not start updater process: error=%d", GetLastError()); } +#else + if (popen(command_line.c_str(), "r") == nullptr) + { + ERROR_LOG(COMMON, "Could not start updater process: error=%d", errno); + } +#endif + #endif }