From 0f362f8a44990028db6fec16b37a71ce5d59b4a9 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Fri, 7 Mar 2025 14:35:57 +1100 Subject: [PATCH 01/18] chore: manage mutagen daemon lifecycle --- .swiftlint.yml | 4 + Coder Desktop/.swiftformat | 2 +- .../Coder Desktop/Coder_DesktopApp.swift | 17 +- .../FileSync/FileSyncDaemon.swift | 172 ++++++++++ .../Coder Desktop/FileSync/daemon.grpc.swift | 299 ++++++++++++++++++ .../Coder Desktop/FileSync/daemon.pb.swift | 83 +++++ .../Coder Desktop/FileSync/daemon.proto | 11 + Coder Desktop/Coder Desktop/State.swift | 1 + .../MenuState.swift} | 0 .../{ => VPN}/NetworkExtension.swift | 0 .../Coder Desktop/{ => VPN}/VPNService.swift | 0 .../VPNSystemExtension.swift} | 0 Coder Desktop/project.yml | 10 + Makefile | 10 +- flake.lock | 86 ++++- flake.nix | 5 +- 16 files changed, 693 insertions(+), 7 deletions(-) create mode 100644 .swiftlint.yml create mode 100644 Coder Desktop/Coder Desktop/FileSync/FileSyncDaemon.swift create mode 100644 Coder Desktop/Coder Desktop/FileSync/daemon.grpc.swift create mode 100644 Coder Desktop/Coder Desktop/FileSync/daemon.pb.swift create mode 100644 Coder Desktop/Coder Desktop/FileSync/daemon.proto rename Coder Desktop/Coder Desktop/{VPNMenuState.swift => VPN/MenuState.swift} (100%) rename Coder Desktop/Coder Desktop/{ => VPN}/NetworkExtension.swift (100%) rename Coder Desktop/Coder Desktop/{ => VPN}/VPNService.swift (100%) rename Coder Desktop/Coder Desktop/{SystemExtension.swift => VPN/VPNSystemExtension.swift} (100%) diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..df9827e --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,4 @@ +# TODO: Remove this once the grpc-swift-protobuf generator adds a lint disable comment +excluded: + - "**/*.pb.swift" + - "**/*.grpc.swift" \ No newline at end of file diff --git a/Coder Desktop/.swiftformat b/Coder Desktop/.swiftformat index cb200b4..b34aa3f 100644 --- a/Coder Desktop/.swiftformat +++ b/Coder Desktop/.swiftformat @@ -1,3 +1,3 @@ --selfrequired log,info,error,debug,critical,fault ---exclude **.pb.swift +--exclude **.pb.swift,**.grpc.swift --condassignment always \ 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 f434e31..15f07ab 100644 --- a/Coder Desktop/Coder Desktop/Coder_DesktopApp.swift +++ b/Coder Desktop/Coder Desktop/Coder_DesktopApp.swift @@ -30,10 +30,12 @@ class AppDelegate: NSObject, NSApplicationDelegate { private var menuBar: MenuBarController? let vpn: CoderVPNService let state: AppState + let fileSyncDaemon: MutagenDaemon override init() { vpn = CoderVPNService() state = AppState(onChange: vpn.configureTunnelProviderProtocol) + fileSyncDaemon = MutagenDaemon() } func applicationDidFinishLaunching(_: Notification) { @@ -56,14 +58,25 @@ class AppDelegate: NSObject, NSApplicationDelegate { state.reconfigure() } } + // TODO: Start the daemon only once a file sync is configured + Task { + try? await fileSyncDaemon.start() + } } // This function MUST eventually call `NSApp.reply(toApplicationShouldTerminate: true)` // or return `.terminateNow` func applicationShouldTerminate(_: NSApplication) -> NSApplication.TerminateReply { - if !state.stopVPNOnQuit { return .terminateNow } Task { - await vpn.stop() + let vpnStop = Task { + if !state.stopVPNOnQuit { + await vpn.stop() + } + } + let fileSyncStop = Task { + try? await fileSyncDaemon.stop() + } + _ = await (vpnStop.value, fileSyncStop.value) NSApp.reply(toApplicationShouldTerminate: true) } return .terminateLater diff --git a/Coder Desktop/Coder Desktop/FileSync/FileSyncDaemon.swift b/Coder Desktop/Coder Desktop/FileSync/FileSyncDaemon.swift new file mode 100644 index 0000000..c5b1aa0 --- /dev/null +++ b/Coder Desktop/Coder Desktop/FileSync/FileSyncDaemon.swift @@ -0,0 +1,172 @@ +import Foundation +import GRPC +import NIO +import os + +@MainActor +protocol FileSyncDaemon: ObservableObject { + var state: DaemonState { get } + func start() async throws + func stop() async throws +} + +@MainActor +class MutagenDaemon: FileSyncDaemon { + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "mutagen") + + @Published var state: DaemonState = .stopped + + private var mutagenProcess: Process? + private var mutagenPipe: Pipe? + private let mutagenPath: URL + private let mutagenDataDirectory: URL + private let mutagenDaemonSocket: URL + + private var group: MultiThreadedEventLoopGroup? + private var channel: GRPCChannel? + private var client: Daemon_DaemonAsyncClient? + + init() { + #if arch(arm64) + mutagenPath = Bundle.main.url(forResource: "mutagen-darwin-arm64", withExtension: nil)! + #elseif arch(x86_64) + mutagenPath = Bundle.main.url(forResource: "mutagen-darwin-amd64", withExtension: nil)! + #else + fatalError("unknown architecture") + #endif + mutagenDataDirectory = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first!.appending(path: "Coder Desktop").appending(path: "Mutagen") + mutagenDaemonSocket = mutagenDataDirectory.appending(path: "daemon").appending(path: "daemon.sock") + // It shouldn't be fatal if the app was built without Mutagen embedded, + // but file sync will be unavailable. + if !FileManager.default.fileExists(atPath: mutagenPath.path) { + logger.warning("Mutagen not embedded in app, file sync will be unavailable") + state = .unavailable + } + } + + func start() async throws { + if case .unavailable = state { return } + + // Stop an orphaned daemon, if there is one + try? await connect() + try? await stop() + + (mutagenProcess, mutagenPipe) = createMutagenProcess() + do { + try mutagenProcess?.run() + } catch { + state = .failed("Failed to start file sync daemon: \(error)") + throw MutagenDaemonError.daemonStartFailure(error) + } + + try await connect() + + state = .running + } + + private func connect() async throws { + guard client == nil else { + // Already connected + return + } + group = MultiThreadedEventLoopGroup(numberOfThreads: 1) + do { + channel = try GRPCChannelPool.with( + target: .unixDomainSocket(mutagenDaemonSocket.path), + transportSecurity: .plaintext, + eventLoopGroup: group! + ) + client = Daemon_DaemonAsyncClient(channel: channel!) + logger.info("Successfully connected to mutagen daemon via gRPC") + } catch { + logger.error("Failed to connect to gRPC: \(error)") + try await cleanupGRPC() + throw MutagenDaemonError.connectionFailure(error) + } + } + + private func cleanupGRPC() async throws { + try? await channel?.close().get() + try? await group?.shutdownGracefully() + + client = nil + channel = nil + group = nil + } + + func stop() async throws { + if case .unavailable = state { return } + state = .stopped + guard FileManager.default.fileExists(atPath: mutagenDaemonSocket.path) else { + return + } + + // "We don't check the response or error, because the daemon + // may terminate before it has a chance to send the response." + _ = try? await client?.terminate( + Daemon_TerminateRequest(), + callOptions: .init(timeLimit: .timeout(.milliseconds(500))) + ) + + // Clean up gRPC connection + try? await cleanupGRPC() + + // Ensure the process is terminated + mutagenProcess?.terminate() + logger.info("Daemon stopped and gRPC connection closed") + } + + private func createMutagenProcess() -> (Process, Pipe) { + let outputPipe = Pipe() + outputPipe.fileHandleForReading.readabilityHandler = logOutput + let process = Process() + process.executableURL = mutagenPath + process.arguments = ["daemon", "run"] + process.environment = [ + "MUTAGEN_DATA_DIRECTORY": mutagenDataDirectory.path, + ] + process.standardOutput = outputPipe + process.standardError = outputPipe + process.terminationHandler = terminationHandler + return (process, outputPipe) + } + + private nonisolated func terminationHandler(process _: Process) { + Task { @MainActor in + self.mutagenPipe?.fileHandleForReading.readabilityHandler = nil + mutagenProcess = nil + + try? await cleanupGRPC() + + switch self.state { + case .stopped: + logger.info("mutagen daemon stopped") + return + default: + logger.error("mutagen daemon exited unexpectedly") + self.state = .failed("File sync daemon terminated unexpectedly") + } + } + } + + private nonisolated func logOutput(pipe: FileHandle) { + if let line = String(data: pipe.availableData, encoding: .utf8), line != "" { + logger.info("\(line)") + } + } +} + +enum DaemonState { + case running + case stopped + case failed(String) + case unavailable +} + +enum MutagenDaemonError: Error { + case daemonStartFailure(Error) + case connectionFailure(Error) +} diff --git a/Coder Desktop/Coder Desktop/FileSync/daemon.grpc.swift b/Coder Desktop/Coder Desktop/FileSync/daemon.grpc.swift new file mode 100644 index 0000000..a741e4d --- /dev/null +++ b/Coder Desktop/Coder Desktop/FileSync/daemon.grpc.swift @@ -0,0 +1,299 @@ +// +// DO NOT EDIT. +// swift-format-ignore-file +// +// Generated by the protocol buffer compiler. +// Source: Coder Desktop/Coder Desktop/FileSync/daemon.proto +// +import GRPC +import NIO +import NIOConcurrencyHelpers +import SwiftProtobuf + + +/// Usage: instantiate `Daemon_DaemonClient`, then call methods of this protocol to make API calls. +internal protocol Daemon_DaemonClientProtocol: GRPCClient { + var serviceName: String { get } + var interceptors: Daemon_DaemonClientInterceptorFactoryProtocol? { get } + + func terminate( + _ request: Daemon_TerminateRequest, + callOptions: CallOptions? + ) -> UnaryCall +} + +extension Daemon_DaemonClientProtocol { + internal var serviceName: String { + return "daemon.Daemon" + } + + /// Unary call to Terminate + /// + /// - Parameters: + /// - request: Request to send to Terminate. + /// - callOptions: Call options. + /// - Returns: A `UnaryCall` with futures for the metadata, status and response. + internal func terminate( + _ request: Daemon_TerminateRequest, + callOptions: CallOptions? = nil + ) -> UnaryCall { + return self.makeUnaryCall( + path: Daemon_DaemonClientMetadata.Methods.terminate.path, + request: request, + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: self.interceptors?.makeTerminateInterceptors() ?? [] + ) + } +} + +@available(*, deprecated) +extension Daemon_DaemonClient: @unchecked Sendable {} + +@available(*, deprecated, renamed: "Daemon_DaemonNIOClient") +internal final class Daemon_DaemonClient: Daemon_DaemonClientProtocol { + private let lock = Lock() + private var _defaultCallOptions: CallOptions + private var _interceptors: Daemon_DaemonClientInterceptorFactoryProtocol? + internal let channel: GRPCChannel + internal var defaultCallOptions: CallOptions { + get { self.lock.withLock { return self._defaultCallOptions } } + set { self.lock.withLockVoid { self._defaultCallOptions = newValue } } + } + internal var interceptors: Daemon_DaemonClientInterceptorFactoryProtocol? { + get { self.lock.withLock { return self._interceptors } } + set { self.lock.withLockVoid { self._interceptors = newValue } } + } + + /// Creates a client for the daemon.Daemon service. + /// + /// - Parameters: + /// - channel: `GRPCChannel` to the service host. + /// - defaultCallOptions: Options to use for each service call if the user doesn't provide them. + /// - interceptors: A factory providing interceptors for each RPC. + internal init( + channel: GRPCChannel, + defaultCallOptions: CallOptions = CallOptions(), + interceptors: Daemon_DaemonClientInterceptorFactoryProtocol? = nil + ) { + self.channel = channel + self._defaultCallOptions = defaultCallOptions + self._interceptors = interceptors + } +} + +internal struct Daemon_DaemonNIOClient: Daemon_DaemonClientProtocol { + internal var channel: GRPCChannel + internal var defaultCallOptions: CallOptions + internal var interceptors: Daemon_DaemonClientInterceptorFactoryProtocol? + + /// Creates a client for the daemon.Daemon service. + /// + /// - Parameters: + /// - channel: `GRPCChannel` to the service host. + /// - defaultCallOptions: Options to use for each service call if the user doesn't provide them. + /// - interceptors: A factory providing interceptors for each RPC. + internal init( + channel: GRPCChannel, + defaultCallOptions: CallOptions = CallOptions(), + interceptors: Daemon_DaemonClientInterceptorFactoryProtocol? = nil + ) { + self.channel = channel + self.defaultCallOptions = defaultCallOptions + self.interceptors = interceptors + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) +internal protocol Daemon_DaemonAsyncClientProtocol: GRPCClient { + static var serviceDescriptor: GRPCServiceDescriptor { get } + var interceptors: Daemon_DaemonClientInterceptorFactoryProtocol? { get } + + func makeTerminateCall( + _ request: Daemon_TerminateRequest, + callOptions: CallOptions? + ) -> GRPCAsyncUnaryCall +} + +@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) +extension Daemon_DaemonAsyncClientProtocol { + internal static var serviceDescriptor: GRPCServiceDescriptor { + return Daemon_DaemonClientMetadata.serviceDescriptor + } + + internal var interceptors: Daemon_DaemonClientInterceptorFactoryProtocol? { + return nil + } + + internal func makeTerminateCall( + _ request: Daemon_TerminateRequest, + callOptions: CallOptions? = nil + ) -> GRPCAsyncUnaryCall { + return self.makeAsyncUnaryCall( + path: Daemon_DaemonClientMetadata.Methods.terminate.path, + request: request, + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: self.interceptors?.makeTerminateInterceptors() ?? [] + ) + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) +extension Daemon_DaemonAsyncClientProtocol { + internal func terminate( + _ request: Daemon_TerminateRequest, + callOptions: CallOptions? = nil + ) async throws -> Daemon_TerminateResponse { + return try await self.performAsyncUnaryCall( + path: Daemon_DaemonClientMetadata.Methods.terminate.path, + request: request, + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: self.interceptors?.makeTerminateInterceptors() ?? [] + ) + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) +internal struct Daemon_DaemonAsyncClient: Daemon_DaemonAsyncClientProtocol { + internal var channel: GRPCChannel + internal var defaultCallOptions: CallOptions + internal var interceptors: Daemon_DaemonClientInterceptorFactoryProtocol? + + internal init( + channel: GRPCChannel, + defaultCallOptions: CallOptions = CallOptions(), + interceptors: Daemon_DaemonClientInterceptorFactoryProtocol? = nil + ) { + self.channel = channel + self.defaultCallOptions = defaultCallOptions + self.interceptors = interceptors + } +} + +internal protocol Daemon_DaemonClientInterceptorFactoryProtocol: Sendable { + + /// - Returns: Interceptors to use when invoking 'terminate'. + func makeTerminateInterceptors() -> [ClientInterceptor] +} + +internal enum Daemon_DaemonClientMetadata { + internal static let serviceDescriptor = GRPCServiceDescriptor( + name: "Daemon", + fullName: "daemon.Daemon", + methods: [ + Daemon_DaemonClientMetadata.Methods.terminate, + ] + ) + + internal enum Methods { + internal static let terminate = GRPCMethodDescriptor( + name: "Terminate", + path: "/daemon.Daemon/Terminate", + type: GRPCCallType.unary + ) + } +} + +/// To build a server, implement a class that conforms to this protocol. +internal protocol Daemon_DaemonProvider: CallHandlerProvider { + var interceptors: Daemon_DaemonServerInterceptorFactoryProtocol? { get } + + func terminate(request: Daemon_TerminateRequest, context: StatusOnlyCallContext) -> EventLoopFuture +} + +extension Daemon_DaemonProvider { + internal var serviceName: Substring { + return Daemon_DaemonServerMetadata.serviceDescriptor.fullName[...] + } + + /// Determines, calls and returns the appropriate request handler, depending on the request's method. + /// Returns nil for methods not handled by this service. + internal func handle( + method name: Substring, + context: CallHandlerContext + ) -> GRPCServerHandlerProtocol? { + switch name { + case "Terminate": + return UnaryServerHandler( + context: context, + requestDeserializer: ProtobufDeserializer(), + responseSerializer: ProtobufSerializer(), + interceptors: self.interceptors?.makeTerminateInterceptors() ?? [], + userFunction: self.terminate(request:context:) + ) + + default: + return nil + } + } +} + +/// To implement a server, implement an object which conforms to this protocol. +@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) +internal protocol Daemon_DaemonAsyncProvider: CallHandlerProvider, Sendable { + static var serviceDescriptor: GRPCServiceDescriptor { get } + var interceptors: Daemon_DaemonServerInterceptorFactoryProtocol? { get } + + func terminate( + request: Daemon_TerminateRequest, + context: GRPCAsyncServerCallContext + ) async throws -> Daemon_TerminateResponse +} + +@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) +extension Daemon_DaemonAsyncProvider { + internal static var serviceDescriptor: GRPCServiceDescriptor { + return Daemon_DaemonServerMetadata.serviceDescriptor + } + + internal var serviceName: Substring { + return Daemon_DaemonServerMetadata.serviceDescriptor.fullName[...] + } + + internal var interceptors: Daemon_DaemonServerInterceptorFactoryProtocol? { + return nil + } + + internal func handle( + method name: Substring, + context: CallHandlerContext + ) -> GRPCServerHandlerProtocol? { + switch name { + case "Terminate": + return GRPCAsyncServerHandler( + context: context, + requestDeserializer: ProtobufDeserializer(), + responseSerializer: ProtobufSerializer(), + interceptors: self.interceptors?.makeTerminateInterceptors() ?? [], + wrapping: { try await self.terminate(request: $0, context: $1) } + ) + + default: + return nil + } + } +} + +internal protocol Daemon_DaemonServerInterceptorFactoryProtocol: Sendable { + + /// - Returns: Interceptors to use when handling 'terminate'. + /// Defaults to calling `self.makeInterceptors()`. + func makeTerminateInterceptors() -> [ServerInterceptor] +} + +internal enum Daemon_DaemonServerMetadata { + internal static let serviceDescriptor = GRPCServiceDescriptor( + name: "Daemon", + fullName: "daemon.Daemon", + methods: [ + Daemon_DaemonServerMetadata.Methods.terminate, + ] + ) + + internal enum Methods { + internal static let terminate = GRPCMethodDescriptor( + name: "Terminate", + path: "/daemon.Daemon/Terminate", + type: GRPCCallType.unary + ) + } +} diff --git a/Coder Desktop/Coder Desktop/FileSync/daemon.pb.swift b/Coder Desktop/Coder Desktop/FileSync/daemon.pb.swift new file mode 100644 index 0000000..78ceb68 --- /dev/null +++ b/Coder Desktop/Coder Desktop/FileSync/daemon.pb.swift @@ -0,0 +1,83 @@ +// DO NOT EDIT. +// swift-format-ignore-file +// swiftlint:disable all +// +// Generated by the Swift generator plugin for the protocol buffer compiler. +// Source: Coder Desktop/Coder Desktop/FileSync/daemon.proto +// +// For information on using the generated types, please see the documentation: +// https://github.com/apple/swift-protobuf/ + +import SwiftProtobuf + +// If the compiler emits an error on this type, it is because this file +// was generated by a version of the `protoc` Swift plug-in that is +// incompatible with the version of SwiftProtobuf to which you are linking. +// Please ensure that you are building against the same version of the API +// that was used to generate this file. +fileprivate struct _GeneratedWithProtocGenSwiftVersion: SwiftProtobuf.ProtobufAPIVersionCheck { + struct _2: SwiftProtobuf.ProtobufAPIVersion_2 {} + typealias Version = _2 +} + +struct Daemon_TerminateRequest: 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. + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} +} + +struct Daemon_TerminateResponse: 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. + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} +} + +// MARK: - Code below here is support for the SwiftProtobuf runtime. + +fileprivate let _protobuf_package = "daemon" + +extension Daemon_TerminateRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".TerminateRequest" + static let _protobuf_nameMap = SwiftProtobuf._NameMap() + + mutating func decodeMessage(decoder: inout D) throws { + // Load everything into unknown fields + while try decoder.nextFieldNumber() != nil {} + } + + func traverse(visitor: inout V) throws { + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: Daemon_TerminateRequest, rhs: Daemon_TerminateRequest) -> Bool { + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension Daemon_TerminateResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".TerminateResponse" + static let _protobuf_nameMap = SwiftProtobuf._NameMap() + + mutating func decodeMessage(decoder: inout D) throws { + // Load everything into unknown fields + while try decoder.nextFieldNumber() != nil {} + } + + func traverse(visitor: inout V) throws { + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: Daemon_TerminateResponse, rhs: Daemon_TerminateResponse) -> Bool { + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} diff --git a/Coder Desktop/Coder Desktop/FileSync/daemon.proto b/Coder Desktop/Coder Desktop/FileSync/daemon.proto new file mode 100644 index 0000000..4431b35 --- /dev/null +++ b/Coder Desktop/Coder Desktop/FileSync/daemon.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package daemon; + +message TerminateRequest{} + +message TerminateResponse{} + +service Daemon { + rpc Terminate(TerminateRequest) returns (TerminateResponse) {} +} diff --git a/Coder Desktop/Coder Desktop/State.swift b/Coder Desktop/Coder Desktop/State.swift index a8404ff..3e723c9 100644 --- a/Coder Desktop/Coder Desktop/State.swift +++ b/Coder Desktop/Coder Desktop/State.swift @@ -4,6 +4,7 @@ import KeychainAccess import NetworkExtension import SwiftUI +@MainActor class AppState: ObservableObject { let appId = Bundle.main.bundleIdentifier! diff --git a/Coder Desktop/Coder Desktop/VPNMenuState.swift b/Coder Desktop/Coder Desktop/VPN/MenuState.swift similarity index 100% rename from Coder Desktop/Coder Desktop/VPNMenuState.swift rename to Coder Desktop/Coder Desktop/VPN/MenuState.swift diff --git a/Coder Desktop/Coder Desktop/NetworkExtension.swift b/Coder Desktop/Coder Desktop/VPN/NetworkExtension.swift similarity index 100% rename from Coder Desktop/Coder Desktop/NetworkExtension.swift rename to Coder Desktop/Coder Desktop/VPN/NetworkExtension.swift diff --git a/Coder Desktop/Coder Desktop/VPNService.swift b/Coder Desktop/Coder Desktop/VPN/VPNService.swift similarity index 100% rename from Coder Desktop/Coder Desktop/VPNService.swift rename to Coder Desktop/Coder Desktop/VPN/VPNService.swift diff --git a/Coder Desktop/Coder Desktop/SystemExtension.swift b/Coder Desktop/Coder Desktop/VPN/VPNSystemExtension.swift similarity index 100% rename from Coder Desktop/Coder Desktop/SystemExtension.swift rename to Coder Desktop/Coder Desktop/VPN/VPNSystemExtension.swift diff --git a/Coder Desktop/project.yml b/Coder Desktop/project.yml index 2872515..507f666 100644 --- a/Coder Desktop/project.yml +++ b/Coder Desktop/project.yml @@ -105,6 +105,10 @@ packages: LaunchAtLogin: url: https://github.com/sindresorhus/LaunchAtLogin-modern from: 1.1.0 + GRPC: + url: https://github.com/grpc/grpc-swift + # v2 does not support macOS 14.0 + exactVersion: 1.24.2 targets: Coder Desktop: @@ -112,6 +116,8 @@ targets: platform: macOS sources: - path: Coder Desktop + - path: Resources + buildPhase: resources entitlements: path: Coder Desktop/Coder_Desktop.entitlements properties: @@ -155,6 +161,10 @@ targets: - package: FluidMenuBarExtra - package: KeychainAccess - package: LaunchAtLogin + - package: GRPC + - package: SwiftProtobuf + - package: SwiftProtobuf + product: SwiftProtobufPluginLibrary scheme: testPlans: - path: Coder Desktop.xctestplan diff --git a/Makefile b/Makefile index e823a13..13c6531 100644 --- a/Makefile +++ b/Makefile @@ -33,7 +33,7 @@ APP_SIGNING_KEYCHAIN := $(if $(wildcard $(KEYCHAIN_FILE)),$(shell realpath $(KEY .PHONY: setup setup: \ $(XCPROJECT) \ - $(PROJECT)/VPNLib/vpn.pb.swift + proto $(XCPROJECT): $(PROJECT)/project.yml cd $(PROJECT); \ @@ -48,6 +48,12 @@ $(XCPROJECT): $(PROJECT)/project.yml $(PROJECT)/VPNLib/vpn.pb.swift: $(PROJECT)/VPNLib/vpn.proto protoc --swift_opt=Visibility=public --swift_out=. 'Coder Desktop/VPNLib/vpn.proto' +$(PROJECT)/Coder\ Desktop/FileSync/daemon.pb.swift: $(PROJECT)/Coder\ Desktop/FileSync/daemon.proto + protoc \ + --swift_out=.\ + --grpc-swift_out=. \ + 'Coder Desktop/Coder Desktop/FileSync/daemon.proto' + $(KEYCHAIN_FILE): security create-keychain -p "" "$(APP_SIGNING_KEYCHAIN)" security set-keychain-settings -lut 21600 "$(APP_SIGNING_KEYCHAIN)" @@ -130,7 +136,7 @@ clean/build: rm -rf build/ release/ $$out .PHONY: proto -proto: $(PROJECT)/VPNLib/vpn.pb.swift ## Generate Swift files from protobufs +proto: $(PROJECT)/VPNLib/vpn.pb.swift $(PROJECT)/Coder\ Desktop/FileSync/daemon.pb.swift ## Generate Swift files from protobufs .PHONY: help help: ## Show this help diff --git a/flake.lock b/flake.lock index b5b7415..f256611 100644 --- a/flake.lock +++ b/flake.lock @@ -1,5 +1,23 @@ { "nodes": { + "flake-parts": { + "inputs": { + "nixpkgs-lib": "nixpkgs-lib" + }, + "locked": { + "lastModified": 1717285511, + "narHash": "sha256-iKzJcpdXih14qYVcZ9QC9XuZYnPc6T8YImb6dX166kw=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "2a55567fcf15b1b1c7ed712a2c6fadaec7412ea8", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, "flake-utils": { "inputs": { "systems": "systems" @@ -18,7 +36,72 @@ "type": "github" } }, + "grpc-swift": { + "inputs": { + "flake-parts": "flake-parts", + "grpc-swift-src": "grpc-swift-src", + "nixpkgs": "nixpkgs" + }, + "locked": { + "lastModified": 1734611727, + "narHash": "sha256-HWyTCVTAZ+R2fmK6+FoG72U1f7srF6dqaZJANsd1heE=", + "owner": "i10416", + "repo": "grpc-swift-flake", + "rev": "b3e21ab4c686be29af42ccd36c4cc476a1ccbd8e", + "type": "github" + }, + "original": { + "owner": "i10416", + "repo": "grpc-swift-flake", + "type": "github" + } + }, + "grpc-swift-src": { + "flake": false, + "locked": { + "lastModified": 1726668274, + "narHash": "sha256-uI8MpRIGGn/d00pNzBxEZgQ06Q9Ladvdlc5cGNhOnkI=", + "owner": "grpc", + "repo": "grpc-swift", + "rev": "07123ed731671e800ab8d641006613612e954746", + "type": "github" + }, + "original": { + "owner": "grpc", + "ref": "refs/tags/1.23.1", + "repo": "grpc-swift", + "type": "github" + } + }, "nixpkgs": { + "locked": { + "lastModified": 1715499532, + "narHash": "sha256-9UJLb8rdi2VokYcfOBQHUzP3iNxOPNWcbK++ENElpk0=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "af8b9db5c00f1a8e4b83578acc578ff7d823b786", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-lib": { + "locked": { + "lastModified": 1717284937, + "narHash": "sha256-lIbdfCsf8LMFloheeE6N31+BMIeixqyQWbSr2vk79EQ=", + "type": "tarball", + "url": "https://github.com/NixOS/nixpkgs/archive/eb9ceca17df2ea50a250b6b27f7bf6ab0186f198.tar.gz" + }, + "original": { + "type": "tarball", + "url": "https://github.com/NixOS/nixpkgs/archive/eb9ceca17df2ea50a250b6b27f7bf6ab0186f198.tar.gz" + } + }, + "nixpkgs_2": { "locked": { "lastModified": 1740560979, "narHash": "sha256-Vr3Qi346M+8CjedtbyUevIGDZW8LcA1fTG0ugPY/Hic=", @@ -37,7 +120,8 @@ "root": { "inputs": { "flake-utils": "flake-utils", - "nixpkgs": "nixpkgs" + "grpc-swift": "grpc-swift", + "nixpkgs": "nixpkgs_2" } }, "systems": { diff --git a/flake.nix b/flake.nix index 0b09753..cb74d81 100644 --- a/flake.nix +++ b/flake.nix @@ -4,6 +4,7 @@ inputs = { nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; flake-utils.url = "github:numtide/flake-utils"; + grpc-swift.url = "github:i10416/grpc-swift-flake"; }; outputs = @@ -11,6 +12,7 @@ self, nixpkgs, flake-utils, + grpc-swift, }: flake-utils.lib.eachSystem (with flake-utils.lib.system; [ @@ -40,7 +42,8 @@ git gnumake protobuf_28 - protoc-gen-swift + grpc-swift.packages.${system}.protoc-gen-grpc-swift + grpc-swift.packages.${system}.protoc-gen-swift swiftformat swiftlint xcbeautify From 513ccd8c396d071324f921d08ec00241b8b3faa6 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Fri, 7 Mar 2025 14:41:39 +1100 Subject: [PATCH 02/18] handle missing mutagen --- .../Coder Desktop/FileSync/FileSyncDaemon.swift | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Coder Desktop/Coder Desktop/FileSync/FileSyncDaemon.swift b/Coder Desktop/Coder Desktop/FileSync/FileSyncDaemon.swift index c5b1aa0..1d27c36 100644 --- a/Coder Desktop/Coder Desktop/FileSync/FileSyncDaemon.swift +++ b/Coder Desktop/Coder Desktop/FileSync/FileSyncDaemon.swift @@ -18,7 +18,7 @@ class MutagenDaemon: FileSyncDaemon { private var mutagenProcess: Process? private var mutagenPipe: Pipe? - private let mutagenPath: URL + private let mutagenPath: URL! private let mutagenDataDirectory: URL private let mutagenDaemonSocket: URL @@ -28,10 +28,11 @@ class MutagenDaemon: FileSyncDaemon { init() { #if arch(arm64) - mutagenPath = Bundle.main.url(forResource: "mutagen-darwin-arm64", withExtension: nil)! + mutagenPath = Bundle.main.url(forResource: "mutagen-darwin-arm64", withExtension: nil) #elseif arch(x86_64) - mutagenPath = Bundle.main.url(forResource: "mutagen-darwin-amd64", withExtension: nil)! + mutagenPath = Bundle.main.url(forResource: "mutagen-darwin-amd64", withExtension: nil) #else + mutagenPath = nil fatalError("unknown architecture") #endif mutagenDataDirectory = FileManager.default.urls( @@ -41,7 +42,7 @@ class MutagenDaemon: FileSyncDaemon { mutagenDaemonSocket = mutagenDataDirectory.appending(path: "daemon").appending(path: "daemon.sock") // It shouldn't be fatal if the app was built without Mutagen embedded, // but file sync will be unavailable. - if !FileManager.default.fileExists(atPath: mutagenPath.path) { + if mutagenPath == nil { logger.warning("Mutagen not embedded in app, file sync will be unavailable") state = .unavailable } From 3291e73f6a9cc2122b3e9e730ebbc79c64fafe34 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Fri, 7 Mar 2025 14:44:57 +1100 Subject: [PATCH 03/18] fixup --- Coder Desktop/Coder Desktop/FileSync/FileSyncDaemon.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Coder Desktop/Coder Desktop/FileSync/FileSyncDaemon.swift b/Coder Desktop/Coder Desktop/FileSync/FileSyncDaemon.swift index 1d27c36..9432565 100644 --- a/Coder Desktop/Coder Desktop/FileSync/FileSyncDaemon.swift +++ b/Coder Desktop/Coder Desktop/FileSync/FileSyncDaemon.swift @@ -32,7 +32,6 @@ class MutagenDaemon: FileSyncDaemon { #elseif arch(x86_64) mutagenPath = Bundle.main.url(forResource: "mutagen-darwin-amd64", withExtension: nil) #else - mutagenPath = nil fatalError("unknown architecture") #endif mutagenDataDirectory = FileManager.default.urls( From 9be61730d8f2a2fc5e667140d3b3ca055712dc8f Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Fri, 7 Mar 2025 14:47:31 +1100 Subject: [PATCH 04/18] gitkeep resources --- Coder Desktop/Resources/.gitkeep | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 Coder Desktop/Resources/.gitkeep diff --git a/Coder Desktop/Resources/.gitkeep b/Coder Desktop/Resources/.gitkeep new file mode 100644 index 0000000..e69de29 From b0cbab87cdb45b648e98dd5760d2fea399dad40e Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Fri, 7 Mar 2025 15:07:32 +1100 Subject: [PATCH 05/18] fixup --- Coder Desktop/Coder Desktop/FileSync/FileSyncDaemon.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Coder Desktop/Coder Desktop/FileSync/FileSyncDaemon.swift b/Coder Desktop/Coder Desktop/FileSync/FileSyncDaemon.swift index 9432565..d376f29 100644 --- a/Coder Desktop/Coder Desktop/FileSync/FileSyncDaemon.swift +++ b/Coder Desktop/Coder Desktop/FileSync/FileSyncDaemon.swift @@ -101,6 +101,7 @@ class MutagenDaemon: FileSyncDaemon { if case .unavailable = state { return } state = .stopped guard FileManager.default.fileExists(atPath: mutagenDaemonSocket.path) else { + // Already stopped return } @@ -111,10 +112,8 @@ class MutagenDaemon: FileSyncDaemon { callOptions: .init(timeLimit: .timeout(.milliseconds(500))) ) - // Clean up gRPC connection try? await cleanupGRPC() - // Ensure the process is terminated mutagenProcess?.terminate() logger.info("Daemon stopped and gRPC connection closed") } From ebcadbeb61033a01da344d4a4a379f7aa1c5b71c Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Mon, 10 Mar 2025 14:02:25 +1100 Subject: [PATCH 06/18] only import swiftprotobuf in vpnlib to debug runtime crash --- .../Coder Desktop/Coder_DesktopApp.swift | 1 + .../FileSync/FileSyncDaemon.swift | 16 ++++++++-------- .../FileSync/daemon.grpc.swift | 2 +- .../FileSync/daemon.pb.swift | 2 +- .../FileSync/daemon.proto | 0 Coder Desktop/project.yml | 5 +---- Makefile | 6 +++--- 7 files changed, 15 insertions(+), 17 deletions(-) rename Coder Desktop/{Coder Desktop => VPNLib}/FileSync/FileSyncDaemon.swift (94%) rename Coder Desktop/{Coder Desktop => VPNLib}/FileSync/daemon.grpc.swift (99%) rename Coder Desktop/{Coder Desktop => VPNLib}/FileSync/daemon.pb.swift (98%) rename Coder Desktop/{Coder Desktop => VPNLib}/FileSync/daemon.proto (100%) diff --git a/Coder Desktop/Coder Desktop/Coder_DesktopApp.swift b/Coder Desktop/Coder Desktop/Coder_DesktopApp.swift index 15f07ab..083b8fa 100644 --- a/Coder Desktop/Coder Desktop/Coder_DesktopApp.swift +++ b/Coder Desktop/Coder Desktop/Coder_DesktopApp.swift @@ -1,6 +1,7 @@ import FluidMenuBarExtra import NetworkExtension import SwiftUI +import VPNLib @main struct DesktopApp: App { diff --git a/Coder Desktop/Coder Desktop/FileSync/FileSyncDaemon.swift b/Coder Desktop/VPNLib/FileSync/FileSyncDaemon.swift similarity index 94% rename from Coder Desktop/Coder Desktop/FileSync/FileSyncDaemon.swift rename to Coder Desktop/VPNLib/FileSync/FileSyncDaemon.swift index d376f29..02c0f98 100644 --- a/Coder Desktop/Coder Desktop/FileSync/FileSyncDaemon.swift +++ b/Coder Desktop/VPNLib/FileSync/FileSyncDaemon.swift @@ -4,17 +4,17 @@ import NIO import os @MainActor -protocol FileSyncDaemon: ObservableObject { +public protocol FileSyncDaemon: ObservableObject { var state: DaemonState { get } func start() async throws func stop() async throws } @MainActor -class MutagenDaemon: FileSyncDaemon { +public class MutagenDaemon: FileSyncDaemon { private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "mutagen") - @Published var state: DaemonState = .stopped + @Published public var state: DaemonState = .stopped private var mutagenProcess: Process? private var mutagenPipe: Pipe? @@ -26,7 +26,7 @@ class MutagenDaemon: FileSyncDaemon { private var channel: GRPCChannel? private var client: Daemon_DaemonAsyncClient? - init() { + public init() { #if arch(arm64) mutagenPath = Bundle.main.url(forResource: "mutagen-darwin-arm64", withExtension: nil) #elseif arch(x86_64) @@ -47,7 +47,7 @@ class MutagenDaemon: FileSyncDaemon { } } - func start() async throws { + public func start() async throws { if case .unavailable = state { return } // Stop an orphaned daemon, if there is one @@ -97,7 +97,7 @@ class MutagenDaemon: FileSyncDaemon { group = nil } - func stop() async throws { + public func stop() async throws { if case .unavailable = state { return } state = .stopped guard FileManager.default.fileExists(atPath: mutagenDaemonSocket.path) else { @@ -158,14 +158,14 @@ class MutagenDaemon: FileSyncDaemon { } } -enum DaemonState { +public enum DaemonState { case running case stopped case failed(String) case unavailable } -enum MutagenDaemonError: Error { +public enum MutagenDaemonError: Error { case daemonStartFailure(Error) case connectionFailure(Error) } diff --git a/Coder Desktop/Coder Desktop/FileSync/daemon.grpc.swift b/Coder Desktop/VPNLib/FileSync/daemon.grpc.swift similarity index 99% rename from Coder Desktop/Coder Desktop/FileSync/daemon.grpc.swift rename to Coder Desktop/VPNLib/FileSync/daemon.grpc.swift index a741e4d..4fbe078 100644 --- a/Coder Desktop/Coder Desktop/FileSync/daemon.grpc.swift +++ b/Coder Desktop/VPNLib/FileSync/daemon.grpc.swift @@ -3,7 +3,7 @@ // swift-format-ignore-file // // Generated by the protocol buffer compiler. -// Source: Coder Desktop/Coder Desktop/FileSync/daemon.proto +// Source: Coder Desktop/VPNLib/FileSync/daemon.proto // import GRPC import NIO diff --git a/Coder Desktop/Coder Desktop/FileSync/daemon.pb.swift b/Coder Desktop/VPNLib/FileSync/daemon.pb.swift similarity index 98% rename from Coder Desktop/Coder Desktop/FileSync/daemon.pb.swift rename to Coder Desktop/VPNLib/FileSync/daemon.pb.swift index 78ceb68..4ed73c6 100644 --- a/Coder Desktop/Coder Desktop/FileSync/daemon.pb.swift +++ b/Coder Desktop/VPNLib/FileSync/daemon.pb.swift @@ -3,7 +3,7 @@ // swiftlint:disable all // // Generated by the Swift generator plugin for the protocol buffer compiler. -// Source: Coder Desktop/Coder Desktop/FileSync/daemon.proto +// Source: Coder Desktop/VPNLib/FileSync/daemon.proto // // For information on using the generated types, please see the documentation: // https://github.com/apple/swift-protobuf/ diff --git a/Coder Desktop/Coder Desktop/FileSync/daemon.proto b/Coder Desktop/VPNLib/FileSync/daemon.proto similarity index 100% rename from Coder Desktop/Coder Desktop/FileSync/daemon.proto rename to Coder Desktop/VPNLib/FileSync/daemon.proto diff --git a/Coder Desktop/project.yml b/Coder Desktop/project.yml index 507f666..9d18b77 100644 --- a/Coder Desktop/project.yml +++ b/Coder Desktop/project.yml @@ -161,10 +161,6 @@ targets: - package: FluidMenuBarExtra - package: KeychainAccess - package: LaunchAtLogin - - package: GRPC - - package: SwiftProtobuf - - package: SwiftProtobuf - product: SwiftProtobufPluginLibrary scheme: testPlans: - path: Coder Desktop.xctestplan @@ -263,6 +259,7 @@ targets: - package: SwiftProtobuf - package: SwiftProtobuf product: SwiftProtobufPluginLibrary + - package: GRPC - target: CoderSDK embed: false diff --git a/Makefile b/Makefile index 13c6531..1a0cb60 100644 --- a/Makefile +++ b/Makefile @@ -48,11 +48,11 @@ $(XCPROJECT): $(PROJECT)/project.yml $(PROJECT)/VPNLib/vpn.pb.swift: $(PROJECT)/VPNLib/vpn.proto protoc --swift_opt=Visibility=public --swift_out=. 'Coder Desktop/VPNLib/vpn.proto' -$(PROJECT)/Coder\ Desktop/FileSync/daemon.pb.swift: $(PROJECT)/Coder\ Desktop/FileSync/daemon.proto +$(PROJECT)/VPNLib/FileSync/daemon.pb.swift: $(PROJECT)/VPNLib/FileSync/daemon.proto protoc \ --swift_out=.\ --grpc-swift_out=. \ - 'Coder Desktop/Coder Desktop/FileSync/daemon.proto' + 'Coder Desktop/VPNLib/FileSync/daemon.proto' $(KEYCHAIN_FILE): security create-keychain -p "" "$(APP_SIGNING_KEYCHAIN)" @@ -136,7 +136,7 @@ clean/build: rm -rf build/ release/ $$out .PHONY: proto -proto: $(PROJECT)/VPNLib/vpn.pb.swift $(PROJECT)/Coder\ Desktop/FileSync/daemon.pb.swift ## Generate Swift files from protobufs +proto: $(PROJECT)/VPNLib/vpn.pb.swift $(PROJECT)/VPNLib/FileSync/daemon.pb.swift ## Generate Swift files from protobufs .PHONY: help help: ## Show this help From 5ed38938bcc8a12617150d33e75cdccecc47a6b5 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Mon, 10 Mar 2025 14:33:53 +1100 Subject: [PATCH 07/18] extract to seperate lib --- .../Coder Desktop/Coder_DesktopApp.swift | 2 +- .../FileSync => FSLib}/FileSyncDaemon.swift | 0 .../FileSync => FSLib}/daemon.grpc.swift | 2 +- .../FileSync => FSLib}/daemon.pb.swift | 2 +- .../{VPNLib/FileSync => FSLib}/daemon.proto | 0 Coder Desktop/project.yml | 28 ++++++++++++++++++- Makefile | 6 ++-- 7 files changed, 33 insertions(+), 7 deletions(-) rename Coder Desktop/{VPNLib/FileSync => FSLib}/FileSyncDaemon.swift (100%) rename Coder Desktop/{VPNLib/FileSync => FSLib}/daemon.grpc.swift (99%) rename Coder Desktop/{VPNLib/FileSync => FSLib}/daemon.pb.swift (98%) rename Coder Desktop/{VPNLib/FileSync => FSLib}/daemon.proto (100%) diff --git a/Coder Desktop/Coder Desktop/Coder_DesktopApp.swift b/Coder Desktop/Coder Desktop/Coder_DesktopApp.swift index 083b8fa..6029547 100644 --- a/Coder Desktop/Coder Desktop/Coder_DesktopApp.swift +++ b/Coder Desktop/Coder Desktop/Coder_DesktopApp.swift @@ -1,7 +1,7 @@ import FluidMenuBarExtra import NetworkExtension import SwiftUI -import VPNLib +import FSLib @main struct DesktopApp: App { diff --git a/Coder Desktop/VPNLib/FileSync/FileSyncDaemon.swift b/Coder Desktop/FSLib/FileSyncDaemon.swift similarity index 100% rename from Coder Desktop/VPNLib/FileSync/FileSyncDaemon.swift rename to Coder Desktop/FSLib/FileSyncDaemon.swift diff --git a/Coder Desktop/VPNLib/FileSync/daemon.grpc.swift b/Coder Desktop/FSLib/daemon.grpc.swift similarity index 99% rename from Coder Desktop/VPNLib/FileSync/daemon.grpc.swift rename to Coder Desktop/FSLib/daemon.grpc.swift index 4fbe078..f556d65 100644 --- a/Coder Desktop/VPNLib/FileSync/daemon.grpc.swift +++ b/Coder Desktop/FSLib/daemon.grpc.swift @@ -3,7 +3,7 @@ // swift-format-ignore-file // // Generated by the protocol buffer compiler. -// Source: Coder Desktop/VPNLib/FileSync/daemon.proto +// Source: Coder Desktop/FSLib/daemon.proto // import GRPC import NIO diff --git a/Coder Desktop/VPNLib/FileSync/daemon.pb.swift b/Coder Desktop/FSLib/daemon.pb.swift similarity index 98% rename from Coder Desktop/VPNLib/FileSync/daemon.pb.swift rename to Coder Desktop/FSLib/daemon.pb.swift index 4ed73c6..9d1f232 100644 --- a/Coder Desktop/VPNLib/FileSync/daemon.pb.swift +++ b/Coder Desktop/FSLib/daemon.pb.swift @@ -3,7 +3,7 @@ // swiftlint:disable all // // Generated by the Swift generator plugin for the protocol buffer compiler. -// Source: Coder Desktop/VPNLib/FileSync/daemon.proto +// Source: Coder Desktop/FSLib/daemon.proto // // For information on using the generated types, please see the documentation: // https://github.com/apple/swift-protobuf/ diff --git a/Coder Desktop/VPNLib/FileSync/daemon.proto b/Coder Desktop/FSLib/daemon.proto similarity index 100% rename from Coder Desktop/VPNLib/FileSync/daemon.proto rename to Coder Desktop/FSLib/daemon.proto diff --git a/Coder Desktop/project.yml b/Coder Desktop/project.yml index 9d18b77..c9eb763 100644 --- a/Coder Desktop/project.yml +++ b/Coder Desktop/project.yml @@ -5,6 +5,10 @@ options: macOS: "14.0" xcodeVersion: "1600" minimumXcodeGenVersion: "2.42.0" + # Excldue `.proto` files from all build phases + fileTypes: + proto: + buildPhase: none settings: base: @@ -156,6 +160,8 @@ targets: embed: true - target: VPNLib embed: true + - target: FSLib + embed: true - target: VPN embed: without-signing # Embed without signing. - package: FluidMenuBarExtra @@ -259,7 +265,6 @@ targets: - package: SwiftProtobuf - package: SwiftProtobuf product: SwiftProtobufPluginLibrary - - package: GRPC - target: CoderSDK embed: false @@ -278,6 +283,27 @@ targets: embed: false - package: Mocker + + FSLib: + type: framework + platform: macOS + sources: + - path: FSLib + settings: + base: + INFOPLIST_KEY_NSHumanReadableCopyright: "" + PRODUCT_NAME: "$(TARGET_NAME:c99extidentifier)" + SWIFT_EMIT_LOC_STRINGS: YES + GENERATE_INFOPLIST_FILE: YES + DYLIB_COMPATIBILITY_VERSION: 1 + DYLIB_CURRENT_VERSION: 1 + DYLIB_INSTALL_NAME_BASE: "@rpath" + dependencies: + - package: SwiftProtobuf + - package: SwiftProtobuf + product: SwiftProtobufPluginLibrary + - package: GRPC + CoderSDK: type: framework platform: macOS diff --git a/Makefile b/Makefile index 1a0cb60..4c88e86 100644 --- a/Makefile +++ b/Makefile @@ -48,11 +48,11 @@ $(XCPROJECT): $(PROJECT)/project.yml $(PROJECT)/VPNLib/vpn.pb.swift: $(PROJECT)/VPNLib/vpn.proto protoc --swift_opt=Visibility=public --swift_out=. 'Coder Desktop/VPNLib/vpn.proto' -$(PROJECT)/VPNLib/FileSync/daemon.pb.swift: $(PROJECT)/VPNLib/FileSync/daemon.proto +$(PROJECT)/FSLib/daemon.pb.swift: $(PROJECT)/FSLib/daemon.proto protoc \ --swift_out=.\ --grpc-swift_out=. \ - 'Coder Desktop/VPNLib/FileSync/daemon.proto' + 'Coder Desktop/FSLib/daemon.proto' $(KEYCHAIN_FILE): security create-keychain -p "" "$(APP_SIGNING_KEYCHAIN)" @@ -136,7 +136,7 @@ clean/build: rm -rf build/ release/ $$out .PHONY: proto -proto: $(PROJECT)/VPNLib/vpn.pb.swift $(PROJECT)/VPNLib/FileSync/daemon.pb.swift ## Generate Swift files from protobufs +proto: $(PROJECT)/VPNLib/vpn.pb.swift $(PROJECT)/FSLib/daemon.pb.swift ## Generate Swift files from protobufs .PHONY: help help: ## Show this help From c1947aa19fa3266658a3b211dffa7938667adc23 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Mon, 10 Mar 2025 14:52:34 +1100 Subject: [PATCH 08/18] move back to vpnlib --- .../Coder Desktop/Coder_DesktopApp.swift | 2 +- .../FileSync}/FileSyncDaemon.swift | 0 .../FileSync}/daemon.grpc.swift | 2 +- .../FileSync}/daemon.pb.swift | 2 +- .../{FSLib => VPNLib/FileSync}/daemon.proto | 0 Coder Desktop/project.yml | 25 +------------------ Makefile | 6 ++--- 7 files changed, 7 insertions(+), 30 deletions(-) rename Coder Desktop/{FSLib => VPNLib/FileSync}/FileSyncDaemon.swift (100%) rename Coder Desktop/{FSLib => VPNLib/FileSync}/daemon.grpc.swift (99%) rename Coder Desktop/{FSLib => VPNLib/FileSync}/daemon.pb.swift (98%) rename Coder Desktop/{FSLib => VPNLib/FileSync}/daemon.proto (100%) diff --git a/Coder Desktop/Coder Desktop/Coder_DesktopApp.swift b/Coder Desktop/Coder Desktop/Coder_DesktopApp.swift index 6029547..083b8fa 100644 --- a/Coder Desktop/Coder Desktop/Coder_DesktopApp.swift +++ b/Coder Desktop/Coder Desktop/Coder_DesktopApp.swift @@ -1,7 +1,7 @@ import FluidMenuBarExtra import NetworkExtension import SwiftUI -import FSLib +import VPNLib @main struct DesktopApp: App { diff --git a/Coder Desktop/FSLib/FileSyncDaemon.swift b/Coder Desktop/VPNLib/FileSync/FileSyncDaemon.swift similarity index 100% rename from Coder Desktop/FSLib/FileSyncDaemon.swift rename to Coder Desktop/VPNLib/FileSync/FileSyncDaemon.swift diff --git a/Coder Desktop/FSLib/daemon.grpc.swift b/Coder Desktop/VPNLib/FileSync/daemon.grpc.swift similarity index 99% rename from Coder Desktop/FSLib/daemon.grpc.swift rename to Coder Desktop/VPNLib/FileSync/daemon.grpc.swift index f556d65..4fbe078 100644 --- a/Coder Desktop/FSLib/daemon.grpc.swift +++ b/Coder Desktop/VPNLib/FileSync/daemon.grpc.swift @@ -3,7 +3,7 @@ // swift-format-ignore-file // // Generated by the protocol buffer compiler. -// Source: Coder Desktop/FSLib/daemon.proto +// Source: Coder Desktop/VPNLib/FileSync/daemon.proto // import GRPC import NIO diff --git a/Coder Desktop/FSLib/daemon.pb.swift b/Coder Desktop/VPNLib/FileSync/daemon.pb.swift similarity index 98% rename from Coder Desktop/FSLib/daemon.pb.swift rename to Coder Desktop/VPNLib/FileSync/daemon.pb.swift index 9d1f232..4ed73c6 100644 --- a/Coder Desktop/FSLib/daemon.pb.swift +++ b/Coder Desktop/VPNLib/FileSync/daemon.pb.swift @@ -3,7 +3,7 @@ // swiftlint:disable all // // Generated by the Swift generator plugin for the protocol buffer compiler. -// Source: Coder Desktop/FSLib/daemon.proto +// Source: Coder Desktop/VPNLib/FileSync/daemon.proto // // For information on using the generated types, please see the documentation: // https://github.com/apple/swift-protobuf/ diff --git a/Coder Desktop/FSLib/daemon.proto b/Coder Desktop/VPNLib/FileSync/daemon.proto similarity index 100% rename from Coder Desktop/FSLib/daemon.proto rename to Coder Desktop/VPNLib/FileSync/daemon.proto diff --git a/Coder Desktop/project.yml b/Coder Desktop/project.yml index c9eb763..aa834a1 100644 --- a/Coder Desktop/project.yml +++ b/Coder Desktop/project.yml @@ -5,7 +5,6 @@ options: macOS: "14.0" xcodeVersion: "1600" minimumXcodeGenVersion: "2.42.0" - # Excldue `.proto` files from all build phases fileTypes: proto: buildPhase: none @@ -160,8 +159,6 @@ targets: embed: true - target: VPNLib embed: true - - target: FSLib - embed: true - target: VPN embed: without-signing # Embed without signing. - package: FluidMenuBarExtra @@ -265,6 +262,7 @@ targets: - package: SwiftProtobuf - package: SwiftProtobuf product: SwiftProtobufPluginLibrary + - package: GRPC - target: CoderSDK embed: false @@ -283,27 +281,6 @@ targets: embed: false - package: Mocker - - FSLib: - type: framework - platform: macOS - sources: - - path: FSLib - settings: - base: - INFOPLIST_KEY_NSHumanReadableCopyright: "" - PRODUCT_NAME: "$(TARGET_NAME:c99extidentifier)" - SWIFT_EMIT_LOC_STRINGS: YES - GENERATE_INFOPLIST_FILE: YES - DYLIB_COMPATIBILITY_VERSION: 1 - DYLIB_CURRENT_VERSION: 1 - DYLIB_INSTALL_NAME_BASE: "@rpath" - dependencies: - - package: SwiftProtobuf - - package: SwiftProtobuf - product: SwiftProtobufPluginLibrary - - package: GRPC - CoderSDK: type: framework platform: macOS diff --git a/Makefile b/Makefile index 4c88e86..1a0cb60 100644 --- a/Makefile +++ b/Makefile @@ -48,11 +48,11 @@ $(XCPROJECT): $(PROJECT)/project.yml $(PROJECT)/VPNLib/vpn.pb.swift: $(PROJECT)/VPNLib/vpn.proto protoc --swift_opt=Visibility=public --swift_out=. 'Coder Desktop/VPNLib/vpn.proto' -$(PROJECT)/FSLib/daemon.pb.swift: $(PROJECT)/FSLib/daemon.proto +$(PROJECT)/VPNLib/FileSync/daemon.pb.swift: $(PROJECT)/VPNLib/FileSync/daemon.proto protoc \ --swift_out=.\ --grpc-swift_out=. \ - 'Coder Desktop/FSLib/daemon.proto' + 'Coder Desktop/VPNLib/FileSync/daemon.proto' $(KEYCHAIN_FILE): security create-keychain -p "" "$(APP_SIGNING_KEYCHAIN)" @@ -136,7 +136,7 @@ clean/build: rm -rf build/ release/ $$out .PHONY: proto -proto: $(PROJECT)/VPNLib/vpn.pb.swift $(PROJECT)/FSLib/daemon.pb.swift ## Generate Swift files from protobufs +proto: $(PROJECT)/VPNLib/vpn.pb.swift $(PROJECT)/VPNLib/FileSync/daemon.pb.swift ## Generate Swift files from protobufs .PHONY: help help: ## Show this help From b13a44ffff170030209ac7b02bff597f51f7a4bc Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Mon, 10 Mar 2025 16:00:32 +1100 Subject: [PATCH 09/18] remove duplicate framework --- Coder Desktop/project.yml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/Coder Desktop/project.yml b/Coder Desktop/project.yml index aa834a1..37bc395 100644 --- a/Coder Desktop/project.yml +++ b/Coder Desktop/project.yml @@ -154,11 +154,16 @@ targets: DSTROOT: $(LOCAL_APPS_DIR)/Coder INSTALL_PATH: / SKIP_INSTALL: NO + LD_RUNPATH_SEARCH_PATHS: + # Load frameworks from the SE bundle. + - "@executable_path/../../Contents/Library/SystemExtensions/com.coder.Coder-Desktop.VPN.systemextension/Contents/Frameworks" + - "@executable_path/../Frameworks" + - "@loader_path/Frameworks" dependencies: - target: CoderSDK - embed: true + embed: false # Loaded from SE bundle - target: VPNLib - embed: true + embed: false # Loaded from SE bundle - target: VPN embed: without-signing # Embed without signing. - package: FluidMenuBarExtra @@ -233,8 +238,10 @@ targets: # Empty outside of release builds PROVISIONING_PROFILE_SPECIFIER: ${EXT_PROVISIONING_PROFILE_ID} dependencies: + # The app loads the framework embedded here too - target: VPNLib embed: true + # The app loads the framework embedded here too - target: CoderSDK embed: true - sdk: NetworkExtension.framework From c9cba6df4e61cddcd4c9e2f8c64d1dbd42111216 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Mon, 10 Mar 2025 16:45:50 +1100 Subject: [PATCH 10/18] logging --- Coder Desktop/VPNLib/FileSync/FileSyncDaemon.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Coder Desktop/VPNLib/FileSync/FileSyncDaemon.swift b/Coder Desktop/VPNLib/FileSync/FileSyncDaemon.swift index 02c0f98..10fc2bf 100644 --- a/Coder Desktop/VPNLib/FileSync/FileSyncDaemon.swift +++ b/Coder Desktop/VPNLib/FileSync/FileSyncDaemon.swift @@ -65,6 +65,7 @@ public class MutagenDaemon: FileSyncDaemon { try await connect() state = .running + logger.info("mutagen daemon started") } private func connect() async throws { @@ -80,7 +81,9 @@ public class MutagenDaemon: FileSyncDaemon { eventLoopGroup: group! ) client = Daemon_DaemonAsyncClient(channel: channel!) - logger.info("Successfully connected to mutagen daemon via gRPC") + logger.info( + "Successfully connected to mutagen daemon, socket: \(self.mutagenDaemonSocket.path, privacy: .public)" + ) } catch { logger.error("Failed to connect to gRPC: \(error)") try await cleanupGRPC() @@ -124,6 +127,7 @@ public class MutagenDaemon: FileSyncDaemon { let process = Process() process.executableURL = mutagenPath process.arguments = ["daemon", "run"] + logger.info("setting mutagen data directory: \(self.mutagenDataDirectory.path, privacy: .public)") process.environment = [ "MUTAGEN_DATA_DIRECTORY": mutagenDataDirectory.path, ] From 2b673b88727a28bfb452cc50d914ff99b6645cb2 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Mon, 10 Mar 2025 17:16:45 +1100 Subject: [PATCH 11/18] review --- .../VPNLib/FileSync/FileSyncDaemon.swift | 27 +++++----- Makefile | 3 +- flake.lock | 49 ++++++------------- flake.nix | 12 ++++- 4 files changed, 42 insertions(+), 49 deletions(-) diff --git a/Coder Desktop/VPNLib/FileSync/FileSyncDaemon.swift b/Coder Desktop/VPNLib/FileSync/FileSyncDaemon.swift index 10fc2bf..cca8442 100644 --- a/Coder Desktop/VPNLib/FileSync/FileSyncDaemon.swift +++ b/Coder Desktop/VPNLib/FileSync/FileSyncDaemon.swift @@ -6,8 +6,8 @@ import os @MainActor public protocol FileSyncDaemon: ObservableObject { var state: DaemonState { get } - func start() async throws - func stop() async throws + func start() async throws(DaemonError) + func stop() async throws(DaemonError) } @MainActor @@ -47,7 +47,7 @@ public class MutagenDaemon: FileSyncDaemon { } } - public func start() async throws { + public func start() async throws(DaemonError) { if case .unavailable = state { return } // Stop an orphaned daemon, if there is one @@ -59,16 +59,21 @@ public class MutagenDaemon: FileSyncDaemon { try mutagenProcess?.run() } catch { state = .failed("Failed to start file sync daemon: \(error)") - throw MutagenDaemonError.daemonStartFailure(error) + throw DaemonError.daemonStartFailure(error) } - try await connect() + do { + try await connect() + } catch { + state = .failed("failed to connect to file sync daemon: \(error)") + throw DaemonError.daemonStartFailure(error) + } state = .running - logger.info("mutagen daemon started") + logger.info("mutagen daemon started, pid: \(self.mutagenProcess?.processIdentifier.description ?? "unknown")") } - private func connect() async throws { + private func connect() async throws(DaemonError) { guard client == nil else { // Already connected return @@ -86,8 +91,8 @@ public class MutagenDaemon: FileSyncDaemon { ) } catch { logger.error("Failed to connect to gRPC: \(error)") - try await cleanupGRPC() - throw MutagenDaemonError.connectionFailure(error) + try? await cleanupGRPC() + throw DaemonError.connectionFailure(error) } } @@ -100,7 +105,7 @@ public class MutagenDaemon: FileSyncDaemon { group = nil } - public func stop() async throws { + public func stop() async throws(DaemonError) { if case .unavailable = state { return } state = .stopped guard FileManager.default.fileExists(atPath: mutagenDaemonSocket.path) else { @@ -169,7 +174,7 @@ public enum DaemonState { case unavailable } -public enum MutagenDaemonError: Error { +public enum DaemonError: Error { case daemonStartFailure(Error) case connectionFailure(Error) } diff --git a/Makefile b/Makefile index 1a0cb60..d22e6c3 100644 --- a/Makefile +++ b/Makefile @@ -33,7 +33,8 @@ APP_SIGNING_KEYCHAIN := $(if $(wildcard $(KEYCHAIN_FILE)),$(shell realpath $(KEY .PHONY: setup setup: \ $(XCPROJECT) \ - proto + $(PROJECT)/VPNLib/vpn.proto \ + $(PROJECT)/VPNLib/FileSync/daemon.proto $(XCPROJECT): $(PROJECT)/project.yml cd $(PROJECT); \ diff --git a/flake.lock b/flake.lock index f256611..011c0d0 100644 --- a/flake.lock +++ b/flake.lock @@ -2,14 +2,16 @@ "nodes": { "flake-parts": { "inputs": { - "nixpkgs-lib": "nixpkgs-lib" + "nixpkgs-lib": [ + "nixpkgs" + ] }, "locked": { - "lastModified": 1717285511, - "narHash": "sha256-iKzJcpdXih14qYVcZ9QC9XuZYnPc6T8YImb6dX166kw=", + "lastModified": 1741352980, + "narHash": "sha256-+u2UunDA4Cl5Fci3m7S643HzKmIDAe+fiXrLqYsR2fs=", "owner": "hercules-ci", "repo": "flake-parts", - "rev": "2a55567fcf15b1b1c7ed712a2c6fadaec7412ea8", + "rev": "f4330d22f1c5d2ba72d3d22df5597d123fdb60a9", "type": "github" }, "original": { @@ -38,9 +40,13 @@ }, "grpc-swift": { "inputs": { - "flake-parts": "flake-parts", + "flake-parts": [ + "flake-parts" + ], "grpc-swift-src": "grpc-swift-src", - "nixpkgs": "nixpkgs" + "nixpkgs": [ + "nixpkgs" + ] }, "locked": { "lastModified": 1734611727, @@ -74,34 +80,6 @@ } }, "nixpkgs": { - "locked": { - "lastModified": 1715499532, - "narHash": "sha256-9UJLb8rdi2VokYcfOBQHUzP3iNxOPNWcbK++ENElpk0=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "af8b9db5c00f1a8e4b83578acc578ff7d823b786", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixpkgs-unstable", - "repo": "nixpkgs", - "type": "github" - } - }, - "nixpkgs-lib": { - "locked": { - "lastModified": 1717284937, - "narHash": "sha256-lIbdfCsf8LMFloheeE6N31+BMIeixqyQWbSr2vk79EQ=", - "type": "tarball", - "url": "https://github.com/NixOS/nixpkgs/archive/eb9ceca17df2ea50a250b6b27f7bf6ab0186f198.tar.gz" - }, - "original": { - "type": "tarball", - "url": "https://github.com/NixOS/nixpkgs/archive/eb9ceca17df2ea50a250b6b27f7bf6ab0186f198.tar.gz" - } - }, - "nixpkgs_2": { "locked": { "lastModified": 1740560979, "narHash": "sha256-Vr3Qi346M+8CjedtbyUevIGDZW8LcA1fTG0ugPY/Hic=", @@ -119,9 +97,10 @@ }, "root": { "inputs": { + "flake-parts": "flake-parts", "flake-utils": "flake-utils", "grpc-swift": "grpc-swift", - "nixpkgs": "nixpkgs_2" + "nixpkgs": "nixpkgs" } }, "systems": { diff --git a/flake.nix b/flake.nix index cb74d81..ab3ab0a 100644 --- a/flake.nix +++ b/flake.nix @@ -4,15 +4,23 @@ inputs = { nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; flake-utils.url = "github:numtide/flake-utils"; - grpc-swift.url = "github:i10416/grpc-swift-flake"; + flake-parts = { + url = "github:hercules-ci/flake-parts"; + inputs.nixpkgs-lib.follows = "nixpkgs"; + }; + grpc-swift = { + url = "github:i10416/grpc-swift-flake"; + inputs.nixpkgs.follows = "nixpkgs"; + inputs.flake-parts.follows = "flake-parts"; + }; }; outputs = { - self, nixpkgs, flake-utils, grpc-swift, + ... }: flake-utils.lib.eachSystem (with flake-utils.lib.system; [ From 76abed54601fd1b1a952205a8f3121b3ba9b908f Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Mon, 10 Mar 2025 17:38:26 +1100 Subject: [PATCH 12/18] error handling --- .../VPNLib/FileSync/FileSyncDaemon.swift | 53 ++++++++++++++----- 1 file changed, 41 insertions(+), 12 deletions(-) diff --git a/Coder Desktop/VPNLib/FileSync/FileSyncDaemon.swift b/Coder Desktop/VPNLib/FileSync/FileSyncDaemon.swift index cca8442..8ce4387 100644 --- a/Coder Desktop/VPNLib/FileSync/FileSyncDaemon.swift +++ b/Coder Desktop/VPNLib/FileSync/FileSyncDaemon.swift @@ -6,15 +6,19 @@ import os @MainActor public protocol FileSyncDaemon: ObservableObject { var state: DaemonState { get } - func start() async throws(DaemonError) - func stop() async throws(DaemonError) + func start() async + func stop() async } @MainActor public class MutagenDaemon: FileSyncDaemon { private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "mutagen") - @Published public var state: DaemonState = .stopped + @Published public var state: DaemonState = .stopped { + didSet { + logger.info("daemon state changed: \(self.state.description)") + } + } private var mutagenProcess: Process? private var mutagenPipe: Pipe? @@ -47,26 +51,24 @@ public class MutagenDaemon: FileSyncDaemon { } } - public func start() async throws(DaemonError) { + public func start() async { if case .unavailable = state { return } // Stop an orphaned daemon, if there is one try? await connect() - try? await stop() + await stop() (mutagenProcess, mutagenPipe) = createMutagenProcess() do { try mutagenProcess?.run() } catch { - state = .failed("Failed to start file sync daemon: \(error)") - throw DaemonError.daemonStartFailure(error) + state = .failed(DaemonError.daemonStartFailure(error)) } do { try await connect() } catch { - state = .failed("failed to connect to file sync daemon: \(error)") - throw DaemonError.daemonStartFailure(error) + state = .failed(DaemonError.daemonStartFailure(error)) } state = .running @@ -105,7 +107,7 @@ public class MutagenDaemon: FileSyncDaemon { group = nil } - public func stop() async throws(DaemonError) { + public func stop() async { if case .unavailable = state { return } state = .stopped guard FileManager.default.fileExists(atPath: mutagenDaemonSocket.path) else { @@ -155,7 +157,7 @@ public class MutagenDaemon: FileSyncDaemon { return default: logger.error("mutagen daemon exited unexpectedly") - self.state = .failed("File sync daemon terminated unexpectedly") + self.state = .failed(.terminatedUnexpectedly) } } } @@ -170,11 +172,38 @@ public class MutagenDaemon: FileSyncDaemon { public enum DaemonState { case running case stopped - case failed(String) + case failed(DaemonError) case unavailable + + var description: String { + switch self { + case .running: + "Running" + case .stopped: + "Stopped" + case let .failed(error): + "Failed: \(error)" + case .unavailable: + "Unavailable" + } + } } public enum DaemonError: Error { case daemonStartFailure(Error) case connectionFailure(Error) + case terminatedUnexpectedly + + var description: String { + switch self { + case let .daemonStartFailure(error): + "Daemon start failure: \(error)" + case let .connectionFailure(error): + "Connection failure: \(error)" + case .terminatedUnexpectedly: + "Daemon terminated unexpectedly" + } + } + + var localizedDescription: String { description } } From f2fc365ae5b07682265f8630ca6fd75609d77135 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Mon, 10 Mar 2025 17:48:36 +1100 Subject: [PATCH 13/18] log privacy --- Coder Desktop/VPNLib/FileSync/FileSyncDaemon.swift | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/Coder Desktop/VPNLib/FileSync/FileSyncDaemon.swift b/Coder Desktop/VPNLib/FileSync/FileSyncDaemon.swift index 8ce4387..ed5e5ad 100644 --- a/Coder Desktop/VPNLib/FileSync/FileSyncDaemon.swift +++ b/Coder Desktop/VPNLib/FileSync/FileSyncDaemon.swift @@ -16,7 +16,7 @@ public class MutagenDaemon: FileSyncDaemon { @Published public var state: DaemonState = .stopped { didSet { - logger.info("daemon state changed: \(self.state.description)") + logger.info("daemon state changed: \(self.state.description, privacy: .public)") } } @@ -72,7 +72,12 @@ public class MutagenDaemon: FileSyncDaemon { } state = .running - logger.info("mutagen daemon started, pid: \(self.mutagenProcess?.processIdentifier.description ?? "unknown")") + logger.info( + """ + mutagen daemon started, pid: + \(self.mutagenProcess?.processIdentifier.description ?? "unknown", privacy: .public) + """ + ) } private func connect() async throws(DaemonError) { @@ -164,7 +169,7 @@ public class MutagenDaemon: FileSyncDaemon { private nonisolated func logOutput(pipe: FileHandle) { if let line = String(data: pipe.availableData, encoding: .utf8), line != "" { - logger.info("\(line)") + logger.info("\(line, privacy: .public)") } } } From 21bb1695cba03dd2c9b45151461c6019033dc673 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Mon, 10 Mar 2025 18:01:26 +1100 Subject: [PATCH 14/18] fixup --- Coder Desktop/Coder Desktop/Coder_DesktopApp.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Coder Desktop/Coder Desktop/Coder_DesktopApp.swift b/Coder Desktop/Coder Desktop/Coder_DesktopApp.swift index 083b8fa..73b18da 100644 --- a/Coder Desktop/Coder Desktop/Coder_DesktopApp.swift +++ b/Coder Desktop/Coder Desktop/Coder_DesktopApp.swift @@ -61,7 +61,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { } // TODO: Start the daemon only once a file sync is configured Task { - try? await fileSyncDaemon.start() + await fileSyncDaemon.start() } } @@ -75,7 +75,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } let fileSyncStop = Task { - try? await fileSyncDaemon.stop() + await fileSyncDaemon.stop() } _ = await (vpnStop.value, fileSyncStop.value) NSApp.reply(toApplicationShouldTerminate: true) From 2bf41aa78772f272aa01c84ea7e7c7e62e4097a3 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Mon, 10 Mar 2025 21:09:57 +1100 Subject: [PATCH 15/18] fixup --- Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index d22e6c3..f31e8b1 100644 --- a/Makefile +++ b/Makefile @@ -33,8 +33,8 @@ APP_SIGNING_KEYCHAIN := $(if $(wildcard $(KEYCHAIN_FILE)),$(shell realpath $(KEY .PHONY: setup setup: \ $(XCPROJECT) \ - $(PROJECT)/VPNLib/vpn.proto \ - $(PROJECT)/VPNLib/FileSync/daemon.proto + $(PROJECT)/VPNLib/vpn.pb.swift \ + $(PROJECT)/VPNLib/FileSync/daemon.pb.swift $(XCPROJECT): $(PROJECT)/project.yml cd $(PROJECT); \ From 16a7263817e9c82234563fb82ab438ec3be78823 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 11 Mar 2025 14:46:45 +1100 Subject: [PATCH 16/18] process -> subprocess package --- .../VPNLib/FileSync/FileSyncDaemon.swift | 77 +++++++++++-------- Coder Desktop/project.yml | 4 + 2 files changed, 48 insertions(+), 33 deletions(-) diff --git a/Coder Desktop/VPNLib/FileSync/FileSyncDaemon.swift b/Coder Desktop/VPNLib/FileSync/FileSyncDaemon.swift index ed5e5ad..9324c07 100644 --- a/Coder Desktop/VPNLib/FileSync/FileSyncDaemon.swift +++ b/Coder Desktop/VPNLib/FileSync/FileSyncDaemon.swift @@ -2,6 +2,7 @@ import Foundation import GRPC import NIO import os +import Subprocess @MainActor public protocol FileSyncDaemon: ObservableObject { @@ -20,8 +21,7 @@ public class MutagenDaemon: FileSyncDaemon { } } - private var mutagenProcess: Process? - private var mutagenPipe: Pipe? + private var mutagenProcess: Subprocess? private let mutagenPath: URL! private let mutagenDataDirectory: URL private let mutagenDaemonSocket: URL @@ -58,24 +58,42 @@ public class MutagenDaemon: FileSyncDaemon { try? await connect() await stop() - (mutagenProcess, mutagenPipe) = createMutagenProcess() + mutagenProcess = createMutagenProcess() + // swiftlint:disable:next large_tuple + let (standardOutput, standardError, waitForExit): (Pipe.AsyncBytes, Pipe.AsyncBytes, @Sendable () async -> Void) do { - try mutagenProcess?.run() + (standardOutput, standardError, waitForExit) = try mutagenProcess!.run() } catch { state = .failed(DaemonError.daemonStartFailure(error)) + return + } + + Task { + await streamHandler(io: standardOutput) + logger.info("standard output stream closed") + } + + Task { + await streamHandler(io: standardError) + logger.info("standard error stream closed") + } + + Task { + await terminationHandler(waitForExit: waitForExit) } do { try await connect() } catch { state = .failed(DaemonError.daemonStartFailure(error)) + return } state = .running logger.info( """ mutagen daemon started, pid: - \(self.mutagenProcess?.processIdentifier.description ?? "unknown", privacy: .public) + \(self.mutagenProcess?.pid.description ?? "unknown", privacy: .public) """ ) } @@ -129,46 +147,39 @@ public class MutagenDaemon: FileSyncDaemon { try? await cleanupGRPC() - mutagenProcess?.terminate() + mutagenProcess?.kill() + mutagenProcess = nil logger.info("Daemon stopped and gRPC connection closed") } - private func createMutagenProcess() -> (Process, Pipe) { - let outputPipe = Pipe() - outputPipe.fileHandleForReading.readabilityHandler = logOutput - let process = Process() - process.executableURL = mutagenPath - process.arguments = ["daemon", "run"] - logger.info("setting mutagen data directory: \(self.mutagenDataDirectory.path, privacy: .public)") + private func createMutagenProcess() -> Subprocess { + let process = Subprocess([mutagenPath.path, "daemon", "run"]) process.environment = [ "MUTAGEN_DATA_DIRECTORY": mutagenDataDirectory.path, ] - process.standardOutput = outputPipe - process.standardError = outputPipe - process.terminationHandler = terminationHandler - return (process, outputPipe) + logger.info("setting mutagen data directory: \(self.mutagenDataDirectory.path, privacy: .public)") + return process } - private nonisolated func terminationHandler(process _: Process) { - Task { @MainActor in - self.mutagenPipe?.fileHandleForReading.readabilityHandler = nil - mutagenProcess = nil + private func terminationHandler(waitForExit: @Sendable () async -> Void) async { + await waitForExit() - try? await cleanupGRPC() - - switch self.state { - case .stopped: - logger.info("mutagen daemon stopped") - return - default: - logger.error("mutagen daemon exited unexpectedly") - self.state = .failed(.terminatedUnexpectedly) - } + switch state { + case .stopped: + logger.info("mutagen daemon stopped") + default: + logger.error( + """ + mutagen daemon exited unexpectedly with code: + \(self.mutagenProcess?.exitCode.description ?? "unknown") + """ + ) + state = .failed(.terminatedUnexpectedly) } } - private nonisolated func logOutput(pipe: FileHandle) { - if let line = String(data: pipe.availableData, encoding: .utf8), line != "" { + private func streamHandler(io: Pipe.AsyncBytes) async { + for await line in io.lines { logger.info("\(line, privacy: .public)") } } diff --git a/Coder Desktop/project.yml b/Coder Desktop/project.yml index 37bc395..4b0eef6 100644 --- a/Coder Desktop/project.yml +++ b/Coder Desktop/project.yml @@ -112,6 +112,9 @@ packages: url: https://github.com/grpc/grpc-swift # v2 does not support macOS 14.0 exactVersion: 1.24.2 + Subprocess: + url: https://github.com/jamf/Subprocess + revision: 9d67b79 targets: Coder Desktop: @@ -270,6 +273,7 @@ targets: - package: SwiftProtobuf product: SwiftProtobufPluginLibrary - package: GRPC + - package: Subprocess - target: CoderSDK embed: false From 382fd9b9a354ac18117675649432ef0d5de4604f Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 11 Mar 2025 16:27:10 +1100 Subject: [PATCH 17/18] use async let --- Coder Desktop/Coder Desktop/Coder_DesktopApp.swift | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/Coder Desktop/Coder Desktop/Coder_DesktopApp.swift b/Coder Desktop/Coder Desktop/Coder_DesktopApp.swift index 73b18da..411481e 100644 --- a/Coder Desktop/Coder Desktop/Coder_DesktopApp.swift +++ b/Coder Desktop/Coder Desktop/Coder_DesktopApp.swift @@ -69,15 +69,13 @@ class AppDelegate: NSObject, NSApplicationDelegate { // or return `.terminateNow` func applicationShouldTerminate(_: NSApplication) -> NSApplication.TerminateReply { Task { - let vpnStop = Task { - if !state.stopVPNOnQuit { - await vpn.stop() + async let vpnTask: Void = { + if await !self.state.stopVPNOnQuit { + await self.vpn.stop() } - } - let fileSyncStop = Task { - await fileSyncDaemon.stop() - } - _ = await (vpnStop.value, fileSyncStop.value) + }() + async let fileSyncTask: Void = self.fileSyncDaemon.stop() + _ = await (vpnTask, fileSyncTask) NSApp.reply(toApplicationShouldTerminate: true) } return .terminateLater From ad1b24d394ba828f58e4a3682ccb2af3ba7a6157 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 11 Mar 2025 20:28:10 +1100 Subject: [PATCH 18/18] fixup --- Coder Desktop/Coder Desktop/Coder_DesktopApp.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Coder Desktop/Coder Desktop/Coder_DesktopApp.swift b/Coder Desktop/Coder Desktop/Coder_DesktopApp.swift index 411481e..1d379e9 100644 --- a/Coder Desktop/Coder Desktop/Coder_DesktopApp.swift +++ b/Coder Desktop/Coder Desktop/Coder_DesktopApp.swift @@ -70,7 +70,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { func applicationShouldTerminate(_: NSApplication) -> NSApplication.TerminateReply { Task { async let vpnTask: Void = { - if await !self.state.stopVPNOnQuit { + if await self.state.stopVPNOnQuit { await self.vpn.stop() } }()