diff --git a/launcher/Application.cpp b/launcher/Application.cpp index e89813876..9576ae695 100644 --- a/launcher/Application.cpp +++ b/launcher/Application.cpp @@ -706,6 +706,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()); @@ -956,12 +966,27 @@ Application::Application(int& argc, char** argv) : QApplication(argc, argv) // Themes m_themeManager = std::make_unique(); +#ifdef Q_OS_MACOS + // for macOS: getting directory settings will generate URL security-scoped bookmarks if needed and not present + // this facilitates a smooth transition from a non-sandboxed version of the launcher, that likely can access the directory, + // and a sandboxed version that can't access the directory without a bookmark + // this section can likely be removed once the sandboxed version has been released for a while and migrations aren't done anymore + { + m_settings->get("InstanceDir"); + m_settings->get("CentralModsDir"); + m_settings->get("IconsDir"); + m_settings->get("DownloadsDir"); + m_settings->get("SkinsDir"); + m_settings->get("JavaDir"); + } +#endif + // initialize and load all instances { 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 20612584c..67ffa4dfb 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..5bddf0e31 --- /dev/null +++ b/launcher/macsandbox/SecurityBookmarkFileAccess.h @@ -0,0 +1,89 @@ +// 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; + + bool m_readOnly; + + NSURL* securityScopedBookmarkToNSURL(QByteArray& bookmark, bool& isStale); +public: + /// \param readOnly A boolean indicating whether the bookmark should be read-only. + SecurityBookmarkFileAccess(bool readOnly = false); + ~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); + + /// Returns true if access to the `path` is currently being maintained by this object. + bool isAccessingPath(const QString& path); +}; + +#endif //FILEACCESS_H diff --git a/launcher/macsandbox/SecurityBookmarkFileAccess.mm b/launcher/macsandbox/SecurityBookmarkFileAccess.mm new file mode 100644 index 000000000..bee854abe --- /dev/null +++ b/launcher/macsandbox/SecurityBookmarkFileAccess.mm @@ -0,0 +1,172 @@ +// 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]; + NSData* bookmark; + if ([m_paths objectForKey:[nsurl path]]) { + bookmark = m_paths[[nsurl path]]; + } else { + bookmark = [nsurl bookmarkDataWithOptions:NSURLBookmarkCreationWithSecurityScope | + (m_readOnly ? NSURLBookmarkCreationSecurityScopeAllowOnlyReadAccess : 0) + includingResourceValuesForKeys:nil + relativeToURL:nil + error:&error]; + } + if (error) { + return {}; + } + + // remove/reapply access to ensure that write access is immediately cut off for read-only bookmarks + // sometimes you need to call this twice to actually stop access (extra calls aren't harmful) + [nsurl stopAccessingSecurityScopedResource]; + [nsurl stopAccessingSecurityScopedResource]; + nsurl = [NSURL URLByResolvingBookmarkData:bookmark + options:NSURLBookmarkResolutionWithSecurityScope | + (m_readOnly ? NSURLBookmarkCreationSecurityScopeAllowOnlyReadAccess : 0) + relativeToURL:nil + bookmarkDataIsStale:nil + error:&error]; + m_paths[[nsurl path]] = bookmark; + m_bookmarks[bookmark] = nsurl; + + QByteArray qBookmark = QByteArray::fromNSData(bookmark); + bool isStale = false; + startUsingSecurityScopedBookmark(qBookmark, isStale); + + return qBookmark; +} + +SecurityBookmarkFileAccess::SecurityBookmarkFileAccess(bool readOnly) : m_readOnly(readOnly) +{ + m_bookmarks = [NSMutableDictionary new]; + m_paths = [NSMutableDictionary new]; + m_activeURLs = [NSMutableSet new]; +} + +SecurityBookmarkFileAccess::~SecurityBookmarkFileAccess() +{ + for (NSURL* url : m_activeURLs) { + [url stopAccessingSecurityScopedResource]; + } +} + +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 | + (m_readOnly ? NSURLBookmarkCreationSecurityScopeAllowOnlyReadAccess : 0) + relativeToURL:nil + bookmarkDataIsStale:&localStale + error:&error]; + if (error) { + return nil; + } + isStale = localStale; + if (isStale) { + NSData* nsBookmark = [nsurl bookmarkDataWithOptions:NSURLBookmarkCreationWithSecurityScope | + (m_readOnly ? NSURLBookmarkCreationSecurityScopeAllowOnlyReadAccess : 0) + includingResourceValuesForKeys:nil + relativeToURL:nil + error:&error]; + if (error) { + return nil; + } + bookmark = QByteArray::fromNSData(nsBookmark); + } + + NSData* nsBookmark = bookmark.toNSData(); + m_paths[[nsurl path]] = nsBookmark; + m_bookmarks[nsBookmark] = nsurl; + + 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; + + [url stopAccessingSecurityScopedResource]; + 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]; + [url stopAccessingSecurityScopedResource]; + + [m_activeURLs removeObject:url]; + [m_paths removeObjectForKey:[url path]]; + [m_bookmarks removeObjectForKey:bookmark.toNSData()]; + } +} + +bool SecurityBookmarkFileAccess::isAccessingPath(const QString& path) +{ + NSData* bookmark = [m_paths objectForKey:path.toNSString()]; + if (!bookmark && path.endsWith('/')) { + bookmark = [m_paths objectForKey:path.left(path.length() - 1).toNSString()]; + } + if (!bookmark) { + return false; + } + NSURL* url = [m_bookmarks objectForKey:bookmark]; + return [m_activeURLs containsObject:url]; +} 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();