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>
13 files changed,
+181
−12
CLAUDE.md+13 −0ios/LChatMac/Assets.xcassets/AppIconDev.appiconset/Contents.json+15 −0ios/LChatMac/Assets.xcassets/AppIconDev.appiconset/icon_1024.pngbinaryios/LChatMac/Assets.xcassets/AppIconDev.appiconset/icon_128.pngbinaryios/LChatMac/Assets.xcassets/AppIconDev.appiconset/icon_16.pngbinaryios/LChatMac/Assets.xcassets/AppIconDev.appiconset/icon_256.pngbinaryios/LChatMac/Assets.xcassets/AppIconDev.appiconset/icon_32.pngbinaryios/LChatMac/Assets.xcassets/AppIconDev.appiconset/icon_512.pngbinaryios/LChatMac/Assets.xcassets/AppIconDev.appiconset/icon_64.pngbinaryios/LChatMac/Info.plist+4 −4ios/project.yml+23 −7tools/dev+57 −1tools/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") |