eleven / ocho Public

mac: side-by-side dev alts — ./tools/dev alt [slug] builds an 'Eleven Dev' flavour (#1024)

The LChatMac target gains an identity knob-set (bundle id suffix, display
name, icon set, Sparkle auto-check) so a dev build runs BESIDE the
Sparkle-updated app instead of replacing it: its own sandbox container +
Launch Services identity, the orange hue-shifted icon (same mark, never a
new one; regenerate with tools/make-dev-icon.swift), a suffixed URL scheme
so it never captures the browser sign-in handoff, and update checks off so
it never offers to 'update' itself to stable. The keychain access group is
unchanged, so an alt sees the signed-in spaces via the shared home session
— verified live: fresh container self-populated the rail on first
activation, both apps running at once. Slugs (dev, dev2, …) give parallel
sessions their own alts, web-canary style; each new slug auto-mints its
App ID/profile via -allowProvisioningUpdates.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 23596677b9c2e8a4f78c731e66f8b80599e6470d parent 976a153
13 files changed, +181 −12
  • CLAUDE.md +13 −0
  • ios/LChatMac/Assets.xcassets/AppIconDev.appiconset/Contents.json +15 −0
  • ios/LChatMac/Assets.xcassets/AppIconDev.appiconset/icon_1024.png binary
  • ios/LChatMac/Assets.xcassets/AppIconDev.appiconset/icon_128.png binary
  • ios/LChatMac/Assets.xcassets/AppIconDev.appiconset/icon_16.png binary
  • ios/LChatMac/Assets.xcassets/AppIconDev.appiconset/icon_256.png binary
  • ios/LChatMac/Assets.xcassets/AppIconDev.appiconset/icon_32.png binary
  • ios/LChatMac/Assets.xcassets/AppIconDev.appiconset/icon_512.png binary
  • ios/LChatMac/Assets.xcassets/AppIconDev.appiconset/icon_64.png binary
  • ios/LChatMac/Info.plist +4 −4
  • ios/project.yml +23 −7
  • tools/dev +57 −1
  • tools/make-dev-icon.swift +69 −0
diff --git a/CLAUDE.md b/CLAUDE.md
index 085529c..af6fecb 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -596,6 +596,7 @@ Simple command-line interface for common dev tasks. Run from anywhere in the rep
./tools/dev build ios # Build native iOS app (xcodebuild)
./tools/dev run mac # Launch the Mac app
./tools/dev run ios # Run iOS app on simulator
+./tools/dev alt [slug] # Build + launch a side-by-side dev alt of the Mac app
./tools/dev up # Start local dev server (persistent config)
./tools/dev seed-mac # Seed Mac app with dev server credentials
./tools/dev link # Print dev server sign-in link
@@ -606,6 +607,18 @@ Simple command-line interface for common dev tasks. Run from anywhere in the rep
All build and test commands are thin wrappers around the underlying toolchain (xcodebuild, go, npm). The `dev` subcommands orchestrate the dev server (`tools/lchat-dev-up.mjs`) and app seeding scripts. The `run` commands automatically build if needed, so you can skip the explicit build step. **Reuse and extend this as the single entry point for all dev workflows** — don't hand-roll xcodebuild invocations or embed them in Claude prompts. When adding new dev commands, document them here and in the script's usage header.
+🤖 **Dev alts (`./tools/dev alt [slug]`)** build the same LChatMac target under an
+alternate identity — bundle id `…messenger.mac.alt-<slug>`, name "Eleven Dev",
+the orange dev icon (`AppIconDev`, regenerate with `swift tools/make-dev-icon.swift`),
+Sparkle auto-checks off — so a dev build runs **beside** the Sparkle-updated app
+instead of replacing it. The keychain access group is unchanged, so an alt sees
+the signed-in spaces: **it's the real account, not a sandbox** (and while it
+runs you'll get doubled notification banners — quit it when done). One running
+app per slug; parallel sessions pick different slugs (`dev`, `dev2`, …) the way
+web canaries pick subdomains. Never install an alt (or any Debug build) to
+/Applications — that's how a stale dev build ends up masquerading as the
+released app.
+
Typical workflow:
```sh
diff --git a/ios/LChatMac/Assets.xcassets/AppIconDev.appiconset/Contents.json b/ios/LChatMac/Assets.xcassets/AppIconDev.appiconset/Contents.json
new file mode 100644
index 0000000..11bdd1e
--- /dev/null
+++ b/ios/LChatMac/Assets.xcassets/AppIconDev.appiconset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images" : [
+ { "idiom" : "mac", "size" : "16x16", "scale" : "1x", "filename" : "icon_16.png" },
+ { "idiom" : "mac", "size" : "16x16", "scale" : "2x", "filename" : "icon_32.png" },
+ { "idiom" : "mac", "size" : "32x32", "scale" : "1x", "filename" : "icon_32.png" },
+ { "idiom" : "mac", "size" : "32x32", "scale" : "2x", "filename" : "icon_64.png" },
+ { "idiom" : "mac", "size" : "128x128", "scale" : "1x", "filename" : "icon_128.png" },
+ { "idiom" : "mac", "size" : "128x128", "scale" : "2x", "filename" : "icon_256.png" },
+ { "idiom" : "mac", "size" : "256x256", "scale" : "1x", "filename" : "icon_256.png" },
+ { "idiom" : "mac", "size" : "256x256", "scale" : "2x", "filename" : "icon_512.png" },
+ { "idiom" : "mac", "size" : "512x512", "scale" : "1x", "filename" : "icon_512.png" },
+ { "idiom" : "mac", "size" : "512x512", "scale" : "2x", "filename" : "icon_1024.png" }
+ ],
+ "info" : { "version" : 1, "author" : "xcode" }
+}
\ No newline at end of file
diff --git a/ios/LChatMac/Assets.xcassets/AppIconDev.appiconset/icon_1024.png b/ios/LChatMac/Assets.xcassets/AppIconDev.appiconset/icon_1024.png
new file mode 100644
index 0000000..f68a8bc
Binary files /dev/null and b/ios/LChatMac/Assets.xcassets/AppIconDev.appiconset/icon_1024.png differ
diff --git a/ios/LChatMac/Assets.xcassets/AppIconDev.appiconset/icon_128.png b/ios/LChatMac/Assets.xcassets/AppIconDev.appiconset/icon_128.png
new file mode 100644
index 0000000..efccc3f
Binary files /dev/null and b/ios/LChatMac/Assets.xcassets/AppIconDev.appiconset/icon_128.png differ
diff --git a/ios/LChatMac/Assets.xcassets/AppIconDev.appiconset/icon_16.png b/ios/LChatMac/Assets.xcassets/AppIconDev.appiconset/icon_16.png
new file mode 100644
index 0000000..5fd129b
Binary files /dev/null and b/ios/LChatMac/Assets.xcassets/AppIconDev.appiconset/icon_16.png differ
diff --git a/ios/LChatMac/Assets.xcassets/AppIconDev.appiconset/icon_256.png b/ios/LChatMac/Assets.xcassets/AppIconDev.appiconset/icon_256.png
new file mode 100644
index 0000000..4ff3de9
Binary files /dev/null and b/ios/LChatMac/Assets.xcassets/AppIconDev.appiconset/icon_256.png differ
diff --git a/ios/LChatMac/Assets.xcassets/AppIconDev.appiconset/icon_32.png b/ios/LChatMac/Assets.xcassets/AppIconDev.appiconset/icon_32.png
new file mode 100644
index 0000000..3ee149e
Binary files /dev/null and b/ios/LChatMac/Assets.xcassets/AppIconDev.appiconset/icon_32.png differ
diff --git a/ios/LChatMac/Assets.xcassets/AppIconDev.appiconset/icon_512.png b/ios/LChatMac/Assets.xcassets/AppIconDev.appiconset/icon_512.png
new file mode 100644
index 0000000..84a9bdb
Binary files /dev/null and b/ios/LChatMac/Assets.xcassets/AppIconDev.appiconset/icon_512.png differ
diff --git a/ios/LChatMac/Assets.xcassets/AppIconDev.appiconset/icon_64.png b/ios/LChatMac/Assets.xcassets/AppIconDev.appiconset/icon_64.png
new file mode 100644
index 0000000..8051a51
Binary files /dev/null and b/ios/LChatMac/Assets.xcassets/AppIconDev.appiconset/icon_64.png differ
diff --git a/ios/LChatMac/Info.plist b/ios/LChatMac/Info.plist
index ea3fc0c..b753504 100644
--- a/ios/LChatMac/Info.plist
+++ b/ios/LChatMac/Info.plist
@@ -5,7 +5,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
- <string>Eleven</string>
+ <string>$(LCHAT_MAC_DISPLAY_NAME)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
@@ -13,7 +13,7 @@
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
- <string>Eleven</string>
+ <string>$(LCHAT_MAC_DISPLAY_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
@@ -25,7 +25,7 @@
<string>com.elevenmessenger.messenger</string>
<key>CFBundleURLSchemes</key>
<array>
- <string>elevenmessenger</string>
+ <string>elevenmessenger$(LCHAT_MAC_ALT_SUFFIX)</string>
</array>
</dict>
</array>
@@ -43,7 +43,7 @@
<string>INSendMessageIntent</string>
</array>
<key>SUEnableAutomaticChecks</key>
- <true/>
+ <string>$(LCHAT_MAC_SPARKLE_AUTOCHECK)</string>
<key>SUEnableInstallerLauncherService</key>
<true/>
<key>SUFeedURL</key>
diff --git a/ios/project.yml b/ios/project.yml
index bdd1dab..c040983 100644
--- a/ios/project.yml
+++ b/ios/project.yml
@@ -113,11 +113,22 @@ targets:
- package: LChatCore
settings:
base:
- PRODUCT_NAME: Eleven
- PRODUCT_BUNDLE_IDENTIFIER: com.elevenmessenger.messenger.mac
+ # Dev alts (./tools/dev alt <slug>): one knob-set turns this target
+ # into a side-by-side dev flavour — distinct bundle id (its own
+ # sandbox container + Launch Services identity, so it runs alongside
+ # the Sparkle-updated app), "Eleven Dev" naming, the orange icon, and
+ # Sparkle's auto-check off. The keychain access group is unchanged,
+ # so an alt sees the same signed-in spaces (identities + home session
+ # ride the team-shared group, not the bundle id).
+ LCHAT_MAC_ALT_SUFFIX: ""
+ LCHAT_MAC_DISPLAY_NAME: Eleven
+ LCHAT_MAC_APPICON: AppIcon
+ LCHAT_MAC_SPARKLE_AUTOCHECK: "YES"
+ PRODUCT_NAME: $(LCHAT_MAC_DISPLAY_NAME)
+ PRODUCT_BUNDLE_IDENTIFIER: com.elevenmessenger.messenger.mac$(LCHAT_MAC_ALT_SUFFIX)
CODE_SIGN_STYLE: Automatic
CODE_SIGN_ENTITLEMENTS: LChatMac/LChatMac.entitlements
- ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
+ ASSETCATALOG_COMPILER_APPICON_NAME: $(LCHAT_MAC_APPICON)
# Required for notarization (Developer ID distribution, see
# ios/release-mac.sh). Sparkle's bundled XPC helpers need no entitlement
# exceptions on top of this for a non-sandboxed app.
@@ -173,16 +184,19 @@ targets:
properties:
CFBundleVersion: "$(CURRENT_PROJECT_VERSION)"
CFBundleShortVersionString: "$(MARKETING_VERSION)"
- CFBundleDisplayName: Eleven
- CFBundleName: Eleven
+ CFBundleDisplayName: $(LCHAT_MAC_DISPLAY_NAME)
+ CFBundleName: $(LCHAT_MAC_DISPLAY_NAME)
LSApplicationCategoryType: public.app-category.social-networking
# Same custom scheme as the iOS app, so the welcome page's "Open the app"
# button works on the Mac too. (No associated domains here — the Mac app
# is ad-hoc signed, so universal links can't validate.)
+ # The scheme carries the alt suffix so a dev alt never captures the
+ # browser sign-in handoff (elevenmessenger://) from the real app —
+ # alts don't need it anyway; they read the shared keychain session.
CFBundleURLTypes:
- CFBundleURLName: com.elevenmessenger.messenger
CFBundleURLSchemes:
- - elevenmessenger
+ - elevenmessenger$(LCHAT_MAC_ALT_SUFFIX)
NSAppTransportSecurity:
NSAllowsLocalNetworking: true
# Sparkle auto-update feed, published by ios/release-mac.sh to the
@@ -196,7 +210,9 @@ targets:
SUPublicEDKey: "TkXBJrY0JvTeeCaHbEZlT5v5apJaOsG1op8uq7n1TsM="
# Skips Sparkle's own first-run "should Eleven check for updates
# automatically?" prompt — background checking is the default we want.
- SUEnableAutomaticChecks: true
+ # A string on purpose (Sparkle reads it with -boolValue): dev alts
+ # pass NO so they never offer to "update" themselves to stable.
+ SUEnableAutomaticChecks: $(LCHAT_MAC_SPARKLE_AUTOCHECK)
# The app is sandboxed (LChatMac.entitlements), so Sparkle installs
# updates through its bundled InstallerLauncher XPC service instead of
# spawning the installer directly. Its Downloader service stays off —
diff --git a/tools/dev b/tools/dev
index c4a1529..7fa9bd0 100755
--- a/tools/dev
+++ b/tools/dev
@@ -5,6 +5,7 @@
// ./tools/dev build mac Build the native Mac app (LChatMac scheme)
// ./tools/dev build ios Build the native iOS app (LChat scheme)
// ./tools/dev run mac Launch the Mac app
+// ./tools/dev alt [slug] Build + launch a side-by-side dev alt of the Mac app
// ./tools/dev run ios Run the iOS app on simulator (see instructions)
// ./tools/dev up Start the local dev server (persistent, reusable config)
// ./tools/dev seed-mac Seed the Mac app with dev server credentials
@@ -353,6 +354,55 @@ const commands = {
},
};
+// Side-by-side dev alt of the Mac app (CLAUDE.md § Dev CLI): the same
+// LChatMac target built under an alternate identity — bundle id suffix
+// (.alt-<slug>, so it gets its own sandbox container + Launch Services
+// identity and runs BESIDE the Sparkle-updated app), "Eleven Dev" naming,
+// the orange dev icon, and Sparkle auto-checks off. The keychain access
+// group is unchanged, so the alt sees the same signed-in spaces — it is the
+// REAL account, not a sandbox. One running app per slug; parallel sessions
+// pick different slugs (dev, dev2, …) like web canaries. First build of a
+// new slug mints its App ID/profile via -allowProvisioningUpdates.
+async function altMac(rawSlug) {
+ const slug = (rawSlug || "dev").toLowerCase().replace(/[^a-z0-9-]/g, "");
+ if (!slug) {
+ console.error("Bad slug — letters/digits/hyphens only.");
+ process.exit(1);
+ }
+ const name = slug === "dev" ? "Eleven Dev" : `Eleven Dev ${slug}`;
+ const suffix = `.alt-${slug}`;
+ if (needsXcodegen()) {
+ console.log("Generating Xcode project...");
+ run("xcodegen", ["generate"], { cwd: IOS_DIR });
+ }
+ const derived = path.join(IOS_DIR, `build-alt-${slug}`);
+ console.log(`Building ${name} (…messenger.mac${suffix})...`);
+ const result = spawnSync("xcodebuild", [
+ "-project", path.join(IOS_DIR, "LChat.xcodeproj"),
+ "-scheme", "LChatMac",
+ "-destination", "generic/platform=macOS",
+ "-derivedDataPath", derived,
+ "-allowProvisioningUpdates",
+ `LCHAT_MAC_ALT_SUFFIX=${suffix}`,
+ `LCHAT_MAC_DISPLAY_NAME=${name}`,
+ "LCHAT_MAC_APPICON=AppIconDev",
+ "LCHAT_MAC_SPARKLE_AUTOCHECK=NO",
+ "build",
+ ], { cwd: IOS_DIR, encoding: "utf8" });
+ if (result.status !== 0) {
+ console.error("Build failed!");
+ const errs = (result.stdout || "").split("\n").filter((l) => l.includes("error:"));
+ console.error(errs.slice(-20).join("\n") || result.stderr);
+ process.exit(result.status ?? 1);
+ }
+ // Relaunch cleanly on rebuild: quit this slug's running copy (matched by
+ // its per-slug build path, so other alts and the real app are untouched).
+ spawnSync("pkill", ["-f", path.join(derived, "Build", "Products", "Debug")]);
+ const appPath = path.join(derived, "Build", "Products", "Debug", `${name}.app`);
+ console.log(`Launching ${appPath}`);
+ run("open", [appPath]);
+}
+
const cmd = process.argv.slice(2).join(" ");
if (!cmd || cmd === "-h" || cmd === "--help" || cmd === "help") {
@@ -361,6 +411,7 @@ if (!cmd || cmd === "-h" || cmd === "--help" || cmd === "help") {
console.log(" ./tools/dev build mac Build native Mac app");
console.log(" ./tools/dev build ios Build native iOS app");
console.log(" ./tools/dev run mac Launch the Mac app");
+ console.log(" ./tools/dev alt [slug] Build + launch a side-by-side dev alt (orange icon)");
console.log(" ./tools/dev run ios Run iOS app on simulator");
console.log(" ./tools/dev up Start local dev server");
console.log(" ./tools/dev seed-mac Seed Mac app with dev credentials");
@@ -373,7 +424,12 @@ if (!cmd || cmd === "-h" || cmd === "--help" || cmd === "help") {
process.exit(cmd ? 0 : 1);
}
-if (commands[cmd]) {
+if (process.argv[2] === "alt") {
+ Promise.resolve(altMac(process.argv[3])).catch((e) => {
+ console.error(`Error: ${e.message}`);
+ process.exit(1);
+ });
+} else if (commands[cmd]) {
// Some commands are sync (build/test) and return undefined — wrap, so the
// failure handler works for both instead of crashing on `.catch`.
Promise.resolve(commands[cmd]()).catch((e) => {
diff --git a/tools/make-dev-icon.swift b/tools/make-dev-icon.swift
new file mode 100644
index 0000000..6515bc0
--- /dev/null
+++ b/tools/make-dev-icon.swift
@@ -0,0 +1,69 @@
+#!/usr/bin/swift
+// 🤖 Regenerates the Mac dev-alt icon set (AppIconDev.appiconset) from the
+// canonical app icon: the SAME purple "11" tile, hue-rotated to orange, so a
+// dev build is unmistakable in the Dock without inventing a new mark
+// (CLAUDE.md: never hand-roll a substitute glyph). Run from the repo root:
+//
+// swift tools/make-dev-icon.swift
+//
+// Committed output; re-run only if the canonical icon changes.
+import AppKit
+import CoreImage
+
+let src = "ios/LChatMac/Assets.xcassets/AppIcon.appiconset/icon_1024.png"
+let outDir = "ios/LChatMac/Assets.xcassets/AppIconDev.appiconset"
+
+guard let image = CIImage(contentsOf: URL(fileURLWithPath: src)) else {
+ fatalError("can't read \(src) — run from the repo root")
+}
+
+// Purple sits ~253°, orange ~30°: rotate the hue down to orange. White
+// and near-white (the "11") are hue-less, so they ride through untouched.
+let hue = CIFilter(name: "CIHueAdjust", parameters: [
+ kCIInputImageKey: image,
+ kCIInputAngleKey: NSNumber(value: -220.0 * .pi / 180.0),
+])!
+guard let tinted = hue.outputImage else { fatalError("hue adjust failed") }
+
+let ctx = CIContext()
+guard let full = ctx.createCGImage(tinted, from: tinted.extent) else {
+ fatalError("render failed")
+}
+
+try? FileManager.default.createDirectory(atPath: outDir, withIntermediateDirectories: true)
+
+for size in [16, 32, 64, 128, 256, 512, 1024] {
+ let rep = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: size, pixelsHigh: size,
+ bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true,
+ isPlanar: false, colorSpaceName: .deviceRGB,
+ bytesPerRow: 0, bitsPerPixel: 0)!
+ let nsCtx = NSGraphicsContext(bitmapImageRep: rep)!
+ NSGraphicsContext.saveGraphicsState()
+ NSGraphicsContext.current = nsCtx
+ nsCtx.cgContext.interpolationQuality = .high
+ nsCtx.cgContext.draw(full, in: CGRect(x: 0, y: 0, width: size, height: size))
+ NSGraphicsContext.restoreGraphicsState()
+ let png = rep.representation(using: .png, properties: [:])!
+ try! png.write(to: URL(fileURLWithPath: "\(outDir)/icon_\(size).png"))
+ print("wrote \(outDir)/icon_\(size).png")
+}
+
+let contents = """
+{
+ "images" : [
+ { "idiom" : "mac", "size" : "16x16", "scale" : "1x", "filename" : "icon_16.png" },
+ { "idiom" : "mac", "size" : "16x16", "scale" : "2x", "filename" : "icon_32.png" },
+ { "idiom" : "mac", "size" : "32x32", "scale" : "1x", "filename" : "icon_32.png" },
+ { "idiom" : "mac", "size" : "32x32", "scale" : "2x", "filename" : "icon_64.png" },
+ { "idiom" : "mac", "size" : "128x128", "scale" : "1x", "filename" : "icon_128.png" },
+ { "idiom" : "mac", "size" : "128x128", "scale" : "2x", "filename" : "icon_256.png" },
+ { "idiom" : "mac", "size" : "256x256", "scale" : "1x", "filename" : "icon_256.png" },
+ { "idiom" : "mac", "size" : "256x256", "scale" : "2x", "filename" : "icon_512.png" },
+ { "idiom" : "mac", "size" : "512x512", "scale" : "1x", "filename" : "icon_512.png" },
+ { "idiom" : "mac", "size" : "512x512", "scale" : "2x", "filename" : "icon_1024.png" }
+ ],
+ "info" : { "version" : 1, "author" : "xcode" }
+}
+"""
+try! contents.write(toFile: "\(outDir)/Contents.json", atomically: true, encoding: .utf8)
+print("wrote \(outDir)/Contents.json")