Skip to content

feat: add auto-updates #176

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Jun 2, 2025
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
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand Down
4 changes: 4 additions & 0 deletions Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import NetworkExtension
import os
import SDWebImageSVGCoder
import SDWebImageSwiftUI
import Sparkle
import SwiftUI
import UserNotifications
import VPNLib
Expand All @@ -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) {
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions Coder-Desktop/Coder-Desktop/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,7 @@
<string>Ae2oQLTcx89/a73XrpOt+IVvqdo+fMTjo3UKEm77VdA=</string>
<key>CommitHash</key>
<string>$(GIT_COMMIT_HASH)</string>
<key>SUFeedURL</key>
<string>https://releases.coder.com/coder-desktop/mac/appcast.xml</string>
</dict>
</plist>
87 changes: 87 additions & 0 deletions Coder-Desktop/Coder-Desktop/UpdaterService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
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! {
didSet {
if let autoCheckForUpdates, 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
autoCheckForUpdates = updater.automaticallyChecksForUpdates
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<String> {
// 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<NSString>,
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
}
}
2 changes: 1 addition & 1 deletion Coder-Desktop/Coder-Desktop/VPN/VPNSystemExtension.swift
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ class SystemExtensionDelegate<AsyncDelegate: SystemExtensionAsyncRecorder>:
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
Expand Down
19 changes: 15 additions & 4 deletions Coder-Desktop/Coder-Desktop/Views/Settings/GeneralTab.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import SwiftUI

struct GeneralTab: View {
@EnvironmentObject var state: AppState
@EnvironmentObject var updater: UpdaterService
var body: some View {
Form {
Section {
Expand All @@ -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()
}
4 changes: 2 additions & 2 deletions Coder-Desktop/project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 15 additions & 31 deletions scripts/update-cask.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@ set -euo pipefail
usage() {
echo "Usage: $0 [--version <version>] [--assignee <github handle>]"
echo " --version <version> Set the VERSION variable to fetch and generate the cask file for"
echo " --assignee <github handle> Set the ASSIGNE variable to assign the PR to (optional)"
echo " --assignee <github handle> 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
Expand All @@ -19,7 +19,7 @@ while [[ "$#" -gt 0 ]]; do
shift 2
;;
--assignee)
ASSIGNE="$2"
ASSIGNEE="$2"
shift 2
;;
-h | --help)
Expand All @@ -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
}
Expand All @@ -54,55 +54,39 @@ 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)
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"

# 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
mkdir -p "$TAP_CHECKOUT_FOLDER"/Casks

# Overwrite the cask file
cat >"$TAP_CHECHOUT_FOLDER"/Casks/coder-desktop${SUFFIX}.rb <<EOF
cask "coder-desktop${SUFFIX}" do
cat >"$TAP_CHECKOUT_FOLDER"/Casks/coder-desktop.rb <<EOF
cask "coder-desktop" do
version "${VERSION#v}"
sha256 $([ "$IS_PREVIEW" = true ] && echo ":no_check" || echo "\"${HASH}\"")
sha256 "${HASH}"

url "https://github.com/coder/coder-desktop-macos/releases/download/$([ "$IS_PREVIEW" = true ] && echo "${TAG}" || echo "v#{version}")/Coder-Desktop.pkg"
url "https://github.com/coder/coder-desktop-macos/releases/download/v#{version}/Coder-Desktop.pkg"
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"

pkg "Coder-Desktop.pkg"
Expand Down Expand Up @@ -132,5 +116,5 @@ if [[ "$pr_count" -eq 0 ]]; then
--base master --head "$BREW_BRANCH" \
--title "Coder Desktop $VERSION" \
--body "This automatic PR was triggered by the release of Coder Desktop $VERSION" \
${ASSIGNE:+ --assignee "$ASSIGNE" --reviewer "$ASSIGNE"}
${ASSIGNEE:+ --assignee "$ASSIGNEE" --reviewer "$ASSIGNEE"}
fi
Loading