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..1d379e9 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 { @@ -30,10 +31,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 +59,23 @@ class AppDelegate: NSObject, NSApplicationDelegate { state.reconfigure() } } + // TODO: Start the daemon only once a file sync is configured + Task { + 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() + async let vpnTask: Void = { + if await self.state.stopVPNOnQuit { + await self.vpn.stop() + } + }() + async let fileSyncTask: Void = self.fileSyncDaemon.stop() + _ = await (vpnTask, fileSyncTask) NSApp.reply(toApplicationShouldTerminate: true) } return .terminateLater 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/Resources/.gitkeep b/Coder Desktop/Resources/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Coder Desktop/VPNLib/FileSync/FileSyncDaemon.swift b/Coder Desktop/VPNLib/FileSync/FileSyncDaemon.swift new file mode 100644 index 0000000..9324c07 --- /dev/null +++ b/Coder Desktop/VPNLib/FileSync/FileSyncDaemon.swift @@ -0,0 +1,225 @@ +import Foundation +import GRPC +import NIO +import os +import Subprocess + +@MainActor +public protocol FileSyncDaemon: ObservableObject { + var state: DaemonState { get } + 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 { + didSet { + logger.info("daemon state changed: \(self.state.description, privacy: .public)") + } + } + + private var mutagenProcess: Subprocess? + 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? + + public 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 mutagenPath == nil { + logger.warning("Mutagen not embedded in app, file sync will be unavailable") + state = .unavailable + } + } + + public func start() async { + if case .unavailable = state { return } + + // Stop an orphaned daemon, if there is one + try? await connect() + await stop() + + mutagenProcess = createMutagenProcess() + // swiftlint:disable:next large_tuple + let (standardOutput, standardError, waitForExit): (Pipe.AsyncBytes, Pipe.AsyncBytes, @Sendable () async -> Void) + do { + (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?.pid.description ?? "unknown", privacy: .public) + """ + ) + } + + private func connect() async throws(DaemonError) { + 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, socket: \(self.mutagenDaemonSocket.path, privacy: .public)" + ) + } catch { + logger.error("Failed to connect to gRPC: \(error)") + try? await cleanupGRPC() + throw DaemonError.connectionFailure(error) + } + } + + private func cleanupGRPC() async throws { + try? await channel?.close().get() + try? await group?.shutdownGracefully() + + client = nil + channel = nil + group = nil + } + + public func stop() async { + if case .unavailable = state { return } + state = .stopped + guard FileManager.default.fileExists(atPath: mutagenDaemonSocket.path) else { + // Already stopped + 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))) + ) + + try? await cleanupGRPC() + + mutagenProcess?.kill() + mutagenProcess = nil + logger.info("Daemon stopped and gRPC connection closed") + } + + private func createMutagenProcess() -> Subprocess { + let process = Subprocess([mutagenPath.path, "daemon", "run"]) + process.environment = [ + "MUTAGEN_DATA_DIRECTORY": mutagenDataDirectory.path, + ] + logger.info("setting mutagen data directory: \(self.mutagenDataDirectory.path, privacy: .public)") + return process + } + + private func terminationHandler(waitForExit: @Sendable () async -> Void) async { + await waitForExit() + + 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 func streamHandler(io: Pipe.AsyncBytes) async { + for await line in io.lines { + logger.info("\(line, privacy: .public)") + } + } +} + +public enum DaemonState { + case running + case stopped + 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 } +} diff --git a/Coder Desktop/VPNLib/FileSync/daemon.grpc.swift b/Coder Desktop/VPNLib/FileSync/daemon.grpc.swift new file mode 100644 index 0000000..4fbe078 --- /dev/null +++ b/Coder Desktop/VPNLib/FileSync/daemon.grpc.swift @@ -0,0 +1,299 @@ +// +// DO NOT EDIT. +// swift-format-ignore-file +// +// Generated by the protocol buffer compiler. +// Source: Coder Desktop/VPNLib/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/VPNLib/FileSync/daemon.pb.swift b/Coder Desktop/VPNLib/FileSync/daemon.pb.swift new file mode 100644 index 0000000..4ed73c6 --- /dev/null +++ b/Coder Desktop/VPNLib/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/VPNLib/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/VPNLib/FileSync/daemon.proto b/Coder Desktop/VPNLib/FileSync/daemon.proto new file mode 100644 index 0000000..4431b35 --- /dev/null +++ b/Coder Desktop/VPNLib/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/project.yml b/Coder Desktop/project.yml index 2872515..4b0eef6 100644 --- a/Coder Desktop/project.yml +++ b/Coder Desktop/project.yml @@ -5,6 +5,9 @@ options: macOS: "14.0" xcodeVersion: "1600" minimumXcodeGenVersion: "2.42.0" + fileTypes: + proto: + buildPhase: none settings: base: @@ -105,6 +108,13 @@ 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 + Subprocess: + url: https://github.com/jamf/Subprocess + revision: 9d67b79 targets: Coder Desktop: @@ -112,6 +122,8 @@ targets: platform: macOS sources: - path: Coder Desktop + - path: Resources + buildPhase: resources entitlements: path: Coder Desktop/Coder_Desktop.entitlements properties: @@ -145,11 +157,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 @@ -224,8 +241,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 @@ -253,6 +272,8 @@ targets: - package: SwiftProtobuf - package: SwiftProtobuf product: SwiftProtobufPluginLibrary + - package: GRPC + - package: Subprocess - target: CoderSDK embed: false diff --git a/Makefile b/Makefile index e823a13..f31e8b1 100644 --- a/Makefile +++ b/Makefile @@ -33,7 +33,8 @@ APP_SIGNING_KEYCHAIN := $(if $(wildcard $(KEYCHAIN_FILE)),$(shell realpath $(KEY .PHONY: setup setup: \ $(XCPROJECT) \ - $(PROJECT)/VPNLib/vpn.pb.swift + $(PROJECT)/VPNLib/vpn.pb.swift \ + $(PROJECT)/VPNLib/FileSync/daemon.pb.swift $(XCPROJECT): $(PROJECT)/project.yml cd $(PROJECT); \ @@ -48,6 +49,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)/VPNLib/FileSync/daemon.pb.swift: $(PROJECT)/VPNLib/FileSync/daemon.proto + protoc \ + --swift_out=.\ + --grpc-swift_out=. \ + 'Coder Desktop/VPNLib/FileSync/daemon.proto' + $(KEYCHAIN_FILE): security create-keychain -p "" "$(APP_SIGNING_KEYCHAIN)" security set-keychain-settings -lut 21600 "$(APP_SIGNING_KEYCHAIN)" @@ -130,7 +137,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)/VPNLib/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..011c0d0 100644 --- a/flake.lock +++ b/flake.lock @@ -1,5 +1,25 @@ { "nodes": { + "flake-parts": { + "inputs": { + "nixpkgs-lib": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1741352980, + "narHash": "sha256-+u2UunDA4Cl5Fci3m7S643HzKmIDAe+fiXrLqYsR2fs=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "f4330d22f1c5d2ba72d3d22df5597d123fdb60a9", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, "flake-utils": { "inputs": { "systems": "systems" @@ -18,6 +38,47 @@ "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": 1740560979, @@ -36,7 +97,9 @@ }, "root": { "inputs": { + "flake-parts": "flake-parts", "flake-utils": "flake-utils", + "grpc-swift": "grpc-swift", "nixpkgs": "nixpkgs" } }, diff --git a/flake.nix b/flake.nix index 0b09753..ab3ab0a 100644 --- a/flake.nix +++ b/flake.nix @@ -4,13 +4,23 @@ inputs = { nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; flake-utils.url = "github:numtide/flake-utils"; + 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; [ @@ -40,7 +50,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