Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 28 additions & 9 deletions .github/workflows/nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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}"
Expand Down Expand Up @@ -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
Expand All @@ -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")"
Expand All @@ -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")"
Expand All @@ -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: |
Expand All @@ -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

Expand Down
1 change: 0 additions & 1 deletion Sources/Update/UpdateController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ class UpdateController {
"SUEnableAutomaticChecks": false,
"SUSendProfileInfo": false,
"SUAutomaticallyUpdate": false,
UpdateChannelSettings.includeNightlyBuildsKey: UpdateChannelSettings.defaultIncludeNightlyBuilds,
])

let hostBundle = Bundle.main
Expand Down
34 changes: 9 additions & 25 deletions Sources/Update/UpdateDelegate.swift
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Return fallback feed URL when SUFeedURL is missing

When SUFeedURL is absent or empty in Info.plist (for example in custom/local builds or misconfigured packaging), this delegate now logs and records the stable fallback URL but still returns feedURL directly, which is nil/empty in that case. Sparkle then receives no usable feed URL and update checks can fail, whereas the previous implementation always returned a concrete fallback URL.

Useful? React with 👍 / 👎.

}

/// Called when an update is scheduled to install silently,
Expand Down
21 changes: 0 additions & 21 deletions Sources/cmuxApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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()
Expand Down
53 changes: 0 additions & 53 deletions cmuxTests/CmuxWebViewKeyEquivalentTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Loading