diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5138fe84..cd62aa6e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,7 +50,7 @@ jobs: - name: Authenticate to Google Cloud id: gcloud_auth - uses: google-github-actions/auth@71f986410dfbc7added4569d411d040a91dc6935 # v2.1.8 + uses: google-github-actions/auth@ba79af03959ebeac9769e648f473a284504d9193 # v2.1.10 with: workload_identity_provider: ${{ secrets.GCP_WORKLOAD_ID_PROVIDER }} service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }} @@ -103,7 +103,7 @@ jobs: popd gsutil -h "Cache-Control:no-cache,max-age=0" cp ./appcast.xml "gs://releases.coder.com/coder-desktop/mac/appcast.xml" env: - VERSION_DESCRIPTION: ${{ github.event_name == 'release' && github.event.release.body || '' }} + VERSION_DESCRIPTION: ${{ (github.event_name == 'release' && github.event.release.body) || (github.event_name == 'push' && github.event.head_commit.message) || '' }} update-cask: name: Update homebrew-coder cask diff --git a/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/1024.png b/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/1024.png index cc20c781..7ab987c4 100644 Binary files a/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/1024.png and b/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/1024.png differ diff --git a/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/128.png b/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/128.png index 5e20c554..82746ce3 100644 Binary files a/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/128.png and b/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/128.png differ diff --git a/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/128@2x.png b/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/128@2x.png new file mode 100644 index 00000000..bdb8b9ba Binary files /dev/null and b/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/128@2x.png differ diff --git a/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/16.png b/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/16.png index 70645cab..72cda2de 100644 Binary files a/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/16.png and b/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/16.png differ diff --git a/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/16@2x.png b/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/16@2x.png new file mode 100644 index 00000000..52ebf9d0 Binary files /dev/null and b/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/16@2x.png differ diff --git a/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/256.png b/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/256.png index 3d5fedb7..bdb8b9ba 100644 Binary files a/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/256.png and b/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/256.png differ diff --git a/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/32.png b/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/32.png index ee3b6142..52ebf9d0 100644 Binary files a/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/32.png and b/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/32.png differ diff --git a/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/32@2x.png b/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/32@2x.png new file mode 100644 index 00000000..1b4d34d8 Binary files /dev/null and b/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/32@2x.png differ diff --git a/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/512.png b/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/512.png index d4d68ed0..5a3a95b2 100644 Binary files a/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/512.png and b/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/512.png differ diff --git a/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/512@2x.png b/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/512@2x.png new file mode 100644 index 00000000..5a3a95b2 Binary files /dev/null and b/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/512@2x.png differ diff --git a/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/64.png b/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/64.png deleted file mode 100644 index b3b212ed..00000000 Binary files a/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/64.png and /dev/null differ diff --git a/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/Contents.json b/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/Contents.json index d4e03efc..417149d7 100644 --- a/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,68 +1,68 @@ { - "images" : [ + "images": [ { - "filename" : "16.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "16x16" + "filename": "16.png", + "idiom": "mac", + "scale": "1x", + "size": "16x16" }, { - "filename" : "32.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "16x16" + "filename": "16@2x.png", + "idiom": "mac", + "scale": "2x", + "size": "16x16" }, { - "filename" : "32.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "32x32" + "filename": "32.png", + "idiom": "mac", + "scale": "1x", + "size": "32x32" }, { - "filename" : "64.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "32x32" + "filename": "32@2x.png", + "idiom": "mac", + "scale": "2x", + "size": "32x32" }, { - "filename" : "128.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "128x128" + "filename": "128.png", + "idiom": "mac", + "scale": "1x", + "size": "128x128" }, { - "filename" : "256.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "128x128" + "filename": "128@2x.png", + "idiom": "mac", + "scale": "2x", + "size": "128x128" }, { - "filename" : "256.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "256x256" + "filename": "256.png", + "idiom": "mac", + "scale": "1x", + "size": "256x256" }, { - "filename" : "512.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "256x256" + "filename": "512.png", + "idiom": "mac", + "scale": "2x", + "size": "256x256" }, { - "filename" : "512.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "512x512" + "filename": "512@2x.png", + "idiom": "mac", + "scale": "1x", + "size": "512x512" }, { - "filename" : "1024.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "512x512" + "filename": "1024.png", + "idiom": "mac", + "scale": "2x", + "size": "512x512" } ], - "info" : { - "author" : "xcode", - "version" : 1 + "info": { + "author": "xcode", + "version": 1 } } diff --git a/Coder-Desktop/Coder-Desktop/Assets.xcassets/MenuBarIcon.imageset/Contents.json b/Coder-Desktop/Coder-Desktop/Assets.xcassets/MenuBarIcon.imageset/Contents.json index a0327138..5e75486c 100644 --- a/Coder-Desktop/Coder-Desktop/Assets.xcassets/MenuBarIcon.imageset/Contents.json +++ b/Coder-Desktop/Coder-Desktop/Assets.xcassets/MenuBarIcon.imageset/Contents.json @@ -1,40 +1,26 @@ { "images" : [ { - "filename" : "coder_icon_16_dark.png", - "idiom" : "mac", + "filename" : "logo.svg", + "idiom" : "universal", "scale" : "1x" }, { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "filename" : "coder_icon_16.png", - "idiom" : "mac", - "scale" : "1x" - }, - { - "filename" : "coder_icon_32_dark.png", - "idiom" : "mac", + "filename" : "logo.svg", + "idiom" : "universal", "scale" : "2x" }, { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "filename" : "coder_icon_32.png", - "idiom" : "mac", - "scale" : "2x" + "filename" : "logo.svg", + "idiom" : "universal", + "scale" : "3x" } ], "info" : { "author" : "xcode", "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" } } diff --git a/Coder-Desktop/Coder-Desktop/Assets.xcassets/MenuBarIcon.imageset/coder_icon_16.png b/Coder-Desktop/Coder-Desktop/Assets.xcassets/MenuBarIcon.imageset/coder_icon_16.png deleted file mode 100644 index 3112e48e..00000000 Binary files a/Coder-Desktop/Coder-Desktop/Assets.xcassets/MenuBarIcon.imageset/coder_icon_16.png and /dev/null differ diff --git a/Coder-Desktop/Coder-Desktop/Assets.xcassets/MenuBarIcon.imageset/coder_icon_16_dark.png b/Coder-Desktop/Coder-Desktop/Assets.xcassets/MenuBarIcon.imageset/coder_icon_16_dark.png deleted file mode 100644 index 884c9699..00000000 Binary files a/Coder-Desktop/Coder-Desktop/Assets.xcassets/MenuBarIcon.imageset/coder_icon_16_dark.png and /dev/null differ diff --git a/Coder-Desktop/Coder-Desktop/Assets.xcassets/MenuBarIcon.imageset/coder_icon_32.png b/Coder-Desktop/Coder-Desktop/Assets.xcassets/MenuBarIcon.imageset/coder_icon_32.png deleted file mode 100644 index 1e3ae4b9..00000000 Binary files a/Coder-Desktop/Coder-Desktop/Assets.xcassets/MenuBarIcon.imageset/coder_icon_32.png and /dev/null differ diff --git a/Coder-Desktop/Coder-Desktop/Assets.xcassets/MenuBarIcon.imageset/coder_icon_32_dark.png b/Coder-Desktop/Coder-Desktop/Assets.xcassets/MenuBarIcon.imageset/coder_icon_32_dark.png deleted file mode 100644 index 05bf4d41..00000000 Binary files a/Coder-Desktop/Coder-Desktop/Assets.xcassets/MenuBarIcon.imageset/coder_icon_32_dark.png and /dev/null differ diff --git a/Coder-Desktop/Coder-Desktop/Assets.xcassets/MenuBarIcon.imageset/logo.svg b/Coder-Desktop/Coder-Desktop/Assets.xcassets/MenuBarIcon.imageset/logo.svg new file mode 100644 index 00000000..57a37920 --- /dev/null +++ b/Coder-Desktop/Coder-Desktop/Assets.xcassets/MenuBarIcon.imageset/logo.svg @@ -0,0 +1,17 @@ + + + + + + + + + 0,4.24-5.66,4.24S0,7.97,0,5Z"/> + + + \ No newline at end of file diff --git a/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift b/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift index 3080e8c1..de12c6e1 100644 --- a/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift +++ b/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift @@ -84,6 +84,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { } func applicationDidFinishLaunching(_: Notification) { + // We have important file sync and network info behind tooltips, + // so the default delay is too long. + UserDefaults.standard.setValue(Theme.Animation.tooltipDelay, forKey: "NSInitialToolTipDelay") // Init SVG loader SDImageCodersManager.shared.addCoder(SDImageSVGCoder.shared) diff --git a/Coder-Desktop/Coder-Desktop/Info.plist b/Coder-Desktop/Coder-Desktop/Info.plist index f127b2c0..a9555823 100644 --- a/Coder-Desktop/Coder-Desktop/Info.plist +++ b/Coder-Desktop/Coder-Desktop/Info.plist @@ -37,5 +37,7 @@ $(GIT_COMMIT_HASH) SUFeedURL https://releases.coder.com/coder-desktop/mac/appcast.xml + SUAllowsAutomaticUpdates + diff --git a/Coder-Desktop/Coder-Desktop/Preview Content/PreviewVPN.swift b/Coder-Desktop/Coder-Desktop/Preview Content/PreviewVPN.swift index 4d4e9f90..91d5bf5e 100644 --- a/Coder-Desktop/Coder-Desktop/Preview Content/PreviewVPN.swift +++ b/Coder-Desktop/Coder-Desktop/Preview Content/PreviewVPN.swift @@ -5,21 +5,21 @@ import SwiftUI final class PreviewVPN: Coder_Desktop.VPNService { @Published var state: Coder_Desktop.VPNServiceState = .connected @Published var menuState: VPNMenuState = .init(agents: [ - UUID(): Agent(id: UUID(), name: "dev", status: .error, hosts: ["asdf.coder"], wsName: "dogfood2", + UUID(): Agent(id: UUID(), name: "dev", status: .no_recent_handshake, hosts: ["asdf.coder"], wsName: "dogfood2", wsID: UUID(), primaryHost: "asdf.coder"), UUID(): Agent(id: UUID(), name: "dev", status: .okay, hosts: ["asdf.coder"], wsName: "testing-a-very-long-name", wsID: UUID(), primaryHost: "asdf.coder"), - UUID(): Agent(id: UUID(), name: "dev", status: .warn, hosts: ["asdf.coder"], wsName: "opensrc", + UUID(): Agent(id: UUID(), name: "dev", status: .high_latency, hosts: ["asdf.coder"], wsName: "opensrc", wsID: UUID(), primaryHost: "asdf.coder"), UUID(): Agent(id: UUID(), name: "dev", status: .off, hosts: ["asdf.coder"], wsName: "gvisor", wsID: UUID(), primaryHost: "asdf.coder"), UUID(): Agent(id: UUID(), name: "dev", status: .off, hosts: ["asdf.coder"], wsName: "example", wsID: UUID(), primaryHost: "asdf.coder"), - UUID(): Agent(id: UUID(), name: "dev", status: .error, hosts: ["asdf.coder"], wsName: "dogfood2", + UUID(): Agent(id: UUID(), name: "dev", status: .no_recent_handshake, hosts: ["asdf.coder"], wsName: "dogfood2", wsID: UUID(), primaryHost: "asdf.coder"), UUID(): Agent(id: UUID(), name: "dev", status: .okay, hosts: ["asdf.coder"], wsName: "testing-a-very-long-name", wsID: UUID(), primaryHost: "asdf.coder"), - UUID(): Agent(id: UUID(), name: "dev", status: .warn, hosts: ["asdf.coder"], wsName: "opensrc", + UUID(): Agent(id: UUID(), name: "dev", status: .high_latency, hosts: ["asdf.coder"], wsName: "opensrc", wsID: UUID(), primaryHost: "asdf.coder"), UUID(): Agent(id: UUID(), name: "dev", status: .off, hosts: ["asdf.coder"], wsName: "gvisor", wsID: UUID(), primaryHost: "asdf.coder"), diff --git a/Coder-Desktop/Coder-Desktop/Theme.swift b/Coder-Desktop/Coder-Desktop/Theme.swift index c697f1e3..ca7e77c1 100644 --- a/Coder-Desktop/Coder-Desktop/Theme.swift +++ b/Coder-Desktop/Coder-Desktop/Theme.swift @@ -11,10 +11,13 @@ enum Theme { static let appIconWidth: CGFloat = 17 static let appIconHeight: CGFloat = 17 static let appIconSize: CGSize = .init(width: appIconWidth, height: appIconHeight) + + static let tableFooterIconSize: CGFloat = 28 } enum Animation { static let collapsibleDuration = 0.2 + static let tooltipDelay: Int = 250 // milliseconds } static let defaultVisibleAgents = 5 diff --git a/Coder-Desktop/Coder-Desktop/VPN/MenuState.swift b/Coder-Desktop/Coder-Desktop/VPN/MenuState.swift index c989c1d7..d13be3c6 100644 --- a/Coder-Desktop/Coder-Desktop/VPN/MenuState.swift +++ b/Coder-Desktop/Coder-Desktop/VPN/MenuState.swift @@ -1,4 +1,5 @@ import Foundation +import SwiftProtobuf import SwiftUI import VPNLib @@ -9,6 +10,29 @@ struct Agent: Identifiable, Equatable, Comparable, Hashable { let hosts: [String] let wsName: String let wsID: UUID + let lastPing: LastPing? + let lastHandshake: Date? + + init(id: UUID, + name: String, + status: AgentStatus, + hosts: [String], + wsName: String, + wsID: UUID, + lastPing: LastPing? = nil, + lastHandshake: Date? = nil, + primaryHost: String) + { + self.id = id + self.name = name + self.status = status + self.hosts = hosts + self.wsName = wsName + self.wsID = wsID + self.lastPing = lastPing + self.lastHandshake = lastHandshake + self.primaryHost = primaryHost + } // Agents are sorted by status, and then by name static func < (lhs: Agent, rhs: Agent) -> Bool { @@ -18,21 +42,94 @@ struct Agent: Identifiable, Equatable, Comparable, Hashable { return lhs.wsName.localizedCompare(rhs.wsName) == .orderedAscending } + var statusString: String { + switch status { + case .okay, .high_latency: + break + default: + return status.description + } + + guard let lastPing else { + // Either: + // - Old coder deployment + // - We haven't received any pings yet + return status.description + } + + let highLatencyWarning = status == .high_latency ? "(High latency)" : "" + + var str: String + if lastPing.didP2p { + str = """ + You're connected peer-to-peer. \(highLatencyWarning) + + You ↔ \(lastPing.latency.prettyPrintMs) ↔ \(wsName) + """ + } else { + str = """ + You're connected through a DERP relay. \(highLatencyWarning) + We'll switch over to peer-to-peer when available. + + Total latency: \(lastPing.latency.prettyPrintMs) + """ + // We're not guranteed to have the preferred DERP latency + if let preferredDerpLatency = lastPing.preferredDerpLatency { + str += "\nYou ↔ \(lastPing.preferredDerp): \(preferredDerpLatency.prettyPrintMs)" + let derpToWorkspaceEstLatency = lastPing.latency - preferredDerpLatency + // We're not guaranteed the preferred derp latency is less than + // the total, as they might have been recorded at slightly + // different times, and we don't want to show a negative value. + if derpToWorkspaceEstLatency > 0 { + str += "\n\(lastPing.preferredDerp) ↔ \(wsName): \(derpToWorkspaceEstLatency.prettyPrintMs)" + } + } + } + str += "\n\nLast handshake: \(lastHandshake?.relativeTimeString ?? "Unknown")" + return str + } + let primaryHost: String } +extension TimeInterval { + var prettyPrintMs: String { + let milliseconds = self * 1000 + return "\(milliseconds.formatted(.number.precision(.fractionLength(2)))) ms" + } +} + +struct LastPing: Equatable, Hashable { + let latency: TimeInterval + let didP2p: Bool + let preferredDerp: String + let preferredDerpLatency: TimeInterval? +} + enum AgentStatus: Int, Equatable, Comparable { case okay = 0 - case warn = 1 - case error = 2 - case off = 3 + case connecting = 1 + case high_latency = 2 + case no_recent_handshake = 3 + case off = 4 + + public var description: String { + switch self { + case .okay: "Connected" + case .connecting: "Connecting..." + case .high_latency: "Connected, but with high latency" // Message currently unused + case .no_recent_handshake: "Could not establish a connection to the agent. Retrying..." + case .off: "Offline" + } + } public var color: Color { switch self { case .okay: .green - case .warn: .yellow - case .error: .red + case .high_latency: .yellow + case .no_recent_handshake: .red case .off: .secondary + case .connecting: .yellow } } @@ -87,14 +184,27 @@ struct VPNMenuState { workspace.agents.insert(id) workspaces[wsID] = workspace + var lastPing: LastPing? + if agent.hasLastPing { + lastPing = LastPing( + latency: agent.lastPing.latency.timeInterval, + didP2p: agent.lastPing.didP2P, + preferredDerp: agent.lastPing.preferredDerp, + preferredDerpLatency: + agent.lastPing.hasPreferredDerpLatency + ? agent.lastPing.preferredDerpLatency.timeInterval + : nil + ) + } agents[id] = Agent( id: id, name: agent.name, - // If last handshake was not within last five minutes, the agent is unhealthy - status: agent.lastHandshake.date > Date.now.addingTimeInterval(-300) ? .okay : .warn, + status: agent.status, hosts: nonEmptyHosts, wsName: workspace.name, wsID: wsID, + lastPing: lastPing, + lastHandshake: agent.lastHandshake.maybeDate, // Hosts arrive sorted by length, the shortest looks best in the UI. primaryHost: nonEmptyHosts.first! ) @@ -154,3 +264,49 @@ struct VPNMenuState { workspaces.removeAll() } } + +extension Date { + var relativeTimeString: String { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .full + if Date.now.timeIntervalSince(self) < 1.0 { + // Instead of showing "in 0 seconds" + return "Just now" + } + return formatter.localizedString(for: self, relativeTo: Date.now) + } +} + +extension SwiftProtobuf.Google_Protobuf_Timestamp { + var maybeDate: Date? { + guard seconds > 0 else { return nil } + return date + } +} + +extension Vpn_Agent { + var healthyLastHandshakeMin: Date { + Date.now.addingTimeInterval(-300) // 5 minutes ago + } + + var healthyPingMax: TimeInterval { 0.15 } // 150ms + + var status: AgentStatus { + // Initially the handshake is missing + guard let lastHandshake = lastHandshake.maybeDate else { + return .connecting + } + // If last handshake was not within the last five minutes, the agent + // is potentially unhealthy. + guard lastHandshake >= healthyLastHandshakeMin else { + return .no_recent_handshake + } + // No ping data, but we have a recent handshake. + // We show green for backwards compatibility with old Coder + // deployments. + guard hasLastPing else { + return .okay + } + return lastPing.latency.timeInterval < healthyPingMax ? .okay : .high_latency + } +} diff --git a/Coder-Desktop/Coder-Desktop/Views/CircularProgressView.swift b/Coder-Desktop/Coder-Desktop/Views/CircularProgressView.swift index fc359e83..7b143969 100644 --- a/Coder-Desktop/Coder-Desktop/Views/CircularProgressView.swift +++ b/Coder-Desktop/Coder-Desktop/Views/CircularProgressView.swift @@ -8,45 +8,35 @@ struct CircularProgressView: View { var primaryColor: Color = .secondary var backgroundColor: Color = .secondary.opacity(0.3) - @State private var rotation = 0.0 - @State private var trimAmount: CGFloat = 0.15 - var autoCompleteThreshold: Float? var autoCompleteDuration: TimeInterval? var body: some View { ZStack { - // Background circle - Circle() - .stroke(backgroundColor, style: StrokeStyle(lineWidth: strokeWidth, lineCap: .round)) - .frame(width: diameter, height: diameter) - Group { - if let value { - // Determinate gauge + if let value { + ZStack { + Circle() + .stroke(backgroundColor, style: StrokeStyle(lineWidth: strokeWidth, lineCap: .round)) + Circle() .trim(from: 0, to: CGFloat(displayValue(for: value))) .stroke(primaryColor, style: StrokeStyle(lineWidth: strokeWidth, lineCap: .round)) - .frame(width: diameter, height: diameter) .rotationEffect(.degrees(-90)) .animation(autoCompleteAnimation(for: value), value: value) - } else { - // Indeterminate gauge - Circle() - .trim(from: 0, to: trimAmount) - .stroke(primaryColor, style: StrokeStyle(lineWidth: strokeWidth, lineCap: .round)) - .frame(width: diameter, height: diameter) - .rotationEffect(.degrees(rotation)) } + .frame(width: diameter, height: diameter) + + } else { + IndeterminateSpinnerView( + diameter: diameter, + strokeWidth: strokeWidth, + primaryColor: NSColor(primaryColor), + backgroundColor: NSColor(backgroundColor) + ) + .frame(width: diameter, height: diameter) } } .frame(width: diameter + strokeWidth * 2, height: diameter + strokeWidth * 2) - .onAppear { - if value == nil { - withAnimation(.linear(duration: 0.8).repeatForever(autoreverses: false)) { - rotation = 360 - } - } - } } private func displayValue(for value: Float) -> Float { @@ -78,3 +68,55 @@ extension CircularProgressView { return view } } + +// We note a constant >10% CPU usage when using a SwiftUI rotation animation that +// repeats forever, while this implementation, using Core Animation, uses <1% CPU. +struct IndeterminateSpinnerView: NSViewRepresentable { + var diameter: CGFloat + var strokeWidth: CGFloat + var primaryColor: NSColor + var backgroundColor: NSColor + + func makeNSView(context _: Context) -> NSView { + let view = NSView(frame: NSRect(x: 0, y: 0, width: diameter, height: diameter)) + view.wantsLayer = true + + guard let viewLayer = view.layer else { return view } + + let fullPath = NSBezierPath( + ovalIn: NSRect(x: 0, y: 0, width: diameter, height: diameter) + ).cgPath + + let backgroundLayer = CAShapeLayer() + backgroundLayer.path = fullPath + backgroundLayer.strokeColor = backgroundColor.cgColor + backgroundLayer.fillColor = NSColor.clear.cgColor + backgroundLayer.lineWidth = strokeWidth + viewLayer.addSublayer(backgroundLayer) + + let foregroundLayer = CAShapeLayer() + + foregroundLayer.frame = viewLayer.bounds + foregroundLayer.path = fullPath + foregroundLayer.strokeColor = primaryColor.cgColor + foregroundLayer.fillColor = NSColor.clear.cgColor + foregroundLayer.lineWidth = strokeWidth + foregroundLayer.lineCap = .round + foregroundLayer.strokeStart = 0 + foregroundLayer.strokeEnd = 0.15 + viewLayer.addSublayer(foregroundLayer) + + let rotationAnimation = CABasicAnimation(keyPath: "transform.rotation") + rotationAnimation.fromValue = 0 + rotationAnimation.toValue = 2 * Double.pi + rotationAnimation.duration = 1.0 + rotationAnimation.repeatCount = .infinity + rotationAnimation.isRemovedOnCompletion = false + + foregroundLayer.add(rotationAnimation, forKey: "rotationAnimation") + + return view + } + + func updateNSView(_: NSView, context _: Context) {} +} diff --git a/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift b/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift index 74006359..302bd135 100644 --- a/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift +++ b/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift @@ -47,7 +47,7 @@ struct FileSyncConfig: View { } }) .frame(minWidth: 400, minHeight: 200) - .padding(.bottom, 25) + .padding(.bottom, Theme.Size.tableFooterIconSize) .overlay(alignment: .bottom) { tableFooter } @@ -121,8 +121,8 @@ struct FileSyncConfig: View { Button { addingNewSession = true } label: { - Image(systemName: "plus") - .frame(width: 24, height: 24).help("Create") + FooterIcon(systemName: "plus") + .help("Create") }.disabled(vpn.menuState.agents.isEmpty) sessionControls } @@ -139,21 +139,25 @@ struct FileSyncConfig: View { Divider() Button { Task { await delete(session: selectedSession) } } label: { - Image(systemName: "minus").frame(width: 24, height: 24).help("Terminate") + FooterIcon(systemName: "minus") + .help("Terminate") } Divider() Button { Task { await pauseResume(session: selectedSession) } } label: { if selectedSession.status.isResumable { - Image(systemName: "play").frame(width: 24, height: 24).help("Pause") + FooterIcon(systemName: "play") + .help("Resume") } else { - Image(systemName: "pause").frame(width: 24, height: 24).help("Resume") + FooterIcon(systemName: "pause") + .help("Pause") } } Divider() Button { Task { await reset(session: selectedSession) } } label: { - Image(systemName: "arrow.clockwise").frame(width: 24, height: 24).help("Reset") + FooterIcon(systemName: "arrow.clockwise") + .help("Reset") } } } @@ -199,6 +203,18 @@ struct FileSyncConfig: View { } } +struct FooterIcon: View { + let systemName: String + + var body: some View { + Image(systemName: systemName) + .frame( + width: Theme.Size.tableFooterIconSize, + height: Theme.Size.tableFooterIconSize + ) + } +} + #if DEBUG #Preview { FileSyncConfig() diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift index 3b92dc9d..880241a0 100644 --- a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift +++ b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift @@ -21,6 +21,13 @@ enum VPNMenuItem: Equatable, Comparable, Identifiable { } } + var statusString: String { + switch self { + case let .agent(agent): agent.statusString + case .offlineWorkspace: status.description + } + } + var id: UUID { switch self { case let .agent(agent): agent.id @@ -224,13 +231,16 @@ struct MenuItemIcons: View { StatusDot(color: item.status.color) .padding(.trailing, 3) .padding(.top, 1) + .help(item.statusString) MenuItemIconButton(systemName: "doc.on.doc", action: copyToClipboard) .font(.system(size: 9)) .symbolVariant(.fill) + .help("Copy hostname") MenuItemIconButton(systemName: "globe", action: { openURL(wsURL) }) .contentShape(Rectangle()) .font(.system(size: 12)) .padding(.trailing, Theme.Size.trayMargin) + .help("Open in browser") } } diff --git a/Coder-Desktop/Coder-DesktopTests/AgentsTests.swift b/Coder-Desktop/Coder-DesktopTests/AgentsTests.swift index 741b32e5..8f84ab3d 100644 --- a/Coder-Desktop/Coder-DesktopTests/AgentsTests.swift +++ b/Coder-Desktop/Coder-DesktopTests/AgentsTests.swift @@ -28,6 +28,7 @@ struct AgentsTests { hosts: ["a\($0).coder"], wsName: "ws\($0)", wsID: UUID(), + lastPing: nil, primaryHost: "a\($0).coder" ) return (agent.id, agent) diff --git a/Coder-Desktop/Coder-DesktopTests/VPNMenuStateTests.swift b/Coder-Desktop/Coder-DesktopTests/VPNMenuStateTests.swift index d82aff8e..dbd61a93 100644 --- a/Coder-Desktop/Coder-DesktopTests/VPNMenuStateTests.swift +++ b/Coder-Desktop/Coder-DesktopTests/VPNMenuStateTests.swift @@ -18,6 +18,10 @@ struct VPNMenuStateTests { $0.workspaceID = workspaceID.uuidData $0.name = "dev" $0.lastHandshake = .init(date: Date.now) + $0.lastPing = .with { + $0.latency = .init(floatLiteral: 0.05) + $0.didP2P = true + } $0.fqdn = ["foo.coder"] } @@ -29,6 +33,9 @@ struct VPNMenuStateTests { #expect(storedAgent.wsName == "foo") #expect(storedAgent.primaryHost == "foo.coder") #expect(storedAgent.status == .okay) + #expect(storedAgent.statusString.contains("You're connected peer-to-peer.")) + #expect(storedAgent.statusString.contains("You ↔ 50.00 ms ↔ foo")) + #expect(storedAgent.statusString.contains("Last handshake: Just now")) } @Test @@ -72,6 +79,49 @@ struct VPNMenuStateTests { #expect(state.workspaces[workspaceID] == nil) } + @Test + mutating func testUpsertAgent_poorConnection() async throws { + let agentID = UUID() + let workspaceID = UUID() + state.upsertWorkspace(Vpn_Workspace.with { $0.id = workspaceID.uuidData; $0.name = "foo" }) + + let agent = Vpn_Agent.with { + $0.id = agentID.uuidData + $0.workspaceID = workspaceID.uuidData + $0.name = "agent1" + $0.lastHandshake = .init(date: Date.now) + $0.lastPing = .with { + $0.latency = .init(seconds: 1) + } + $0.fqdn = ["foo.coder"] + } + + state.upsertAgent(agent) + + let storedAgent = try #require(state.agents[agentID]) + #expect(storedAgent.status == .high_latency) + } + + @Test + mutating func testUpsertAgent_connecting() async throws { + let agentID = UUID() + let workspaceID = UUID() + state.upsertWorkspace(Vpn_Workspace.with { $0.id = workspaceID.uuidData; $0.name = "foo" }) + + let agent = Vpn_Agent.with { + $0.id = agentID.uuidData + $0.workspaceID = workspaceID.uuidData + $0.name = "agent1" + $0.lastHandshake = .init() + $0.fqdn = ["foo.coder"] + } + + state.upsertAgent(agent) + + let storedAgent = try #require(state.agents[agentID]) + #expect(storedAgent.status == .connecting) + } + @Test mutating func testUpsertAgent_unhealthyAgent() async throws { let agentID = UUID() @@ -89,7 +139,7 @@ struct VPNMenuStateTests { state.upsertAgent(agent) let storedAgent = try #require(state.agents[agentID]) - #expect(storedAgent.status == .warn) + #expect(storedAgent.status == .no_recent_handshake) } @Test @@ -114,6 +164,9 @@ struct VPNMenuStateTests { $0.workspaceID = workspaceID.uuidData $0.name = "agent1" // Same name as old agent $0.lastHandshake = .init(date: Date.now) + $0.lastPing = .with { + $0.latency = .init(floatLiteral: 0.05) + } $0.fqdn = ["foo.coder"] } @@ -146,6 +199,10 @@ struct VPNMenuStateTests { $0.workspaceID = workspaceID.uuidData $0.name = "agent1" $0.lastHandshake = .init(date: Date.now.addingTimeInterval(-200)) + $0.lastPing = .with { + $0.didP2P = false + $0.latency = .init(floatLiteral: 0.05) + } $0.fqdn = ["foo.coder"] } state.upsertAgent(agent) @@ -155,6 +212,10 @@ struct VPNMenuStateTests { #expect(output[0].id == agentID) #expect(output[0].wsName == "foo") #expect(output[0].status == .okay) + let storedAgentFromSort = try #require(state.agents[agentID]) + #expect(storedAgentFromSort.statusString.contains("You're connected through a DERP relay.")) + #expect(storedAgentFromSort.statusString.contains("Total latency: 50.00 ms")) + #expect(storedAgentFromSort.statusString.contains("Last handshake: 3 minutes ago")) } @Test diff --git a/Coder-Desktop/VPN/Manager.swift b/Coder-Desktop/VPN/Manager.swift index 649a1612..952e301e 100644 --- a/Coder-Desktop/VPN/Manager.swift +++ b/Coder-Desktop/VPN/Manager.swift @@ -40,7 +40,6 @@ actor Manager { dest: dest, urlSession: URLSession(configuration: sessionConfig) ) { progress in - // TODO: Debounce, somehow pushProgress(stage: .downloading, downloadProgress: progress) } } catch { @@ -322,7 +321,7 @@ func writeVpnLog(_ log: Vpn_Log) { category: log.loggerNames.joined(separator: ".") ) let fields = log.fields.map { "\($0.name): \($0.value)" }.joined(separator: ", ") - logger.log(level: level, "\(log.message, privacy: .public): \(fields, privacy: .public)") + logger.log(level: level, "\(log.message, privacy: .public)\(fields.isEmpty ? "" : ": \(fields)", privacy: .public)") } private func removeQuarantine(_ dest: URL) async throws(ManagerError) { diff --git a/Coder-Desktop/VPNLib/Download.swift b/Coder-Desktop/VPNLib/Download.swift index 99febc29..f6ffe5bc 100644 --- a/Coder-Desktop/VPNLib/Download.swift +++ b/Coder-Desktop/VPNLib/Download.swift @@ -146,15 +146,15 @@ func etag(data: Data) -> String { } public enum DownloadError: Error { - case unexpectedStatusCode(Int) + case unexpectedStatusCode(Int, url: String) case invalidResponse case networkError(any Error, url: String) case fileOpError(any Error) public var description: String { switch self { - case let .unexpectedStatusCode(code): - "Unexpected HTTP status code: \(code)" + case let .unexpectedStatusCode(code, url): + "Unexpected HTTP status code: \(code) - \(url)" case let .networkError(error, url): "Network error: \(url) - \(error.localizedDescription)" case let .fileOpError(error): @@ -232,7 +232,12 @@ extension DownloadManager: URLSessionDownloadDelegate { } guard httpResponse.statusCode == 200 else { - continuation.resume(throwing: DownloadError.unexpectedStatusCode(httpResponse.statusCode)) + continuation.resume( + throwing: DownloadError.unexpectedStatusCode( + httpResponse.statusCode, + url: httpResponse.url?.absoluteString ?? "Unknown URL" + ) + ) return } diff --git a/Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift b/Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift index 80fa76ff..3ae85b87 100644 --- a/Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift +++ b/Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift @@ -47,9 +47,6 @@ public extension MutagenDaemon { } } do { - // The first creation will need to transfer the agent binary - // TODO: Because this is pretty long, we should show progress updates - // using the prompter messages _ = try await client!.sync.create(req, callOptions: .init(timeLimit: .timeout(sessionMgmtReqTimeout * 4))) } catch { throw .grpcFailure(error) diff --git a/Coder-Desktop/VPNLib/vpn.pb.swift b/Coder-Desktop/VPNLib/vpn.pb.swift index 3e728045..3f630d0e 100644 --- a/Coder-Desktop/VPNLib/vpn.pb.swift +++ b/Coder-Desktop/VPNLib/vpn.pb.swift @@ -520,11 +520,63 @@ public struct Vpn_Agent: @unchecked Sendable { /// Clears the value of `lastHandshake`. Subsequent reads from it will return its default value. public mutating func clearLastHandshake() {self._lastHandshake = nil} + /// If unset, a successful ping has not yet been made. + public var lastPing: Vpn_LastPing { + get {return _lastPing ?? Vpn_LastPing()} + set {_lastPing = newValue} + } + /// Returns true if `lastPing` has been explicitly set. + public var hasLastPing: Bool {return self._lastPing != nil} + /// Clears the value of `lastPing`. Subsequent reads from it will return its default value. + public mutating func clearLastPing() {self._lastPing = nil} + public var unknownFields = SwiftProtobuf.UnknownStorage() public init() {} fileprivate var _lastHandshake: SwiftProtobuf.Google_Protobuf_Timestamp? = nil + fileprivate var _lastPing: Vpn_LastPing? = nil +} + +public struct Vpn_LastPing: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + /// latency is the RTT of the ping to the agent. + public var latency: SwiftProtobuf.Google_Protobuf_Duration { + get {return _latency ?? SwiftProtobuf.Google_Protobuf_Duration()} + set {_latency = newValue} + } + /// Returns true if `latency` has been explicitly set. + public var hasLatency: Bool {return self._latency != nil} + /// Clears the value of `latency`. Subsequent reads from it will return its default value. + public mutating func clearLatency() {self._latency = nil} + + /// did_p2p indicates whether the ping was sent P2P, or over DERP. + public var didP2P: Bool = false + + /// preferred_derp is the human readable name of the preferred DERP region, + /// or the region used for the last ping, if it was sent over DERP. + public var preferredDerp: String = String() + + /// preferred_derp_latency is the last known latency to the preferred DERP + /// region. Unset if the region does not appear in the DERP map. + public var preferredDerpLatency: SwiftProtobuf.Google_Protobuf_Duration { + get {return _preferredDerpLatency ?? SwiftProtobuf.Google_Protobuf_Duration()} + set {_preferredDerpLatency = newValue} + } + /// Returns true if `preferredDerpLatency` has been explicitly set. + public var hasPreferredDerpLatency: Bool {return self._preferredDerpLatency != nil} + /// Clears the value of `preferredDerpLatency`. Subsequent reads from it will return its default value. + public mutating func clearPreferredDerpLatency() {self._preferredDerpLatency = nil} + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} + + fileprivate var _latency: SwiftProtobuf.Google_Protobuf_Duration? = nil + fileprivate var _preferredDerpLatency: SwiftProtobuf.Google_Protobuf_Duration? = nil } /// NetworkSettingsRequest is based on @@ -1579,6 +1631,7 @@ extension Vpn_Agent: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementation 4: .same(proto: "fqdn"), 5: .standard(proto: "ip_addrs"), 6: .standard(proto: "last_handshake"), + 7: .standard(proto: "last_ping"), ] public mutating func decodeMessage(decoder: inout D) throws { @@ -1593,6 +1646,7 @@ extension Vpn_Agent: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementation case 4: try { try decoder.decodeRepeatedStringField(value: &self.fqdn) }() case 5: try { try decoder.decodeRepeatedStringField(value: &self.ipAddrs) }() case 6: try { try decoder.decodeSingularMessageField(value: &self._lastHandshake) }() + case 7: try { try decoder.decodeSingularMessageField(value: &self._lastPing) }() default: break } } @@ -1621,6 +1675,9 @@ extension Vpn_Agent: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementation try { if let v = self._lastHandshake { try visitor.visitSingularMessageField(value: v, fieldNumber: 6) } }() + try { if let v = self._lastPing { + try visitor.visitSingularMessageField(value: v, fieldNumber: 7) + } }() try unknownFields.traverse(visitor: &visitor) } @@ -1631,6 +1688,61 @@ extension Vpn_Agent: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementation if lhs.fqdn != rhs.fqdn {return false} if lhs.ipAddrs != rhs.ipAddrs {return false} if lhs._lastHandshake != rhs._lastHandshake {return false} + if lhs._lastPing != rhs._lastPing {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension Vpn_LastPing: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".LastPing" + public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .same(proto: "latency"), + 2: .standard(proto: "did_p2p"), + 3: .standard(proto: "preferred_derp"), + 4: .standard(proto: "preferred_derp_latency"), + ] + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularMessageField(value: &self._latency) }() + case 2: try { try decoder.decodeSingularBoolField(value: &self.didP2P) }() + case 3: try { try decoder.decodeSingularStringField(value: &self.preferredDerp) }() + case 4: try { try decoder.decodeSingularMessageField(value: &self._preferredDerpLatency) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + try { if let v = self._latency { + try visitor.visitSingularMessageField(value: v, fieldNumber: 1) + } }() + if self.didP2P != false { + try visitor.visitSingularBoolField(value: self.didP2P, fieldNumber: 2) + } + if !self.preferredDerp.isEmpty { + try visitor.visitSingularStringField(value: self.preferredDerp, fieldNumber: 3) + } + try { if let v = self._preferredDerpLatency { + try visitor.visitSingularMessageField(value: v, fieldNumber: 4) + } }() + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Vpn_LastPing, rhs: Vpn_LastPing) -> Bool { + if lhs._latency != rhs._latency {return false} + if lhs.didP2P != rhs.didP2P {return false} + if lhs.preferredDerp != rhs.preferredDerp {return false} + if lhs._preferredDerpLatency != rhs._preferredDerpLatency {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } diff --git a/Coder-Desktop/VPNLib/vpn.proto b/Coder-Desktop/VPNLib/vpn.proto index b3fe54c5..59ea1933 100644 --- a/Coder-Desktop/VPNLib/vpn.proto +++ b/Coder-Desktop/VPNLib/vpn.proto @@ -3,6 +3,7 @@ option go_package = "github.com/coder/coder/v2/vpn"; option csharp_namespace = "Coder.Desktop.Vpn.Proto"; import "google/protobuf/timestamp.proto"; +import "google/protobuf/duration.proto"; package vpn; @@ -130,6 +131,21 @@ message Agent { // last_handshake is the primary indicator of whether we are connected to a peer. Zero value or // anything longer than 5 minutes ago means there is a problem. google.protobuf.Timestamp last_handshake = 6; + // If unset, a successful ping has not yet been made. + optional LastPing last_ping = 7; +} + +message LastPing { + // latency is the RTT of the ping to the agent. + google.protobuf.Duration latency = 1; + // did_p2p indicates whether the ping was sent P2P, or over DERP. + bool did_p2p = 2; + // preferred_derp is the human readable name of the preferred DERP region, + // or the region used for the last ping, if it was sent over DERP. + string preferred_derp = 3; + // preferred_derp_latency is the last known latency to the preferred DERP + // region. Unset if the region does not appear in the DERP map. + optional google.protobuf.Duration preferred_derp_latency = 4; } // NetworkSettingsRequest is based on diff --git a/README.md b/README.md new file mode 100644 index 00000000..53df24d6 --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +# Coder Desktop for macOS + +Coder Desktop allows you to work on your Coder workspaces as though they're +on your local network, with no port-forwarding required. + +## Features: + +- Make your workspaces accessible from a `.coder` hostname. +- Configure bidirectional file sync sessions between local and remote + directories. +- Convenient one-click access to Coder workspace app IDEs, tools and VNC/RDP clients. + +Learn more about Coder Desktop in the +[official documentation](https://coder.com/docs/user-guides/desktop). + +This repo contains the Swift source code for Coder Desktop for macOS. You can +download the latest version from the GitHub releases. + +## Contributing + +See [CONTRIBUTING.MD](CONTRIBUTING.md) + +## License + +The Coder Desktop for macOS source is licensed under the GNU Affero General +Public License v3.0 (AGPL-3.0). + +Some vendored files in this repo are licensed separately. The license for these +files can be found in the same directory as the files. diff --git a/scripts/update-cask.sh b/scripts/update-cask.sh index a679fee4..478ea610 100755 --- a/scripts/update-cask.sh +++ b/scripts/update-cask.sh @@ -85,8 +85,8 @@ cask "coder-desktop" do name "Coder Desktop" desc "Native desktop client for Coder" homepage "https://github.com/coder/coder-desktop-macos" - auto_updates true + auto_updates true depends_on macos: ">= :sonoma" pkg "Coder-Desktop.pkg"