From 707be44aaf286236daa4b7683b8f4536aba8386a Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 20 Feb 2026 03:54:07 -0800 Subject: [PATCH] Separate cmux NIGHTLY as standalone app with its own bundle ID (#164) The nightly build is now a distinct app called "cmux NIGHTLY" with bundle ID com.cmuxterm.app.nightly, allowing side-by-side installation with the stable release. The nightly appcast URL is baked into the app's Info.plist by CI, so no in-app channel switching is needed. - Nightly workflow: rename app to "cmux NIGHTLY", set bundle ID to com.cmuxterm.app.nightly, hardcode nightly Sparkle feed URL, publish DMG as cmux-nightly-macos.dmg - Remove "Receive Nightly Builds" toggle from settings - Remove UpdateChannelSettings enum and simplify feed URL resolution to just use SUFeedURL from Info.plist - Remove UpdateChannelSettingsTests (no longer applicable) --- .github/workflows/nightly.yml | 37 +++++++++---- Sources/Update/UpdateController.swift | 1 - Sources/Update/UpdateDelegate.swift | 34 ++++-------- Sources/cmuxApp.swift | 21 -------- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 53 ------------------- 5 files changed, 37 insertions(+), 109 deletions(-) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 07508b10..d099a70b 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -151,16 +151,29 @@ jobs: run: | xcodebuild -scheme cmux -configuration Release -derivedDataPath build CODE_SIGNING_ALLOWED=NO build - - name: Inject Sparkle keys and nightly metadata + - name: Inject nightly identity and metadata run: | set -euo pipefail - APP_PLIST="build/Build/Products/Release/cmux.app/Contents/Info.plist" + APP_DIR="build/Build/Products/Release" + APP_PLIST="${APP_DIR}/cmux.app/Contents/Info.plist" SHORT_SHA="${{ needs.decide.outputs.short_sha }}" + # --- Separate app identity: "cmux NIGHTLY" with its own bundle ID --- + /usr/libexec/PlistBuddy -c "Set :CFBundleName cmux NIGHTLY" "$APP_PLIST" + /usr/libexec/PlistBuddy -c "Set :CFBundleDisplayName cmux NIGHTLY" "$APP_PLIST" + /usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier com.cmuxterm.app.nightly" "$APP_PLIST" + + # Rename the .app bundle to match the display name + mv "${APP_DIR}/cmux.app" "${APP_DIR}/cmux NIGHTLY.app" + + # Update plist path after rename + APP_PLIST="${APP_DIR}/cmux NIGHTLY.app/Contents/Info.plist" + + # --- Sparkle: point at the nightly appcast --- /usr/libexec/PlistBuddy -c "Delete :SUPublicEDKey" "$APP_PLIST" >/dev/null 2>&1 || true /usr/libexec/PlistBuddy -c "Delete :SUFeedURL" "$APP_PLIST" >/dev/null 2>&1 || true /usr/libexec/PlistBuddy -c "Add :SUPublicEDKey string ${SPARKLE_PUBLIC_KEY}" "$APP_PLIST" - /usr/libexec/PlistBuddy -c "Add :SUFeedURL string https://github.com/manaflow-ai/cmux/releases/latest/download/appcast.xml" "$APP_PLIST" + /usr/libexec/PlistBuddy -c "Add :SUFeedURL string https://github.com/manaflow-ai/cmux/releases/download/nightly/appcast.xml" "$APP_PLIST" # Marketing version: append -nightly.YYYYMMDD so users can identify the channel and date BASE_MARKETING=$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "$APP_PLIST") @@ -175,6 +188,8 @@ jobs: /usr/libexec/PlistBuddy -c "Delete :CMUXCommit" "$APP_PLIST" >/dev/null 2>&1 || true /usr/libexec/PlistBuddy -c "Add :CMUXCommit string ${SHORT_SHA}" "$APP_PLIST" + echo "Nightly app name: cmux NIGHTLY" + echo "Nightly bundle ID: com.cmuxterm.app.nightly" echo "Nightly marketing version: ${BASE_MARKETING}-nightly.${NIGHTLY_DATE}" echo "Nightly build number: ${NIGHTLY_BUILD}" echo "Commit SHA: ${SHORT_SHA}" @@ -210,7 +225,7 @@ jobs: echo "Missing APPLE_SIGNING_IDENTITY secret" >&2 exit 1 fi - APP_PATH="build/Build/Products/Release/cmux.app" + APP_PATH="build/Build/Products/Release/cmux NIGHTLY.app" ENTITLEMENTS="cmux.entitlements" CLI_PATH="$APP_PATH/Contents/Resources/bin/cmux" if [ -f "$CLI_PATH" ]; then @@ -230,9 +245,9 @@ jobs: echo "Missing notarization secrets (APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, APPLE_TEAM_ID)" >&2 exit 1 fi - APP_PATH="build/Build/Products/Release/cmux.app" + APP_PATH="build/Build/Products/Release/cmux NIGHTLY.app" ZIP_SUBMIT="cmux-nightly-notary.zip" - DMG_RELEASE="cmux-macos.dmg" + DMG_RELEASE="cmux-nightly-macos.dmg" ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" "$ZIP_SUBMIT" APP_SUBMIT_JSON="$(xcrun notarytool submit "$ZIP_SUBMIT" --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD" --wait --output-format json)" APP_SUBMIT_ID="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])' <<<"$APP_SUBMIT_JSON")" @@ -250,7 +265,7 @@ jobs: --identity="$APPLE_SIGNING_IDENTITY" \ "$APP_PATH" \ ./ - mv ./cmux*.dmg "$DMG_RELEASE" + mv ./"cmux NIGHTLY"*.dmg "$DMG_RELEASE" 2>/dev/null || mv ./cmux*.dmg "$DMG_RELEASE" DMG_SUBMIT_JSON="$(xcrun notarytool submit "$DMG_RELEASE" --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD" --wait --output-format json)" DMG_SUBMIT_ID="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])' <<<"$DMG_SUBMIT_JSON")" DMG_STATUS="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["status"])' <<<"$DMG_SUBMIT_JSON")" @@ -270,7 +285,7 @@ jobs: echo "Missing SPARKLE_PRIVATE_KEY secret" >&2 exit 1 fi - ./scripts/sparkle_generate_appcast.sh cmux-macos.dmg nightly appcast.xml + ./scripts/sparkle_generate_appcast.sh cmux-nightly-macos.dmg nightly appcast.xml - name: Move nightly tag to built commit run: | @@ -289,8 +304,12 @@ jobs: make_latest: false body: | Automated nightly build for `${{ needs.decide.outputs.short_sha }}`. + + **cmux NIGHTLY** is a separate app (bundle ID `com.cmuxterm.app.nightly`) that can be installed alongside the stable release. It receives nightly updates automatically via its own Sparkle feed. + + [Download cmux-nightly-macos.dmg](https://github.com/manaflow-ai/cmux/releases/download/nightly/cmux-nightly-macos.dmg) files: | - cmux-macos.dmg + cmux-nightly-macos.dmg appcast.xml overwrite_files: true diff --git a/Sources/Update/UpdateController.swift b/Sources/Update/UpdateController.swift index 5c53d0cc..0fc1c4e1 100644 --- a/Sources/Update/UpdateController.swift +++ b/Sources/Update/UpdateController.swift @@ -31,7 +31,6 @@ class UpdateController { "SUEnableAutomaticChecks": false, "SUSendProfileInfo": false, "SUAutomaticallyUpdate": false, - UpdateChannelSettings.includeNightlyBuildsKey: UpdateChannelSettings.defaultIncludeNightlyBuilds, ]) let hostBundle = Bundle.main diff --git a/Sources/Update/UpdateDelegate.swift b/Sources/Update/UpdateDelegate.swift index d9af9dbd..876a7617 100644 --- a/Sources/Update/UpdateDelegate.swift +++ b/Sources/Update/UpdateDelegate.swift @@ -1,26 +1,6 @@ import Sparkle import Cocoa -enum UpdateChannelSettings { - static let includeNightlyBuildsKey = "cmux.includeNightlyBuilds" - static let defaultIncludeNightlyBuilds = false - - static let stableFeedURL = "https://github.com/manaflow-ai/cmux/releases/latest/download/appcast.xml" - static let nightlyFeedURL = "https://github.com/manaflow-ai/cmux/releases/download/nightly/appcast.xml" - - static func resolvedFeedURLString( - infoFeedURL: String?, - defaults: UserDefaults = .standard - ) -> (url: String, isNightly: Bool, usedFallback: Bool) { - let stableURL = (infoFeedURL?.isEmpty == false) ? infoFeedURL! : stableFeedURL - let includeNightlyBuilds = defaults.bool(forKey: includeNightlyBuildsKey) - if includeNightlyBuilds { - return (url: nightlyFeedURL, isNightly: true, usedFallback: false) - } - return (url: stableURL, isNightly: false, usedFallback: stableURL == stableFeedURL) - } -} - extension UpdateDriver: SPUUpdaterDelegate { func feedURLString(for updater: SPUUpdater) -> String? { #if DEBUG @@ -31,11 +11,15 @@ extension UpdateDriver: SPUUpdaterDelegate { return override } #endif - let infoURL = Bundle.main.object(forInfoDictionaryKey: "SUFeedURL") as? String - let resolved = UpdateChannelSettings.resolvedFeedURLString(infoFeedURL: infoURL) - UpdateLogStore.shared.append("update channel: \(resolved.isNightly ? "nightly" : "stable")") - recordFeedURLString(resolved.url, usedFallback: resolved.usedFallback) - return resolved.url + // The feed URL is baked into Info.plist at build time: + // - Stable releases use the stable appcast URL + // - cmux NIGHTLY has the nightly appcast URL injected by CI + let feedURL = Bundle.main.object(forInfoDictionaryKey: "SUFeedURL") as? String + let isNightly = feedURL?.contains("/nightly/") == true + UpdateLogStore.shared.append("update channel: \(isNightly ? "nightly" : "stable")") + let usedFallback = feedURL == nil || feedURL?.isEmpty == true + recordFeedURLString(feedURL ?? "https://github.com/manaflow-ai/cmux/releases/latest/download/appcast.xml", usedFallback: usedFallback) + return feedURL } /// Called when an update is scheduled to install silently, diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 973409e9..681a152a 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -2239,7 +2239,6 @@ struct SettingsView: View { @AppStorage(BrowserSearchSettings.searchEngineKey) private var browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue @AppStorage(BrowserSearchSettings.searchSuggestionsEnabledKey) private var browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled @AppStorage(NotificationBadgeSettings.dockBadgeEnabledKey) private var notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled - @AppStorage(UpdateChannelSettings.includeNightlyBuildsKey) private var includeNightlyBuilds = UpdateChannelSettings.defaultIncludeNightlyBuilds @AppStorage(WorkspacePlacementSettings.placementKey) private var newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue @State private var shortcutResetToken = UUID() @State private var topBlurOpacity: Double = 0 @@ -2317,25 +2316,6 @@ struct SettingsView: View { } } - SettingsSectionHeader(title: "Updates") - SettingsCard { - SettingsCardRow( - "Receive Nightly Builds", - subtitle: includeNightlyBuilds - ? "Using nightly update channel. Builds may be less stable." - : "Using stable update channel." - ) { - Toggle("", isOn: $includeNightlyBuilds) - .labelsHidden() - .controlSize(.small) - .accessibilityIdentifier("SettingsIncludeNightlyBuildsToggle") - } - - SettingsCardDivider() - - SettingsCardNote("Nightly builds are published from the latest main branch commit when available.") - } - SettingsSectionHeader(title: "Automation") SettingsCard { SettingsCardRow( @@ -2551,7 +2531,6 @@ struct SettingsView: View { browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled - includeNightlyBuilds = UpdateChannelSettings.defaultIncludeNightlyBuilds newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue KeyboardShortcutSettings.resetAll() shortcutResetToken = UUID() diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 11059b44..8982078a 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -339,59 +339,6 @@ final class WorkspacePlacementSettingsTests: XCTestCase { } } -final class UpdateChannelSettingsTests: XCTestCase { - func testDefaultNightlyPreferenceIsDisabled() { - XCTAssertFalse(UpdateChannelSettings.defaultIncludeNightlyBuilds) - } - - func testResolvedFeedFallsBackToStableWhenInfoFeedMissing() { - let suiteName = "UpdateChannelSettingsTests.MissingInfo.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create isolated UserDefaults suite") - return - } - defer { defaults.removePersistentDomain(forName: suiteName) } - - let resolved = UpdateChannelSettings.resolvedFeedURLString(infoFeedURL: nil, defaults: defaults) - XCTAssertEqual(resolved.url, UpdateChannelSettings.stableFeedURL) - XCTAssertFalse(resolved.isNightly) - XCTAssertTrue(resolved.usedFallback) - } - - func testResolvedFeedUsesInfoFeedForStableChannel() { - let suiteName = "UpdateChannelSettingsTests.InfoFeed.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create isolated UserDefaults suite") - return - } - defer { defaults.removePersistentDomain(forName: suiteName) } - - let infoFeed = "https://example.com/custom/appcast.xml" - let resolved = UpdateChannelSettings.resolvedFeedURLString(infoFeedURL: infoFeed, defaults: defaults) - XCTAssertEqual(resolved.url, infoFeed) - XCTAssertFalse(resolved.isNightly) - XCTAssertFalse(resolved.usedFallback) - } - - func testResolvedFeedUsesNightlyWhenPreferenceEnabled() { - let suiteName = "UpdateChannelSettingsTests.Nightly.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create isolated UserDefaults suite") - return - } - defer { defaults.removePersistentDomain(forName: suiteName) } - - defaults.set(true, forKey: UpdateChannelSettings.includeNightlyBuildsKey) - let resolved = UpdateChannelSettings.resolvedFeedURLString( - infoFeedURL: "https://example.com/custom/appcast.xml", - defaults: defaults - ) - XCTAssertEqual(resolved.url, UpdateChannelSettings.nightlyFeedURL) - XCTAssertTrue(resolved.isNightly) - XCTAssertFalse(resolved.usedFallback) - } -} - final class WorkspaceReorderTests: XCTestCase { @MainActor func testReorderWorkspaceMovesWorkspaceToRequestedIndex() {