Skip to content

Commit d36bacb

Browse files
authored
Merge pull request #9023 from nextcloud/i2h3/proper-macos-sandboxing
macOS App Sandbox
2 parents 7f276dc + 6dcc1fd commit d36bacb

File tree

62 files changed

+1735
-1459
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

62 files changed

+1735
-1459
lines changed

.github/workflows/macos-build-and-test.yml

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,18 +26,20 @@ jobs:
2626
build:
2727
name: Build nextcloud-client
2828
timeout-minutes: 60
29-
runs-on: macos-15
29+
runs-on: macos-latest
3030
steps:
3131
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
3232
with:
3333
fetch-depth: 1
3434

35-
- name: List Xcode installations
36-
run: sudo ls -1 /Applications | grep "Xcode"
37-
38-
- name: Select Xcode 16.3
39-
run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer
40-
35+
- name: Setup Xcode
36+
uses: maxim-lobanov/setup-xcode@v1
37+
with:
38+
xcode-version: latest-stable
39+
40+
- name: Show Swift version
41+
run: swift --version
42+
4143
- name: Set up Python ${{ inputs.PYTHON_VERSION }}
4244
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
4345
with:
@@ -74,7 +76,7 @@ jobs:
7476
name: Run tests
7577
needs: build
7678
timeout-minutes: 60
77-
runs-on: macos-15
79+
runs-on: macos-latest
7880
steps:
7981
- name: Restore cached Craft directories containing the built client
8082
uses: actions/cache/restore@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1

CMakeLists.txt

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -237,10 +237,6 @@ if(OWNCLOUD_5XX_NO_BLACKLIST)
237237
add_definitions(-DOWNCLOUD_5XX_NO_BLACKLIST=1)
238238
endif()
239239

240-
if(APPLE)
241-
set( SOCKETAPI_TEAM_IDENTIFIER_PREFIX "" CACHE STRING "SocketApi prefix (including a following dot) that must match the codesign key's TeamIdentifier/Organizational Unit" )
242-
endif()
243-
244240
if(BUILD_CLIENT)
245241
OPTION(GUI_TESTING "Build with gui introspection features of socket api" OFF)
246242

NEXTCLOUD.cmake

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ set( APPLICATION_ICON_SET "SVG" )
3131
set( APPLICATION_SERVER_URL "" CACHE STRING "URL for the server to use. If entered, the UI field will be pre-filled with it" )
3232
set( APPLICATION_SERVER_URL_ENFORCE ON ) # If set and APPLICATION_SERVER_URL is defined, the server can only connect to the pre-defined URL
3333
set( APPLICATION_REV_DOMAIN "com.nextcloud.desktopclient" )
34+
set( DEVELOPMENT_TEAM "NKUJUXUJ3B" CACHE STRING "Apple Development Team ID for code signing" )
3435
set( APPLICATION_VIRTUALFILE_SUFFIX "nextcloud" CACHE STRING "Virtual file suffix (not including the .)")
3536
set( APPLICATION_OCSP_STAPLING_ENABLED OFF )
3637
set( APPLICATION_FORBID_BAD_SSL OFF )

REUSE.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ SPDX-License-Identifier = "GPL-2.0-or-later"
3131

3232
[[annotations]]
3333
path = [
34+
"admin/osx/container-migration.plist.cmake",
3435
"admin/osx/TransifexStringCatalogSanitizer/Package.swift",
3536
"admin/osx/TransifexStringCatalogSanitizer/Package.resolved",
3637
"admin/osx/TransifexStringCatalogSanitizer/README.md",

admin/osx/CMakeLists.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ else()
2121
set(DEBUG_ENTITLEMENTS "")
2222
endif()
2323

24+
# Sandbox migration description
25+
configure_file(container-migration.plist.cmake ${CMAKE_CURRENT_BINARY_DIR}/container-migration.plist @ONLY)
26+
install(FILES ${CMAKE_BINARY_DIR}/admin/osx/container-migration.plist
27+
DESTINATION ${OWNCLOUD_OSX_BUNDLE}/Contents/Resources)
28+
2429
configure_file(create_mac.sh.cmake ${CMAKE_CURRENT_BINARY_DIR}/create_mac.sh)
2530
configure_file(macosx.entitlements.cmake ${CMAKE_CURRENT_BINARY_DIR}/macosx.entitlements)
2631
configure_file(macosx.pkgproj.cmake ${CMAKE_CURRENT_BINARY_DIR}/macosx.pkgproj)
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+
<plist version="1.0">
4+
<dict>
5+
<key>Move</key>
6+
<array>
7+
<string>${Library}/Preferences/@APPLICATION_NAME@</string>
8+
</array>
9+
</dict>
10+
</plist>

admin/osx/macosx.entitlements.cmake

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,15 @@
22
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
33
<plist version="1.0">
44
<dict>
5+
<key>com.apple.security.app-sandbox</key>
6+
<true/>
7+
<key>com.apple.security.network.client</key>
8+
<true/>
9+
<key>com.apple.security.files.user-selected.read-write</key>
10+
<true/>
511
<key>com.apple.security.application-groups</key>
612
<array>
7-
<string>@SOCKETAPI_TEAM_IDENTIFIER_PREFIX@@APPLICATION_REV_DOMAIN@</string>
13+
<string>@DEVELOPMENT_TEAM@.@APPLICATION_REV_DOMAIN@</string>
814
</array>
915
@DEBUG_ENTITLEMENTS@
1016
</dict>

cmake/modules/MacOSXBundleInfo.plist.in

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,6 @@
9191
</dict>
9292
</array>
9393
<key>NCFPKAppGroupIdentifier</key>
94-
<string>@SOCKETAPI_TEAM_IDENTIFIER_PREFIX@@APPLICATION_REV_DOMAIN@</string>
94+
<string>@DEVELOPMENT_TEAM@.@APPLICATION_REV_DOMAIN@</string>
9595
</dict>
9696
</plist>

config.h.in

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
#cmakedefine WITH_QTKEYCHAIN 1
66
#cmakedefine BUILD_FILE_PROVIDER_MODULE "@BUILD_FILE_PROVIDER_MODULE@"
77
#cmakedefine WITH_PROVIDERS "@WITH_PROVIDERS@"
8-
#define SOCKETAPI_TEAM_IDENTIFIER_PREFIX "@SOCKETAPI_TEAM_IDENTIFIER_PREFIX@"
8+
#cmakedefine DEVELOPMENT_TEAM "@DEVELOPMENT_TEAM@"
99

1010
#cmakedefine THEME_CLASS @THEME_CLASS@
1111
#cmakedefine THEME_INCLUDE @THEME_INCLUDE@

doc/macOS-Sandbox-Qt.md

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
<!--
2+
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3+
- SPDX-License-Identifier: GPL-2.0-or-later
4+
-->
5+
6+
# macOS App Sandbox Support for Qt Applications
7+
8+
## Overview
9+
10+
This document explains how to make the Nextcloud Desktop Client work properly with macOS App Sandbox when using Qt. The key issue is that Qt's `QFileDialog` returns security-scoped URLs that require explicit access management in sandboxed applications.
11+
12+
## The Problem
13+
14+
When running a sandboxed macOS application with the `com.apple.security.files.user-selected.read-write` entitlement, file operations on user-selected files (via `QFileDialog`) will fail unless you explicitly:
15+
16+
1. Call `startAccessingSecurityScopedResource()` on the URL before accessing the file
17+
2. Call `stopAccessingSecurityScopedResource()` when done
18+
19+
This is **required by macOS sandbox security**, but Qt does not handle this automatically. The underlying issue is that:
20+
21+
- `QFileDialog::getSaveFileUrl()` returns a `QUrl` that represents a security-scoped bookmark
22+
- Without calling `startAccessingSecurityScopedResource()`, the sandboxed app has no permission to access the file
23+
- Even though you have the entitlement, you must explicitly claim access for each user-selected file
24+
25+
## The Solution
26+
27+
### 1. Security-Scoped Access Wrapper
28+
29+
We created a RAII wrapper class `MacSandboxSecurityScopedAccess` (in `utility_mac_sandbox.h/mm`) that:
30+
31+
- Automatically calls `startAccessingSecurityScopedResource()` in the constructor
32+
- Automatically calls `stopAccessingSecurityScopedResource()` in the destructor
33+
- Uses unique_ptr for exception safety
34+
- Provides `isValid()` to check if access was successfully obtained
35+
36+
### 2. Usage Pattern
37+
38+
```cpp
39+
#ifdef Q_OS_MACOS
40+
#include "utility_mac_sandbox.h"
41+
#endif
42+
43+
void MyClass::saveFile()
44+
{
45+
const auto fileUrl = QFileDialog::getSaveFileUrl(
46+
this,
47+
tr("Save File"),
48+
QUrl::fromLocalFile(QDir::homePath()),
49+
tr("Text Files (*.txt)")
50+
);
51+
52+
if (fileUrl.isEmpty()) {
53+
return;
54+
}
55+
56+
#ifdef Q_OS_MACOS
57+
// Acquire security-scoped access for the user-selected file
58+
auto scopedAccess = Utility::MacSandboxSecurityScopedAccess::create(fileUrl);
59+
60+
if (!scopedAccess->isValid()) {
61+
// Handle error - access could not be obtained
62+
QMessageBox::critical(this, tr("Error"), tr("Could not access file"));
63+
return;
64+
}
65+
// scopedAccess will automatically release when it goes out of scope
66+
#endif
67+
68+
// Now you can safely access the file
69+
QFile file(fileUrl.toLocalFile());
70+
if (file.open(QIODevice::WriteOnly)) {
71+
// Write to file...
72+
}
73+
}
74+
```
75+
76+
### 3. Required Entitlements
77+
78+
In `admin/osx/macosx.entitlements.cmake`, ensure you have:
79+
80+
```xml
81+
<key>com.apple.security.app-sandbox</key>
82+
<true/>
83+
<key>com.apple.security.files.user-selected.read-write</key>
84+
<true/>
85+
```
86+
87+
## Key Requirements for Qt + macOS Sandbox
88+
89+
### 1. Use QFileDialog URL-based Methods
90+
91+
Always use the URL-based variants of QFileDialog methods:
92+
-`QFileDialog::getSaveFileUrl()`
93+
-`QFileDialog::getOpenFileUrl()`
94+
-`QFileDialog::getOpenFileUrls()`
95+
-`QFileDialog::getSaveFileName()` - returns QString, not security-scoped
96+
-`QFileDialog::getOpenFileName()` - returns QString, not security-scoped
97+
98+
### 2. Wrap File Access with Security Scoping
99+
100+
```cpp
101+
#ifdef Q_OS_MACOS
102+
auto scopedAccess = Utility::MacSandboxSecurityScopedAccess::create(fileUrl);
103+
if (!scopedAccess->isValid()) {
104+
// Handle error
105+
return;
106+
}
107+
#endif
108+
// Access file here
109+
// scopedAccess releases automatically when going out of scope
110+
```
111+
112+
### 3. Handle Scope Lifetime Correctly
113+
114+
The security-scoped access must remain valid for the entire duration of file access:
115+
116+
```cpp
117+
// ✅ CORRECT - scopedAccess lives until after file operations
118+
#ifdef Q_OS_MACOS
119+
auto scopedAccess = Utility::MacSandboxSecurityScopedAccess::create(fileUrl);
120+
if (!scopedAccess->isValid()) {
121+
return;
122+
}
123+
#endif
124+
125+
QFile file(fileUrl.toLocalFile());
126+
file.open(QIODevice::WriteOnly);
127+
file.write(data);
128+
file.close();
129+
// scopedAccess destructor called here
130+
131+
// ❌ WRONG - scopedAccess destroyed before file operations
132+
#ifdef Q_OS_MACOS
133+
{
134+
auto scopedAccess = Utility::MacSandboxSecurityScopedAccess::create(fileUrl);
135+
if (!scopedAccess->isValid()) {
136+
return;
137+
}
138+
} // scopedAccess destroyed here!
139+
#endif
140+
141+
QFile file(fileUrl.toLocalFile()); // This will fail!
142+
file.open(QIODevice::WriteOnly); // No longer have access
143+
```
144+
145+
### 4. Consider All File Operations
146+
147+
This applies to ANY file operation on user-selected files:
148+
- Reading files
149+
- Writing files
150+
- Creating archives/zip files
151+
- Copying files
152+
- Moving files
153+
- Checking file existence/permissions
154+
155+
## Common Pitfalls
156+
157+
### 1. Using QString-based paths instead of QUrl
158+
159+
```cpp
160+
// ❌ WRONG - loses security-scoped bookmark
161+
QString path = QFileDialog::getSaveFileName(...);
162+
163+
// ✅ CORRECT - preserves security-scoped bookmark
164+
QUrl url = QFileDialog::getSaveFileUrl(...);
165+
```
166+
167+
### 2. Converting QUrl too early
168+
169+
```cpp
170+
// ❌ WRONG - converts to string before starting access
171+
QUrl url = QFileDialog::getSaveFileUrl(...);
172+
QString path = url.toLocalFile(); // Loses security scope!
173+
#ifdef Q_OS_MACOS
174+
auto access = Utility::MacSandboxSecurityScopedAccess::create(QUrl::fromLocalFile(path)); // Won't work
175+
#endif
176+
177+
// ✅ CORRECT - start access before conversion
178+
QUrl url = QFileDialog::getSaveFileUrl(...);
179+
#ifdef Q_OS_MACOS
180+
auto access = Utility::MacSandboxSecurityScopedAccess::create(url); // Works!
181+
#endif
182+
QString path = url.toLocalFile();
183+
```
184+
185+
### 3. Forgetting to check isValid()
186+
187+
```cpp
188+
// ❌ RISKY - doesn't check if access was obtained
189+
auto scopedAccess = Utility::MacSandboxSecurityScopedAccess::create(fileUrl);
190+
QFile file(fileUrl.toLocalFile()); // Might fail silently
191+
192+
// ✅ CORRECT - always check validity
193+
auto scopedAccess = Utility::MacSandboxSecurityScopedAccess::create(fileUrl);
194+
if (!scopedAccess->isValid()) {
195+
// Show error to user
196+
return;
197+
}
198+
QFile file(fileUrl.toLocalFile()); // Now safe to use
199+
```
200+
201+
## Testing Sandbox Behavior
202+
203+
To test if your app properly handles sandbox restrictions:
204+
205+
1. **Build with proper entitlements**: Ensure the app is codesigned with the entitlements file
206+
2. **Test file operations**: Try to save/open files in various locations
207+
3. **Check Console.app**: Look for sandbox violation messages like:
208+
```
209+
Sandbox: MyApp(12345) deny(1) file-write-create /Users/...
210+
```
211+
4. **Test without access calls**: Temporarily remove the security-scoped access calls to verify they're needed
212+
213+
## References
214+
215+
- [Apple Documentation: App Sandbox](https://developer.apple.com/documentation/security/app_sandbox)
216+
- [Apple Documentation: Security-Scoped Bookmarks](https://developer.apple.com/documentation/foundation/nsurl/1417051-startaccessingsecurityscopedreso)
217+
- [Qt Documentation: QFileDialog](https://doc.qt.io/qt-6/qfiledialog.html)
218+
219+
## Files Modified
220+
221+
- `src/common/utility_mac_sandbox.h` - Header for security-scoped access wrapper
222+
- `src/common/utility_mac_sandbox.mm` - Implementation using Objective-C++
223+
- `src/common/common.cmake` - Added new files to build system
224+
- `src/gui/generalsettings.cpp` - Fixed debug archive creation to use security-scoped access
225+
226+
## Future Work
227+
228+
Consider auditing all uses of `QFileDialog` in the codebase to ensure they:
229+
1. Use URL-based methods (`getSaveFileUrl`, `getOpenFileUrl`, etc.)
230+
2. Properly acquire security-scoped access on macOS
231+
3. Handle access errors gracefully

0 commit comments

Comments
 (0)