Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 161e5c2

Browse files
authoredJan 14, 2025··
chore: add dylib downloader and validator (#16)
First PR for #2. This PR adds an abstraction for downloading & validating the dylib from a Coder server, and the network extension scaffolding. It also adds a `TunnelHandle` type for owning the pair of pipes passed to the dylib, and the handle to the dylib itself. You cannot create a unit test target that targets a System Extension. So, this PR extracts the portion of the network extension that we'd like to test into it's own Framework, `VPNLib`. Of note is that `SwiftProtobuf` doesn't have a stable ABI (as it doesn't use [library evolution](https://www.swift.org/blog/library-evolution/)), so the Framework has the `Build libraries for distribution` setting disabled. This shouldn't effect anything. Exporting the `SwiftProtobuf` types should be fine provided we don't import `SwiftProtobuf` in to the `VPN` target as well.
1 parent e9f5c6f commit 161e5c2

File tree

21 files changed

+1059
-387
lines changed

21 files changed

+1059
-387
lines changed
 

‎Coder Desktop/Coder Desktop.xcodeproj/project.pbxproj

Lines changed: 304 additions & 75 deletions
Large diffs are not rendered by default.

‎Coder Desktop/Coder Desktop.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved

Lines changed: 10 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Coder Desktop/Coder Desktop.xcodeproj/xcshareddata/xcschemes/Coder Desktop.xcscheme

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,20 @@
6262
parallelizable = "YES">
6363
<BuildableReference
6464
BuildableIdentifier = "primary"
65-
BlueprintIdentifier = "961679D82D030E1D00B2B6DF"
66-
BuildableName = "ProtoTests.xctest"
67-
BlueprintName = "ProtoTests"
65+
BlueprintIdentifier = "AA3B3DA02D2D23860099996A"
66+
BuildableName = "VPNLib.framework"
67+
BlueprintName = "VPNLib"
68+
ReferencedContainer = "container:Coder Desktop.xcodeproj">
69+
</BuildableReference>
70+
</TestableReference>
71+
<TestableReference
72+
skipped = "NO"
73+
parallelizable = "YES">
74+
<BuildableReference
75+
BuildableIdentifier = "primary"
76+
BlueprintIdentifier = "AA3B3DA72D2D23860099996A"
77+
BuildableName = "VPNLibTests.xctest"
78+
BlueprintName = "VPNLibTests"
6879
ReferencedContainer = "container:Coder Desktop.xcodeproj">
6980
</BuildableReference>
7081
</TestableReference>

‎Coder Desktop/Coder Desktop.xcodeproj/xcshareddata/xcschemes/ProtoTests.xcscheme renamed to ‎Coder Desktop/Coder Desktop.xcodeproj/xcshareddata/xcschemes/VPN.xcscheme

Lines changed: 19 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -6,26 +6,29 @@
66
parallelizeBuildables = "YES"
77
buildImplicitDependencies = "YES"
88
buildArchitectures = "Automatic">
9+
<BuildActionEntries>
10+
<BuildActionEntry
11+
buildForTesting = "YES"
12+
buildForRunning = "YES"
13+
buildForProfiling = "YES"
14+
buildForArchiving = "YES"
15+
buildForAnalyzing = "YES">
16+
<BuildableReference
17+
BuildableIdentifier = "primary"
18+
BlueprintIdentifier = "9616792F2CFF117300B2B6DF"
19+
BuildableName = "com.coder.Coder-Desktop.VPN.systemextension"
20+
BlueprintName = "VPN"
21+
ReferencedContainer = "container:Coder Desktop.xcodeproj">
22+
</BuildableReference>
23+
</BuildActionEntry>
24+
</BuildActionEntries>
925
</BuildAction>
1026
<TestAction
1127
buildConfiguration = "Debug"
1228
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
1329
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
1430
shouldUseLaunchSchemeArgsEnv = "YES"
1531
shouldAutocreateTestPlan = "YES">
16-
<Testables>
17-
<TestableReference
18-
skipped = "NO"
19-
parallelizable = "YES">
20-
<BuildableReference
21-
BuildableIdentifier = "primary"
22-
BlueprintIdentifier = "961679D82D030E1D00B2B6DF"
23-
BuildableName = "ProtoTests.xctest"
24-
BlueprintName = "ProtoTests"
25-
ReferencedContainer = "container:Coder Desktop.xcodeproj">
26-
</BuildableReference>
27-
</TestableReference>
28-
</Testables>
2932
</TestAction>
3033
<LaunchAction
3134
buildConfiguration = "Debug"
@@ -37,16 +40,6 @@
3740
debugDocumentVersioning = "YES"
3841
debugServiceExtension = "internal"
3942
allowLocationSimulation = "YES">
40-
<BuildableProductRunnable
41-
runnableDebuggingMode = "0">
42-
<BuildableReference
43-
BuildableIdentifier = "primary"
44-
BlueprintIdentifier = "961678FB2CFF100D00B2B6DF"
45-
BuildableName = "Coder Desktop.app"
46-
BlueprintName = "Coder Desktop"
47-
ReferencedContainer = "container:Coder Desktop.xcodeproj">
48-
</BuildableReference>
49-
</BuildableProductRunnable>
5043
</LaunchAction>
5144
<ProfileAction
5245
buildConfiguration = "Release"
@@ -57,9 +50,9 @@
5750
<MacroExpansion>
5851
<BuildableReference
5952
BuildableIdentifier = "primary"
60-
BlueprintIdentifier = "961678FB2CFF100D00B2B6DF"
61-
BuildableName = "Coder Desktop.app"
62-
BlueprintName = "Coder Desktop"
53+
BlueprintIdentifier = "9616792F2CFF117300B2B6DF"
54+
BuildableName = "com.coder.Coder-Desktop.VPN.systemextension"
55+
BlueprintName = "VPN"
6356
ReferencedContainer = "container:Coder Desktop.xcodeproj">
6457
</BuildableReference>
6558
</MacroExpansion>

‎Coder Desktop/Coder Desktop.xctestplan

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,15 @@
1919
{
2020
"target" : {
2121
"containerPath" : "container:Coder Desktop.xcodeproj",
22-
"identifier" : "961679D82D030E1D00B2B6DF",
23-
"name" : "ProtoTests"
22+
"identifier" : "9616790E2CFF100E00B2B6DF",
23+
"name" : "Coder DesktopTests"
2424
}
2525
},
2626
{
2727
"target" : {
2828
"containerPath" : "container:Coder Desktop.xcodeproj",
29-
"identifier" : "9616790E2CFF100E00B2B6DF",
30-
"name" : "Coder DesktopTests"
29+
"identifier" : "AA3B3DA72D2D23860099996A",
30+
"name" : "VPNLibTests"
3131
}
3232
},
3333
{

‎Coder Desktop/Coder Desktop/SDK/Client.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import Alamofire
22
import Foundation
33

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

120-
struct ValidationError: Decodable {
120+
struct FieldValidation: Decodable {
121121
let field: String
122122
let detail: String
123123
}

‎Coder Desktop/VPN/Manager.swift

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import NetworkExtension
2+
import os
3+
import VPNLib
4+
5+
actor Manager {
6+
let ptp: PacketTunnelProvider
7+
8+
var tunnelHandle: TunnelHandle?
9+
var speaker: Speaker<Vpn_ManagerMessage, Vpn_TunnelMessage>?
10+
// TODO: XPC Speaker
11+
12+
private let dest = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)
13+
.first!.appending(path: "coder-vpn.dylib")
14+
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "manager")
15+
16+
init(with: PacketTunnelProvider) {
17+
ptp = with
18+
}
19+
}

‎Coder Desktop/VPN/PacketTunnelProvider.swift

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,62 @@
11
import NetworkExtension
2+
import os
23

3-
class PacketTunnelProvider: NEPacketTunnelProvider {
4-
override func startTunnel(options _: [String: NSObject]?, completionHandler _: @escaping (Error?) -> Void) {
5-
// Add code here to start the process of connecting the tunnel.
4+
/* From <sys/kern_control.h> */
5+
let CTLIOCGINFO: UInt = 0xC064_4E03
6+
7+
class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
8+
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "network-extension")
9+
private var manager: Manager?
10+
11+
private var tunnelFileDescriptor: Int32? {
12+
var ctlInfo = ctl_info()
13+
withUnsafeMutablePointer(to: &ctlInfo.ctl_name) {
14+
$0.withMemoryRebound(to: CChar.self, capacity: MemoryLayout.size(ofValue: $0.pointee)) {
15+
_ = strcpy($0, "com.apple.net.utun_control")
16+
}
17+
}
18+
for fd: Int32 in 0 ... 1024 {
19+
var addr = sockaddr_ctl()
20+
var ret: Int32 = -1
21+
var len = socklen_t(MemoryLayout.size(ofValue: addr))
22+
withUnsafeMutablePointer(to: &addr) {
23+
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
24+
ret = getpeername(fd, $0, &len)
25+
}
26+
}
27+
if ret != 0 || addr.sc_family != AF_SYSTEM {
28+
continue
29+
}
30+
if ctlInfo.ctl_id == 0 {
31+
ret = ioctl(fd, CTLIOCGINFO, &ctlInfo)
32+
if ret != 0 {
33+
continue
34+
}
35+
}
36+
if addr.sc_id == ctlInfo.ctl_id {
37+
return fd
38+
}
39+
}
40+
return nil
41+
}
42+
43+
override func startTunnel(options _: [String: NSObject]?, completionHandler: @escaping (Error?) -> Void) {
44+
guard manager == nil else {
45+
logger.error("startTunnel called with non-nil Manager")
46+
completionHandler(nil)
47+
return
48+
}
49+
manager = Manager(with: self)
50+
completionHandler(nil)
651
}
752

853
override func stopTunnel(with _: NEProviderStopReason, completionHandler: @escaping () -> Void) {
9-
// Add code here to start the process of stopping the tunnel.
54+
guard manager == nil else {
55+
logger.error("stopTunnel called with nil Manager")
56+
completionHandler()
57+
return
58+
}
59+
manager = nil
1060
completionHandler()
1161
}
1262

‎Coder Desktop/VPN/TunnelHandle.swift

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import Foundation
2+
import os
3+
4+
let startSymbol = "OpenTunnel"
5+
6+
actor TunnelHandle {
7+
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "tunnel-handle")
8+
9+
private let tunnelWritePipe: Pipe
10+
private let tunnelReadPipe: Pipe
11+
private let dylibHandle: UnsafeMutableRawPointer
12+
13+
var writeHandle: FileHandle { tunnelReadPipe.fileHandleForWriting }
14+
var readHandle: FileHandle { tunnelWritePipe.fileHandleForReading }
15+
16+
init(dylibPath: URL) throws(TunnelHandleError) {
17+
guard let dylibHandle = dlopen(dylibPath.path, RTLD_NOW | RTLD_LOCAL) else {
18+
throw .dylib(dlerror().flatMap { String(cString: $0) } ?? "UNKNOWN")
19+
}
20+
self.dylibHandle = dylibHandle
21+
22+
guard let startSym = dlsym(dylibHandle, startSymbol) else {
23+
throw .symbol(startSymbol, dlerror().flatMap { String(cString: $0) } ?? "UNKNOWN")
24+
}
25+
let openTunnelFn = unsafeBitCast(startSym, to: OpenTunnel.self)
26+
tunnelReadPipe = Pipe()
27+
tunnelWritePipe = Pipe()
28+
let res = openTunnelFn(tunnelReadPipe.fileHandleForReading.fileDescriptor,
29+
tunnelWritePipe.fileHandleForWriting.fileDescriptor)
30+
guard res == 0 else {
31+
throw .openTunnel(OpenTunnelError(rawValue: res) ?? .unknown)
32+
}
33+
}
34+
35+
// This could be an isolated deinit in Swift 6.1
36+
func close() throws(TunnelHandleError) {
37+
var errs: [Error] = []
38+
if dlclose(dylibHandle) == 0 {
39+
errs.append(TunnelHandleError.dylib(dlerror().flatMap { String(cString: $0) } ?? "UNKNOWN"))
40+
}
41+
do {
42+
try writeHandle.close()
43+
} catch {
44+
errs.append(error)
45+
}
46+
do {
47+
try readHandle.close()
48+
} catch {
49+
errs.append(error)
50+
}
51+
if !errs.isEmpty {
52+
throw .close(errs)
53+
}
54+
}
55+
}
56+
57+
enum TunnelHandleError: Error {
58+
case dylib(String)
59+
case symbol(String, String)
60+
case openTunnel(OpenTunnelError)
61+
case pipe(any Error)
62+
case close([any Error])
63+
64+
var description: String {
65+
switch self {
66+
case let .pipe(err): return "pipe error: \(err)"
67+
case let .dylib(d): return d
68+
case let .symbol(symbol, message): return "\(symbol): \(message)"
69+
case let .openTunnel(error): return "OpenTunnel: \(error.message)"
70+
case let .close(errs): return "close tunnel: \(errs.map(\.localizedDescription).joined(separator: ", "))"
71+
}
72+
}
73+
}
74+
75+
enum OpenTunnelError: Int32 {
76+
case errDupReadFD = -2
77+
case errDupWriteFD = -3
78+
case errOpenPipe = -4
79+
case errNewTunnel = -5
80+
case unknown = -99
81+
82+
var message: String {
83+
switch self {
84+
case .errDupReadFD: return "Failed to duplicate read file descriptor"
85+
case .errDupWriteFD: return "Failed to duplicate write file descriptor"
86+
case .errOpenPipe: return "Failed to open the pipe"
87+
case .errNewTunnel: return "Failed to create a new tunnel"
88+
case .unknown: return "Unknown error code"
89+
}
90+
}
91+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
#ifndef CoderPacketTunnelProvider_Bridging_Header_h
2+
#define CoderPacketTunnelProvider_Bridging_Header_h
3+
4+
// GoInt32 OpenTunnel(GoInt32 cReadFD, GoInt32 cWriteFD);
5+
typedef int(*OpenTunnel)(int, int);
6+
7+
#endif /* CoderPacketTunnelProvider_Bridging_Header_h */

‎Coder Desktop/VPNLib/Download.swift

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import CryptoKit
2+
import Foundation
3+
4+
public enum ValidationError: Error {
5+
case fileNotFound
6+
case unableToCreateStaticCode
7+
case invalidSignature
8+
case unableToRetrieveInfo
9+
case invalidIdentifier(identifier: String?)
10+
case invalidTeamIdentifier(identifier: String?)
11+
case missingInfoPList
12+
case invalidVersion(version: String?)
13+
14+
public var errorDescription: String? {
15+
switch self {
16+
case .fileNotFound:
17+
return "The file does not exist."
18+
case .unableToCreateStaticCode:
19+
return "Unable to create a static code object."
20+
case .invalidSignature:
21+
return "The file's signature is invalid."
22+
case .unableToRetrieveInfo:
23+
return "Unable to retrieve signing information."
24+
case let .invalidIdentifier(identifier):
25+
return "Invalid identifier: \(identifier ?? "unknown")."
26+
case let .invalidVersion(version):
27+
return "Invalid runtime version: \(version ?? "unknown")."
28+
case let .invalidTeamIdentifier(identifier):
29+
return "Invalid team identifier: \(identifier ?? "unknown")."
30+
case .missingInfoPList:
31+
return "Info.plist is not embedded within the dylib."
32+
}
33+
}
34+
}
35+
36+
public class SignatureValidator {
37+
private static let expectedName = "CoderVPN"
38+
private static let expectedIdentifier = "com.coder.Coder-Desktop.VPN.dylib"
39+
private static let expectedTeamIdentifier = "4399GN35BJ"
40+
private static let minDylibVersion = "2.18.1"
41+
42+
private static let infoIdentifierKey = "CFBundleIdentifier"
43+
private static let infoNameKey = "CFBundleName"
44+
private static let infoShortVersionKey = "CFBundleShortVersionString"
45+
46+
private static let signInfoFlags: SecCSFlags = .init(rawValue: kSecCSSigningInformation)
47+
48+
public static func validate(path: URL) throws(ValidationError) {
49+
guard FileManager.default.fileExists(atPath: path.path) else {
50+
throw .fileNotFound
51+
}
52+
53+
var staticCode: SecStaticCode?
54+
let status = SecStaticCodeCreateWithPath(path as CFURL, SecCSFlags(), &staticCode)
55+
guard status == errSecSuccess, let code = staticCode else {
56+
throw .unableToCreateStaticCode
57+
}
58+
59+
let validateStatus = SecStaticCodeCheckValidity(code, SecCSFlags(), nil)
60+
guard validateStatus == errSecSuccess else {
61+
throw .invalidSignature
62+
}
63+
64+
var information: CFDictionary?
65+
let infoStatus = SecCodeCopySigningInformation(code, signInfoFlags, &information)
66+
guard infoStatus == errSecSuccess, let info = information as? [String: Any] else {
67+
throw .unableToRetrieveInfo
68+
}
69+
70+
guard let identifier = info[kSecCodeInfoIdentifier as String] as? String,
71+
identifier == expectedIdentifier
72+
else {
73+
throw .invalidIdentifier(identifier: info[kSecCodeInfoIdentifier as String] as? String)
74+
}
75+
76+
guard let teamIdentifier = info[kSecCodeInfoTeamIdentifier as String] as? String,
77+
teamIdentifier == expectedTeamIdentifier
78+
else {
79+
throw .invalidTeamIdentifier(
80+
identifier: info[kSecCodeInfoTeamIdentifier as String] as? String
81+
)
82+
}
83+
84+
guard let infoPlist = info[kSecCodeInfoPList as String] as? [String: AnyObject] else {
85+
throw .missingInfoPList
86+
}
87+
88+
guard let plistIdent = infoPlist[infoIdentifierKey] as? String, plistIdent == expectedIdentifier else {
89+
throw .invalidIdentifier(identifier: infoPlist[infoIdentifierKey] as? String)
90+
}
91+
92+
guard let plistName = infoPlist[infoNameKey] as? String, plistName == expectedName else {
93+
throw .invalidIdentifier(identifier: infoPlist[infoNameKey] as? String)
94+
}
95+
96+
guard let dylibVersion = infoPlist[infoShortVersionKey] as? String,
97+
minDylibVersion.compare(dylibVersion, options: .numeric) != .orderedDescending
98+
else {
99+
throw .invalidVersion(version: infoPlist[infoShortVersionKey] as? String)
100+
}
101+
}
102+
}
103+
104+
public func download(src: URL, dest: URL) async throws(DownloadError) {
105+
var req = URLRequest(url: src)
106+
if FileManager.default.fileExists(atPath: dest.path) {
107+
if let existingFileData = try? Data(contentsOf: dest, options: .mappedIfSafe) {
108+
req.setValue(etag(data: existingFileData), forHTTPHeaderField: "If-None-Match")
109+
}
110+
}
111+
// TODO: Add Content-Length headers to coderd, add download progress delegate
112+
let tempURL: URL
113+
let response: URLResponse
114+
do {
115+
(tempURL, response) = try await URLSession.shared.download(for: req)
116+
} catch {
117+
throw .networkError(error)
118+
}
119+
defer {
120+
if FileManager.default.fileExists(atPath: tempURL.path) {
121+
try? FileManager.default.removeItem(at: tempURL)
122+
}
123+
}
124+
125+
guard let httpResponse = response as? HTTPURLResponse else {
126+
throw .invalidResponse
127+
}
128+
guard httpResponse.statusCode != 304 else {
129+
// We already have the latest dylib downloaded on disk
130+
return
131+
}
132+
133+
guard httpResponse.statusCode == 200 else {
134+
throw .unexpectedStatusCode(httpResponse.statusCode)
135+
}
136+
137+
do {
138+
if FileManager.default.fileExists(atPath: dest.path) {
139+
try FileManager.default.removeItem(at: dest)
140+
}
141+
try FileManager.default.moveItem(at: tempURL, to: dest)
142+
} catch {
143+
throw .fileOpError(error)
144+
}
145+
}
146+
147+
func etag(data: Data) -> String {
148+
let sha1Hash = Insecure.SHA1.hash(data: data)
149+
let etag = sha1Hash.map { String(format: "%02x", $0) }.joined()
150+
return "\"\(etag)\""
151+
}
152+
153+
public enum DownloadError: Error {
154+
case unexpectedStatusCode(Int)
155+
case invalidResponse
156+
case networkError(any Error)
157+
case fileOpError(any Error)
158+
159+
var localizedDescription: String {
160+
switch self {
161+
case let .unexpectedStatusCode(code):
162+
return "Unexpected HTTP status code: \(code)"
163+
case let .networkError(error):
164+
return "Network error: \(error.localizedDescription)"
165+
case let .fileOpError(error):
166+
return "File operation error: \(error.localizedDescription)"
167+
case .invalidResponse:
168+
return "Received non-HTTP response"
169+
}
170+
}
171+
}

‎Coder Desktop/Proto/Receiver.swift renamed to ‎Coder Desktop/VPNLib/Receiver.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ actor Receiver<RecvMsg: Message> {
77
private let dispatch: DispatchIO
88
private let queue: DispatchQueue
99
private var running = false
10-
private let logger = Logger(subsystem: "com.coder.Coder-Desktop", category: "proto")
10+
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "proto")
1111

1212
/// Creates an instance using the given `DispatchIO` channel and queue.
1313
init(dispatch: DispatchIO, queue: DispatchQueue) {

‎Coder Desktop/Proto/Sender.swift renamed to ‎Coder Desktop/VPNLib/Sender.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import SwiftProtobuf
33

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

99
init(writeFD: FileHandle) {

‎Coder Desktop/Proto/Speaker.swift renamed to ‎Coder Desktop/VPNLib/Speaker.swift

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ let newLine = 0x0A
66
let headerPreamble = "codervpn"
77

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

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

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

133-
enum IncomingMessage {
133+
public enum IncomingMessage: Sendable {
134134
case message(RecvMsg)
135135
case RPC(RPCRequest<SendMsg, RecvMsg>)
136136
}
137137
}
138138

139139
extension Speaker: AsyncSequence, AsyncIteratorProtocol {
140-
typealias Element = IncomingMessage
140+
public typealias Element = IncomingMessage
141141

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

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

280-
struct RPCRequest<SendMsg: RPCMessage & Message, RecvMsg: RPCMessage & Sendable>: Sendable {
280+
public struct RPCRequest<SendMsg: RPCMessage & Message, RecvMsg: RPCMessage & Sendable>: Sendable {
281281
let msg: RecvMsg
282282
private let sender: Sender<SendMsg>
283283

‎Coder Desktop/VPNLib/VPNLib.h

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
#import <Foundation/Foundation.h>
2+
3+
//! Project version number for VPNLib.
4+
FOUNDATION_EXPORT double VPNLibVersionNumber;
5+
6+
//! Project version string for VPNLib.
7+
FOUNDATION_EXPORT const unsigned char VPNLibVersionString[];
8+
9+
// In this header, you should import all the public headers of your framework using statements like #import <VPNLib/PublicHeader.h>
10+
11+

‎Coder Desktop/Proto/vpn.pb.swift renamed to ‎Coder Desktop/VPNLib/vpn.pb.swift

Lines changed: 258 additions & 258 deletions
Large diffs are not rendered by default.
File renamed without changes.
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import Foundation
2+
import Mocker
3+
import Testing
4+
@testable import VPNLib
5+
6+
@Suite(.timeLimit(.minutes(1)))
7+
struct DownloadTests {
8+
@Test
9+
func downloadFile() async throws {
10+
let destinationURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
11+
let testData = Data("foo".utf8)
12+
13+
let fileURL = URL(string: "http://example.com/test1.txt")!
14+
Mock(url: fileURL, contentType: .html, statusCode: 200, data: [.get: testData]).register()
15+
16+
try await download(src: fileURL, dest: destinationURL)
17+
18+
try #require(FileManager.default.fileExists(atPath: destinationURL.path))
19+
defer { try? FileManager.default.removeItem(at: destinationURL) }
20+
21+
let downloadedData = try Data(contentsOf: destinationURL)
22+
#expect(downloadedData == testData)
23+
}
24+
25+
@Test
26+
func fileNotModified() async throws {
27+
let testData = Data("foo bar".utf8)
28+
let fileURL = URL(string: "http://example.com/test2.txt")!
29+
30+
let destinationURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
31+
defer { try? FileManager.default.removeItem(at: destinationURL) }
32+
33+
Mock(url: fileURL, contentType: .html, statusCode: 200, data: [.get: testData]).register()
34+
35+
try await download(src: fileURL, dest: destinationURL)
36+
try #require(FileManager.default.fileExists(atPath: destinationURL.path))
37+
let downloadedData = try Data(contentsOf: destinationURL)
38+
#expect(downloadedData == testData)
39+
40+
var mock = Mock(url: fileURL, contentType: .html, statusCode: 304, data: [.get: Data()])
41+
var etagIncluded = false
42+
mock.onRequestHandler = OnRequestHandler { request in
43+
etagIncluded = request.value(forHTTPHeaderField: "If-None-Match") == etag(data: testData)
44+
}
45+
mock.register()
46+
47+
try await download(src: fileURL, dest: destinationURL)
48+
let unchangedData = try Data(contentsOf: destinationURL)
49+
#expect(unchangedData == testData)
50+
#expect(etagIncluded)
51+
}
52+
53+
@Test
54+
func fileUpdated() async throws {
55+
let ogData = Data("foo bar".utf8)
56+
let newData = Data("foo bar qux".utf8)
57+
58+
let fileURL = URL(string: "http://example.com/test3.txt")!
59+
let destinationURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
60+
defer { try? FileManager.default.removeItem(at: destinationURL) }
61+
62+
Mock(url: fileURL, contentType: .html, statusCode: 200, data: [.get: ogData]).register()
63+
64+
try await download(src: fileURL, dest: destinationURL)
65+
try #require(FileManager.default.fileExists(atPath: destinationURL.path))
66+
var downloadedData = try Data(contentsOf: destinationURL)
67+
#expect(downloadedData == ogData)
68+
69+
var mock = Mock(url: fileURL, contentType: .html, statusCode: 200, data: [.get: newData])
70+
var etagIncluded = false
71+
mock.onRequestHandler = OnRequestHandler { request in
72+
etagIncluded = request.value(forHTTPHeaderField: "If-None-Match") == etag(data: ogData)
73+
}
74+
mock.register()
75+
76+
try await download(src: fileURL, dest: destinationURL)
77+
downloadedData = try Data(contentsOf: destinationURL)
78+
#expect(downloadedData == newData)
79+
#expect(etagIncluded)
80+
}
81+
}

‎Coder Desktop/ProtoTests/ProtoTests.swift renamed to ‎Coder Desktop/VPNLibTests/ProtoTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
@testable import Coder_Desktop
21
import Foundation
32
import Testing
3+
@testable import VPNLib
44

55
@Suite(.timeLimit(.minutes(1)))
66
struct SenderReceiverTests {

‎Coder Desktop/ProtoTests/SpeakerTests.swift renamed to ‎Coder Desktop/VPNLibTests/SpeakerTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
@testable import Coder_Desktop
21
import Foundation
32
import Testing
3+
@testable import VPNLib
44

55
@Suite(.timeLimit(.minutes(1)))
66
struct SpeakerTests: Sendable {

‎Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,4 @@ clean:
3434
-project $(PROJECT)
3535

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

0 commit comments

Comments
 (0)
Please sign in to comment.