Skip to content

chore: add dylib downloader and validator #16

New issue

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

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

Already on GitHub? Sign in to your account

Merged
merged 17 commits into from
Jan 14, 2025
Merged
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
379 changes: 304 additions & 75 deletions Coder Desktop/Coder Desktop.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -62,9 +62,20 @@
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "961679D82D030E1D00B2B6DF"
BuildableName = "ProtoTests.xctest"
BlueprintName = "ProtoTests"
BlueprintIdentifier = "AA3B3DA02D2D23860099996A"
BuildableName = "VPNLib.framework"
BlueprintName = "VPNLib"
ReferencedContainer = "container:Coder Desktop.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "AA3B3DA72D2D23860099996A"
BuildableName = "VPNLibTests.xctest"
BlueprintName = "VPNLibTests"
ReferencedContainer = "container:Coder Desktop.xcodeproj">
</BuildableReference>
</TestableReference>
Original file line number Diff line number Diff line change
@@ -6,26 +6,29 @@
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "9616792F2CFF117300B2B6DF"
BuildableName = "com.coder.Coder-Desktop.VPN.systemextension"
BlueprintName = "VPN"
ReferencedContainer = "container:Coder Desktop.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "961679D82D030E1D00B2B6DF"
BuildableName = "ProtoTests.xctest"
BlueprintName = "ProtoTests"
ReferencedContainer = "container:Coder Desktop.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
@@ -37,16 +40,6 @@
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "961678FB2CFF100D00B2B6DF"
BuildableName = "Coder Desktop.app"
BlueprintName = "Coder Desktop"
ReferencedContainer = "container:Coder Desktop.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
@@ -57,9 +50,9 @@
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "961678FB2CFF100D00B2B6DF"
BuildableName = "Coder Desktop.app"
BlueprintName = "Coder Desktop"
BlueprintIdentifier = "9616792F2CFF117300B2B6DF"
BuildableName = "com.coder.Coder-Desktop.VPN.systemextension"
BlueprintName = "VPN"
ReferencedContainer = "container:Coder Desktop.xcodeproj">
</BuildableReference>
</MacroExpansion>
8 changes: 4 additions & 4 deletions Coder Desktop/Coder Desktop.xctestplan
Original file line number Diff line number Diff line change
@@ -19,15 +19,15 @@
{
"target" : {
"containerPath" : "container:Coder Desktop.xcodeproj",
"identifier" : "961679D82D030E1D00B2B6DF",
"name" : "ProtoTests"
"identifier" : "9616790E2CFF100E00B2B6DF",
"name" : "Coder DesktopTests"
}
},
{
"target" : {
"containerPath" : "container:Coder Desktop.xcodeproj",
"identifier" : "9616790E2CFF100E00B2B6DF",
"name" : "Coder DesktopTests"
"identifier" : "AA3B3DA72D2D23860099996A",
"name" : "VPNLibTests"
}
},
{
6 changes: 3 additions & 3 deletions Coder Desktop/Coder Desktop/SDK/Client.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Alamofire
import Foundation

protocol Client {
protocol Client: Sendable {
init(url: URL, token: String?)
func user(_ ident: String) async throws(ClientError) -> User
}
@@ -114,10 +114,10 @@ struct APIError: Decodable {
struct Response: Decodable {
let message: String
let detail: String?
let validations: [ValidationError]?
let validations: [FieldValidation]?
}

struct ValidationError: Decodable {
struct FieldValidation: Decodable {
let field: String
let detail: String
}
21 changes: 21 additions & 0 deletions Coder Desktop/VPN/Manager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import NetworkExtension
import os
import VPNLib

actor Manager {
let ptp: PacketTunnelProvider
let downloader: Downloader

var tunnelHandle: TunnelHandle?
var speaker: Speaker<Vpn_ManagerMessage, Vpn_TunnelMessage>?
// TODO: XPC Speaker

private let dest = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)
.first!.appending(path: "coder-vpn.dylib")
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "manager")

init(with: PacketTunnelProvider) {
ptp = with
downloader = Downloader()
}
}
58 changes: 54 additions & 4 deletions Coder Desktop/VPN/PacketTunnelProvider.swift
Original file line number Diff line number Diff line change
@@ -1,12 +1,62 @@
import NetworkExtension
import os

class PacketTunnelProvider: NEPacketTunnelProvider {
override func startTunnel(options _: [String: NSObject]?, completionHandler _: @escaping (Error?) -> Void) {
// Add code here to start the process of connecting the tunnel.
/* From <sys/kern_control.h> */
let CTLIOCGINFO: UInt = 0xC064_4E03

class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "network-extension")
private var manager: Manager?

private var tunnelFileDescriptor: Int32? {
var ctlInfo = ctl_info()
withUnsafeMutablePointer(to: &ctlInfo.ctl_name) {
$0.withMemoryRebound(to: CChar.self, capacity: MemoryLayout.size(ofValue: $0.pointee)) {
_ = strcpy($0, "com.apple.net.utun_control")
}
}
for fd: Int32 in 0 ... 1024 {
var addr = sockaddr_ctl()
var ret: Int32 = -1
var len = socklen_t(MemoryLayout.size(ofValue: addr))
withUnsafeMutablePointer(to: &addr) {
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
ret = getpeername(fd, $0, &len)
}
}
if ret != 0 || addr.sc_family != AF_SYSTEM {
continue
}
if ctlInfo.ctl_id == 0 {
ret = ioctl(fd, CTLIOCGINFO, &ctlInfo)
if ret != 0 {
continue
}
}
if addr.sc_id == ctlInfo.ctl_id {
return fd
}
}
return nil
}

override func startTunnel(options _: [String: NSObject]?, completionHandler: @escaping (Error?) -> Void) {
guard manager == nil else {
logger.error("startTunnel called with non-nil Manager")
completionHandler(nil)
return
}
manager = Manager(with: self)
completionHandler(nil)
}

override func stopTunnel(with _: NEProviderStopReason, completionHandler: @escaping () -> Void) {
// Add code here to start the process of stopping the tunnel.
guard manager == nil else {
logger.error("stopTunnel called with nil Manager")
completionHandler()
return
}
manager = nil
completionHandler()
}

80 changes: 80 additions & 0 deletions Coder Desktop/VPN/TunnelHandle.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import Foundation
import os

let startSymbol = "OpenTunnel"

actor TunnelHandle {
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "tunnel-handle")

private let tunnelWritePipe: Pipe
private let tunnelReadPipe: Pipe
private let dylibHandle: UnsafeMutableRawPointer

var writeHandle: FileHandle { tunnelReadPipe.fileHandleForWriting }
var readHandle: FileHandle { tunnelWritePipe.fileHandleForReading }

init(dylibPath: URL) throws(TunnelHandleError) {
guard let dylibHandle = dlopen(dylibPath.path, RTLD_NOW | RTLD_LOCAL) else {
var errStr = "UNKNOWN"
let e = dlerror()
if e != nil {
errStr = String(cString: e!)
}
throw .dylib(errStr)
}
self.dylibHandle = dylibHandle

guard let startSym = dlsym(dylibHandle, startSymbol) else {
var errStr = "UNKNOWN"
let e = dlerror()
if e != nil {
errStr = String(cString: e!)
}
throw .symbol(startSymbol, errStr)
}
let openTunnelFn = unsafeBitCast(startSym, to: OpenTunnel.self)
tunnelReadPipe = Pipe()
tunnelWritePipe = Pipe()
let res = openTunnelFn(tunnelReadPipe.fileHandleForReading.fileDescriptor,
tunnelWritePipe.fileHandleForWriting.fileDescriptor)
guard res == 0 else {
throw .openTunnel(OpenTunnelError(rawValue: res) ?? .unknown)
}
}

func close() throws {
dlclose(dylibHandle)
}
}

enum TunnelHandleError: Error {
case dylib(String)
case symbol(String, String)
case openTunnel(OpenTunnelError)

var description: String {
switch self {
case let .dylib(d): return d
case let .symbol(symbol, message): return "\(symbol): \(message)"
case let .openTunnel(error): return "OpenTunnel: \(error.message)"
}
}
}

enum OpenTunnelError: Int32 {
case errDupReadFD = -2
case errDupWriteFD = -3
case errOpenPipe = -4
case errNewTunnel = -5
case unknown = -99

var message: String {
switch self {
case .errDupReadFD: return "Failed to duplicate read file descriptor"
case .errDupWriteFD: return "Failed to duplicate write file descriptor"
case .errOpenPipe: return "Failed to open the pipe"
case .errNewTunnel: return "Failed to create a new tunnel"
case .unknown: return "Unknown error code"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#ifndef CoderPacketTunnelProvider_Bridging_Header_h
#define CoderPacketTunnelProvider_Bridging_Header_h

// GoInt32 OpenTunnel(GoInt32 cReadFD, GoInt32 cWriteFD);
typedef int(*OpenTunnel)(int, int);

#endif /* CoderPacketTunnelProvider_Bridging_Header_h */
167 changes: 167 additions & 0 deletions Coder Desktop/VPNLib/Downloader.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import CryptoKit
import Foundation

public protocol Validator: Sendable {
func validate(path: URL) async throws
}

public enum ValidationError: LocalizedError {
case fileNotFound
case unableToCreateStaticCode
case invalidSignature
case unableToRetrieveInfo
case invalidIdentifier(identifier: String?)
case invalidTeamIdentifier(identifier: String?)
case missingInfoPList
case invalidVersion(version: String?)

public var errorDescription: String? {
switch self {
case .fileNotFound:
return "The file does not exist."
case .unableToCreateStaticCode:
return "Unable to create a static code object."
case .invalidSignature:
return "The file's signature is invalid."
case .unableToRetrieveInfo:
return "Unable to retrieve signing information."
case let .invalidIdentifier(identifier):
return "Invalid identifier: \(identifier ?? "unknown")."
case let .invalidVersion(version):
return "Invalid runtime version: \(version ?? "unknown")."
case let .invalidTeamIdentifier(identifier):
return "Invalid team identifier: \(identifier ?? "unknown")."
case .missingInfoPList:
return "Info.plist is not embedded within the dylib."
}
}
}

public struct SignatureValidator: Validator {
private let expectedName = "CoderVPN"
private let expectedIdentifier = "com.coder.Coder-Desktop.VPN.dylib"
private let expectedTeamIdentifier = "4399GN35BJ"
private let minDylibVersion = "2.18.1"

private let infoIdentifierKey = "CFBundleIdentifier"
private let infoNameKey = "CFBundleName"
private let infoShortVersionKey = "CFBundleShortVersionString"

private let signInfoFlags: SecCSFlags = .init(rawValue: kSecCSSigningInformation)

public init() {}

public func validate(path: URL) throws {
guard FileManager.default.fileExists(atPath: path.path) else {
throw ValidationError.fileNotFound
}

var staticCode: SecStaticCode?
let status = SecStaticCodeCreateWithPath(path as CFURL, SecCSFlags(), &staticCode)
guard status == errSecSuccess, let code = staticCode else {
throw ValidationError.unableToCreateStaticCode
}

let validateStatus = SecStaticCodeCheckValidity(code, SecCSFlags(), nil)
guard validateStatus == errSecSuccess else {
throw ValidationError.invalidSignature
}

var information: CFDictionary?
let infoStatus = SecCodeCopySigningInformation(code, signInfoFlags, &information)
guard infoStatus == errSecSuccess, let info = information as? [String: Any] else {
throw ValidationError.unableToRetrieveInfo
}

guard let identifier = info[kSecCodeInfoIdentifier as String] as? String,
identifier == expectedIdentifier
else {
throw ValidationError.invalidIdentifier(identifier: info[kSecCodeInfoIdentifier as String] as? String)
}

guard let teamIdentifier = info[kSecCodeInfoTeamIdentifier as String] as? String,
teamIdentifier == expectedTeamIdentifier
else {
throw ValidationError.invalidTeamIdentifier(
identifier: info[kSecCodeInfoTeamIdentifier as String] as? String
)
}

guard let infoPlist = info[kSecCodeInfoPList as String] as? [String: AnyObject] else {
throw ValidationError.missingInfoPList
}

guard let plistIdent = infoPlist[infoIdentifierKey] as? String, plistIdent == expectedIdentifier else {
throw ValidationError.invalidIdentifier(identifier: infoPlist[infoIdentifierKey] as? String)
}

guard let plistName = infoPlist[infoNameKey] as? String, plistName == expectedName else {
throw ValidationError.invalidIdentifier(identifier: infoPlist[infoNameKey] as? String)
}

guard let dylibVersion = infoPlist[infoShortVersionKey] as? String,
minDylibVersion.compare(dylibVersion, options: .numeric) != .orderedDescending
else {
throw ValidationError.invalidVersion(version: infoPlist[infoShortVersionKey] as? String)
}
}
}

public actor Downloader {
let validator: Validator
public init(validator: Validator = SignatureValidator()) {
self.validator = validator
}

public func download(src: URL, dest: URL) async throws {
var req = URLRequest(url: src)
if FileManager.default.fileExists(atPath: dest.path) {
if let existingFileData = try? Data(contentsOf: dest) {
req.setValue(etag(data: existingFileData), forHTTPHeaderField: "If-None-Match")
}
}
// TODO: Add Content-Length headers to coderd, add download progress delegate
let (tempURL, response) = try await URLSession.shared.download(for: req)
defer {
if FileManager.default.fileExists(atPath: dest.path) {
do { try FileManager.default.removeItem(at: tempURL) } catch {}
}
}

guard let httpResponse = response as? HTTPURLResponse else {
throw DownloadError.invalidResponse
}
guard httpResponse.statusCode != 304 else {
return
}
guard (200 ..< 300).contains(httpResponse.statusCode) else {
throw DownloadError.unexpectedStatusCode(httpResponse.statusCode)
}

if FileManager.default.fileExists(atPath: dest.path) {
try FileManager.default.removeItem(at: dest)
}
try FileManager.default.moveItem(at: tempURL, to: dest)
try await validator.validate(path: dest)
}
}

func etag(data: Data) -> String {
let sha1Hash = Insecure.SHA1.hash(data: data)
let etag = sha1Hash.map { String(format: "%02x", $0) }.joined()
return "\"\(etag)\""
}

enum DownloadError: LocalizedError {
case unexpectedStatusCode(Int)
case invalidResponse

var localizedDescription: String {
switch self {
case let .unexpectedStatusCode(code):
return "Unexpected status code: \(code)"
case .invalidResponse:
return "Received non-HTTP response"
}
}
}
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -3,7 +3,7 @@ import SwiftProtobuf

/// A actor that serializes and sends VPN protocol messages over a `FileHandle`, which is typically
/// the write-side of a `Pipe`.
actor Sender<SendMsg: Message> {
public actor Sender<SendMsg: Message> {
private let writeFD: FileHandle

init(writeFD: FileHandle) {
Original file line number Diff line number Diff line change
@@ -6,7 +6,7 @@ let newLine = 0x0A
let headerPreamble = "codervpn"

/// A message that has the `rpc` property for recording participation in a unary RPC.
protocol RPCMessage: Sendable {
public protocol RPCMessage: Sendable {
var rpc: Vpn_RPC { get set }
/// Returns true if `rpc` has been explicitly set.
var hasRpc: Bool { get }
@@ -50,8 +50,8 @@ struct ProtoVersion: CustomStringConvertible, Equatable, Codable {
}

/// An actor that communicates using the VPN protocol
actor Speaker<SendMsg: RPCMessage & Message, RecvMsg: RPCMessage & Message> {
private let logger = Logger(subsystem: "com.coder.Coder-Desktop", category: "proto")
public actor Speaker<SendMsg: RPCMessage & Message, RecvMsg: RPCMessage & Message> {
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "proto")
private let writeFD: FileHandle
private let readFD: FileHandle
private let dispatch: DispatchIO
@@ -62,7 +62,7 @@ actor Speaker<SendMsg: RPCMessage & Message, RecvMsg: RPCMessage & Message> {
let role: ProtoRole

/// Creates an instance that communicates over the provided file handles.
init(writeFD: FileHandle, readFD: FileHandle) {
public init(writeFD: FileHandle, readFD: FileHandle) {
self.writeFD = writeFD
self.readFD = readFD
sender = Sender(writeFD: writeFD)
@@ -130,20 +130,20 @@ actor Speaker<SendMsg: RPCMessage & Message, RecvMsg: RPCMessage & Message> {
}
}

enum IncomingMessage {
public enum IncomingMessage: Sendable {
case message(RecvMsg)
case RPC(RPCRequest<SendMsg, RecvMsg>)
}
}

extension Speaker: AsyncSequence, AsyncIteratorProtocol {
typealias Element = IncomingMessage
public typealias Element = IncomingMessage

public nonisolated func makeAsyncIterator() -> Speaker<SendMsg, RecvMsg> {
self
}

func next() async throws -> IncomingMessage? {
public func next() async throws -> IncomingMessage? {
for try await msg in try await receiver.messages() {
guard msg.hasRpc else {
return .message(msg)
@@ -277,7 +277,7 @@ enum HandshakeError: Error {
case unsupportedVersion([ProtoVersion])
}

struct RPCRequest<SendMsg: RPCMessage & Message, RecvMsg: RPCMessage & Sendable>: Sendable {
public struct RPCRequest<SendMsg: RPCMessage & Message, RecvMsg: RPCMessage & Sendable>: Sendable {
let msg: RecvMsg
private let sender: Sender<SendMsg>

11 changes: 11 additions & 0 deletions Coder Desktop/VPNLib/VPNLib.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
#import <Foundation/Foundation.h>

//! Project version number for VPNLib.
FOUNDATION_EXPORT double VPNLibVersionNumber;

//! Project version string for VPNLib.
FOUNDATION_EXPORT const unsigned char VPNLibVersionString[];

// In this header, you should import all the public headers of your framework using statements like #import <VPNLib/PublicHeader.h>


516 changes: 258 additions & 258 deletions Coder Desktop/Proto/vpn.pb.swift → Coder Desktop/VPNLib/vpn.pb.swift

Large diffs are not rendered by default.

File renamed without changes.
75 changes: 75 additions & 0 deletions Coder Desktop/VPNLibTests/DownloaderTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import Foundation
import Mocker
import Testing
@testable import VPNLib

struct NoopValidator: Validator {
func validate(path _: URL) async throws {}
}

@Suite
struct DownloaderTests {
let downloader = Downloader(validator: NoopValidator())

@Test
func downloadFile() async throws {
let destinationURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
let testData = Data("foo".utf8)

let fileURL = URL(string: "http://example.com/test1.txt")!
Mock(url: fileURL, contentType: .html, statusCode: 200, data: [.get: testData]).register()

try await downloader.download(src: fileURL, dest: destinationURL)

try #require(FileManager.default.fileExists(atPath: destinationURL.path))
defer { try? FileManager.default.removeItem(at: destinationURL) }

let downloadedData = try Data(contentsOf: destinationURL)
#expect(downloadedData == testData)
}

@Test
func fileNotModified() async throws {
let testData = Data("foo bar".utf8)
let fileURL = URL(string: "http://example.com/test2.txt")!

let destinationURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: destinationURL) }

Mock(url: fileURL, contentType: .html, statusCode: 200, data: [.get: testData]).register()

try await downloader.download(src: fileURL, dest: destinationURL)
try #require(FileManager.default.fileExists(atPath: destinationURL.path))
let downloadedData = try Data(contentsOf: destinationURL)
#expect(downloadedData == testData)

Mock(url: fileURL, contentType: .html, statusCode: 304, data: [.get: Data()]).register()

try await downloader.download(src: fileURL, dest: destinationURL)
let unchangedData = try Data(contentsOf: destinationURL)
#expect(unchangedData == testData)
}

@Test
func fileUpdated() async throws {
let ogData = Data("foo bar".utf8)
let newData = Data("foo bar qux".utf8)

let fileURL = URL(string: "http://example.com/test3.txt")!
let destinationURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: destinationURL) }

Mock(url: fileURL, contentType: .html, statusCode: 200, data: [.get: ogData]).register()

try await downloader.download(src: fileURL, dest: destinationURL)
try #require(FileManager.default.fileExists(atPath: destinationURL.path))
var downloadedData = try Data(contentsOf: destinationURL)
#expect(downloadedData == ogData)

Mock(url: fileURL, contentType: .html, statusCode: 200, data: [.get: newData]).register()

try await downloader.download(src: fileURL, dest: destinationURL)
downloadedData = try Data(contentsOf: destinationURL)
#expect(downloadedData == newData)
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
@testable import Coder_Desktop
import Foundation
import Testing
@testable import VPNLib

@Suite(.timeLimit(.minutes(1)))
struct SenderReceiverTests {
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
@testable import Coder_Desktop
import Foundation
import Testing
@testable import VPNLib

@Suite(.timeLimit(.minutes(1)))
struct SpeakerTests: Sendable {
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -34,4 +34,4 @@ clean:
-project $(PROJECT)

proto:
protoc --swift_out=. 'Coder Desktop/Proto/vpn.proto'
protoc --swift_opt=Visibility=public --swift_out=. 'Coder Desktop/VPNLib/vpn.proto'