Skip to content
Open
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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ jobs:
- name: Validate release asset guard
run: node scripts/release_asset_guard.test.js

- name: Validate app signing helper
run: ./tests/test_ci_codesign_app_bundle.sh

- name: Validate current GhosttyKit checksum pin
run: ./tests/test_ci_ghosttykit_checksum_present.sh

Expand Down
11 changes: 1 addition & 10 deletions .github/workflows/nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -368,16 +368,7 @@ jobs:
for APP_PATH in \
"build-universal/Build/Products/Release/cmux NIGHTLY.app"
do
CLI_PATH="$APP_PATH/Contents/Resources/bin/cmux"
HELPER_PATH="$APP_PATH/Contents/Resources/bin/ghostty"
if [ -f "$CLI_PATH" ]; then
/usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" "$CLI_PATH"
fi
if [ -f "$HELPER_PATH" ]; then
/usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" "$HELPER_PATH"
fi
/usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" --deep "$APP_PATH"
/usr/bin/codesign --verify --deep --strict --verbose=2 "$APP_PATH"
./scripts/codesign_app_bundle.sh "$APP_PATH" "$APPLE_SIGNING_IDENTITY" "$ENTITLEMENTS"
done

- name: Notarize apps and dmgs
Expand Down
11 changes: 1 addition & 10 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -235,16 +235,7 @@ jobs:
fi
APP_PATH="build/Build/Products/Release/cmux.app"
ENTITLEMENTS="cmux.entitlements"
CLI_PATH="$APP_PATH/Contents/Resources/bin/cmux"
HELPER_PATH="$APP_PATH/Contents/Resources/bin/ghostty"
if [ -f "$CLI_PATH" ]; then
/usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" "$CLI_PATH"
fi
if [ -f "$HELPER_PATH" ]; then
/usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" "$HELPER_PATH"
fi
/usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" --deep "$APP_PATH"
/usr/bin/codesign --verify --deep --strict --verbose=2 "$APP_PATH"
./scripts/codesign_app_bundle.sh "$APP_PATH" "$APPLE_SIGNING_IDENTITY" "$ENTITLEMENTS"

- name: Notarize app
if: steps.guard_release_assets.outputs.skip_all != 'true'
Expand Down
51 changes: 51 additions & 0 deletions Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -77797,6 +77797,57 @@
}
}
}
},
"update.downloadLatestDmg": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Download Latest DMG"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "最新のDMGをダウンロード"
}
}
}
},
"update.error.installationFailed.message": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "cmux couldn't install the downloaded update. Download the latest DMG to update manually."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "cmux はダウンロード済みのアップデートをインストールできませんでした。手動で更新するには最新のDMGをダウンロードしてください。"
}
}
}
},
"update.error.installationFailed.title": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Update Installation Failed"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "アップデートのインストールに失敗しました"
}
}
}
}
}
}
48 changes: 48 additions & 0 deletions Sources/Update/UpdateDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,20 @@ class UpdateDriver: NSObject, SPUUserDriver {
acknowledgement: @escaping () -> Void) {
let details = formatErrorForLog(error)
UpdateLogStore.shared.append("show updater error: \(details)")
if usesStandardPresentation,
let manualDownloadURL = UpdateViewModel.manualDownloadURL(for: error, infoFeedURLString: lastFeedURLString) {
clearCustomStateForStandardPresentation()
presentManualDownloadAlert(for: error, manualDownloadURL: manualDownloadURL) { [weak self] shouldRetry in
self?.finishUserInitiatedCheckPresentation()
acknowledgement()
guard shouldRetry else { return }
DispatchQueue.main.async {
guard let delegate = NSApp.delegate as? AppDelegate else { return }
delegate.checkForUpdates(nil)
}
}
return
}
if usesStandardPresentation {
clearCustomStateForStandardPresentation()
standard.showUpdaterError(error) { [weak self] in
Expand Down Expand Up @@ -457,6 +471,40 @@ class UpdateDriver: NSObject, SPUUserDriver {
}
}

private func presentManualDownloadAlert(
for error: any Error,
manualDownloadURL: URL,
completion: @escaping (Bool) -> Void
) {
runOnMain {
let alert = NSAlert()
alert.alertStyle = .warning
alert.messageText = UpdateViewModel.userFacingErrorTitle(for: error)
alert.informativeText = UpdateViewModel.userFacingErrorMessage(for: error)
alert.addButton(withTitle: String(localized: "update.downloadLatestDmg", defaultValue: "Download Latest DMG"))
alert.addButton(withTitle: String(localized: "common.retry", defaultValue: "Retry"))
alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK"))

let handleResponse: (NSApplication.ModalResponse) -> Void = { response in
switch response {
case .alertFirstButtonReturn:
NSWorkspace.shared.open(manualDownloadURL)
completion(false)
case .alertSecondButtonReturn:
completion(true)
default:
completion(false)
}
}

if let window = NSApp.keyWindow ?? NSApp.mainWindow {
alert.beginSheetModal(for: window, completionHandler: handleResponse)
} else {
handleResponse(alert.runModal())
}
}
}

private func runOnMain(_ action: @escaping () -> Void) {
if Thread.isMainThread {
action()
Expand Down
10 changes: 10 additions & 0 deletions Sources/Update/UpdatePopoverView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,7 @@ fileprivate struct UpdateErrorView: View {
technicalDetails: error.technicalDetails,
feedURLString: error.feedURLString
)
let manualDownloadURL = UpdateViewModel.manualDownloadURL(for: error)

VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
Expand Down Expand Up @@ -389,6 +390,15 @@ fileprivate struct UpdateErrorView: View {

Spacer()

if let manualDownloadURL {
Button(String(localized: "update.downloadLatestDmg", defaultValue: "Download Latest DMG")) {
error.dismiss()
dismiss()
NSWorkspace.shared.open(manualDownloadURL)
}
.controlSize(.small)
}

Button(String(localized: "common.retry", defaultValue: "Retry")) {
error.retry()
dismiss()
Expand Down
44 changes: 41 additions & 3 deletions Sources/Update/UpdateViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import SwiftUI
import Sparkle

class UpdateViewModel: ObservableObject {
static let manualDownloadDMGURLString = "https://github.com/manaflow-ai/cmux/releases/latest/download/cmux-macos.dmg"
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
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 Choose manual DMG URL by update channel

The new fallback link is hardcoded to the stable asset (releases/latest/download/cmux-macos.dmg), but nightly builds use a different feed and asset (releases/download/nightly/appcast.xml and cmux-nightly-macos.dmg in .github/workflows/nightly.yml). That means NIGHTLY users who hit these Sparkle install errors and click “Download Latest DMG” are sent to the stable installer, which silently switches them off the nightly channel. Please derive the manual-download URL from the resolved update channel/feed instead of using a single stable URL.

Useful? React with 👍 / 👎.

static let manualDownloadNightlyDMGURLString = "https://github.com/manaflow-ai/cmux/releases/download/nightly/cmux-nightly-macos.dmg"

@Published var state: UpdateState = .idle
@Published var overrideState: UpdateState?
@Published var detectedUpdateVersion: String?
Expand Down Expand Up @@ -235,8 +238,8 @@ class UpdateViewModel: ObservableObject {
}
if nsError.domain == SUSparkleErrorDomain {
switch nsError.code {
case 4005:
return String(localized: "update.error.permissionError.title", defaultValue: "Updater Permission Error")
case 4000, 4001, 4002, 4003, 4004, 4005, 4010, 4012:
return String(localized: "update.error.installationFailed.title", defaultValue: "Update Installation Failed")
case 2001:
return String(localized: "update.error.downloadFailed.title", defaultValue: "Couldn't Download Update")
case 1000, 1002:
Expand Down Expand Up @@ -282,6 +285,8 @@ class UpdateViewModel: ObservableObject {
}
if nsError.domain == SUSparkleErrorDomain {
switch nsError.code {
case 4000, 4001, 4002, 4003, 4004, 4005, 4010, 4012:
return String(localized: "update.error.installationFailed.message", defaultValue: "cmux couldn't install the downloaded update. Download the latest DMG to update manually.")
case 2001:
return String(localized: "update.error.feedDownload.message", defaultValue: "cmux couldn't download the update feed. Check your connection and try again.")
case 1000, 1002:
Expand All @@ -292,7 +297,7 @@ class UpdateViewModel: ObservableObject {
return String(localized: "update.error.insecureFeed.message", defaultValue: "The update feed is insecure. Please contact support.")
case 1, 2, 3001, 3002:
return String(localized: "update.error.signatureError.message", defaultValue: "The update's signature could not be verified. Please try again later.")
case 1003, 1005, 4005:
case 1003, 1005:
return String(localized: "update.error.permissionError.message", defaultValue: "Move cmux into Applications and relaunch to enable updates.")
default:
break
Expand Down Expand Up @@ -340,6 +345,18 @@ class UpdateViewModel: ObservableObject {
return lines.joined(separator: "\n")
}

static func manualDownloadURL(for error: Swift.Error, infoFeedURLString: String? = nil) -> URL? {
let nsError = error as NSError
guard shouldOfferManualDownload(for: nsError) else { return nil }
let infoFeedURL = infoFeedURLString ?? (Bundle.main.object(forInfoDictionaryKey: "SUFeedURL") as? String)
let isNightly = UpdateFeedResolver.resolvedFeedURLString(infoFeedURL: infoFeedURL).isNightly
return URL(string: isNightly ? manualDownloadNightlyDMGURLString : manualDownloadDMGURLString)
}

static func manualDownloadURL(for updateError: UpdateState.Error) -> URL? {
manualDownloadURL(for: updateError.error, infoFeedURLString: updateError.feedURLString)
}

private static func networkError(from error: NSError) -> NSError? {
if error.domain == NSURLErrorDomain {
return error
Expand All @@ -357,6 +374,14 @@ class UpdateViewModel: ObservableObject {
case 2: return "SUInsufficientSigningError"
case 3: return "SUInsecureFeedURLError"
case 4: return "SUInvalidFeedURLError"
case 4000: return "SUFileCopyFailure"
case 4001: return "SUAuthenticationFailure"
case 4002: return "SUMissingUpdateError"
case 4003: return "SUMissingInstallerToolError"
case 4004: return "SURelaunchError"
case 4005: return "SUInstallationError"
case 4010: return "SUAgentInvalidationError"
case 4012: return "SUInstallationWriteNoPermissionError"
case 1000: return "SUAppcastParseError"
case 1001: return "SUNoUpdateError"
case 1002: return "SUAppcastError"
Expand All @@ -370,6 +395,19 @@ class UpdateViewModel: ObservableObject {
}
}

private static func shouldOfferManualDownload(for error: NSError) -> Bool {
guard error.domain == SUSparkleErrorDomain else {
return false
}

switch error.code {
case 4000, 4001, 4002, 4003, 4004, 4005, 4010, 4012:
return true
default:
return false
}
}

static func normalizedDetectedUpdateVersion(from version: String) -> String? {
let trimmed = version.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
Expand Down
91 changes: 91 additions & 0 deletions cmuxTests/ShortcutAndCommandPaletteTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import WebKit
import ObjectiveC.runtime
import Bonsplit
import UserNotifications
import Sparkle

#if canImport(cmux_DEV)
@testable import cmux_DEV
Expand Down Expand Up @@ -949,6 +950,96 @@ final class UpdateViewModelPresentationTests: XCTestCase {
XCTAssertFalse(viewModel.showsDetectedBackgroundUpdate)
XCTAssertEqual(viewModel.text, "Checking for Updates…")
}

func testSparkleInstallationErrorUsesManualInstallCopy() {
let error = NSError(
domain: SUSparkleErrorDomain,
code: 4005,
userInfo: [NSLocalizedDescriptionKey: "Failed to create installation cache directory"]
)

XCTAssertEqual(UpdateViewModel.userFacingErrorTitle(for: error), "Update Installation Failed")
XCTAssertEqual(
UpdateViewModel.userFacingErrorMessage(for: error),
"cmux couldn't install the downloaded update. Download the latest DMG to update manually."
)
}

func testSparkleInstallationErrorOffersManualDownloadURL() {
let error = NSError(
domain: SUSparkleErrorDomain,
code: 4005,
userInfo: [NSLocalizedDescriptionKey: "Failed to create installation cache directory"]
)

XCTAssertEqual(
UpdateViewModel.manualDownloadURL(
for: error,
infoFeedURLString: UpdateFeedResolver.fallbackFeedURL
)?.absoluteString,
"https://github.com/manaflow-ai/cmux/releases/latest/download/cmux-macos.dmg"
)
}

func testSparkleInstallationErrorOffersNightlyManualDownloadURL() {
let error = NSError(
domain: SUSparkleErrorDomain,
code: 4005,
userInfo: [NSLocalizedDescriptionKey: "Failed to create installation cache directory"]
)

XCTAssertEqual(
UpdateViewModel.manualDownloadURL(
for: error,
infoFeedURLString: "https://github.com/manaflow-ai/cmux/releases/download/nightly/appcast.xml"
)?.absoluteString,
"https://github.com/manaflow-ai/cmux/releases/download/nightly/cmux-nightly-macos.dmg"
)
}

func testUpdateStateErrorManualDownloadURLUsesFeedURLString() {
let updateError = UpdateState.Error(
error: NSError(
domain: SUSparkleErrorDomain,
code: 4005,
userInfo: [NSLocalizedDescriptionKey: "Failed to create installation cache directory"]
),
retry: {},
dismiss: {},
feedURLString: "https://github.com/manaflow-ai/cmux/releases/download/nightly/appcast.xml"
)

XCTAssertEqual(
UpdateViewModel.manualDownloadURL(for: updateError)?.absoluteString,
"https://github.com/manaflow-ai/cmux/releases/download/nightly/cmux-nightly-macos.dmg"
)
}

func testSparkleInstallationErrorDetailsUseSparkleCodeName() {
let error = NSError(
domain: SUSparkleErrorDomain,
code: 4005,
userInfo: [NSLocalizedDescriptionKey: "Failed to create installation cache directory"]
)

let details = UpdateViewModel.errorDetails(
for: error,
technicalDetails: nil,
feedURLString: nil
)

XCTAssertTrue(details.contains("Code: SUInstallationError (4005)"))
}

func testNetworkErrorDoesNotOfferManualDownloadURL() {
let error = NSError(
domain: NSURLErrorDomain,
code: NSURLErrorTimedOut,
userInfo: [NSLocalizedDescriptionKey: "Timed out"]
)

XCTAssertNil(UpdateViewModel.manualDownloadURL(for: error))
}
}

@MainActor
Expand Down
Loading
Loading