Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

feat: add coder connect startup progress indicator#161

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 ourterms of service andprivacy statement. We’ll occasionally send you account related emails.

Already on GitHub?Sign in to your account

Merged
ethanndickson merged 12 commits intomainfromethan/xpc-progress
May 22, 2025
Merged
Show file tree
Hide file tree
Changes fromall 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
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -33,6 +33,8 @@ final class PreviewVPN: Coder_Desktop.VPNService {
self.shouldFail = shouldFail
}

@Published var progress: VPNProgress = .init(stage: .initial, downloadProgress: nil)

var startTask: Task<Void, Never>?
func start() async {
if await startTask?.value != nil {
Expand Down
63 changes: 63 additions & 0 deletionsCoder-Desktop/Coder-Desktop/VPN/VPNProgress.swift
View file
Open in desktop
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
import SwiftUI
import VPNLib

struct VPNProgress {
let stage: ProgressStage
let downloadProgress: DownloadProgress?
}

struct VPNProgressView: View {
let state: VPNServiceState
let progress: VPNProgress

var body: some View {
VStack {
CircularProgressView(value: value)
// We estimate that the last half takes 8 seconds
// so it doesn't appear stuck
.autoComplete(threshold: 0.5, duration: 8)
Text(progressMessage)
.multilineTextAlignment(.center)
}
.padding()
.foregroundStyle(.secondary)
}

var progressMessage: String {
"\(progress.stage.description ?? defaultMessage)\(downloadProgressMessage)"
}

var downloadProgressMessage: String {
progress.downloadProgress.flatMap { "\n\($0.description)" } ?? ""
}

var defaultMessage: String {
state == .connecting ? "Starting Coder Connect..." : "Stopping Coder Connect..."
}

var value: Float? {
guard state == .connecting else {
return nil
}
switch progress.stage {
case .initial:
return 0
case .downloading:
guard let downloadProgress = progress.downloadProgress else {
// We can't make this illegal state unrepresentable because XPC
// doesn't support enums with associated values.
return 0.05
}
// 35MB if the server doesn't give us the expected size
let totalBytes = downloadProgress.totalBytesToWrite ?? 35_000_000
let downloadPercent = min(1.0, Float(downloadProgress.totalBytesWritten) / Float(totalBytes))
return 0.4 * downloadPercent
case .validating:
return 0.43
case .removingQuarantine:
return 0.46
case .startingTunnel:
return 0.50
}
}
}
16 changes: 15 additions & 1 deletionCoder-Desktop/Coder-Desktop/VPN/VPNService.swift
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,7 @@ import VPNLib
protocol VPNService: ObservableObject {
var state: VPNServiceState { get }
var menuState: VPNMenuState { get }
var progress: VPNProgress { get }
func start() async
func stop() async
func configureTunnelProviderProtocol(proto: NETunnelProviderProtocol?)
Expand DownExpand Up@@ -55,7 +56,14 @@ final class CoderVPNService: NSObject, VPNService {
var logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "vpn")
lazy var xpc: VPNXPCInterface = .init(vpn: self)

@Published var tunnelState: VPNServiceState = .disabled
@Published var tunnelState: VPNServiceState = .disabled {
didSet {
if tunnelState == .connecting {
progress = .init(stage: .initial, downloadProgress: nil)
}
}
}

@Published var sysExtnState: SystemExtensionState = .uninstalled
@Published var neState: NetworkExtensionState = .unconfigured
var state: VPNServiceState {
Expand All@@ -72,6 +80,8 @@ final class CoderVPNService: NSObject, VPNService {
return tunnelState
}

@Published var progress: VPNProgress = .init(stage: .initial, downloadProgress: nil)

@Published var menuState: VPNMenuState = .init()

// Whether the VPN should start as soon as possible
Expand DownExpand Up@@ -155,6 +165,10 @@ final class CoderVPNService: NSObject, VPNService {
}
}

func onProgress(stage: ProgressStage, downloadProgress: DownloadProgress?) {
progress = .init(stage: stage, downloadProgress: downloadProgress)
}

func applyPeerUpdate(with update: Vpn_PeerUpdate) {
// Delete agents
update.deletedAgents.forEach { menuState.deleteAgent(withId: $0.id) }
Expand Down
80 changes: 80 additions & 0 deletionsCoder-Desktop/Coder-Desktop/Views/CircularProgressView.swift
View file
Open in desktop
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
import SwiftUI

struct CircularProgressView: View {
let value: Float?

var strokeWidth: CGFloat = 4
var diameter: CGFloat = 22
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
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 + 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 {
if let threshold = autoCompleteThreshold,
value >= threshold, value < 1.0
{
return 1.0
}
return value
}

private func autoCompleteAnimation(for value: Float) -> Animation? {
guard let threshold = autoCompleteThreshold,
let duration = autoCompleteDuration,
value >= threshold, value < 1.0
else {
return .default
}

return .easeOut(duration: duration)
}
}

extension CircularProgressView {
func autoComplete(threshold: Float, duration: TimeInterval) -> CircularProgressView {
var view = self
view.autoCompleteThreshold = threshold
view.autoCompleteDuration = duration
return view
}
}
4 changes: 3 additions & 1 deletionCoder-Desktop/Coder-Desktop/Views/VPN/Agents.swift
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -33,7 +33,9 @@ struct Agents<VPN: VPNService>: View {
if hasToggledExpansion {
return
}
expandedItem = visibleItems.first?.id
withAnimation(.snappy(duration: Theme.Animation.collapsibleDuration)) {
expandedItem = visibleItems.first?.id
}
hasToggledExpansion = true
}
if items.count == 0 {
Expand Down
4 changes: 1 addition & 3 deletionsCoder-Desktop/Coder-Desktop/Views/VPN/VPNState.swift
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,9 +28,7 @@ struct VPNState<VPN: VPNService>: View {
case (.connecting, _), (.disconnecting, _):
HStack {
Spacer()
ProgressView(
vpn.state == .connecting ? "Starting Coder Connect..." : "Stopping Coder Connect..."
).padding()
VPNProgressView(state: vpn.state, progress: vpn.progress)
Spacer()
}
case let (.failed(vpnErr), _):
Expand Down
6 changes: 6 additions & 0 deletionsCoder-Desktop/Coder-Desktop/XPCInterface.swift
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -71,6 +71,12 @@ import VPNLib
}
}

func onProgress(stage: ProgressStage, downloadProgress: DownloadProgress?) {
Task { @MainActor in
svc.onProgress(stage: stage, downloadProgress: downloadProgress)
}
}

// The NE has verified the dylib and knows better than Gatekeeper
func removeQuarantine(path: String, reply: @escaping (Bool) -> Void) {
let reply = CallbackWrapper(reply)
Expand Down
1 change: 1 addition & 0 deletionsCoder-Desktop/Coder-DesktopTests/Util.swift
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,7 @@ class MockVPNService: VPNService, ObservableObject {
@Published var state: Coder_Desktop.VPNServiceState = .disabled
@Published var baseAccessURL: URL = .init(string: "https://dev.coder.com")!
@Published var menuState: VPNMenuState = .init()
@Published var progress: VPNProgress = .init(stage: .initial, downloadProgress: nil)
var onStart: (() async -> Void)?
var onStop: (() async -> Void)?

Expand Down
6 changes: 2 additions & 4 deletionsCoder-Desktop/Coder-DesktopTests/VPNStateTests.swift
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,8 +38,7 @@ struct VPNStateTests {

try await ViewHosting.host(view) {
try await sut.inspection.inspect { view in
let progressView = try view.find(ViewType.ProgressView.self)
#expect(try progressView.labelView().text().string() == "Starting Coder Connect...")
_ = try view.find(text: "Starting Coder Connect...")
}
}
}
Expand All@@ -50,8 +49,7 @@ struct VPNStateTests {

try await ViewHosting.host(view) {
try await sut.inspection.inspect { view in
let progressView = try view.find(ViewType.ProgressView.self)
#expect(try progressView.labelView().text().string() == "Stopping Coder Connect...")
_ = try view.find(text: "Stopping Coder Connect...")
}
}
}
Expand Down
21 changes: 20 additions & 1 deletionCoder-Desktop/VPN/Manager.swift
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -35,10 +35,18 @@ actor Manager {
// Timeout after 5 minutes, or if there's no data for 60 seconds
sessionConfig.timeoutIntervalForRequest = 60
sessionConfig.timeoutIntervalForResource = 300
try await download(src: dylibPath, dest: dest, urlSession: URLSession(configuration: sessionConfig))
try await download(
src: dylibPath,
dest: dest,
urlSession: URLSession(configuration: sessionConfig)
) { progress in
// TODO: Debounce, somehow
pushProgress(stage: .downloading, downloadProgress: progress)
}
} catch {
throw .download(error)
}
pushProgress(stage: .validating)
let client = Client(url: cfg.serverUrl)
let buildInfo: BuildInfoResponse
do {
Expand DownExpand Up@@ -158,6 +166,7 @@ actor Manager {
}

func startVPN() async throws(ManagerError) {
pushProgress(stage: .startingTunnel)
logger.info("sending start rpc")
guard let tunFd = ptp.tunnelFileDescriptor else {
logger.error("no fd")
Expand DownExpand Up@@ -234,6 +243,15 @@ actor Manager {
}
}

func pushProgress(stage: ProgressStage, downloadProgress: DownloadProgress? = nil) {
guard let conn = globalXPCListenerDelegate.conn else {
logger.warning("couldn't send progress message to app: no connection")
return
}
logger.debug("sending progress message to app")
conn.onProgress(stage: stage, downloadProgress: downloadProgress)
}

struct ManagerConfig {
let apiToken: String
let serverUrl: URL
Expand DownExpand Up@@ -312,6 +330,7 @@ private func removeQuarantine(_ dest: URL) async throws(ManagerError) {
let file = NSURL(fileURLWithPath: dest.path)
try? file.getResourceValue(&flag, forKey: kCFURLQuarantinePropertiesKey as URLResourceKey)
if flag != nil {
pushProgress(stage: .removingQuarantine)
// Try the privileged helper first (it may not even be registered)
if await globalHelperXPCSpeaker.tryRemoveQuarantine(path: dest.path) {
// Success!
Expand Down
Loading
Loading

[8]ページ先頭

©2009-2025 Movatter.jp