Skip to content

Commit 8168227

Browse files
author
ebreen
committed
fix(fskit): use extensionkit-extension type to preserve EXAppExtensionAttributes in built plist
The extension was using type: app-extension which maps to com.apple.product-type.app-extension. This triggers Xcode's legacy NSExtension plist processing, which strips all FSKit keys (FSSupportedSchemes, FSShortName, FSPersonalities, etc.) and rewrites the principal class incorrectly. macOS never routed b2:// URLs to it. Changed to type: extensionkit-extension and GENERATE_INFOPLIST_FILE: true to match Apple's own FSKit sample project. The built plist now preserves EXAppExtensionAttributes with all FS* keys intact.
1 parent 7a0077a commit 8168227

2 files changed

Lines changed: 330 additions & 2 deletions

File tree

AGENTS.md

Lines changed: 328 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,328 @@
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)

project.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ targets:
4343
- package: Sparkle
4444

4545
CloudMountExtension:
46-
type: app-extension
46+
type: extensionkit-extension
4747
platform: macOS
4848
sources:
4949
- path: CloudMountExtension
@@ -55,7 +55,7 @@ targets:
5555
INFOPLIST_FILE: CloudMountExtension/Info.plist
5656
CODE_SIGN_ENTITLEMENTS: CloudMountExtension/CloudMountExtension.entitlements
5757
CODE_SIGN_STYLE: Automatic
58-
GENERATE_INFOPLIST_FILE: false
58+
GENERATE_INFOPLIST_FILE: true
5959
LD_RUNPATH_SEARCH_PATHS: "$(inherited) @executable_path/../Frameworks @executable_path/../../../../Frameworks"
6060
dependencies:
6161
- target: CloudMountKit

0 commit comments

Comments
 (0)