|
| 1 | +# AGENTS.md -- AI Agent Context for CloudMount |
| 2 | + |
| 3 | +This file contains critical context for AI coding agents working on the CloudMount codebase. Read this before making changes. |
| 4 | + |
| 5 | +## Project Overview |
| 6 | + |
| 7 | +CloudMount is a native macOS 26+ menu bar app that mounts Backblaze B2 buckets as local Finder volumes using Apple's FSKit framework. Pure Swift, no FUSE, no Rust, no Electron. |
| 8 | + |
| 9 | +- **Owner**: Eirik Breen ([ebreen](https://github.com/ebreen)) |
| 10 | +- **Repo**: `ebreen/cloudmount` |
| 11 | +- **License**: MIT |
| 12 | +- **Apple Team ID**: `66X2XJM3HW` |
| 13 | + |
| 14 | +## Architecture |
| 15 | + |
| 16 | +Three-target XcodeGen project (`project.yml` generates `CloudMount.xcodeproj`): |
| 17 | + |
| 18 | +| Target | Type | Bundle ID | Purpose | |
| 19 | +|--------|------|-----------|---------| |
| 20 | +| CloudMount | application | com.cloudmount.app | SwiftUI menu bar app (LSUIElement) | |
| 21 | +| CloudMountExtension | extensionkit-extension | com.cloudmount.app.extension | FSKit filesystem extension (XPC) | |
| 22 | +| CloudMountKit | framework | com.cloudmount.kit | Shared code: B2 API client, caching, credentials | |
| 23 | + |
| 24 | +The extension runs as an XPC service managed by macOS. When a user mounts a `b2://` URL, the kernel routes filesystem operations to the extension. The main app manages accounts, settings, and mount/unmount via `Process` calls to `mount`/`diskutil`. |
| 25 | + |
| 26 | +### Key technical details |
| 27 | + |
| 28 | +- **Swift 6.0** with strict concurrency (actors, Sendable, @MainActor) |
| 29 | +- **macOS 26.0** (Tahoe) minimum -- FSKit is new in macOS 26 |
| 30 | +- **Hardened Runtime** enabled globally (`ENABLE_HARDENED_RUNTIME: true`) |
| 31 | +- **Sparkle** for auto-updates (only external dependency) |
| 32 | +- **Backblaze B2 Native API v4** (not S3-compatible API) |
| 33 | + |
| 34 | +## Critical: FSKit Extension Configuration |
| 35 | + |
| 36 | +The FSKit extension MUST use the correct product type or it will silently break. |
| 37 | + |
| 38 | +### What goes wrong |
| 39 | + |
| 40 | +If `type: app-extension` is used in project.yml, Xcode generates `com.apple.product-type.app-extension`, which triggers legacy `NSExtension` plist processing. This **strips all FSKit keys** (`FSSupportedSchemes`, `FSShortName`, `FSPersonalities`, etc.) and rewrites the principal class name incorrectly. The extension will build and install but macOS will never route `b2://` URLs to it. |
| 41 | + |
| 42 | +### What is correct |
| 43 | + |
| 44 | +```yaml |
| 45 | +CloudMountExtension: |
| 46 | + type: extensionkit-extension # NOT app-extension |
| 47 | + settings: |
| 48 | + base: |
| 49 | + GENERATE_INFOPLIST_FILE: true # NOT false -- required for EXAppExtensionAttributes preservation |
| 50 | +``` |
| 51 | +
|
| 52 | +This produces `com.apple.product-type.extensionkit-extension`, which preserves `EXAppExtensionAttributes` and all `FS*` keys in the built plist. |
| 53 | + |
| 54 | +### How to verify |
| 55 | + |
| 56 | +After building, inspect the built extension plist: |
| 57 | + |
| 58 | +```bash |
| 59 | +cat build/DerivedData/Build/Products/Debug/CloudMount.app/Contents/Extensions/CloudMountExtension.appex/Contents/Info.plist |
| 60 | +``` |
| 61 | + |
| 62 | +It MUST contain: |
| 63 | +- `EXAppExtensionAttributes` (NOT `NSExtension`) |
| 64 | +- `EXExtensionPointIdentifier` = `com.apple.fskit.fsmodule` |
| 65 | +- `EXExtensionPrincipalClass` = `CloudMountExtension.CloudMountExtensionMain` |
| 66 | +- `FSSupportedSchemes` = `["b2"]` |
| 67 | +- `FSShortName` = `b2` |
| 68 | + |
| 69 | +The extension lives at `Contents/Extensions/` (not `Contents/PlugIns/`). |
| 70 | + |
| 71 | +### Enabling the extension |
| 72 | + |
| 73 | +After installing CloudMount, users must manually enable the FSKit extension: |
| 74 | +System Settings -> General -> Login Items & Extensions -> File System Extensions -> CloudMount |
| 75 | + |
| 76 | +The app has an `ExtensionDetector` that uses a dry-run mount probe (`mount -d -F -t b2 b2://probe /tmp/cloudmount-probe`) to detect enablement status and shows an onboarding flow if needed. |
| 77 | + |
| 78 | +## Code Signing & Certificates |
| 79 | + |
| 80 | +### Two certificates required |
| 81 | + |
| 82 | +| Certificate | Type | Used By | GitHub Secret | |
| 83 | +|-------------|------|---------|---------------| |
| 84 | +| Apple Development: breeneirik@gmail.com | Development | Archive (automatic signing via cloud) | `DEV_CERTIFICATE_BASE64` | |
| 85 | +| Developer ID Application: EIRIK BREEN (66X2XJM3HW) | Distribution | Export (manual signing for developer-id) | `BUILD_CERTIFICATE_BASE64` | |
| 86 | + |
| 87 | +Both are imported into the same temporary keychain during CI. The archive step uses automatic/cloud signing with the Apple Development cert. The export step uses manual signing with the Developer ID Application cert. |
| 88 | + |
| 89 | +### Why two certs? |
| 90 | + |
| 91 | +- The archive uses `CODE_SIGN_STYLE: Automatic` (from project.yml) with `-allowProvisioningUpdates` and App Store Connect API key authentication. This triggers Apple's cloud signing service, which needs an Apple Development cert in the keychain. |
| 92 | +- The export uses `method: developer-id` with `signingStyle: manual`. This re-signs the archive locally for distribution outside the App Store, requiring the Developer ID Application cert. |
| 93 | +- Cloud signing for Developer ID export fails because the team doesn't have API permission to auto-create Developer ID provisioning profiles. |
| 94 | + |
| 95 | +### Provisioning profiles |
| 96 | + |
| 97 | +Two manually-created Developer ID provisioning profiles: |
| 98 | + |
| 99 | +| Profile Name | Bundle ID | GitHub Secret | |
| 100 | +|---|---|---| |
| 101 | +| CloudMount Developer ID | com.cloudmount.app | `APP_PROVISION_PROFILE_BASE64` | |
| 102 | +| CloudMount Extension Developer ID | com.cloudmount.app.extension | `EXT_PROVISION_PROFILE_BASE64` | |
| 103 | + |
| 104 | +These are installed to `~/Library/MobileDevice/Provisioning Profiles/` during CI. |
| 105 | + |
| 106 | +### Entitlements |
| 107 | + |
| 108 | +**Main app** (not sandboxed): |
| 109 | +- `keychain-access-groups`: `$(AppIdentifierPrefix)com.cloudmount.shared` |
| 110 | +- `com.apple.security.application-groups`: `$(TeamIdentifierPrefix)com.cloudmount.app` |
| 111 | + |
| 112 | +**Extension** (sandboxed): |
| 113 | +- `com.apple.developer.fskit.fsmodule`: true |
| 114 | +- `com.apple.security.app-sandbox`: true |
| 115 | +- `com.apple.security.network.client`: true (outbound for B2 API) |
| 116 | +- `keychain-access-groups`: same shared group |
| 117 | +- `com.apple.security.application-groups`: same shared group |
| 118 | + |
| 119 | +The shared keychain group and app group allow the main app to store credentials and mount configs that the extension reads. |
| 120 | + |
| 121 | +## GitHub Secrets (12 total) |
| 122 | + |
| 123 | +| Secret | Purpose | |
| 124 | +|--------|---------| |
| 125 | +| `DEV_CERTIFICATE_BASE64` | Apple Development cert .p12 (base64) | |
| 126 | +| `DEV_P12_PASSWORD` | Password for dev cert .p12 | |
| 127 | +| `BUILD_CERTIFICATE_BASE64` | Developer ID Application cert .p12 (base64) | |
| 128 | +| `P12_PASSWORD` | Password for Developer ID .p12 | |
| 129 | +| `KEYCHAIN_PASSWORD` | Temp CI keychain password (arbitrary) | |
| 130 | +| `APPLE_TEAM_ID` | `66X2XJM3HW` | |
| 131 | +| `APPLE_SIGNING_IDENTITY` | `Developer ID Application: EIRIK BREEN (66X2XJM3HW)` | |
| 132 | +| `APP_STORE_CONNECT_KEY_BASE64` | App Store Connect API key .p8 (base64) | |
| 133 | +| `API_KEY_ID` | `3C7HQ8Q9L9` | |
| 134 | +| `API_ISSUER_ID` | App Store Connect API Issuer ID | |
| 135 | +| `APP_PROVISION_PROFILE_BASE64` | App provisioning profile (base64) | |
| 136 | +| `EXT_PROVISION_PROFILE_BASE64` | Extension provisioning profile (base64) | |
| 137 | +| `TAP_GITHUB_TOKEN` | PAT with push to `ebreen/homebrew-cloudmount` | |
| 138 | + |
| 139 | +## Release Pipeline |
| 140 | + |
| 141 | +Triggered by pushing a tag matching `v[0-9]+.[0-9]+.[0-9]+`. |
| 142 | + |
| 143 | +### Job 1: `build-sign-notarize` (macos-26) |
| 144 | + |
| 145 | +1. Install tools (`create-dmg`, `xcodegen`) |
| 146 | +2. Import both certificates into temporary keychain |
| 147 | +3. Install provisioning profiles |
| 148 | +4. `xcodegen generate` |
| 149 | +5. Set version in Info.plist from tag (CFBundleShortVersionString = tag, CFBundleVersion = github.run_number) |
| 150 | +6. Archive with automatic signing + API key auth |
| 151 | +7. Export with `method: developer-id`, `signingStyle: manual` |
| 152 | +8. Verify codesign |
| 153 | +9. Create DMG via `scripts/create-dmg.sh` |
| 154 | +10. Notarize DMG via `notarytool` (fetches log on failure) |
| 155 | +11. Staple notarization ticket |
| 156 | +12. Generate SHA-256 checksum |
| 157 | +13. Upload artifact |
| 158 | + |
| 159 | +### Job 2: `publish` (ubuntu-latest, `production` environment with required reviewer) |
| 160 | + |
| 161 | +Creates GitHub Release with DMG + checksum. |
| 162 | + |
| 163 | +### Job 3: `bump-cask` (ubuntu-latest) |
| 164 | + |
| 165 | +Clones `ebreen/homebrew-cloudmount`, rewrites `Casks/cloudmount.rb` with new version and SHA-256, commits and pushes. |
| 166 | + |
| 167 | +### Releasing a new version |
| 168 | + |
| 169 | +```bash |
| 170 | +git tag v2.1.0 |
| 171 | +git push origin v2.1.0 |
| 172 | +``` |
| 173 | + |
| 174 | +Then approve the `publish` job in the `production` environment gate on GitHub Actions. |
| 175 | + |
| 176 | +### Common release failures |
| 177 | + |
| 178 | +| Symptom | Cause | Fix | |
| 179 | +|---------|-------|-----| |
| 180 | +| "No certificate for team" during export | Wrong cert type in `BUILD_CERTIFICATE_BASE64` | Must be Developer ID Application, not Apple Development | |
| 181 | +| "No signing certificate Mac Development found" during archive | Dev cert missing from keychain | Check `DEV_CERTIFICATE_BASE64` contains Apple Development cert | |
| 182 | +| "hardened runtime not enabled" during notarization | `ENABLE_HARDENED_RUNTIME` not set | Must be `true` in project.yml global settings | |
| 183 | +| Notarization "Invalid" | Various -- check the log | Workflow fetches `notarytool log` automatically on failure | |
| 184 | +| Export plist errors | Plist indentation | YAML `run: |` blocks strip leading indent; the heredoc content is fine | |
| 185 | +
|
| 186 | +## Sparkle Auto-Updates |
| 187 | + |
| 188 | +- **Feed URL**: `https://raw.githubusercontent.com/ebreen/cloudmount/main/appcast.xml` |
| 189 | +- **EdDSA Public Key**: `3GOokFls9E4GPEG00NfECK7JYQsjdIdRrPvq5kxQgfU=` (in `CloudMount/Info.plist`) |
| 190 | +- **EdDSA Private Key**: Stored in Eirik's macOS Keychain (generated by Sparkle's `generate_keys` tool) |
| 191 | +- **Current state**: `appcast.xml` is a placeholder with no `<item>` entries. Sparkle auto-update publishing is NOT yet automated in the release workflow. |
| 192 | + |
| 193 | +### To automate Sparkle updates in CI |
| 194 | + |
| 195 | +The release workflow needs a step that: |
| 196 | +1. Exports the Sparkle EdDSA private key as a GitHub secret |
| 197 | +2. Uses `sign_update` (from Sparkle's bin/) to sign the DMG |
| 198 | +3. Adds an `<item>` to `appcast.xml` with the signed DMG URL, version, EdDSA signature, and file size |
| 199 | +4. Commits the updated `appcast.xml` back to main |
| 200 | + |
| 201 | +This is not yet implemented. For now, Sparkle will check but find no updates. |
| 202 | + |
| 203 | +## Shared State Between App and Extension |
| 204 | + |
| 205 | +The main app and extension communicate via two mechanisms: |
| 206 | + |
| 207 | +| Mechanism | Key/Group | What's Stored | |
| 208 | +|-----------|-----------|---------------| |
| 209 | +| Keychain | `$(AppIdentifierPrefix)com.cloudmount.shared` | B2 credentials (key ID + application key) as JSON | |
| 210 | +| App Group UserDefaults | `$(TeamIdentifierPrefix)com.cloudmount.app` | Mount configurations (bucket name, mount point, account UUID, cache settings) | |
| 211 | + |
| 212 | +The extension reads these at mount time in `CloudMountFileSystem.loadResource()`. |
| 213 | + |
| 214 | +## B2 API Integration |
| 215 | + |
| 216 | +- Uses B2 Native API v4 (NOT S3-compatible) |
| 217 | +- `B2HTTPClient` is a stateless, Sendable struct with 1:1 method-to-endpoint mapping |
| 218 | +- `B2AuthManager` (actor) handles `b2_authorize_account` and transparent token refresh |
| 219 | +- `B2Client` (actor) is the high-level interface with `withAutoRefresh` retry wrapper |
| 220 | +- Upload flow: `b2_get_upload_url` -> `b2_upload_file` (retry with fresh URL on failure) |
| 221 | +- Rename: server-side `b2_copy_file` + `b2_delete_file_version` (no native rename) |
| 222 | +- Directory rename: not supported (returns ENOTSUP) |
| 223 | + |
| 224 | +## File I/O Strategy |
| 225 | + |
| 226 | +- **Read**: Download entire file to staging on `open()`, read from local file |
| 227 | +- **Write**: Write to local staging file, upload to B2 on `close()` (write-on-close) |
| 228 | +- **Staging**: `StagingManager` actor, temp files with SHA-256 hashed names |
| 229 | +- **File cache**: On-disk LRU cache in `~/Library/Caches/CloudMount/`, default 1 GB limit |
| 230 | +- **Metadata cache**: In-memory TTL cache, default 5 min |
| 231 | +- **Metadata suppression**: `.DS_Store`, `._*`, `.Spotlight-V100`, `.Trashes`, `.fseventsd`, `.TemporaryItems`, etc. are blocked from hitting B2 |
| 232 | + |
| 233 | +## Build & Development |
| 234 | + |
| 235 | +### Prerequisites |
| 236 | + |
| 237 | +```bash |
| 238 | +brew install xcodegen create-dmg |
| 239 | +``` |
| 240 | + |
| 241 | +### Generate and build |
| 242 | + |
| 243 | +```bash |
| 244 | +xcodegen generate |
| 245 | +xcodebuild build -project CloudMount.xcodeproj -scheme CloudMount -configuration Debug \ |
| 246 | + -allowProvisioningUpdates DEVELOPMENT_TEAM=66X2XJM3HW |
| 247 | +``` |
| 248 | + |
| 249 | +### Build without signing (for CI/testing) |
| 250 | + |
| 251 | +```bash |
| 252 | +xcodebuild build -project CloudMount.xcodeproj -scheme CloudMount -configuration Debug \ |
| 253 | + CODE_SIGN_IDENTITY=- CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO |
| 254 | +``` |
| 255 | + |
| 256 | +### Run the debug build |
| 257 | + |
| 258 | +```bash |
| 259 | +open build/DerivedData/Build/Products/Debug/CloudMount.app |
| 260 | +``` |
| 261 | + |
| 262 | +## Source File Map |
| 263 | + |
| 264 | +``` |
| 265 | +CloudMount/ # Main app (SwiftUI menu bar) |
| 266 | + CloudMountApp.swift # @main, Sparkle updater init, MenuBarExtra |
| 267 | + AppState.swift # Observable state: accounts, mounts, monitoring |
| 268 | + MountClient.swift # Mount/unmount via Process (mount -F -t b2 / diskutil) |
| 269 | + MountMonitor.swift # NSWorkspace didMount/didUnmount notifications |
| 270 | + ExtensionDetector.swift # Dry-run probe to detect FSKit extension enablement |
| 271 | + Views/ |
| 272 | + MenuContentView.swift # Menu bar popup content |
| 273 | + SettingsView.swift # Settings window (Credentials, Buckets, General tabs) |
| 274 | + OnboardingView.swift # First-run extension setup guide |
| 275 | + CheckForUpdatesView.swift # Sparkle "Check for Updates" menu command |
| 276 | + |
| 277 | +CloudMountExtension/ # FSKit filesystem extension |
| 278 | + CloudMountExtension.swift # @main entry point (UnaryFileSystemExtension) |
| 279 | + CloudMountFileSystem.swift # FSUnaryFileSystem subclass: probe, load, unload |
| 280 | + B2Volume.swift # FSVolume subclass: volume identity and state |
| 281 | + B2VolumeOperations.swift # FSVolume.Operations: mount, unmount, lookup, enumerate, create, remove, rename, attributes |
| 282 | + B2VolumeReadWrite.swift # FSVolume.ReadWriteOperations + OpenCloseOperations |
| 283 | + B2Item.swift # FSItem subclass for B2 objects |
| 284 | + B2ItemAttributes.swift # FSItem attribute mapping |
| 285 | + StagingManager.swift # Local temp file management for read/write |
| 286 | + MetadataBlocklist.swift # Suppresses macOS metadata files from B2 |
| 287 | + |
| 288 | +CloudMountKit/ # Shared framework |
| 289 | + B2/ |
| 290 | + B2Client.swift # High-level B2 API client (actor) |
| 291 | + B2AuthManager.swift # Token lifecycle and refresh (actor) |
| 292 | + B2HTTPClient.swift # Stateless HTTP client, 1:1 endpoint mapping |
| 293 | + B2Error.swift # Typed error enum with retryable classification |
| 294 | + B2Types.swift # Codable models for B2 API responses |
| 295 | + Cache/ |
| 296 | + FileCache.swift # On-disk LRU file cache (actor) |
| 297 | + MetadataCache.swift # In-memory TTL metadata cache (actor) |
| 298 | + Credentials/ |
| 299 | + CredentialStore.swift # Keychain read/write for B2 credentials |
| 300 | + MountConfig.swift # MountConfiguration model |
| 301 | + AccountConfig.swift # B2Account model |
| 302 | + Config/ |
| 303 | + SharedDefaults.swift # App Group UserDefaults wrapper |
| 304 | +``` |
| 305 | +
|
| 306 | +## Homebrew Distribution |
| 307 | +
|
| 308 | +- **Tap repo**: `ebreen/homebrew-cloudmount` |
| 309 | +- **Cask path**: `Casks/cloudmount.rb` |
| 310 | +- **Install**: `brew install ebreen/cloudmount/cloudmount` |
| 311 | +- Auto-bumped by the `bump-cask` CI job on each release |
| 312 | +- `auto_updates true` in the cask (Sparkle handles in-app updates) |
| 313 | +- `depends_on macos: ">= :tahoe"` |
| 314 | +
|
| 315 | +## CI |
| 316 | +
|
| 317 | +- `.github/workflows/ci.yml` runs on PRs to main |
| 318 | +- Builds in Debug with signing disabled |
| 319 | +- Test step is soft-fail (warning only) |
| 320 | +- Uses `macos-26` runner |
| 321 | +
|
| 322 | +## Things Not Yet Done |
| 323 | +
|
| 324 | +- [ ] Sparkle appcast auto-publishing in release workflow |
| 325 | +- [ ] Automated tests (test step is a no-op currently) |
| 326 | +- [ ] S3-compatible provider support (only B2 for now) |
| 327 | +- [ ] Cache settings UI (configurable in code but not exposed in Settings) |
| 328 | +- [ ] `autoMount` feature (flag exists in MountConfiguration but not wired up) |
0 commit comments