From 710789b70115c043695fe515d7f2b1e8878afb99 Mon Sep 17 00:00:00 2001 From: Kenneth Chew <79120643+kthchew@users.noreply.github.com> Date: Wed, 4 Dec 2024 01:43:28 -0500 Subject: [PATCH] Use security-scoped bookmarks to keep track of data directory settings on macOS This enables sandboxed apps to maintain access to user-selected items. In addition, for both sandboxed and nonsandboxed apps it can keep track of directories even if they are moved or renamed, and can remember access to directories in "sensitive" locations (such as the Documents folder or external drives). Signed-off-by: Kenneth Chew <79120643+kthchew@users.noreply.github.com> --- launcher/Application.cpp | 12 +- launcher/CMakeLists.txt | 9 ++ .../macsandbox/SecurityBookmarkFileAccess.h | 83 +++++++++++ .../macsandbox/SecurityBookmarkFileAccess.mm | 138 ++++++++++++++++++ launcher/settings/SettingsObject.cpp | 114 ++++++++++++++- launcher/settings/SettingsObject.h | 29 +++- .../ui/widgets/MinecraftSettingsWidget.cpp | 2 +- launcher/ui/widgets/MinecraftSettingsWidget.h | 2 +- 8 files changed, 382 insertions(+), 7 deletions(-) create mode 100644 launcher/macsandbox/SecurityBookmarkFileAccess.h create mode 100644 launcher/macsandbox/SecurityBookmarkFileAccess.mm diff --git a/launcher/Application.cpp b/launcher/Application.cpp index 2570beb3c..a532db2cf 100644 --- a/launcher/Application.cpp +++ b/launcher/Application.cpp @@ -709,6 +709,16 @@ Application::Application(int& argc, char** argv) : QApplication(argc, argv) m_settings->registerSetting("SkinsDir", "skins"); m_settings->registerSetting("JavaDir", "java"); +#ifdef Q_OS_MACOS + // Folder security-scoped bookmarks + m_settings->registerSetting("InstanceDirBookmark", ""); + m_settings->registerSetting("CentralModsDirBookmark", ""); + m_settings->registerSetting("IconsDirBookmark", ""); + m_settings->registerSetting("DownloadsDirBookmark", ""); + m_settings->registerSetting("SkinsDirBookmark", ""); + m_settings->registerSetting("JavaDirBookmark", ""); +#endif + // Editors m_settings->registerSetting("JsonEditor", QString()); @@ -964,7 +974,7 @@ Application::Application(int& argc, char** argv) : QApplication(argc, argv) auto InstDirSetting = m_settings->getSetting("InstanceDir"); // instance path: check for problems with '!' in instance path and warn the user in the log // and remember that we have to show him a dialog when the gui starts (if it does so) - QString instDir = InstDirSetting->get().toString(); + QString instDir = m_settings->get("InstanceDir").toString(); qInfo() << "Instance path : " << instDir; if (FS::checkProblemticPathJava(QDir(instDir))) { qWarning() << "Your instance path contains \'!\' and this is known to cause java problems!"; diff --git a/launcher/CMakeLists.txt b/launcher/CMakeLists.txt index 6d6e2ae7f..ecdab490e 100644 --- a/launcher/CMakeLists.txt +++ b/launcher/CMakeLists.txt @@ -1173,6 +1173,15 @@ if (NOT Apple) ) endif() +if (APPLE) + set(LAUNCHER_SOURCES + ${LAUNCHER_SOURCES} + + macsandbox/SecurityBookmarkFileAccess.h + macsandbox/SecurityBookmarkFileAccess.mm + ) +endif() + if(WIN32) set(LAUNCHER_SOURCES console/WindowsConsole.h diff --git a/launcher/macsandbox/SecurityBookmarkFileAccess.h b/launcher/macsandbox/SecurityBookmarkFileAccess.h new file mode 100644 index 000000000..10d75cbd5 --- /dev/null +++ b/launcher/macsandbox/SecurityBookmarkFileAccess.h @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2024 Kenneth Chew <79120643+kthchew@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef FILEACCESS_H +#define FILEACCESS_H + +#include +#include +Q_FORWARD_DECLARE_OBJC_CLASS(NSData); +Q_FORWARD_DECLARE_OBJC_CLASS(NSURL); +Q_FORWARD_DECLARE_OBJC_CLASS(NSString); +Q_FORWARD_DECLARE_OBJC_CLASS(NSAutoreleasePool); +Q_FORWARD_DECLARE_OBJC_CLASS(NSMutableDictionary); +Q_FORWARD_DECLARE_OBJC_CLASS(NSMutableSet); +class QString; +class QByteArray; +class QUrl; + +class SecurityBookmarkFileAccess { + /// The keys are bookmarks and the values are URLs. + NSMutableDictionary* m_bookmarks; + /// The keys are paths and the values are bookmarks. + NSMutableDictionary* m_paths; + /// Contains URLs that are currently being accessed. + NSMutableSet* m_activeURLs; + + NSURL* securityScopedBookmarkToNSURL(QByteArray& bookmark, bool& isStale); +public: + SecurityBookmarkFileAccess(); + ~SecurityBookmarkFileAccess(); + + /// Get a security scoped bookmark from a URL. + /// + /// The URL must be accessible before calling this function. That is, call `startAccessingSecurityScopedResource()` before calling + /// this function. Note that this is called implicitly if the user selects the directory from a file picker. + /// \param url The URL to get the security scoped bookmark from. + /// \return A QByteArray containing the security scoped bookmark. + QByteArray urlToSecurityScopedBookmark(const QUrl& url); + /// Get a security scoped bookmark from a path. + /// + /// The path must be accessible before calling this function. That is, call `startAccessingSecurityScopedResource()` before calling + /// this function. Note that this is called implicitly if the user selects the directory from a file picker. + /// \param path The path to get the security scoped bookmark from. + /// \return A QByteArray containing the security scoped bookmark. + QByteArray pathToSecurityScopedBookmark(const QString& path); + /// Get a QUrl from a security scoped bookmark. If the bookmark is stale, isStale will be set to true and the bookmark will be updated. + /// + /// You must check whether the URL is valid before using it. + /// \param bookmark The security scoped bookmark to get the URL from. + /// \param isStale A boolean that will be set to true if the bookmark is stale. + /// \return The URL from the security scoped bookmark. + QUrl securityScopedBookmarkToURL(QByteArray& bookmark, bool& isStale); + + /// Makes the file or directory at the path pointed to by the bookmark accessible. Unlike `startAccessingSecurityScopedResource()`, this + /// class ensures that only one "access" is active at a time. Calling this function again after the security-scoped resource has + /// already been used will do nothing, and a single call to `stopUsingSecurityScopedBookmark()` will release the resource provided that + /// this is the only `SecurityBookmarkFileAccess` accessing the resource. + /// + /// If the bookmark is stale, `isStale` will be set to true and the bookmark will be updated. Stored copies of the bookmark need to be + /// updated. + /// \param bookmark The security scoped bookmark to start accessing. + /// \param isStale A boolean that will be set to true if the bookmark is stale. + /// \return A boolean indicating whether the bookmark was successfully accessed. + bool startUsingSecurityScopedBookmark(QByteArray& bookmark, bool& isStale); + void stopUsingSecurityScopedBookmark(QByteArray& bookmark); +}; + +#endif //FILEACCESS_H diff --git a/launcher/macsandbox/SecurityBookmarkFileAccess.mm b/launcher/macsandbox/SecurityBookmarkFileAccess.mm new file mode 100644 index 000000000..721c3fd47 --- /dev/null +++ b/launcher/macsandbox/SecurityBookmarkFileAccess.mm @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2024 Kenneth Chew <79120643+kthchew@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "SecurityBookmarkFileAccess.h" + +#include +#include +#include + +QByteArray SecurityBookmarkFileAccess::urlToSecurityScopedBookmark(const QUrl& url) +{ + if (!url.isLocalFile()) + return {}; + + NSError* error = nil; + NSURL* nsurl = [url.toNSURL() absoluteURL]; + if ([m_paths objectForKey:[nsurl path]]) { + return QByteArray::fromNSData(m_paths[[nsurl path]]); + } + [m_activeURLs addObject:nsurl]; + NSData* bookmark = [nsurl bookmarkDataWithOptions:NSURLBookmarkCreationWithSecurityScope + includingResourceValuesForKeys:nil + relativeToURL:nil + error:&error]; + if (error) { + return {}; + } + m_paths[[nsurl path]] = bookmark; + m_bookmarks[bookmark] = nsurl; + [m_activeURLs addObject:nsurl]; + + return QByteArray::fromNSData(bookmark); +} + +SecurityBookmarkFileAccess::SecurityBookmarkFileAccess() +{ + m_bookmarks = [NSMutableDictionary new]; + m_paths = [NSMutableDictionary new]; + m_activeURLs = [NSMutableSet new]; +} + +SecurityBookmarkFileAccess::~SecurityBookmarkFileAccess() +{ + for (NSURL* url : m_activeURLs) { + [url stopAccessingSecurityScopedResource]; + } + [m_bookmarks release]; + [m_paths release]; + [m_activeURLs release]; +} + +QByteArray SecurityBookmarkFileAccess::pathToSecurityScopedBookmark(const QString& path) +{ + return urlToSecurityScopedBookmark(QUrl::fromLocalFile(path)); +} + +NSURL* SecurityBookmarkFileAccess::securityScopedBookmarkToNSURL(QByteArray& bookmark, bool& isStale) +{ + NSError* error = nil; + BOOL localStale = NO; + NSURL* nsurl = [NSURL URLByResolvingBookmarkData:bookmark.toNSData() + options:NSURLBookmarkResolutionWithSecurityScope + relativeToURL:nil + bookmarkDataIsStale:&localStale + error:&error]; + if (error) { + return nil; + } + isStale = localStale; + if (isStale) { + NSData* nsBookmark = [nsurl bookmarkDataWithOptions:NSURLBookmarkCreationWithSecurityScope + includingResourceValuesForKeys:nil + relativeToURL:nil + error:&error]; + if (error) { + return nil; + } + m_paths[[nsurl path]] = nsBookmark; + m_bookmarks[nsBookmark] = nsurl; + bookmark = QByteArray::fromNSData(nsBookmark); + } + + return nsurl; +} + +QUrl SecurityBookmarkFileAccess::securityScopedBookmarkToURL(QByteArray& bookmark, bool& isStale) +{ + if (bookmark.isEmpty()) + return {}; + + NSURL* url = securityScopedBookmarkToNSURL(bookmark, isStale); + if (!url) + return {}; + + return QUrl::fromNSURL(url); +} + +bool SecurityBookmarkFileAccess::startUsingSecurityScopedBookmark(QByteArray& bookmark, bool& isStale) +{ + NSURL* url = [m_bookmarks objectForKey:bookmark.toNSData()] ? m_bookmarks[bookmark.toNSData()] : securityScopedBookmarkToNSURL(bookmark, isStale); + if ([m_activeURLs containsObject:url]) + return false; + + if ([url startAccessingSecurityScopedResource]) { + [m_activeURLs addObject:url]; + return true; + } + return false; +} + +void SecurityBookmarkFileAccess::stopUsingSecurityScopedBookmark(QByteArray& bookmark) +{ + if (![m_bookmarks objectForKey:bookmark.toNSData()]) + return; + NSURL* url = m_bookmarks[bookmark.toNSData()]; + + if ([m_activeURLs containsObject:url]) { + [url stopAccessingSecurityScopedResource]; + [m_activeURLs removeObject:url]; + [m_bookmarks removeObjectForKey:bookmark.toNSData()]; + [m_paths removeObjectForKey:[url path]]; + } +} diff --git a/launcher/settings/SettingsObject.cpp b/launcher/settings/SettingsObject.cpp index 7501d6748..8bc691f4c 100644 --- a/launcher/settings/SettingsObject.cpp +++ b/launcher/settings/SettingsObject.cpp @@ -20,6 +20,12 @@ #include "settings/Setting.h" #include +#include +#include + +#ifdef Q_OS_MACOS +#include "macsandbox/SecurityBookmarkFileAccess.h" +#endif SettingsObject::SettingsObject(QObject* parent) : QObject(parent) {} @@ -78,9 +84,17 @@ std::shared_ptr SettingsObject::getSetting(const QString& id) const return m_settings[id]; } -QVariant SettingsObject::get(const QString& id) const +QVariant SettingsObject::get(const QString& id) { auto setting = getSetting(id); + +#ifdef Q_OS_MACOS + // for macOS, use a security scoped bookmark for the paths + if (id.endsWith("Dir")) { + return { getPathFromBookmark(id) }; + } +#endif + return (setting ? setting->get() : QVariant()); } @@ -90,11 +104,105 @@ bool SettingsObject::set(const QString& id, QVariant value) if (!setting) { qCritical() << QString("Error changing setting %1. Setting doesn't exist.").arg(id); return false; - } else { - setting->set(value); + } + +#ifdef Q_OS_MACOS + // for macOS, keep a security scoped bookmark for the paths + if (value.userType() == QMetaType::QString && id.endsWith("Dir")) { + setPathWithBookmark(id, value.toString()); + } +#endif + + setting->set(std::move(value)); + return true; +} + +#ifdef Q_OS_MACOS +QString SettingsObject::getPathFromBookmark(const QString& id) +{ + auto setting = getSetting(id); + if (!setting) { + qCritical() << QString("Error changing setting %1. Setting doesn't exist.").arg(id); + return ""; + } + + // there is no need to use bookmarks if the default value is used or the directory is within the data directory (already can access) + if (setting->get() == setting->defValue() || QDir(setting->get().toString()).absolutePath().startsWith(QDir::current().absolutePath())) { + return setting->get().toString(); + } + + auto bookmarkId = id + "Bookmark"; + auto bookmarkSetting = getSetting(bookmarkId); + if (!bookmarkSetting) { + qCritical() << QString("Error changing setting %1. Bookmark setting doesn't exist.").arg(id); + return ""; + } + + QByteArray bookmark = bookmarkSetting->get().toByteArray(); + if (bookmark.isEmpty()) { + qDebug() << "Creating bookmark for" << id << "at" << setting->get().toString(); + setPathWithBookmark(id, setting->get().toString()); + return setting->get().toString(); + } + bool stale; + QUrl url = m_sandboxedFileAccess.securityScopedBookmarkToURL(bookmark, stale); + if (url.isValid()) { + if (stale) { + setting->set(url.path()); + bookmarkSetting->set(bookmark); + } + + m_sandboxedFileAccess.startUsingSecurityScopedBookmark(bookmark, stale); + // already did a stale check, no need to do it again + + // convert to relative path to current directory if `url` is a descendant of the current directory + QDir currentDir = QDir::current().absolutePath(); + return url.path().startsWith(currentDir.absolutePath()) ? currentDir.relativeFilePath(url.path()) : url.path(); + } + + return setting->get().toString(); +} + +bool SettingsObject::setPathWithBookmark(const QString& id, const QString& path) +{ + auto setting = getSetting(id); + if (!setting) { + qCritical() << QString("Error changing setting %1. Setting doesn't exist.").arg(id); + return false; + } + + QDir dir(path); + if (!dir.exists()) { + qCritical() << QString("Error changing setting %1. Path doesn't exist.").arg(id); + return false; + } + QString absolutePath = dir.absolutePath(); + QString bookmarkId = id + "Bookmark"; + std::shared_ptr bookmarkSetting = getSetting(bookmarkId); + // there is no need to use bookmarks if the default value is used or the directory is within the data directory (already can access) + if (path == setting->defValue().toString() || absolutePath.startsWith(QDir::current().absolutePath())) { + bookmarkSetting->reset(); return true; } + QByteArray bytes = m_sandboxedFileAccess.pathToSecurityScopedBookmark(absolutePath); + if (bytes.isEmpty()) { + qCritical() << QString("Failed to create bookmark for %1 - no access?").arg(id); + // TODO: show an alert to the user asking them to reselect the directory + return false; + } + auto oldBookmark = bookmarkSetting->get().toByteArray(); + m_sandboxedFileAccess.stopUsingSecurityScopedBookmark(oldBookmark); + if (!bytes.isEmpty() && bookmarkSetting) { + bookmarkSetting->set(bytes); + bool stale; + m_sandboxedFileAccess.startUsingSecurityScopedBookmark(bytes, stale); + // just created the bookmark, it shouldn't be stale + } + + setting->set(path); + return true; } +#endif void SettingsObject::reset(const QString& id) const { diff --git a/launcher/settings/SettingsObject.h b/launcher/settings/SettingsObject.h index bd3f71b36..abd6c29c5 100644 --- a/launcher/settings/SettingsObject.h +++ b/launcher/settings/SettingsObject.h @@ -23,6 +23,10 @@ #include #include +#ifdef Q_OS_MACOS +#include "macsandbox/SecurityBookmarkFileAccess.h" +#endif + class Setting; class SettingsObject; @@ -119,7 +123,27 @@ class SettingsObject : public QObject { * \return The setting's value as a QVariant. * If no setting with the given ID exists, returns an invalid QVariant. */ - QVariant get(const QString& id) const; + QVariant get(const QString& id); + +#ifdef Q_OS_MACOS + /*! + * \brief Get the path to the file or directory represented by the bookmark stored in the associated setting. + * \param id The setting ID of the relevant directory - this should not include "Bookmark" at the end. + * \return A path to the file or directory represented by the bookmark. + * If a bookmark is not valid or stored, use default logic (directly return the stored path). + * This can attempt to create a bookmark if the path is accessible and the bookmark is not valid. + */ + QString getPathFromBookmark(const QString& id); + /*! + * \brief Set a security-scoped bookmark to the provided path for the associated setting. + * \param id The setting ID of the relevant directory - this should not include "Bookmark" at the end. + * \param path The new desired path. + * \return A boolean indicating whether a bookmark was successfully set. + * The path needs to be accessible to the launcher before calling this function. For example, + * it could come from a user selection in an open panel. + */ + bool setPathWithBookmark(const QString& id, const QString& path); +#endif /*! * \brief Sets the value of the setting with the given ID. @@ -207,6 +231,9 @@ class SettingsObject : public QObject { private: QMap> m_settings; +#ifdef Q_OS_MACOS + SecurityBookmarkFileAccess m_sandboxedFileAccess; +#endif protected: bool m_suspendSave = false; diff --git a/launcher/ui/widgets/MinecraftSettingsWidget.cpp b/launcher/ui/widgets/MinecraftSettingsWidget.cpp index b738cab74..e57233b90 100644 --- a/launcher/ui/widgets/MinecraftSettingsWidget.cpp +++ b/launcher/ui/widgets/MinecraftSettingsWidget.cpp @@ -488,7 +488,7 @@ void MinecraftSettingsWidget::openGlobalSettings() APPLICATION->ShowGlobalSettings(this, "minecraft-settings"); } -void MinecraftSettingsWidget::updateAccountsMenu(const SettingsObject& settings) +void MinecraftSettingsWidget::updateAccountsMenu(SettingsObject& settings) { m_ui->instanceAccountSelector->clear(); auto accounts = APPLICATION->accounts(); diff --git a/launcher/ui/widgets/MinecraftSettingsWidget.h b/launcher/ui/widgets/MinecraftSettingsWidget.h index 9e3e7afb2..0dd8e6ba7 100644 --- a/launcher/ui/widgets/MinecraftSettingsWidget.h +++ b/launcher/ui/widgets/MinecraftSettingsWidget.h @@ -54,7 +54,7 @@ class MinecraftSettingsWidget : public QWidget { private: void openGlobalSettings(); - void updateAccountsMenu(const SettingsObject& settings); + void updateAccountsMenu(SettingsObject& settings); bool isQuickPlaySupported(); private slots: void saveSelectedLoaders();