Emerson Beta — a Sparkle channel that runs on the Claude CLI
23 of 25 tasks done
Date: 2026-10-07
Status: 0.1.0 published 2026-10-10 (LochLustra e853953); tester checks pending
Goal: Give a remote tester an Emerson build that runs model calls through his own claude login and updates itself through Sparkle, the way Code Raven's beta does. The App Store / TestFlight build stays sandboxed, API-only, and free of Sparkle.
Why the TestFlight build can't do this
- TestFlight builds are Release builds.
LLMTransport.currentreturns.apiwheneverDEBUGis off (Emerson/Services/ClaudeCLITransport.swift:16-24), and the CLI transport and its Settings picker are compiled only under#if DEBUG. - Release builds sign with
Emerson.entitlements, which turns the App Sandbox on. A sandboxed app cannot launchclaude. - The tester's banner, "LLMClient.LLMError error 8", is
missingAPIKey. Swift numbers the cases that carry a payload first, which putsmissingAPIKeyat 8; this was confirmed by compiling the enum.LLMErrorhas noLocalizedErrorconformance, so macOS prints the raw code.
Shape of the solution
A second app target, Emerson Beta, built from the same source folder:
| Emerson (App Store / TestFlight) | Emerson Beta (Sparkle) | |
|---|---|---|
| Bundle ID | com.lochlustra.Emerson | com.lochlustra.Emerson.beta |
| Sandbox | on | off |
| Model calls | Anthropic API | Claude CLI by default, API selectable |
| Updates | App Store / TestFlight | Sparkle appcast on lochlustra.com |
| Data folder | Application Support/Emerson (inside the container) | Application Support/Emerson Beta |
| Distribution signing | App Store | Developer ID, notarized DMG |
A build configuration cannot do this job. Sparkle arrives as a Swift package, and Xcode links a package into every configuration of the target that depends on it. App Store review rejects self-updating apps, so Sparkle has to live in a target the App Store build never touches.
Compile flags
| Target / configuration | SWIFT_ACTIVE_COMPILATION_CONDITIONS |
|---|---|
| Emerson Debug | DEBUG CLAUDE_CLI |
| Emerson Release | *(none)* |
| Emerson Beta Debug | DEBUG CLAUDE_CLI EMERSON_BETA |
| Emerson Beta Release | CLAUDE_CLI EMERSON_BETA |
CLAUDE_CLIgates the CLI transport. One flag replacesDEBUGat every CLI site, so the question "can this build launchclaude?" has one answer in one place.EMERSON_BETAgates Sparkle and the beta's data folder.DEBUGkeeps gating Persona Lab and the other developer panels, which stay out of the beta's Release build.
Phase 1 — Target and build settings
- Duplicate the
Emersontarget asEmerson Beta. Point it at the same synchronized root group (Emerson/), which Xcode 16 allows a second target to share. - Build settings for the beta target:
PRODUCT_BUNDLE_IDENTIFIER = com.lochlustra.Emerson.betaPRODUCT_NAME = Emerson BetaCODE_SIGN_ENTITLEMENTS = Emerson/Emerson-Beta.entitlementsENABLE_HARDENED_RUNTIME = YES(notarization requires it)INFOPLIST_FILE = Emerson/Emerson-Beta-Info.plist, merged with the generated plist (GENERATE_INFOPLIST_FILEstaysYES)- The compile flags from the table above
Emerson/Emerson-Beta.entitlements:com.apple.security.app-sandbox = falseand nothing else.- It leaves out
keychain-access-groups. Without restricted entitlements, a Developer ID export needs no provisioning profile. Code Raven's export needs-allowProvisioningUpdatesonly because of its iCloud entitlement. - Consequence: the beta keeps its API key (if one is ever entered) in its own Keychain item and does not see the App Store build's key.
- It leaves out
Emerson/Emerson-Beta-Info.plist:SUFeedURL,SUPublicEDKey,SUEnableAutomaticChecks = YES.- Exclude
SparkleUpdater.swift,Emerson-Beta-Info.plist, andEmerson-Beta.entitlementsfrom the main target through the synchronized group's membership exceptions. The Swift file is also wrapped in#if EMERSON_BETA, so a missed exception still compiles to nothing. - Add a shared scheme
Emerson Beta: Run uses Debug, Archive uses Release. Tests stay on theEmersonscheme. - Add
CLAUDE_CLIto the main target's Debug configuration.
Phase 2 — Code gates
- CLI transport. Change
#if DEBUGto#if CLAUDE_CLIat:ClaudeCLITransport.swift: theLLMTransport.currentbody and theClaudeCLITransportenum. The XCTest guard insidecurrentstays underDEBUG.LLMClient.swift: the branches insendandstream.SettingsView.swift: the transport picker section. Its title drops "developer build only" whenEMERSON_BETAis set.- Update the doc comment on
LLMTransport. "Developer and tester builds" becomes literally true.
- Separate data folder. In
AppStorageLocation.swift:folderNamebecomes"Emerson Beta"underEMERSON_BETA.legacyStoreURLsreturns[]underEMERSON_BETA. The beta never adopts the Atticus-era store ordefault.store. Adoption only copies and leaves the source in place, but a beta on Sarah's Mac has no business taking a copy of her writing and migrating it.
- Other hard-coded folder names.
BackupFolder.swift:61andPersonaLabStore.swift:18spell out"Emerson". Both switch toAppStorageLocation.folderName.- The
BackupFolderchange matters. If the beta and the Debug build both write readable copies into~/Documents/Emerson, each app's pruning runs over the other's version files.
- The
- Readable errors. Conform
LLMClient.LLMErrortoLocalizedError.missingAPIKeyreads "No Anthropic API key. Add one in Settings."- The
server(_, "Claude CLI: …")cases pass the CLI's own text through, which already covers "not logged in". - This change helps the TestFlight build too.
Phase 3 — Sparkle
- Add the Sparkle package (
https://github.com/sparkle-project/Sparkle, 2.x) and link it toEmerson Betaonly. Emerson/Services/SparkleUpdater.swift, adapted from Code Raven's:- Use
@Observableto matchLLMClient, in place ofObservableObject. - Drop the
canImportstub and theprintdiagnostics. - Wrap the whole file in
#if EMERSON_BETA.
- Use
- Add a Check for Updates… command after the About item (
CommandGroup(after: .appInfo)), underEMERSON_BETA. - Add an Updates section in Settings (automatic checks, check now, version), under
EMERSON_BETA. - Signing key:
- Run
generate_keys --account emerson-beta. A baregenerate_keysreturns Code Raven's existing key from the login Keychain. - Export the private key with
generate_keys --account emerson-beta -x <file>and store it alongside Code Raven's backup. Losing it means no update can ever be signed for installed copies again.
- Run
Phase 4 — Deploy script
scripts/deploy-beta.sh, adapted from Code Raven's, plus scripts/ExportOptions-Beta.plist (method = developer-id).
| Step | Emerson Beta specifics |
|---|---|
| Version | MARKETING_VERSION passed on the xcodebuild command line only. Code Raven's script seds every MARKETING_VERSION in the project, which here would also rewrite the App Store target. |
| Build number | CURRENT_PROJECT_VERSION = $(date +%Y%m%d%H%M), as Code Raven does. Sparkle compares CFBundleVersion. |
| Archive / export | Scheme Emerson Beta, Release, Developer ID export. |
| DMG | releases/Emerson-Beta-<version>.dmg, signed with Developer ID Application: Loch Lustra, LLC (X7BPXRX9NA). |
| Notarize | Reuse the CodeRaven-Notarization keychain profile. It is a team-level App Store Connect key. |
| Appcast | generate_appcast --account emerson-beta. |
| Upload | Vercel Blob, reusing the CodeRaven-Blob token. |
| Publish | appcast.xml and an unlisted download page into ~/Developer/LochLustra/emerson/beta/, served at https://lochlustra.com/emerson/beta/. Reachable only by direct link (see Discoverability). |
- Add
.sparkle/,build/,releases/,appcast/to.gitignore. - Publishing to lochlustra.com is outward-facing. The script stops before committing the LochLustra site, and Sarah pushes it.
Discoverability
Decided 2026-10-07: the Emerson and Code Raven beta pages are reachable only by direct link.
vercel.jsonin the LochLustra site already sendsX-Robots-Tag: noindex, nofollow, noarchivefor/emerson/beta/,/emerson/beta/*, and the same paths under/coderaven/beta(LochLustra commits16cd7ab,de151cb). The DMGs, appcast, and README are covered as well as the HTML.- The download page also carries
<meta name="robots" content="noindex, nofollow">. - No link from the lochlustra.com homepage or any other page, and no sitemap entry.
- DMGs live in Vercel Blob under unguessable URLs. The appcast URL is embedded in the app, so anyone holding the app can find the feed; that is acceptable.
- After the first deploy, confirm
curl -sI https://lochlustra.com/emerson/beta/shows thex-robots-tagheader.
Phase 5 — Verification
xcodebuildboth schemes. Run theEmersontest suite (StoreMigrationTestsincluded).- On an exported App Store archive:
codesign -d --entitlements - Emerson.appstill shows the sandbox on.Contents/Frameworkscontains noSparkle.framework.
- On the beta DMG:
spctl --assess --type open --context context:primary-signature -vpasses, andcodesign -d --entitlements -shows the sandbox off. - First launch of the beta on Sarah's Mac:
- Note the modification time of
~/Library/Application Support/Emerson/store/emerson.storebeforehand. Don't open the store. - Launch the beta, then confirm
Application Support/Emerson Beta/exists and the Debug store's modification time is unchanged. - A throwaway macOS user account is a stricter first test, if wanted.
- Note the modification time of
- Run a critique in the beta on the CLI transport. Switch to the API with no key and confirm the readable error.
- Sparkle round trip: install 0.1.1, publish 0.1.2, and confirm the update installs and relaunches.
Tester setup (one time)
- Install Claude Code (
curl -fsSL https://claude.ai/install.sh | bash), runclaudeonce, and log in. - Download the DMG and drag Emerson Beta to Applications.
- Open it. Settings → Model calls should read "Calls run through the local claude CLI."
- Keep or delete the TestFlight Emerson. The two apps store data separately and don't see each other's writing.
Decisions (2026-10-08)
- Display name: "Emerson Beta".
- Shared credentials: reuse Code Raven's
CodeRaven-Notarizationprofile andCodeRaven-Blobtoken. - Discoverability: direct link only (see Phase 4).
- Vercel Authentication (Standard Protection) is on for the lochlustra project, so old deployment URLs require a Vercel login.
Implementation notes (2026-10-09)
Where the build differs from the phases below:
- Beta-only files live in
EmersonBeta/, a second synchronized folder that only the beta target uses:Info.plist,EmersonBeta.entitlements, andSparkleUpdater.swift(updater, menu command, Settings section). This replaces theEmerson-Beta-*files and membership exceptions in Phase 1. The main target never sees the folder, so no#ifis needed inside it. - The beta keeps
keychain-access-groups(its own group,com.lochlustra.Emerson.beta).KeychainServiceuses the data-protection Keychain, which needs a signed application identifier, so Phase 1 step 3's "nothing else" would have broken API-key storage. Export uses-allowProvisioningUpdates; Xcode registered the App ID and profile on the first build. - Sparkle resolved to 2.10.0 (requirement: up to next major from 2.8.1).
Package.resolvedis git-ignored in this repo, so the version isn't pinned. - Signing key: generated under Keychain account
emerson-beta. Public key0Ju5xQvN6EjBykas+W5sDokcDi3cNkFjFj/jABGyCD8=is inEmersonBeta/Info.plist. Backup at.sparkle/emerson-beta-private-key(git-ignored, mode 600). - Readable errors apply to every build, TestFlight included. "LLMError error 8" now reads "No Anthropic API key. Add one in Settings → Anthropic API Key."
- Deploy script (
scripts/deploy-beta.sh) gates onStoreMigrationTests, fails on a sandboxed or debuggable export, fails on rejected notarization (Code Raven's skips it), and stages the page and appcast without committing. Page template:scripts/beta-page.html. - First-launch finding. The first-launch "Keep a copy of your writing" sheet ran in the beta on Sarah's Mac and
~/Documents/Emersonwas chosen, which is the Debug build's backup folder. The beta's store was empty, so its sync wrote and removed nothing; file times in that folder were unchanged. The beta's backup choice was cleared. The sheet and picker now nameEmerson Betaas the folder Documents gets. A beta that does choose another build's folder can't delete that build's files (pruning only touches version folders for its own pieces' IDs), but the copies would mix.
Final review
Pros
- The App Store build changes in two places only: readable error messages, and
CLAUDE_CLIreplacingDEBUGin the main target's Debug configuration. Its Release build ships the same entitlements and no new frameworks. - The tester gets automatic updates. TestFlight gave him those before; a hand-sent Debug build would not.
- The beta's separate data folder and the empty legacy list keep the beta away from Sarah's real store.
- One flag (
CLAUDE_CLI) states which builds can launch a process.
Cons
- There are now two release pipelines. The API path, which App Store users will depend on, gets less real-world testing from this tester.
- Tester usage counts against his Claude plan limits, and responses depend on whatever Claude Code version he has installed. A CLI release that changes
--json-schema,--setting-sources, or thestream-jsonformat breaks the beta until Emerson ships a fix. The Debug build already carries this risk; the beta extends it to someone who can't rebuild. - Two targets share one folder. A new file meant for one target needs a membership exception or a compile flag, which is easy to forget. The
#if EMERSON_BETAwrapper limits the damage to dead code.
Edge cases the plan does not cover
- Schema migrations reach the tester through Sparkle. A migration bug would hit his real writing with no developer at the machine. The pre-migration backups and
StoreRecoveryViewrun in the beta too, but recovering from them remotely means walking him through it. - Moving his TestFlight writing. If he wrote anything in TestFlight first, it stays in the container. Moving it means exporting and re-importing; the beta won't adopt it.
claudeinstalled somewhere unusual (an nvm-managed npm global, for example).executableURL()checks four fixed locations. The fix is to add a Settings field for a custom path or to resolve the path through a login shell.
Adjustments worth considering
- Ship the
LocalizedErrorchange to TestFlight right away. It's independent of everything else and makes the tester's current build explain itself. - Before shipping a schema change on the beta channel, add a deploy-script check that runs
StoreMigrationTestsand refuses to publish on failure.
Confidence: 80/100. The target split, the separate data folder, and the deploy pipeline follow patterns proven in this repo and in Code Raven. Two steps haven't been done in this project and remain unverified: sharing one synchronized root group between two app targets with per-file exceptions, and a Developer ID export without a provisioning profile once keychain-access-groups is dropped. Either could add an hour of Xcode work, but neither changes the design.