From 6a86c64fec2e806d655c066650c44be401436268 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Mon, 2 Jun 2025 12:46:58 +1000 Subject: [PATCH 1/5] feat: add auto-updates --- .../Coder-Desktop/Coder_DesktopApp.swift | 4 + Coder-Desktop/Coder-Desktop/Info.plist | 2 + .../Coder-Desktop/UpdaterService.swift | 86 +++++++++++++++++++ .../VPN/VPNSystemExtension.swift | 2 +- .../Views/Settings/GeneralTab.swift | 19 +++- Coder-Desktop/project.yml | 4 +- scripts/update-cask.sh | 1 + 7 files changed, 111 insertions(+), 7 deletions(-) create mode 100644 Coder-Desktop/Coder-Desktop/UpdaterService.swift diff --git a/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift b/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift index 35aed082..3080e8c1 100644 --- a/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift +++ b/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift @@ -3,6 +3,7 @@ import NetworkExtension import os import SDWebImageSVGCoder import SDWebImageSwiftUI +import Sparkle import SwiftUI import UserNotifications import VPNLib @@ -26,6 +27,7 @@ struct DesktopApp: App { .environmentObject(appDelegate.vpn) .environmentObject(appDelegate.state) .environmentObject(appDelegate.helper) + .environmentObject(appDelegate.autoUpdater) } .windowResizability(.contentSize) Window("Coder File Sync", id: Windows.fileSync.rawValue) { @@ -47,11 +49,13 @@ class AppDelegate: NSObject, NSApplicationDelegate { let urlHandler: URLHandler let notifDelegate: NotifDelegate let helper: HelperService + let autoUpdater: UpdaterService override init() { notifDelegate = NotifDelegate() vpn = CoderVPNService() helper = HelperService() + autoUpdater = UpdaterService() let state = AppState(onChange: vpn.configureTunnelProviderProtocol) vpn.onStart = { // We don't need this to have finished before the VPN actually starts diff --git a/Coder-Desktop/Coder-Desktop/Info.plist b/Coder-Desktop/Coder-Desktop/Info.plist index bb759f6b..f127b2c0 100644 --- a/Coder-Desktop/Coder-Desktop/Info.plist +++ b/Coder-Desktop/Coder-Desktop/Info.plist @@ -35,5 +35,7 @@ Ae2oQLTcx89/a73XrpOt+IVvqdo+fMTjo3UKEm77VdA= CommitHash $(GIT_COMMIT_HASH) + SUFeedURL + https://releases.coder.com/coder-desktop/mac/appcast.xml diff --git a/Coder-Desktop/Coder-Desktop/UpdaterService.swift b/Coder-Desktop/Coder-Desktop/UpdaterService.swift new file mode 100644 index 00000000..7b8d9227 --- /dev/null +++ b/Coder-Desktop/Coder-Desktop/UpdaterService.swift @@ -0,0 +1,86 @@ +import Sparkle +import SwiftUI + +final class UpdaterService: NSObject, ObservableObject { + private lazy var inner: SPUStandardUpdaterController = .init( + startingUpdater: true, + updaterDelegate: self, + userDriverDelegate: self, + ) + private var updater: SPUUpdater! + @Published var canCheckForUpdates = true + + @Published var autoCheckForUpdates: Bool = false { + didSet { + if autoCheckForUpdates != oldValue { + updater.automaticallyChecksForUpdates = autoCheckForUpdates + } + } + } + + @Published var updateChannel: UpdateChannel { + didSet { + UserDefaults.standard.set(updateChannel.rawValue, forKey: Self.updateChannelKey) + } + } + + static let updateChannelKey = "updateChannel" + + override init() { + updateChannel = UserDefaults.standard.string(forKey: Self.updateChannelKey) + .flatMap { UpdateChannel(rawValue: $0) } ?? .stable + super.init() + updater = inner.updater + updater.publisher(for: \.canCheckForUpdates).assign(to: &$canCheckForUpdates) + } + + func checkForUpdates() { + guard canCheckForUpdates else { return } + updater.checkForUpdates() + } +} + +enum UpdateChannel: String, CaseIterable, Identifiable { + case stable + case preview + + var name: String { + switch self { + case .stable: + "Stable" + case .preview: + "Preview" + } + } + + var id: String { rawValue } +} + +extension UpdaterService: SPUUpdaterDelegate { + func allowedChannels(for _: SPUUpdater) -> Set { + // There's currently no point in subscribing to both channels, as + // preview >= stable + [updateChannel.rawValue] + } +} + +extension UpdaterService: SUVersionDisplay { + func formatUpdateVersion( + fromUpdate update: SUAppcastItem, + andBundleDisplayVersion inOutBundleDisplayVersion: AutoreleasingUnsafeMutablePointer, + withBundleVersion bundleVersion: String + ) -> String { + // Replace CFBundleShortVersionString with CFBundleVersion, as the + // latter shows build numbers. + inOutBundleDisplayVersion.pointee = bundleVersion as NSString + // This is already CFBundleVersion, as that's the only version in the + // appcast. + return update.displayVersionString + } +} + +extension UpdaterService: SPUStandardUserDriverDelegate { + func standardUserDriverRequestsVersionDisplayer() -> (any SUVersionDisplay)? { + self + } +} diff --git a/Coder-Desktop/Coder-Desktop/VPN/VPNSystemExtension.swift b/Coder-Desktop/Coder-Desktop/VPN/VPNSystemExtension.swift index 6b242020..cb8db684 100644 --- a/Coder-Desktop/Coder-Desktop/VPN/VPNSystemExtension.swift +++ b/Coder-Desktop/Coder-Desktop/VPN/VPNSystemExtension.swift @@ -174,7 +174,7 @@ class SystemExtensionDelegate: actionForReplacingExtension existing: OSSystemExtensionProperties, withExtension extension: OSSystemExtensionProperties ) -> OSSystemExtensionRequest.ReplacementAction { - logger.info("Replacing \(request.identifier) v\(existing.bundleVersion) with v\(`extension`.bundleVersion)") + logger.info("Replacing \(request.identifier) \(existing.bundleVersion) with \(`extension`.bundleVersion)") // This is counterintuitive, but this function is only called if the // versions are the same in a dev environment. // In a release build, this only gets called when the version string is diff --git a/Coder-Desktop/Coder-Desktop/Views/Settings/GeneralTab.swift b/Coder-Desktop/Coder-Desktop/Views/Settings/GeneralTab.swift index 532d0f00..7af41e4b 100644 --- a/Coder-Desktop/Coder-Desktop/Views/Settings/GeneralTab.swift +++ b/Coder-Desktop/Coder-Desktop/Views/Settings/GeneralTab.swift @@ -3,6 +3,7 @@ import SwiftUI struct GeneralTab: View { @EnvironmentObject var state: AppState + @EnvironmentObject var updater: UpdaterService var body: some View { Form { Section { @@ -18,10 +19,20 @@ struct GeneralTab: View { Text("Start Coder Connect on launch") } } + Section { + Toggle(isOn: $updater.autoCheckForUpdates) { + Text("Automatically check for updates") + } + Picker("Update channel", selection: $updater.updateChannel) { + ForEach(UpdateChannel.allCases) { channel in + Text(channel.name).tag(channel) + } + } + HStack { + Spacer() + Button("Check for updates") { updater.checkForUpdates() }.disabled(!updater.canCheckForUpdates) + } + } }.formStyle(.grouped) } } - -#Preview { - GeneralTab() -} diff --git a/Coder-Desktop/project.yml b/Coder-Desktop/project.yml index 679afad0..166a1570 100644 --- a/Coder-Desktop/project.yml +++ b/Coder-Desktop/project.yml @@ -11,8 +11,8 @@ options: settings: base: - MARKETING_VERSION: ${MARKETING_VERSION} # Sets the version number. - CURRENT_PROJECT_VERSION: ${CURRENT_PROJECT_VERSION} # Sets the build number. + MARKETING_VERSION: ${MARKETING_VERSION} # Sets CFBundleShortVersionString + CURRENT_PROJECT_VERSION: ${CURRENT_PROJECT_VERSION} # CFBundleVersion GIT_COMMIT_HASH: ${GIT_COMMIT_HASH} ALWAYS_SEARCH_USER_PATHS: NO diff --git a/scripts/update-cask.sh b/scripts/update-cask.sh index 4277184a..3366752c 100755 --- a/scripts/update-cask.sh +++ b/scripts/update-cask.sh @@ -101,6 +101,7 @@ cask "coder-desktop${SUFFIX}" do name "Coder Desktop" desc "Native desktop client for Coder" homepage "https://github.com/coder/coder-desktop-macos" + auto_updates true conflicts_with cask: "coder/coder/${CONFLICTS_WITH}" depends_on macos: ">= :sonoma" From 1c7f4decd4c5a306f20b19500dce29de70de600f Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Mon, 2 Jun 2025 12:58:52 +1000 Subject: [PATCH 2/5] remove preview cask updating --- .github/workflows/release.yml | 4 ++-- scripts/update-cask.sh | 29 ++++++----------------------- 2 files changed, 8 insertions(+), 25 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 484d89e6..5138fe84 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -108,7 +108,7 @@ jobs: update-cask: name: Update homebrew-coder cask runs-on: ${{ github.repository_owner == 'coder' && 'depot-macos-latest' || 'macos-latest'}} - if: ${{ github.repository_owner == 'coder' && !inputs.dryrun }} + if: ${{ github.repository_owner == 'coder' && github.event_name == 'release' }} needs: build steps: - name: Checkout @@ -124,7 +124,7 @@ jobs: - name: Update homebrew-coder env: GH_TOKEN: ${{ secrets.CODERCI_GITHUB_TOKEN }} - RELEASE_TAG: ${{ github.event_name == 'release' && github.event.release.tag_name || 'preview' }} + RELEASE_TAG: ${{ github.event.release.tag_name }} ASSIGNEE: ${{ github.actor }} run: | git config --global user.email "ci@coder.com" diff --git a/scripts/update-cask.sh b/scripts/update-cask.sh index 3366752c..98081ec2 100755 --- a/scripts/update-cask.sh +++ b/scripts/update-cask.sh @@ -39,7 +39,7 @@ done echo "Error: VERSION cannot be empty" exit 1 } -[[ "$VERSION" =~ ^v || "$VERSION" == "preview" ]] || { +[[ "$VERSION" =~ ^v ]] || { echo "Error: VERSION must start with a 'v'" exit 1 } @@ -54,12 +54,6 @@ gh release download "$VERSION" \ HASH=$(shasum -a 256 "$GH_RELEASE_FOLDER"/Coder-Desktop.pkg | awk '{print $1}' | tr -d '\n') -IS_PREVIEW=false -if [[ "$VERSION" == "preview" ]]; then - IS_PREVIEW=true - VERSION=$(make 'print-CURRENT_PROJECT_VERSION' | sed 's/CURRENT_PROJECT_VERSION=//g') -fi - # Check out the homebrew tap repo TAP_CHECHOUT_FOLDER=$(mktemp -d) @@ -72,38 +66,27 @@ BREW_BRANCH="auto-release/desktop-$VERSION" # Check if a PR already exists. # Continue on a main branch release, as the sha256 will change. pr_count="$(gh pr list --search "head:$BREW_BRANCH" --json id,closed | jq -r ".[] | select(.closed == false) | .id" | wc -l)" -if [[ "$pr_count" -gt 0 && "$IS_PREVIEW" == false ]]; then +if [[ "$pr_count" -gt 0 ]]; then echo "Bailing out as PR already exists" 2>&1 exit 0 fi git checkout -b "$BREW_BRANCH" -# If this is a main branch build, append a preview suffix to the cask. -SUFFIX="" -CONFLICTS_WITH="coder-desktop-preview" -TAG=$VERSION -if [[ "$IS_PREVIEW" == true ]]; then - SUFFIX="-preview" - CONFLICTS_WITH="coder-desktop" - TAG="preview" -fi - mkdir -p "$TAP_CHECHOUT_FOLDER"/Casks # Overwrite the cask file -cat >"$TAP_CHECHOUT_FOLDER"/Casks/coder-desktop${SUFFIX}.rb <"$TAP_CHECHOUT_FOLDER"/Casks/coder-desktop.rb <= :sonoma" pkg "Coder-Desktop.pkg" From 543390b6cc531243df6ccd3de0f08eb53283299f Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Mon, 2 Jun 2025 13:00:45 +1000 Subject: [PATCH 3/5] once again I have been owned by the fact that I cannot sync the swiftc versions between local and CI --- Coder-Desktop/Coder-Desktop/UpdaterService.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Coder-Desktop/Coder-Desktop/UpdaterService.swift b/Coder-Desktop/Coder-Desktop/UpdaterService.swift index 7b8d9227..d5015d0a 100644 --- a/Coder-Desktop/Coder-Desktop/UpdaterService.swift +++ b/Coder-Desktop/Coder-Desktop/UpdaterService.swift @@ -5,7 +5,7 @@ final class UpdaterService: NSObject, ObservableObject { private lazy var inner: SPUStandardUpdaterController = .init( startingUpdater: true, updaterDelegate: self, - userDriverDelegate: self, + userDriverDelegate: self ) private var updater: SPUUpdater! @Published var canCheckForUpdates = true From 134c7bc49dc9e638d7c4a4d649e35bb3e062b003 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Mon, 2 Jun 2025 13:08:54 +1000 Subject: [PATCH 4/5] typos in update-cask --- scripts/update-cask.sh | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/scripts/update-cask.sh b/scripts/update-cask.sh index 98081ec2..a679fee4 100755 --- a/scripts/update-cask.sh +++ b/scripts/update-cask.sh @@ -4,12 +4,12 @@ set -euo pipefail usage() { echo "Usage: $0 [--version ] [--assignee ]" echo " --version Set the VERSION variable to fetch and generate the cask file for" - echo " --assignee Set the ASSIGNE variable to assign the PR to (optional)" + echo " --assignee Set the ASSIGNEE variable to assign the PR to (optional)" echo " -h, --help Display this help message" } VERSION="" -ASSIGNE="" +ASSIGNEE="" # Parse command line arguments while [[ "$#" -gt 0 ]]; do @@ -19,7 +19,7 @@ while [[ "$#" -gt 0 ]]; do shift 2 ;; --assignee) - ASSIGNE="$2" + ASSIGNEE="$2" shift 2 ;; -h | --help) @@ -55,11 +55,11 @@ gh release download "$VERSION" \ HASH=$(shasum -a 256 "$GH_RELEASE_FOLDER"/Coder-Desktop.pkg | awk '{print $1}' | tr -d '\n') # Check out the homebrew tap repo -TAP_CHECHOUT_FOLDER=$(mktemp -d) +TAP_CHECKOUT_FOLDER=$(mktemp -d) -gh repo clone "coder/homebrew-coder" "$TAP_CHECHOUT_FOLDER" +gh repo clone "coder/homebrew-coder" "$TAP_CHECKOUT_FOLDER" -cd "$TAP_CHECHOUT_FOLDER" +cd "$TAP_CHECKOUT_FOLDER" BREW_BRANCH="auto-release/desktop-$VERSION" @@ -73,10 +73,10 @@ fi git checkout -b "$BREW_BRANCH" -mkdir -p "$TAP_CHECHOUT_FOLDER"/Casks +mkdir -p "$TAP_CHECKOUT_FOLDER"/Casks # Overwrite the cask file -cat >"$TAP_CHECHOUT_FOLDER"/Casks/coder-desktop.rb <"$TAP_CHECKOUT_FOLDER"/Casks/coder-desktop.rb < Date: Mon, 2 Jun 2025 13:55:42 +1000 Subject: [PATCH 5/5] fixup --- Coder-Desktop/Coder-Desktop/UpdaterService.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Coder-Desktop/Coder-Desktop/UpdaterService.swift b/Coder-Desktop/Coder-Desktop/UpdaterService.swift index d5015d0a..23b86b84 100644 --- a/Coder-Desktop/Coder-Desktop/UpdaterService.swift +++ b/Coder-Desktop/Coder-Desktop/UpdaterService.swift @@ -10,9 +10,9 @@ final class UpdaterService: NSObject, ObservableObject { private var updater: SPUUpdater! @Published var canCheckForUpdates = true - @Published var autoCheckForUpdates: Bool = false { + @Published var autoCheckForUpdates: Bool! { didSet { - if autoCheckForUpdates != oldValue { + if let autoCheckForUpdates, autoCheckForUpdates != oldValue { updater.automaticallyChecksForUpdates = autoCheckForUpdates } } @@ -31,6 +31,7 @@ final class UpdaterService: NSObject, ObservableObject { .flatMap { UpdateChannel(rawValue: $0) } ?? .stable super.init() updater = inner.updater + autoCheckForUpdates = updater.automaticallyChecksForUpdates updater.publisher(for: \.canCheckForUpdates).assign(to: &$canCheckForUpdates) }