Skip to content

Commit 5421047

Browse files
authored
Merge pull request #9438 from nextcloud/i2h3/fix/classic-synchronization
Persistent access to security scoped synchronization folders
2 parents 121cd24 + 55d42d5 commit 5421047

12 files changed

Lines changed: 571 additions & 135 deletions

AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ SPDX-FileCopyrightText: <YEAR> Nextcloud GmbH and Nextcloud contributors
3737
SPDX-License-Identifier: GPL-2.0-or-later
3838
```
3939

40+
Avoid creating source files that implement multiple types; instead, place each type in its own dedicated source file.
41+
4042
## Commit and Pull Request Guidelines
4143

4244
- **Commits**: Follow Conventional Commits format. Use `feat: ...`, `fix: ...`, or `refactor: ...` as appropriate in the commit message prefix.

admin/osx/macosx.entitlements.cmake

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
<true/>
99
<key>com.apple.security.files.user-selected.read-write</key>
1010
<true/>
11+
<key>com.apple.security.files.bookmarks.app-scope</key>
12+
<true/>
1113
<key>com.apple.security.application-groups</key>
1214
<array>
1315
<string>@DEVELOPMENT_TEAM@.@APPLICATION_REV_DOMAIN@</string>

src/common/common.cmake

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ elseif(APPLE)
3333
${CMAKE_CURRENT_LIST_DIR}/utility_mac.mm
3434
${CMAKE_CURRENT_LIST_DIR}/utility_mac_sandbox.h
3535
${CMAKE_CURRENT_LIST_DIR}/utility_mac_sandbox.mm
36+
${CMAKE_CURRENT_LIST_DIR}/macsandboxpersistentaccess.h
37+
${CMAKE_CURRENT_LIST_DIR}/macsandboxpersistentaccess.mm
38+
${CMAKE_CURRENT_LIST_DIR}/macsandboxsecurityscopedaccess.h
39+
${CMAKE_CURRENT_LIST_DIR}/macsandboxsecurityscopedaccess.mm
3640
)
3741
elseif(UNIX AND NOT APPLE)
3842
list(APPEND common_SOURCES
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/*
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: GPL-2.0-or-later
4+
*/
5+
6+
#pragma once
7+
8+
#include <QByteArray>
9+
#include <memory>
10+
11+
namespace OCC {
12+
namespace Utility {
13+
14+
/**
15+
* @brief RAII wrapper for persistent security-scoped resource access
16+
*
17+
* Unlike MacSandboxSecurityScopedAccess which is intended for short-lived
18+
* access during a single operation, this class manages long-lived access
19+
* to a security-scoped URL resolved from persisted bookmark data.
20+
*
21+
* Designed to be stored on Folder objects for the folder's entire lifetime,
22+
* ensuring the sandboxed app retains access to sync folder paths across
23+
* restart boundaries.
24+
*
25+
* Usage:
26+
* @code
27+
* QByteArray bookmarkData = folderDefinition.securityScopedBookmarkData;
28+
* auto access = MacSandboxPersistentAccess::createFromBookmarkData(bookmarkData);
29+
* if (access && access->isValid()) {
30+
* // The local sync folder path is now accessible
31+
* }
32+
* @endcode
33+
*/
34+
class MacSandboxPersistentAccess
35+
{
36+
public:
37+
~MacSandboxPersistentAccess();
38+
39+
// Non-copyable, movable
40+
MacSandboxPersistentAccess(const MacSandboxPersistentAccess &) = delete;
41+
MacSandboxPersistentAccess &operator=(const MacSandboxPersistentAccess &) = delete;
42+
MacSandboxPersistentAccess(MacSandboxPersistentAccess &&) noexcept;
43+
MacSandboxPersistentAccess &operator=(MacSandboxPersistentAccess &&) noexcept;
44+
45+
/**
46+
* @brief Create a persistent access wrapper by resolving bookmark data
47+
* @param bookmarkData The app-scoped bookmark data previously created via createSecurityScopedBookmarkData()
48+
* @return A unique pointer to the access wrapper, or nullptr if bookmarkData is empty
49+
*/
50+
[[nodiscard]] static std::unique_ptr<MacSandboxPersistentAccess> createFromBookmarkData(const QByteArray &bookmarkData);
51+
52+
/**
53+
* @brief Check if the security-scoped access was successfully acquired
54+
* @return true if access is valid and the path can be accessed
55+
*/
56+
[[nodiscard]] bool isValid() const;
57+
58+
/**
59+
* @brief Check if the resolved bookmark was reported as stale by macOS
60+
*
61+
* A stale bookmark still works, but may stop working in the future.
62+
* When stale, callers should recreate the bookmark data via
63+
* createSecurityScopedBookmarkData() and persist the new data.
64+
*
65+
* @return true if the bookmark was stale when resolved
66+
*/
67+
[[nodiscard]] bool isStale() const;
68+
69+
private:
70+
explicit MacSandboxPersistentAccess(const QByteArray &bookmarkData);
71+
72+
class Impl;
73+
std::unique_ptr<Impl> _impl;
74+
};
75+
76+
} // namespace Utility
77+
} // namespace OCC
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
/*
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: GPL-2.0-or-later
4+
*/
5+
6+
#include "macsandboxpersistentaccess.h"
7+
8+
#include <QLoggingCategory>
9+
#include <QString>
10+
11+
#import <Foundation/Foundation.h>
12+
13+
Q_LOGGING_CATEGORY(lcMacSandboxPersistent, "nextcloud.common.mac.sandbox.persistent", QtInfoMsg)
14+
15+
namespace OCC {
16+
namespace Utility {
17+
18+
class MacSandboxPersistentAccess::Impl
19+
{
20+
public:
21+
explicit Impl(const QByteArray &bookmarkData)
22+
: _nsUrl(nullptr)
23+
, _hasAccess(false)
24+
, _isStale(false)
25+
{
26+
if (bookmarkData.isEmpty()) {
27+
qCWarning(lcMacSandboxPersistent) << "Empty bookmark data provided";
28+
return;
29+
}
30+
31+
@autoreleasepool {
32+
NSData *nsBookmarkData = [NSData dataWithBytes:bookmarkData.constData()
33+
length:bookmarkData.size()];
34+
if (!nsBookmarkData) {
35+
qCWarning(lcMacSandboxPersistent) << "Failed to create NSData from bookmark data";
36+
return;
37+
}
38+
39+
BOOL isStale = NO;
40+
NSError *error = nil;
41+
_nsUrl = [[NSURL URLByResolvingBookmarkData:nsBookmarkData
42+
options:NSURLBookmarkResolutionWithSecurityScope
43+
relativeToURL:nil
44+
bookmarkDataIsStale:&isStale
45+
error:&error] retain];
46+
47+
if (error) {
48+
qCWarning(lcMacSandboxPersistent) << "Failed to resolve bookmark data:"
49+
<< QString::fromNSString([error localizedDescription]);
50+
return;
51+
}
52+
53+
if (!_nsUrl) {
54+
qCWarning(lcMacSandboxPersistent) << "Resolved URL is nil";
55+
return;
56+
}
57+
58+
if (isStale) {
59+
_isStale = true;
60+
qCWarning(lcMacSandboxPersistent) << "Bookmark data is stale for path:"
61+
<< QString::fromNSString([_nsUrl path])
62+
<< "- the bookmark should be recreated";
63+
}
64+
65+
_hasAccess = [_nsUrl startAccessingSecurityScopedResource];
66+
67+
if (_hasAccess) {
68+
qCInfo(lcMacSandboxPersistent) << "Successfully started persistent access to security-scoped resource:"
69+
<< QString::fromNSString([_nsUrl path]);
70+
} else {
71+
qCWarning(lcMacSandboxPersistent) << "Failed to start persistent access to security-scoped resource:"
72+
<< QString::fromNSString([_nsUrl path]);
73+
}
74+
}
75+
}
76+
77+
~Impl()
78+
{
79+
@autoreleasepool {
80+
if (_hasAccess && _nsUrl) {
81+
[_nsUrl stopAccessingSecurityScopedResource];
82+
qCDebug(lcMacSandboxPersistent) << "Stopped persistent access to security-scoped resource";
83+
_hasAccess = false;
84+
}
85+
86+
if (_nsUrl) {
87+
[_nsUrl release];
88+
_nsUrl = nullptr;
89+
}
90+
}
91+
}
92+
93+
// Non-copyable
94+
Impl(const Impl &) = delete;
95+
Impl &operator=(const Impl &) = delete;
96+
97+
[[nodiscard]] bool hasAccess() const { return _hasAccess; }
98+
[[nodiscard]] bool isStale() const { return _isStale; }
99+
100+
private:
101+
NSURL *_nsUrl;
102+
bool _hasAccess;
103+
bool _isStale;
104+
};
105+
106+
MacSandboxPersistentAccess::MacSandboxPersistentAccess(const QByteArray &bookmarkData)
107+
: _impl(std::make_unique<Impl>(bookmarkData))
108+
{
109+
}
110+
111+
MacSandboxPersistentAccess::~MacSandboxPersistentAccess() = default;
112+
113+
MacSandboxPersistentAccess::MacSandboxPersistentAccess(MacSandboxPersistentAccess &&) noexcept = default;
114+
115+
MacSandboxPersistentAccess &MacSandboxPersistentAccess::operator=(MacSandboxPersistentAccess &&) noexcept = default;
116+
117+
std::unique_ptr<MacSandboxPersistentAccess> MacSandboxPersistentAccess::createFromBookmarkData(const QByteArray &bookmarkData)
118+
{
119+
if (bookmarkData.isEmpty()) {
120+
return nullptr;
121+
}
122+
return std::unique_ptr<MacSandboxPersistentAccess>(new MacSandboxPersistentAccess(bookmarkData));
123+
}
124+
125+
bool MacSandboxPersistentAccess::isValid() const
126+
{
127+
return _impl && _impl->hasAccess();
128+
}
129+
130+
bool MacSandboxPersistentAccess::isStale() const
131+
{
132+
return _impl && _impl->isStale();
133+
}
134+
135+
} // namespace Utility
136+
} // namespace OCC
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/*
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: GPL-2.0-or-later
4+
*/
5+
6+
#pragma once
7+
8+
#include <QUrl>
9+
#include <memory>
10+
11+
namespace OCC {
12+
namespace Utility {
13+
14+
/**
15+
* @brief RAII wrapper for macOS security-scoped resource access
16+
*
17+
* When working with files selected by the user via QFileDialog in a sandboxed
18+
* macOS app, the returned URLs are security-scoped bookmarks that require
19+
* explicit access management via startAccessingSecurityScopedResource() and
20+
* stopAccessingSecurityScopedResource().
21+
*
22+
* This class provides RAII semantics to ensure proper cleanup.
23+
*
24+
* Usage:
25+
* @code
26+
* QUrl fileUrl = QFileDialog::getSaveFileUrl(...);
27+
* if (!fileUrl.isEmpty()) {
28+
* auto scopedAccess = MacSandboxSecurityScopedAccess::create(fileUrl);
29+
* if (scopedAccess->isValid()) {
30+
* // Now you can access the file
31+
* QFile file(fileUrl.toLocalFile());
32+
* file.open(QIODevice::WriteOnly);
33+
* }
34+
* }
35+
* @endcode
36+
*/
37+
class MacSandboxSecurityScopedAccess
38+
{
39+
public:
40+
~MacSandboxSecurityScopedAccess();
41+
42+
// Non-copyable, movable
43+
MacSandboxSecurityScopedAccess(const MacSandboxSecurityScopedAccess&) = delete;
44+
MacSandboxSecurityScopedAccess& operator=(const MacSandboxSecurityScopedAccess&) = delete;
45+
MacSandboxSecurityScopedAccess(MacSandboxSecurityScopedAccess&&) noexcept;
46+
MacSandboxSecurityScopedAccess& operator=(MacSandboxSecurityScopedAccess&&) noexcept;
47+
48+
/**
49+
* @brief Create a security-scoped access wrapper for the given URL
50+
* @param url The URL to access (typically from QFileDialog)
51+
* @return A unique pointer to the access wrapper
52+
*/
53+
[[nodiscard]] static std::unique_ptr<MacSandboxSecurityScopedAccess> create(const QUrl &url);
54+
55+
/**
56+
* @brief Check if the security-scoped access was successfully acquired
57+
* @return true if access is valid and the file can be accessed
58+
*/
59+
[[nodiscard]] bool isValid() const;
60+
61+
private:
62+
explicit MacSandboxSecurityScopedAccess(const QUrl &url);
63+
64+
class Impl;
65+
std::unique_ptr<Impl> _impl;
66+
};
67+
68+
} // namespace Utility
69+
} // namespace OCC

0 commit comments

Comments
 (0)