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