Use security-scoped bookmarks for directory settings on macOS (#3616)

This commit is contained in:
DioEgizio
2025-11-21 18:34:43 +01:00
committed by GitHub
8 changed files with 437 additions and 7 deletions

View File

@@ -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<ThemeManager>();
#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!";

View File

@@ -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

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
#ifndef FILEACCESS_H
#define FILEACCESS_H
#include <QtCore/QMap>
#include <QtCore/QSet>
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

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
#include "SecurityBookmarkFileAccess.h"
#include <Foundation/Foundation.h>
#include <QByteArray>
#include <QUrl>
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];
}

View File

@@ -20,6 +20,12 @@
#include "settings/Setting.h"
#include <QVariant>
#include <QDir>
#include <utility>
#ifdef Q_OS_MACOS
#include "macsandbox/SecurityBookmarkFileAccess.h"
#endif
SettingsObject::SettingsObject(QObject* parent) : QObject(parent) {}
@@ -78,9 +84,17 @@ std::shared_ptr<Setting> 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<Setting> 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
{

View File

@@ -23,6 +23,10 @@
#include <QVariant>
#include <memory>
#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<QString, std::shared_ptr<Setting>> m_settings;
#ifdef Q_OS_MACOS
SecurityBookmarkFileAccess m_sandboxedFileAccess;
#endif
protected:
bool m_suspendSave = false;

View File

@@ -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();

View File

@@ -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();