style(apple): Add SwiftLint (#7909)

Adds SwiftLint using (mostly) default rules to our build pipeline.
Unfortunately, this results in quite a lot of errors / warnings.

All fixes included in this PR are simply refactoring. No functionality
has been changed.


Fixes: #4302
This commit is contained in:
Jamil
2025-01-30 15:09:56 +00:00
committed by GitHub
parent d2b0eca7ed
commit ace4d52346
36 changed files with 752 additions and 1075 deletions

View File

@@ -0,0 +1,7 @@
disabled_rules:
# We use the TODO convention throughout the Firezone codebase. It doesn't
# necessarily imply immediate action needs to be taken.
- todo
excluded:
- FirezoneNetworkExtension/Connlib/Generated/connlib-client-apple/connlib-client-apple.swift
- FirezoneNetworkExtension/Connlib/Generated/SwiftBridgeCore.swift

View File

@@ -303,8 +303,8 @@
isa = PBXNativeTarget;
buildConfigurationList = 8DCC024328D512AE007E12D2 /* Build configuration list for PBXNativeTarget "Firezone" */;
buildPhases = (
8DE721892ACA295200E395D5 /* swift-format */,
8DCC021528D512AC007E12D2 /* Sources */,
8D70FAE62D4971E900216473 /* SwiftLint */,
8DCC021628D512AC007E12D2 /* Frameworks */,
8DCC021728D512AC007E12D2 /* Resources */,
8DC169A32CFF77D1006801B5 /* Embed Foundation Extensions */,
@@ -359,6 +359,7 @@
mainGroup = 8DCC021028D512AC007E12D2;
packageReferences = (
8D4087C72D2464D6005B2BAF /* XCRemoteSwiftPackageReference "sentry-cocoa" */,
8D70FAE52D49714500216473 /* XCRemoteSwiftPackageReference "SwiftLintPlugins" */,
);
productRefGroup = 8DCC021A28D512AC007E12D2 /* Products */;
projectDirPath = "";
@@ -399,6 +400,25 @@
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
8D70FAE62D4971E900216473 /* SwiftLint */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = SwiftLint;
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "if [[ \"$(uname -m)\" == arm64 ]]\nthen\n export PATH=\"/opt/homebrew/bin:$PATH\"\nfi\n\nif command -v swiftlint >/dev/null 2>&1\nthen\n swiftlint --autocorrect\n swiftlint --strict\nelse\n echo \"warning: `swiftlint` command not found - See https://github.com/realm/SwiftLint#installation for installation instructions.\"\nfi\n";
};
8D8555822D0A796100A1EA09 /* Build Connlib */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
@@ -439,25 +459,6 @@
shellPath = /bin/sh;
shellScript = "cd ../../rust/connlib/clients/apple\n./build-rust.sh\n";
};
8DE721892ACA295200E395D5 /* swift-format */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = "swift-format";
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "./lint.sh\n";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@@ -951,6 +952,14 @@
minimumVersion = 8.42.1;
};
};
8D70FAE52D49714500216473 /* XCRemoteSwiftPackageReference "SwiftLintPlugins" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/SimplyDanny/SwiftLintPlugins";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 0.58.2;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */

View File

@@ -8,6 +8,15 @@
"revision" : "f45e9c62d7a4d9258ac3cf35a3acf9dbab4481d1",
"version" : "8.43.0"
}
},
{
"identity" : "swiftlintplugins",
"kind" : "remoteSourceControl",
"location" : "https://github.com/SimplyDanny/SwiftLintPlugins",
"state" : {
"revision" : "7a3d77f3dd9f91d5cea138e52c20cfceabf352de",
"version" : "0.58.2"
}
}
],
"version" : 2

View File

@@ -20,6 +20,6 @@ let package = Package(
.testTarget(
name: "FirezoneKitTests",
dependencies: ["FirezoneKit"]
),
)
]
)

View File

@@ -27,9 +27,9 @@ public class DeviceMetadata {
// Returns the OS version. Must be valid ASCII.
// See https://github.com/firezone/firezone/issues/3034
// See https://github.com/firezone/firezone/issues/5467
let os = ProcessInfo.processInfo.operatingSystemVersion
let osVersion = ProcessInfo.processInfo.operatingSystemVersion
return "\(os.majorVersion).\(os.minorVersion).\(os.patchVersion)"
return "\(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)"
}
#if os(iOS)
@@ -71,7 +71,12 @@ func getDeviceInfo(key: CFString) -> String? {
let platformExpert = IOServiceGetMatchingService(kIOMainPortDefault, matchingDict)
defer { IOObjectRelease(platformExpert) }
if let serial = IORegistryEntryCreateCFProperty(platformExpert, key, kCFAllocatorDefault, 0)?.takeUnretainedValue() as? String {
if let serial = IORegistryEntryCreateCFProperty(
platformExpert,
key,
kCFAllocatorDefault,
0
)?.takeUnretainedValue() as? String {
return serial
}

View File

@@ -77,7 +77,7 @@ public final class Log {
including: [
.totalFileAllocatedSizeKey,
.totalFileSizeKey,
.isRegularFileKey,
.isRegularFileKey
]
) { url, resourceValues in
taskGroup.addTask {
@@ -161,9 +161,9 @@ private final class LogWriter {
.appendingPathExtension("jsonl")
// Create log file
guard fileManager.createFile(atPath: logFileURL.path, contents: "".data(using: .utf8)),
guard fileManager.createFile(atPath: logFileURL.path, contents: Data()),
let handle = try? FileHandle(forWritingTo: logFileURL),
let _ = try? handle.seekToEnd()
(try? handle.seekToEnd()) != nil
else {
logger.error("Could not create log file: \(logFileURL.path)")
return nil
@@ -187,14 +187,13 @@ private final class LogWriter {
severity: severity,
message: message)
guard var jsonData = try? jsonEncoder.encode(logEntry),
let newLineData = "\n".data(using: .utf8)
else {
guard var jsonData = try? jsonEncoder.encode(logEntry)
else {
logger.error("Could not encode log message to JSON!")
return
}
jsonData.append(newLineData)
jsonData.append(Data("\n".utf8))
workQueue.async { [weak self] in
self?.handle.write(jsonData)

View File

@@ -6,111 +6,60 @@
import Foundation
public struct VersionInfo: Decodable {
let apple: SemVerString
public static func from(data: Data?) -> VersionInfo? {
guard let data = data,
let versionString = String(data: data, encoding: .utf8),
let versionString = versionString.data(using: .utf8),
let versionInfo = try? JSONDecoder().decode(VersionInfo.self, from: versionString) else {
return nil
}
return versionInfo
struct SemanticVersion: Comparable, CustomStringConvertible {
var description: String {
return "\(major).\(minor).\(patch)"
}
}
struct SemVerString: Decodable, Comparable {
private let originalString: String
private let semVer: SemanticVersion
private init(originalString: String, semVer: SemanticVersion) {
self.originalString = originalString
self.semVer = semVer
}
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let versionString = try container.decode(String.self)
guard let parsed = SemanticVersion.from(string: versionString) else {
throw DecodingError.dataCorruptedError(in: container,
debugDescription: "Invalid SemVer string format")
}
originalString = versionString
semVer = parsed
}
public static func from(string: String) -> SemVerString? {
guard let parsed = SemanticVersion.from(string: string) else { return nil }
return SemVerString(originalString: string, semVer: parsed)
}
public func versionString() -> String {
originalString
}
static func < (lhs: SemVerString, rhs: SemVerString) -> Bool {
lhs.semVer < rhs.semVer
}
static func == (lhs: SemVerString, rhs: SemVerString) -> Bool {
lhs.semVer == rhs.semVer
}
}
private struct SemanticVersion: Comparable {
let major: Int
let minor: Int
let patch: Int
init(major: Int, minor: Int, patch: Int) {
self.major = major
self.minor = minor
self.patch = patch
enum Error: Swift.Error {
case invalidVersionString
}
private let major: Int
private let minor: Int
private let patch: Int
// This doesn't conform to the full semver spec but it's enough for our use-case
static func parse(versionString: String) -> (major: Int, minor: Int, patch: Int)? {
guard let coreVersion = versionString.components(separatedBy: ["+", "-"]).first else {
return nil
}
init(_ version: String) throws {
guard let coreVersion = version.components(separatedBy: ["+", "-"]).first
else {
throw Error.invalidVersionString
}
let components = coreVersion.split(separator: ".")
guard components.count == 3,
let major = Int(components[0]),
let minor = Int(components[1]),
let patch = Int(components[2]) else {
return nil
}
return (major, minor, patch)
}
let components = coreVersion.split(separator: ".")
static func from(string: String) -> SemanticVersion? {
guard let parsed = parse(versionString: string) else {
return nil
}
return SemanticVersion(major: parsed.major, minor: parsed.minor, patch: parsed.patch)
guard components.count == 3
else {
throw Error.invalidVersionString
}
guard let major = Int(components[0]),
let minor = Int(components[1]),
let patch = Int(components[2])
else {
throw Error.invalidVersionString
}
self.major = major
self.minor = minor
self.patch = patch
}
static func < (lhs: SemanticVersion, rhs: SemanticVersion) -> Bool {
if lhs.major != rhs.major {
return lhs.major < rhs.major
}
if lhs.major != rhs.major {
return lhs.major < rhs.major
}
if lhs.minor != rhs.minor {
return lhs.minor < rhs.minor
}
if lhs.minor != rhs.minor {
return lhs.minor < rhs.minor
}
return lhs.patch < rhs.patch
return lhs.patch < rhs.patch
}
static func == (lhs: SemanticVersion, rhs: SemanticVersion) -> Bool {
return lhs.major == rhs.major &&
lhs.minor == rhs.minor &&
lhs.patch == rhs.patch
return lhs.major == rhs.major &&
lhs.minor == rhs.minor &&
lhs.patch == rhs.patch
}
}

View File

@@ -14,22 +14,22 @@ public enum Telemetry {
private static var _firezoneId: String?
private static var _accountSlug: String?
public static var firezoneId: String? {
get {
return self._firezoneId
}
set {
self._firezoneId = newValue
updateUser(id: self._firezoneId, slug: self._accountSlug)
}
get {
return self._firezoneId
}
}
public static var accountSlug: String? {
get {
return self._accountSlug
}
set {
self._accountSlug = newValue
updateUser(id: self._firezoneId, slug: self._accountSlug)
}
get {
return self._accountSlug
}
}
public static func start() {

View File

@@ -7,8 +7,8 @@
import Foundation
public enum KeychainError: Error {
case securityError(KeychainStatus)
case appleSecError(call: String, status: Keychain.SecStatus)
case securityError(OSStatus)
case appleSecError(call: String, status: OSStatus)
case nilResultFromAppleSecCall(call: String)
case resultFromAppleSecCallIsInvalid(call: String)
case unableToFindSavedItem
@@ -20,28 +20,18 @@ public enum KeychainError: Error {
public enum Keychain {
public typealias PersistentRef = Data
public enum SecStatus: Equatable {
case status(KeychainStatus)
case unknownStatus(OSStatus)
init(_ osStatus: OSStatus) {
if let status = KeychainStatus(rawValue: osStatus) {
self = .status(status)
} else {
self = .unknownStatus(osStatus)
}
}
var isSuccess: Bool {
return self == .status(.success)
}
enum Result: Int32 {
case success = 0
case itemNotFound = -25300
}
public static func add(query: [CFString: Any]) throws {
var ref: CFTypeRef?
let ret = SecStatus(SecItemAdd(query as CFDictionary, &ref))
guard ret.isSuccess else {
throw KeychainError.appleSecError(call: "SecItemAdd", status: ret)
let status = SecItemAdd(query as CFDictionary, &ref)
guard status == Result.success.rawValue
else {
throw KeychainError.appleSecError(call: "SecItemAdd", status: status)
}
return
@@ -52,11 +42,11 @@ public enum Keychain {
attributesToUpdate: [CFString: Any]
) throws {
let ret = SecStatus(
SecItemUpdate(query as CFDictionary, attributesToUpdate as CFDictionary))
let status = SecItemUpdate(query as CFDictionary, attributesToUpdate as CFDictionary)
guard ret.isSuccess else {
throw KeychainError.appleSecError(call: "SecItemUpdate", status: ret)
guard status == Result.success.rawValue
else {
throw KeychainError.appleSecError(call: "SecItemUpdate", status: status)
}
}
@@ -64,13 +54,13 @@ public enum Keychain {
let query = [
kSecClass: kSecClassGenericPassword,
kSecValuePersistentRef: persistentRef,
kSecReturnData: true,
kSecReturnData: true
] as [CFString: Any]
var result: CFTypeRef?
let ret = SecStatus(SecItemCopyMatching(query as CFDictionary, &result))
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard ret.isSuccess,
guard status == Result.success.rawValue,
let resultData = result as? Data
else {
return nil
@@ -82,13 +72,13 @@ public enum Keychain {
public static func search(query: [CFString: Any]) -> PersistentRef? {
let query = query.merging([
kSecClass: kSecClassGenericPassword,
kSecReturnPersistentRef: true,
kSecReturnPersistentRef: true
]) { (_, new) in new }
var result: CFTypeRef?
let ret = SecStatus(SecItemCopyMatching(query as CFDictionary, &result))
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard ret.isSuccess,
guard status == Result.success.rawValue,
let persistentRef = result as? Data
else {
return nil
@@ -99,11 +89,11 @@ public enum Keychain {
public static func delete(persistentRef: PersistentRef) throws {
let query = [kSecValuePersistentRef: persistentRef] as [CFString: Any]
let ret = SecStatus(SecItemDelete(query as CFDictionary))
let status = SecItemDelete(query as CFDictionary)
guard ret.isSuccess || ret == .status(.itemNotFound)
guard status == Result.success.rawValue || status == Result.itemNotFound.rawValue
else {
throw KeychainError.appleSecError(call: "SecItemDelete", status: ret)
throw KeychainError.appleSecError(call: "SecItemDelete", status: status)
}
}
}

View File

@@ -1,427 +0,0 @@
//
// Status.swift
// (c) 2024 Firezone, Inc.
// LICENSE: Apache-2.0
//
// Copyright (c) 2014 kishikawa katsumi
// The MIT License (MIT)
// https://github.com/kishikawakatsumi/KeychainAccess
import Foundation
public enum KeychainStatus: OSStatus, Error {
case success = 0
case unimplemented = -4
case diskFull = -34
case io = -36
case opWr = -49
case param = -50
case wrPerm = -61
case allocate = -108
case userCanceled = -128
case badReq = -909
case internalComponent = -2070
case notAvailable = -25291
case readOnly = -25292
case authFailed = -25293
case noSuchKeychain = -25294
case invalidKeychain = -25295
case duplicateKeychain = -25296
case duplicateCallback = -25297
case invalidCallback = -25298
case duplicateItem = -25299
case itemNotFound = -25300
case bufferTooSmall = -25301
case dataTooLarge = -25302
case noSuchAttr = -25303
case invalidItemRef = -25304
case invalidSearchRef = -25305
case noSuchClass = -25306
case noDefaultKeychain = -25307
case interactionNotAllowed = -25308
case readOnlyAttr = -25309
case wrongSecVersion = -25310
case keySizeNotAllowed = -25311
case noStorageModule = -25312
case noCertificateModule = -25313
case noPolicyModule = -25314
case interactionRequired = -25315
case dataNotAvailable = -25316
case dataNotModifiable = -25317
case createChainFailed = -25318
case invalidPrefsDomain = -25319
case inDarkWake = -25320
case aclNotSimple = -25240
case policyNotFound = -25241
case invalidTrustSetting = -25242
case noAccessForItem = -25243
case invalidOwnerEdit = -25244
case trustNotAvailable = -25245
case unsupportedFormat = -25256
case unknownFormat = -25257
case keyIsSensitive = -25258
case multiplePrivKeys = -25259
case passphraseRequired = -25260
case invalidPasswordRef = -25261
case invalidTrustSettings = -25262
case noTrustSettings = -25263
case pkcs12VerifyFailure = -25264
case invalidCertificate = -26265
case notSigner = -26267
case policyDenied = -26270
case invalidKey = -26274
case decode = -26275
case `internal` = -26276
case unsupportedAlgorithm = -26268
case unsupportedOperation = -26271
case unsupportedPadding = -26273
case itemInvalidKey = -34000
case itemInvalidKeyType = -34001
case itemInvalidValue = -34002
case itemClassMissing = -34003
case itemMatchUnsupported = -34004
case useItemListUnsupported = -34005
case useKeychainUnsupported = -34006
case useKeychainListUnsupported = -34007
case returnDataUnsupported = -34008
case returnAttributesUnsupported = -34009
case returnRefUnsupported = -34010
case returnPersitentRefUnsupported = -34011
case valueRefUnsupported = -34012
case valuePersistentRefUnsupported = -34013
case returnMissingPointer = -34014
case matchLimitUnsupported = -34015
case itemIllegalQuery = -34016
case waitForCallback = -34017
case missingEntitlement = -34018
case upgradePending = -34019
case mpSignatureInvalid = -25327
case otrTooOld = -25328
case otrIDTooNew = -25329
case serviceNotAvailable = -67585
case insufficientClientID = -67586
case deviceReset = -67587
case deviceFailed = -67588
case appleAddAppACLSubject = -67589
case applePublicKeyIncomplete = -67590
case appleSignatureMismatch = -67591
case appleInvalidKeyStartDate = -67592
case appleInvalidKeyEndDate = -67593
case conversionError = -67594
case appleSSLv2Rollback = -67595
case quotaExceeded = -67596
case fileTooBig = -67597
case invalidDatabaseBlob = -67598
case invalidKeyBlob = -67599
case incompatibleDatabaseBlob = -67600
case incompatibleKeyBlob = -67601
case hostNameMismatch = -67602
case unknownCriticalExtensionFlag = -67603
case noBasicConstraints = -67604
case noBasicConstraintsCA = -67605
case invalidAuthorityKeyID = -67606
case invalidSubjectKeyID = -67607
case invalidKeyUsageForPolicy = -67608
case invalidExtendedKeyUsage = -67609
case invalidIDLinkage = -67610
case pathLengthConstraintExceeded = -67611
case invalidRoot = -67612
case crlExpired = -67613
case crlNotValidYet = -67614
case crlNotFound = -67615
case crlServerDown = -67616
case crlBadURI = -67617
case unknownCertExtension = -67618
case unknownCRLExtension = -67619
case crlNotTrusted = -67620
case crlPolicyFailed = -67621
case idpFailure = -67622
case smimeEmailAddressesNotFound = -67623
case smimeBadExtendedKeyUsage = -67624
case smimeBadKeyUsage = -67625
case smimeKeyUsageNotCritical = -67626
case smimeNoEmailAddress = -67627
case smimeSubjAltNameNotCritical = -67628
case sslBadExtendedKeyUsage = -67629
case ocspBadResponse = -67630
case ocspBadRequest = -67631
case ocspUnavailable = -67632
case ocspStatusUnrecognized = -67633
case endOfData = -67634
case incompleteCertRevocationCheck = -67635
case networkFailure = -67636
case ocspNotTrustedToAnchor = -67637
case recordModified = -67638
case ocspSignatureError = -67639
case ocspNoSigner = -67640
case ocspResponderMalformedReq = -67641
case ocspResponderInternalError = -67642
case ocspResponderTryLater = -67643
case ocspResponderSignatureRequired = -67644
case ocspResponderUnauthorized = -67645
case ocspResponseNonceMismatch = -67646
case codeSigningBadCertChainLength = -67647
case codeSigningNoBasicConstraints = -67648
case codeSigningBadPathLengthConstraint = -67649
case codeSigningNoExtendedKeyUsage = -67650
case codeSigningDevelopment = -67651
case resourceSignBadCertChainLength = -67652
case resourceSignBadExtKeyUsage = -67653
case trustSettingDeny = -67654
case invalidSubjectName = -67655
case unknownQualifiedCertStatement = -67656
case mobileMeRequestQueued = -67657
case mobileMeRequestRedirected = -67658
case mobileMeServerError = -67659
case mobileMeServerNotAvailable = -67660
case mobileMeServerAlreadyExists = -67661
case mobileMeServerServiceErr = -67662
case mobileMeRequestAlreadyPending = -67663
case mobileMeNoRequestPending = -67664
case mobileMeCSRVerifyFailure = -67665
case mobileMeFailedConsistencyCheck = -67666
case notInitialized = -67667
case invalidHandleUsage = -67668
case pvcReferentNotFound = -67669
case functionIntegrityFail = -67670
case internalError = -67671
case memoryError = -67672
case invalidData = -67673
case mdsError = -67674
case invalidPointer = -67675
case selfCheckFailed = -67676
case functionFailed = -67677
case moduleManifestVerifyFailed = -67678
case invalidGUID = -67679
case invalidHandle = -67680
case invalidDBList = -67681
case invalidPassthroughID = -67682
case invalidNetworkAddress = -67683
case crlAlreadySigned = -67684
case invalidNumberOfFields = -67685
case verificationFailure = -67686
case unknownTag = -67687
case invalidSignature = -67688
case invalidName = -67689
case invalidCertificateRef = -67690
case invalidCertificateGroup = -67691
case tagNotFound = -67692
case invalidQuery = -67693
case invalidValue = -67694
case callbackFailed = -67695
case aclDeleteFailed = -67696
case aclReplaceFailed = -67697
case aclAddFailed = -67698
case aclChangeFailed = -67699
case invalidAccessCredentials = -67700
case invalidRecord = -67701
case invalidACL = -67702
case invalidSampleValue = -67703
case incompatibleVersion = -67704
case privilegeNotGranted = -67705
case invalidScope = -67706
case pvcAlreadyConfigured = -67707
case invalidPVC = -67708
case emmLoadFailed = -67709
case emmUnloadFailed = -67710
case addinLoadFailed = -67711
case invalidKeyRef = -67712
case invalidKeyHierarchy = -67713
case addinUnloadFailed = -67714
case libraryReferenceNotFound = -67715
case invalidAddinFunctionTable = -67716
case invalidServiceMask = -67717
case moduleNotLoaded = -67718
case invalidSubServiceID = -67719
case attributeNotInContext = -67720
case moduleManagerInitializeFailed = -67721
case moduleManagerNotFound = -67722
case eventNotificationCallbackNotFound = -67723
case inputLengthError = -67724
case outputLengthError = -67725
case privilegeNotSupported = -67726
case deviceError = -67727
case attachHandleBusy = -67728
case notLoggedIn = -67729
case algorithmMismatch = -67730
case keyUsageIncorrect = -67731
case keyBlobTypeIncorrect = -67732
case keyHeaderInconsistent = -67733
case unsupportedKeyFormat = -67734
case unsupportedKeySize = -67735
case invalidKeyUsageMask = -67736
case unsupportedKeyUsageMask = -67737
case invalidKeyAttributeMask = -67738
case unsupportedKeyAttributeMask = -67739
case invalidKeyLabel = -67740
case unsupportedKeyLabel = -67741
case invalidKeyFormat = -67742
case unsupportedVectorOfBuffers = -67743
case invalidInputVector = -67744
case invalidOutputVector = -67745
case invalidContext = -67746
case invalidAlgorithm = -67747
case invalidAttributeKey = -67748
case missingAttributeKey = -67749
case invalidAttributeInitVector = -67750
case missingAttributeInitVector = -67751
case invalidAttributeSalt = -67752
case missingAttributeSalt = -67753
case invalidAttributePadding = -67754
case missingAttributePadding = -67755
case invalidAttributeRandom = -67756
case missingAttributeRandom = -67757
case invalidAttributeSeed = -67758
case missingAttributeSeed = -67759
case invalidAttributePassphrase = -67760
case missingAttributePassphrase = -67761
case invalidAttributeKeyLength = -67762
case missingAttributeKeyLength = -67763
case invalidAttributeBlockSize = -67764
case missingAttributeBlockSize = -67765
case invalidAttributeOutputSize = -67766
case missingAttributeOutputSize = -67767
case invalidAttributeRounds = -67768
case missingAttributeRounds = -67769
case invalidAlgorithmParms = -67770
case missingAlgorithmParms = -67771
case invalidAttributeLabel = -67772
case missingAttributeLabel = -67773
case invalidAttributeKeyType = -67774
case missingAttributeKeyType = -67775
case invalidAttributeMode = -67776
case missingAttributeMode = -67777
case invalidAttributeEffectiveBits = -67778
case missingAttributeEffectiveBits = -67779
case invalidAttributeStartDate = -67780
case missingAttributeStartDate = -67781
case invalidAttributeEndDate = -67782
case missingAttributeEndDate = -67783
case invalidAttributeVersion = -67784
case missingAttributeVersion = -67785
case invalidAttributePrime = -67786
case missingAttributePrime = -67787
case invalidAttributeBase = -67788
case missingAttributeBase = -67789
case invalidAttributeSubprime = -67790
case missingAttributeSubprime = -67791
case invalidAttributeIterationCount = -67792
case missingAttributeIterationCount = -67793
case invalidAttributeDLDBHandle = -67794
case missingAttributeDLDBHandle = -67795
case invalidAttributeAccessCredentials = -67796
case missingAttributeAccessCredentials = -67797
case invalidAttributePublicKeyFormat = -67798
case missingAttributePublicKeyFormat = -67799
case invalidAttributePrivateKeyFormat = -67800
case missingAttributePrivateKeyFormat = -67801
case invalidAttributeSymmetricKeyFormat = -67802
case missingAttributeSymmetricKeyFormat = -67803
case invalidAttributeWrappedKeyFormat = -67804
case missingAttributeWrappedKeyFormat = -67805
case stagedOperationInProgress = -67806
case stagedOperationNotStarted = -67807
case verifyFailed = -67808
case querySizeUnknown = -67809
case blockSizeMismatch = -67810
case publicKeyInconsistent = -67811
case deviceVerifyFailed = -67812
case invalidLoginName = -67813
case alreadyLoggedIn = -67814
case invalidDigestAlgorithm = -67815
case invalidCRLGroup = -67816
case certificateCannotOperate = -67817
case certificateExpired = -67818
case certificateNotValidYet = -67819
case certificateRevoked = -67820
case certificateSuspended = -67821
case insufficientCredentials = -67822
case invalidAction = -67823
case invalidAuthority = -67824
case verifyActionFailed = -67825
case invalidCertAuthority = -67826
case invaldCRLAuthority = -67827
case invalidCRLEncoding = -67828
case invalidCRLType = -67829
case invalidCRL = -67830
case invalidFormType = -67831
case invalidID = -67832
case invalidIdentifier = -67833
case invalidIndex = -67834
case invalidPolicyIdentifiers = -67835
case invalidTimeString = -67836
case invalidReason = -67837
case invalidRequestInputs = -67838
case invalidResponseVector = -67839
case invalidStopOnPolicy = -67840
case invalidTuple = -67841
case multipleValuesUnsupported = -67842
case notTrusted = -67843
case noDefaultAuthority = -67844
case rejectedForm = -67845
case requestLost = -67846
case requestRejected = -67847
case unsupportedAddressType = -67848
case unsupportedService = -67849
case invalidTupleGroup = -67850
case invalidBaseACLs = -67851
case invalidTupleCredendtials = -67852
case invalidEncoding = -67853
case invalidValidityPeriod = -67854
case invalidRequestor = -67855
case requestDescriptor = -67856
case invalidBundleInfo = -67857
case invalidCRLIndex = -67858
case noFieldValues = -67859
case unsupportedFieldFormat = -67860
case unsupportedIndexInfo = -67861
case unsupportedLocality = -67862
case unsupportedNumAttributes = -67863
case unsupportedNumIndexes = -67864
case unsupportedNumRecordTypes = -67865
case fieldSpecifiedMultiple = -67866
case incompatibleFieldFormat = -67867
case invalidParsingModule = -67868
case databaseLocked = -67869
case datastoreIsOpen = -67870
case missingValue = -67871
case unsupportedQueryLimits = -67872
case unsupportedNumSelectionPreds = -67873
case unsupportedOperator = -67874
case invalidDBLocation = -67875
case invalidAccessRequest = -67876
case invalidIndexInfo = -67877
case invalidNewOwner = -67878
case invalidModifyMode = -67879
case missingRequiredExtension = -67880
case extendedKeyUsageNotCritical = -67881
case timestampMissing = -67882
case timestampInvalid = -67883
case timestampNotTrusted = -67884
case timestampServiceNotAvailable = -67885
case timestampBadAlg = -67886
case timestampBadRequest = -67887
case timestampBadDataFormat = -67888
case timestampTimeNotAvailable = -67889
case timestampUnacceptedPolicy = -67890
case timestampUnacceptedExtension = -67891
case timestampAddInfoNotAvailable = -67892
case timestampSystemFailure = -67893
case signingTimeMissing = -67894
case timestampRejection = -67895
case timestampWaiting = -67896
case timestampRevocationWarning = -67897
case timestampRevocationNotification = -67898
case unexpectedError = -99999
}
extension KeychainStatus {
static func == (lhs: OSStatus, rhs: KeychainStatus) -> Bool {
lhs == rhs.rawValue
}
static func != (lhs: OSStatus, rhs: KeychainStatus) -> Bool {
lhs != rhs.rawValue
}
}

View File

@@ -66,7 +66,10 @@ public class SystemExtensionManager: NSObject, OSSystemExtensionRequestDelegate,
// MARK: - OSSystemExtensionRequestDelegate
// Result of system extension installation
public func request(_ request: OSSystemExtensionRequest, didFinishWithResult result: OSSystemExtensionRequest.Result) {
public func request(
_ request: OSSystemExtensionRequest,
didFinishWithResult result: OSSystemExtensionRequest.Result
) {
guard result == .completed else {
resume(throwing: SystemExtensionError.unknownResult(result))
@@ -83,12 +86,11 @@ public class SystemExtensionManager: NSObject, OSSystemExtensionRequestDelegate,
foundProperties properties: [OSSystemExtensionProperties]
) {
// Standard keys in any bundle. If missing, we've got bigger issues.
let ourBundleVersion = Bundle.main.object(
forInfoDictionaryKey: "CFBundleVersion"
) as! String
let ourBundleShortVersion = Bundle.main.object(
forInfoDictionaryKey: "CFBundleShortVersionString"
) as! String
guard let ourBundleVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String,
let ourBundleShortVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
else {
fatalError("Version should exist in bundle")
}
// Up to date if version and build number match
let isCurrentVersionInstalled = properties.contains { sysex in
@@ -122,7 +124,11 @@ public class SystemExtensionManager: NSObject, OSSystemExtensionRequestDelegate,
// We assume this state until we receive a success response.
}
public func request(_ request: OSSystemExtensionRequest, actionForReplacingExtension existing: OSSystemExtensionProperties, withExtension ext: OSSystemExtensionProperties) -> OSSystemExtensionRequest.ReplacementAction {
public func request(
_ request: OSSystemExtensionRequest,
actionForReplacingExtension existing: OSSystemExtensionProperties,
withExtension ext: OSSystemExtensionProperties
) -> OSSystemExtensionRequest.ReplacementAction {
return .replace
}

View File

@@ -7,6 +7,9 @@
// Abstracts the nitty gritty of loading and saving to our
// VPN configuration in system preferences.
// TODO: Refactor to fix file length
// swiftlint:disable file_length
import CryptoKit
import Foundation
import NetworkExtension
@@ -68,17 +71,17 @@ public enum TunnelMessage: Codable {
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let type = try container.decode(MessageType.self, forKey: .type)
switch type {
case .internetResourceEnabled:
let value = try container.decode(Bool.self, forKey: .value)
self = .internetResourceEnabled(value)
case .getResourceList:
let value = try container.decode(Data.self, forKey: .value)
self = .getResourceList(value)
case .signOut:
self = .signOut
let container = try decoder.container(keyedBy: CodingKeys.self)
let type = try container.decode(MessageType.self, forKey: .type)
switch type {
case .internetResourceEnabled:
let value = try container.decode(Bool.self, forKey: .value)
self = .internetResourceEnabled(value)
case .getResourceList:
let value = try container.decode(Data.self, forKey: .value)
self = .getResourceList(value)
case .signOut:
self = .signOut
case .clearLogs:
self = .clearLogs
case .getLogFolderSize:
@@ -87,19 +90,19 @@ public enum TunnelMessage: Codable {
self = .exportLogs
case .consumeStopReason:
self = .consumeStopReason
}
}
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
case .internetResourceEnabled(let value):
try container.encode(MessageType.internetResourceEnabled, forKey: .type)
try container.encode(value, forKey: .value)
case .getResourceList(let value):
try container.encode(MessageType.getResourceList, forKey: .type)
try container.encode(value, forKey: .value)
case .signOut:
try container.encode(MessageType.signOut, forKey: .type)
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
case .internetResourceEnabled(let value):
try container.encode(MessageType.internetResourceEnabled, forKey: .type)
try container.encode(value, forKey: .value)
case .getResourceList(let value):
try container.encode(MessageType.getResourceList, forKey: .type)
try container.encode(value, forKey: .value)
case .signOut:
try container.encode(MessageType.signOut, forKey: .type)
case .clearLogs:
try container.encode(MessageType.clearLogs, forKey: .type)
case .getLogFolderSize:
@@ -108,10 +111,12 @@ public enum TunnelMessage: Codable {
try container.encode(MessageType.exportLogs, forKey: .type)
case .consumeStopReason:
try container.encode(MessageType.consumeStopReason, forKey: .type)
}
}
}
}
// TODO: Refactor this to remove the lint ignore
// swiftlint:disable:next type_body_length
public class VPNConfigurationManager {
// Connect status updates with our listeners
@@ -133,10 +138,10 @@ public class VPNConfigurationManager {
// Encoder used to send messages to the tunnel
private let encoder = {
let _encoder = PropertyListEncoder()
_encoder.outputFormat = .binary
let encoder = PropertyListEncoder()
encoder.outputFormat = .binary
return _encoder
return encoder
}()
public static let bundleIdentifier: String = "\(Bundle.main.bundleIdentifier!).network-extension"
@@ -174,43 +179,44 @@ public class VPNConfigurationManager {
}
}
func loadFromPreferences(vpnStateUpdateHandler: @escaping @MainActor (NEVPNStatus, Settings?, String?) -> Void) async throws {
func loadFromPreferences(
vpnStateUpdateHandler: @escaping @MainActor (NEVPNStatus, Settings?, String?) -> Void
) async throws {
// loadAllFromPreferences() returns list of VPN configurations created by our main app's bundle ID.
// Since our bundle ID can change (by us), find the one that's current and ignore the others.
let managers = try await NETunnelProviderManager.loadAllFromPreferences()
Log.log("\(#function): \(managers.count) tunnel managers found")
for manager in managers {
if manager.localizedDescription == bundleDescription { // Found it
guard let protocolConfiguration = manager.protocolConfiguration as? NETunnelProviderProtocol,
let providerConfiguration = protocolConfiguration.providerConfiguration as? [String: String]
else {
throw VPNConfigurationManagerError.cannotLoad
}
// Update our state
self.manager = manager
let settings = Settings.fromProviderConfiguration(providerConfiguration)
let actorName = providerConfiguration[VPNConfigurationManagerKeys.actorName]
if let internetResourceEnabled = providerConfiguration[VPNConfigurationManagerKeys.internetResourceEnabled]?.data(using: .utf8) {
self.internetResourceEnabled = (try? JSONDecoder().decode(Bool.self, from: internetResourceEnabled)) ?? false
}
let status = manager.connection.status
// Configure our Telemetry environment
Telemetry.setEnvironmentOrClose(settings.apiURL)
Telemetry.accountSlug = providerConfiguration[VPNConfigurationManagerKeys.accountSlug]
// Share what we found with our caller
await vpnStateUpdateHandler(status, settings, actorName)
// Stop looking for our tunnel
break
for manager in managers where manager.localizedDescription == bundleDescription {
guard let protocolConfiguration = manager.protocolConfiguration as? NETunnelProviderProtocol,
let providerConfiguration = protocolConfiguration.providerConfiguration as? [String: String]
else {
throw VPNConfigurationManagerError.cannotLoad
}
// Update our state
self.manager = manager
let settings = Settings.fromProviderConfiguration(providerConfiguration)
let actorName = providerConfiguration[VPNConfigurationManagerKeys.actorName]
if let internetResourceEnabled = providerConfiguration[
VPNConfigurationManagerKeys.internetResourceEnabled
]?.data(using: .utf8) {
self.internetResourceEnabled = (try? JSONDecoder().decode(Bool.self, from: internetResourceEnabled)) ?? false
}
let status = manager.connection.status
// Configure our Telemetry environment
Telemetry.setEnvironmentOrClose(settings.apiURL)
Telemetry.accountSlug = providerConfiguration[VPNConfigurationManagerKeys.accountSlug]
// Share what we found with our caller
await vpnStateUpdateHandler(status, settings, actorName)
// Stop looking for our tunnel
break
}
// If no tunnel configuration was found, update state to
@@ -255,7 +261,8 @@ public class VPNConfigurationManager {
var newProviderConfiguration = settings.toProviderConfiguration()
// Don't clobber existing actorName
newProviderConfiguration[VPNConfigurationManagerKeys.actorName] = providerConfiguration[VPNConfigurationManagerKeys.actorName]
newProviderConfiguration[VPNConfigurationManagerKeys.actorName] =
providerConfiguration[VPNConfigurationManagerKeys.actorName]
protocolConfiguration.providerConfiguration = newProviderConfiguration
protocolConfiguration.serverAddress = settings.apiURL
manager.protocolConfiguration = protocolConfiguration
@@ -276,13 +283,13 @@ public class VPNConfigurationManager {
// Pass token if provided
if let token = token {
options.merge(["token": token as NSObject]) { _, n in n }
options.merge(["token": token as NSObject]) { _, new in new }
}
// Pass pre-1.4.0 Firezone ID if it exists. Pre 1.4.0 clients will have this
// persisted to the app side container URL.
if let id = FirezoneId.load(.Pre_1_4_0) {
options.merge(["id": id as NSObject]) { _, n in n }
if let id = FirezoneId.load(.pre140) {
options.merge(["id": id as NSObject]) { _, new in new }
}
try session()?.startTunnel(options: options)
@@ -322,7 +329,13 @@ public class VPNConfigurationManager {
self.resourceListHash = Data(SHA256.hash(data: data))
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
self.resourcesListCache = ResourceList.loaded(try! decoder.decode([Resource].self, from: data))
guard let decoded = try? decoder.decode([Resource].self, from: data)
else {
fatalError("Should be able to decode ResourceList")
}
self.resourcesListCache = ResourceList.loaded(decoded)
}
callback(self.resourcesListCache)

View File

@@ -80,7 +80,6 @@ struct AuthClient {
return bytes.map { String(format: "%02hhx", $0) }.joined()
}
// TODO: Use a cryptography lib that the compiler can't optimize out
private func areStringsEqualConstantTime(_ string1: String, _ string2: String) -> Bool {
let charArray1 = string1.utf8CString
let charArray2 = string2.utf8CString

View File

@@ -17,8 +17,8 @@ import Foundation
/// Can be refactored to remove the Version enum all clients >= 1.4.0
public struct FirezoneId {
public enum Version {
case Pre_1_4_0
case Post_1_4_0
case pre140
case post140
}
public static func save(_ id: String) {
@@ -39,9 +39,9 @@ public struct FirezoneId {
public static func load(_ version: Version) -> String? {
let appGroupId = switch version {
case .Post_1_4_0:
case .post140:
BundleHelper.appGroupId
case .Pre_1_4_0:
case .pre140:
#if os(macOS)
"47R2M6779T.group.dev.firezone.firezone"
#elseif os(iOS)

View File

@@ -9,8 +9,8 @@
import Foundation
class StatusSymbol {
static var on: String = "<->"
static var off: String = ""
static var enabled: String = "<->"
static var disabled: String = ""
}
public enum ResourceList {
@@ -21,8 +21,8 @@ public enum ResourceList {
switch self {
case .loading:
[]
case .loaded(let x):
x
case .loaded(let ele):
ele
}
}
}
@@ -36,7 +36,15 @@ public struct Resource: Decodable, Identifiable, Equatable {
public var sites: [Site]
public var type: ResourceType
public init(id: String, name: String, address: String?, addressDescription: String?, status: ResourceStatus, sites: [Site], type: ResourceType) {
public init(
id: String,
name: String,
address: String?,
addressDescription: String?,
status: ResourceStatus,
sites: [Site],
type: ResourceType
) {
self.id = id
self.name = name
self.address = address
@@ -75,14 +83,17 @@ public enum ResourceStatus: String, Decodable {
case .online:
return "You're connected to a healthy Gateway in this Site."
case .unknown:
return "No connection has been attempted to Resources in this Site. Access a Resource to establish a Gateway connection."
return """
No connection has been attempted to Resources in this Site.
Access a Resource to establish a Gateway connection.
"""
}
}
}
public enum ResourceType: String, Decodable {
case dns = "dns"
case cidr = "cidr"
case ip = "ip"
case internet = "internet"
case dns
case cidr
case ip // swiftlint:disable:this identifier_name
case internet
}

View File

@@ -114,7 +114,11 @@ public class SessionNotification: NSObject {
#if os(iOS)
extension SessionNotification: UNUserNotificationCenterDelegate {
public func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
public func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
Log.log("\(#function): 'Sign In' clicked in notification")
let actionId = response.actionIdentifier
let categoryId = response.notification.request.content.categoryIdentifier

View File

@@ -27,7 +27,6 @@ struct Settings: Equatable {
&& !logFilter.isEmpty
}
// Convert provider configuration (which may have empty fields if it was tampered with) to Settings
static func fromProviderConfiguration(_ providerConfiguration: [String: Any]?) -> Settings {
if let providerConfiguration = providerConfiguration as? [String: String] {
@@ -38,7 +37,8 @@ struct Settings: Equatable {
?? Settings.defaultValue.apiURL,
logFilter: providerConfiguration[VPNConfigurationManagerKeys.logFilter]
?? Settings.defaultValue.logFilter,
internetResourceEnabled: getInternetResourceEnabled(internetResourceEnabled: providerConfiguration[VPNConfigurationManagerKeys.internetResourceEnabled])
internetResourceEnabled: getInternetResourceEnabled(
internetResourceEnabled: providerConfiguration[VPNConfigurationManagerKeys.internetResourceEnabled])
)
} else {
return Settings.defaultValue
@@ -46,18 +46,26 @@ struct Settings: Equatable {
}
static private func getInternetResourceEnabled(internetResourceEnabled: String?) -> Bool? {
guard let internetResourceEnabled = internetResourceEnabled, let jsonData = internetResourceEnabled.data(using: .utf8) else { return nil }
guard let internetResourceEnabled = internetResourceEnabled,
let jsonData = internetResourceEnabled.data(using: .utf8)
else { return nil }
return try? JSONDecoder().decode(Bool?.self, from: jsonData)
}
// Used for initializing a new providerConfiguration from Settings
func toProviderConfiguration() -> [String: String] {
guard let data = try? JSONEncoder().encode(internetResourceEnabled),
let string = String(data: data, encoding: .utf8)
else {
fatalError("internetResourceEnabled should be encodable")
}
return [
VPNConfigurationManagerKeys.authBaseURL: authBaseURL,
VPNConfigurationManagerKeys.apiURL: apiURL,
VPNConfigurationManagerKeys.logFilter: logFilter,
VPNConfigurationManagerKeys.internetResourceEnabled: String(data: try! JSONEncoder().encode(internetResourceEnabled) , encoding: .utf8)!,
VPNConfigurationManagerKeys.internetResourceEnabled: string
]
}

View File

@@ -12,7 +12,7 @@ public struct Token: CustomStringConvertible {
kSecAttrLabel: "Firezone token",
kSecAttrAccount: "1",
kSecAttrService: BundleHelper.appGroupId,
kSecAttrDescription: "Firezone access token",
kSecAttrDescription: "Firezone access token"
]
private var data: Data

View File

@@ -22,10 +22,8 @@ struct WebAuthSession {
let anchor = PresentationAnchor()
let authResponse = try await withCheckedThrowingContinuation() { continuation in
let session = ASWebAuthenticationSession(url: url, callbackURLScheme: scheme) {
returnedURL,
error in
let authResponse = try await withCheckedThrowingContinuation { continuation in
let session = ASWebAuthenticationSession(url: url, callbackURLScheme: scheme) { returnedURL, error in
do {
if let error = error as? ASWebAuthenticationSessionError,
error.code == .canceledLogin {

View File

@@ -10,7 +10,7 @@ import UserNotifications
import OSLog
#if os(macOS)
import AppKit
import AppKit
#endif
@MainActor
@@ -81,8 +81,7 @@ public final class Store: ObservableObject {
if let savedValue = try await self.vpnConfigurationManager.consumeStopReason(),
let rawValue = Int(savedValue),
let reason = NEProviderStopReason(rawValue: rawValue),
case .authenticationCanceled = reason
{
case .authenticationCanceled = reason {
#if os(macOS)
await self.sessionNotification.showSignedOutAlertmacOS()
#endif
@@ -97,8 +96,8 @@ public final class Store: ObservableObject {
func checkedSystemExtensionStatus() async throws -> SystemExtensionStatus {
let checker = SystemExtensionManager()
let status = try await withCheckedThrowingContinuation {
(continuation: CheckedContinuation<SystemExtensionStatus, Error>) in
let status =
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<SystemExtensionStatus, Error>) in
checker.checkStatus(
identifier: VPNConfigurationManager.bundleIdentifier,
@@ -122,8 +121,8 @@ public final class Store: ObservableObject {
// Apple recommends installing the system extension as early as possible after app launch.
// See https://developer.apple.com/documentation/systemextensions/installing-system-extensions-and-drivers
self.systemExtensionStatus = try await withCheckedThrowingContinuation {
(continuation: CheckedContinuation<SystemExtensionStatus, Error>) in
self.systemExtensionStatus =
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<SystemExtensionStatus, Error>) in
installer.installSystemExtension(
identifier: VPNConfigurationManager.bundleIdentifier,
@@ -184,7 +183,7 @@ public final class Store: ObservableObject {
Log.log("\(#function)")
// Define the Timer's closure
let updateResources: @Sendable (Timer) -> Void = { _timer in
let updateResources: @Sendable (Timer) -> Void = { _ in
Task.detached { [weak self] in
await self?.vpnConfigurationManager.fetchResources(callback: callback)
}

View File

@@ -38,8 +38,7 @@ public class AppViewModel: ObservableObject {
Task.detached { [weak self] in
guard let self else { return }
do { try await WebAuthSession.signIn(store: self.store) }
catch { Log.error(error) }
do { try await WebAuthSession.signIn(store: self.store) } catch { Log.error(error) }
}
}

View File

@@ -8,7 +8,6 @@
import SwiftUI
import Combine
@MainActor
final class GrantVPNViewModel: ObservableObject {
@Published var isInstalled: Bool = false
@@ -107,9 +106,10 @@ struct GrantVPNView: View {
.frame(maxWidth: 320)
.padding(.horizontal, 10)
Spacer()
Text(
"Firezone requires your permission to create VPN configurations. Until it has that permission, all functionality will be disabled."
)
Text("""
Firezone requires your permission to create VPN configurations.
Until it has that permission, all functionality will be disabled.
""")
.font(.body)
.multilineTextAlignment(.center)
.padding(EdgeInsets(top: 0, leading: 5, bottom: 0, trailing: 5))
@@ -165,11 +165,14 @@ struct GrantVPNView: View {
.padding(.vertical, 10)
.opacity(model.isInstalled ? 0.5 : 1.0)
Spacer()
Button(action: {
model.installSystemExtensionButtonTapped()
}) {
Label("Enable System Extension", systemImage: "gearshape")
}
Button(
action: {
model.installSystemExtensionButtonTapped()
},
label: {
Label("Enable System Extension", systemImage: "gearshape")
}
)
.buttonStyle(.borderedProminent)
.controlSize(.large)
.disabled(model.isInstalled)
@@ -185,11 +188,14 @@ struct GrantVPNView: View {
.font(.body)
.padding(.vertical, 10)
Spacer()
Button(action: {
model.grantPermissionButtonTapped()
}) {
Label("Grant VPN Permission", systemImage: "network.badge.shield.half.filled")
}
Button(
action: {
model.grantPermissionButtonTapped()
},
label: {
Label("Grant VPN Permission", systemImage: "network.badge.shield.half.filled")
}
)
.buttonStyle(.borderedProminent)
.controlSize(.large)
.disabled(!model.isInstalled)

View File

@@ -5,6 +5,9 @@
// Created by Jamil Bou Kheir on 4/2/24.
//
// TODO: Refactor to fix file length
// swiftlint:disable file_length
import Foundation
import Combine
import NetworkExtension
@@ -15,6 +18,7 @@ import SwiftUI
@MainActor
// TODO: Refactor to MenuBarExtra for macOS 13+
// https://developer.apple.com/documentation/swiftui/menubarextra
// swiftlint:disable:next type_body_length
public final class MenuBar: NSObject, ObservableObject {
private var statusItem: NSStatusItem
@@ -41,7 +45,7 @@ public final class MenuBar: NSObject, ObservableObject {
private lazy var connectingAnimationImages = [
NSImage(named: "MenuBarIconConnecting1"),
NSImage(named: "MenuBarIconConnecting2"),
NSImage(named: "MenuBarIconConnecting3"),
NSImage(named: "MenuBarIconConnecting3")
]
private var connectingAnimationImageIndex: Int = 0
private var connectingAnimationTimer: Timer?
@@ -65,17 +69,17 @@ public final class MenuBar: NSObject, ObservableObject {
private func setupObservers() {
model.favorites.$ids
.receive(on: DispatchQueue.main)
.sink(receiveValue: { [weak self] ids in
.sink(receiveValue: { [weak self] _ in
guard let self = self else { return }
// When the user clicks to add or remove a favorite, the menu will close anyway, so just recreate the whole menu.
// This avoids complex logic when changing in and out of the "nothing is favorited" special case
// When the user clicks to add or remove a favorite, the menu will close anyway, so just recreate the whole
// menu. This avoids complex logic when changing in and out of the "nothing is favorited" special case.
self.populateResourceMenus([])
self.populateResourceMenus(model.resources.asArray())
}).store(in: &cancellables)
model.$resources
.receive(on: DispatchQueue.main)
.sink(receiveValue: { [weak self] resources in
.sink(receiveValue: { [weak self] _ in
guard let self = self else { return }
self.populateResourceMenus(model.resources.asArray())
self.handleTunnelStatusOrResourcesChanged()
@@ -83,7 +87,7 @@ public final class MenuBar: NSObject, ObservableObject {
model.$status
.receive(on: DispatchQueue.main)
.sink(receiveValue: { [weak self] status in
.sink(receiveValue: { [weak self] _ in
guard let self = self else { return }
self.vpnStatus = model.status
self.updateStatusItemIcon()
@@ -227,7 +231,7 @@ public final class MenuBar: NSObject, ObservableObject {
menu.addItem(resourcesUnavailableReasonMenuItem)
menu.addItem(resourcesSeparatorMenuItem)
if (!model.favorites.ids.isEmpty) {
if !model.favorites.ids.isEmpty {
menu.addItem(otherResourcesMenuItem)
menu.addItem(otherResourcesSeparatorMenuItem)
}
@@ -272,7 +276,7 @@ public final class MenuBar: NSObject, ObservableObject {
alert.messageText = "Error signing in"
alert.informativeText = error.localizedDescription
alert.alertStyle = .warning
let _ = alert.runModal()
_ = alert.runModal()
}
}
}
@@ -407,9 +411,7 @@ public final class MenuBar: NSObject, ObservableObject {
(connectingAnimationImageIndex + 1) % connectingAnimationImages.count
}
private func handleTunnelStatusOrResourcesChanged() {
let resources = model.resources
let status = model.status
private func updateSignInMenuItems(status: NEVPNStatus?) {
// Update "Sign In" / "Sign Out" menu items
switch status {
case nil:
@@ -446,6 +448,9 @@ public final class MenuBar: NSObject, ObservableObject {
@unknown default:
break
}
}
private func updateResourcesMenuItems(status: NEVPNStatus?, resources: ResourceList) {
// Update resources "header" menu items
switch status {
case .connecting:
@@ -487,6 +492,15 @@ public final class MenuBar: NSObject, ObservableObject {
@unknown default:
break
}
}
private func handleTunnelStatusOrResourcesChanged() {
let resources = model.resources
let status = model.status
updateSignInMenuItems(status: status)
updateResourcesMenuItems(status: status, resources: resources)
quitMenuItem.title = {
switch status {
case .connected, .connecting:
@@ -501,8 +515,8 @@ public final class MenuBar: NSObject, ObservableObject {
switch resources {
case .loading:
return "Loading Resources..."
case .loaded(let x):
if x.isEmpty {
case .loaded(let list):
if list.isEmpty {
return "No Resources"
} else {
return "Resources"
@@ -513,7 +527,7 @@ public final class MenuBar: NSObject, ObservableObject {
private func populateResourceMenus(_ newResources: [Resource]) {
// If we have no favorites, then everything is a favorite
let hasAnyFavorites = newResources.contains { model.favorites.contains($0.id) }
let newFavorites = if (hasAnyFavorites) {
let newFavorites = if hasAnyFavorites {
newResources.filter { model.favorites.contains($0.id) || $0.isInternetResource() }
} else {
newResources
@@ -540,7 +554,7 @@ public final class MenuBar: NSObject, ObservableObject {
// We don't ever need to remove this as the whole menu will be recreated
// if the user updates, and there's no reason for the update to no longer be available
// versions should be monotonically increased.
if (updateChecker.updateAvailable && !updateMenuDisplayed) {
if updateChecker.updateAvailable && !updateMenuDisplayed {
updateMenuDisplayed = true
let index = menu.index(of: settingsMenuItem) + 1
menu.insertItem(NSMenuItem.separator(), at: index)
@@ -570,13 +584,13 @@ public final class MenuBar: NSObject, ObservableObject {
}
private func populateOtherResourcesMenu(_ newOthers: [Resource]) {
if (newOthers.isEmpty) {
if newOthers.isEmpty {
removeItemFromMenu(menu: menu, item: otherResourcesMenuItem)
removeItemFromMenu(menu: menu, item: otherResourcesSeparatorMenuItem)
} else {
let i = menu.index(of: aboutMenuItem)
addItemToMenu(menu: menu, item: otherResourcesMenuItem, at: i)
addItemToMenu(menu: menu, item: otherResourcesSeparatorMenuItem, at: i + 1)
let idx = menu.index(of: aboutMenuItem)
addItemToMenu(menu: menu, item: otherResourcesMenuItem, location: idx)
addItemToMenu(menu: menu, item: otherResourcesSeparatorMenuItem, location: idx + 1)
}
// Update the menu in place so everything won't vanish if it's open when it updates
@@ -598,20 +612,20 @@ public final class MenuBar: NSObject, ObservableObject {
}
private func addItemToMenu(menu: NSMenu, item: NSMenuItem, at: Int) {
private func addItemToMenu(menu: NSMenu, item: NSMenuItem, location: Int) {
// Adding an item that already exists will crash the process, so check for it first.
let i = menu.index(of: otherResourcesMenuItem)
if (i != -1) {
let idx = menu.index(of: otherResourcesMenuItem)
if idx != -1 {
// Item's already in the menu, do nothing
return
}
menu.insertItem(otherResourcesMenuItem, at: at)
menu.insertItem(otherResourcesMenuItem, at: location)
}
private func removeItemFromMenu(menu: NSMenu, item: NSMenuItem) {
// Removing an item that doesn't exist will crash the process, so check for it first.
let i = menu.index(of: item)
if (i == -1) {
let idx = menu.index(of: item)
if idx == -1 {
// Item's already not in the menu, do nothing
return
}
@@ -619,7 +633,7 @@ public final class MenuBar: NSObject, ObservableObject {
}
private func internetResourceTitle(resource: Resource) -> String {
let status = model.store.internetResourceEnabled() ? StatusSymbol.on : StatusSymbol.off
let status = model.store.internetResourceEnabled() ? StatusSymbol.enabled : StatusSymbol.disabled
return status + " " + resource.name
}
@@ -645,6 +659,8 @@ public final class MenuBar: NSObject, ObservableObject {
model.isInternetResourceEnabled() ? "Disable this resource" : "Enable this resource"
}
// TODO: Refactor this when refactoring for macOS 13
// swiftlint:disable:next function_body_length
private func nonInternetResourceHeader(resource: Resource) -> NSMenu {
let subMenu = NSMenu()
@@ -654,13 +670,14 @@ public final class MenuBar: NSObject, ObservableObject {
resourceAddressDescriptionItem.title = addressDescription
if let url = URL(string: addressDescription),
let _ = url.host {
url.host != nil {
// Looks like a URL, so allow opening it
resourceAddressDescriptionItem.action = #selector(resourceURLTapped(_:))
resourceAddressDescriptionItem.toolTip = "Click to open"
// Using Markdown here only to highlight the URL
resourceAddressDescriptionItem.attributedTitle = try? NSAttributedString(markdown: "[\(addressDescription)](\(addressDescription))")
resourceAddressDescriptionItem.attributedTitle =
try? NSAttributedString(markdown: "[\(addressDescription)](\(addressDescription))")
} else {
resourceAddressDescriptionItem.title = addressDescription
resourceAddressDescriptionItem.action = #selector(resourceValueTapped(_:))
@@ -813,12 +830,16 @@ public final class MenuBar: NSObject, ObservableObject {
}
@objc private func addFavoriteTapped(_ sender: NSMenuItem) {
let id = sender.representedObject as! String
guard let id = sender.representedObject as? String
else { fatalError("Expected to receive a String") }
setFavorited(id: id, favorited: true)
}
@objc private func removeFavoriteTapped(_ sender: NSMenuItem) {
let id = sender.representedObject as! String
guard let id = sender.representedObject as? String
else { fatalError("Expected to receive a String") }
setFavorited(id: id, favorited: false)
}
@@ -855,7 +876,11 @@ extension NSImage {
func resized(to newSize: NSSize) -> NSImage {
let newImage = NSImage(size: newSize)
newImage.lockFocus()
self.draw(in: NSRect(origin: .zero, size: newSize), from: NSRect(origin: .zero, size: self.size), operation: .copy, fraction: 1.0)
self.draw(
in: NSRect(origin: .zero, size: newSize),
from: NSRect(origin: .zero, size: self.size),
operation: .copy, fraction: 1.0
)
newImage.unlockFocus()
newImage.size = newSize
return newImage

View File

@@ -37,12 +37,15 @@ struct ResourceView: View {
Text(site.name)
}
.contextMenu {
Button(action: {
copyToClipboard(site.name)
}) {
Text("Copy name")
Image(systemName: "doc.on.doc")
}
Button(
action: {
copyToClipboard(site.name)
},
label: {
Text("Copy name")
Image(systemName: "doc.on.doc")
}
)
}
HStack {
@@ -56,12 +59,15 @@ struct ResourceView: View {
.padding(.leading, 5)
}
.contextMenu {
Button(action: {
copyToClipboard(resource.status.toSiteStatus())
}) {
Text("Copy status")
Image(systemName: "doc.on.doc")
}
Button(
action: {
copyToClipboard(resource.status.toSiteStatus())
},
label: {
Text("Copy status")
Image(systemName: "doc.on.doc")
}
)
}
}
}
@@ -107,12 +113,15 @@ struct NonInternetResourceHeader: View {
Text(resource.name)
}
.contextMenu {
Button(action: {
copyToClipboard(resource.name)
}) {
Text("Copy name")
Image(systemName: "doc.on.doc")
}
Button(
action: {
copyToClipboard(resource.name)
},
label: {
Text("Copy name")
Image(systemName: "doc.on.doc")
}
)
}
HStack {
@@ -122,56 +131,70 @@ struct NonInternetResourceHeader: View {
.foregroundColor(.secondary)
.frame(width: 80, alignment: .leading)
if let url = URL(string: resource.addressDescription ?? resource.address!),
let _ = url.host {
Button(action: {
openURL(url)
}) {
Text(resource.addressDescription ?? resource.address!)
.foregroundColor(.blue)
.underline()
.font(.system(size: 16))
.contextMenu {
Button(action: {
copyToClipboard(resource.addressDescription ?? resource.address!)
}) {
Text("Copy address")
Image(systemName: "doc.on.doc")
url.host != nil {
Button(
action: {
openURL(url)
},
label: {
Text(resource.addressDescription ?? resource.address!)
.foregroundColor(.blue)
.underline()
.font(.system(size: 16))
.contextMenu {
Button(
action: {
copyToClipboard(resource.addressDescription ?? resource.address!)
},
label: {
Text("Copy address")
Image(systemName: "doc.on.doc")
}
)
}
}
}
}
)
} else {
Text(resource.addressDescription ?? resource.address!)
.contextMenu {
Button(action: {
copyToClipboard(resource.addressDescription ?? resource.address!)
}) {
Text("Copy address")
Image(systemName: "doc.on.doc")
}
Button(
action: {
copyToClipboard(resource.addressDescription ?? resource.address!)
},
label: {
Text("Copy address")
Image(systemName: "doc.on.doc")
}
)
}
}
}
if(model.favorites.ids.contains(resource.id)) {
Button(action: {
model.favorites.remove(resource.id)
}) {
HStack {
Image(systemName: "star")
Text("Remove from favorites")
Spacer()
if model.favorites.ids.contains(resource.id) {
Button(
action: {
model.favorites.remove(resource.id)
},
label: {
HStack {
Image(systemName: "star")
Text("Remove from favorites")
Spacer()
}
}
}
)
} else {
Button(action: {
model.favorites.add(resource.id)
}) {
HStack {
Image(systemName: "star.fill")
Text("Add to favorites")
Spacer()
Button(
action: {
model.favorites.add(resource.id)
}, label: {
HStack {
Image(systemName: "star.fill")
Text("Add to favorites")
Spacer()
}
}
}
)
}
}
}
@@ -220,14 +243,17 @@ struct ToggleInternetResourceButton: View {
}
var body: some View {
Button(action: {
model.store.toggleInternetResource(enabled: !model.isInternetResourceEnabled())
}) {
HStack {
Text(toggleResourceEnabledText())
Spacer()
Button(
action: {
model.store.toggleInternetResource(enabled: !model.isInternetResourceEnabled())
},
label: {
HStack {
Text(toggleResourceEnabledText())
Spacer()
}
}
}
)
}
}

View File

@@ -11,7 +11,7 @@ import SwiftUI
@MainActor
public final class SessionViewModel: ObservableObject {
@Published private(set) var actorName: String? = nil
@Published private(set) var actorName: String?
@Published private(set) var favorites: Favorites
@Published private(set) var resources: ResourceList = ResourceList.loading
@Published private(set) var status: NEVPNStatus?
@@ -26,7 +26,7 @@ public final class SessionViewModel: ObservableObject {
favorites.$ids
.receive(on: DispatchQueue.main)
.sink(receiveValue: { [weak self] ids in
.sink(receiveValue: { [weak self] _ in
guard let self = self else { return }
self.objectWillChange.send()
})
@@ -48,7 +48,7 @@ public final class SessionViewModel: ObservableObject {
self.status = status
if status == .connected {
store.beginUpdatingResources() { resources in
store.beginUpdatingResources { resources in
self.resources = resources
}
} else {
@@ -130,7 +130,7 @@ struct ResourceSection: View {
@ObservedObject var model: SessionViewModel
private func internetResourceTitle(resource: Resource) -> String {
let status = model.store.internetResourceEnabled() ? StatusSymbol.on : StatusSymbol.off
let status = model.store.internetResourceEnabled() ? StatusSymbol.enabled : StatusSymbol.disabled
return status + " " + resource.name
}

View File

@@ -4,6 +4,9 @@
// LICENSE: Apache-2.0
//
// TODO: Refactor to fix file length
// swiftlint:disable file_length
import Combine
import OSLog
import SwiftUI
@@ -160,6 +163,8 @@ extension FileManager {
}
}
// TODO: Refactor body length
// swiftlint:disable:next type_body_length
public struct SettingsView: View {
@ObservedObject var favorites: Favorites
@ObservedObject var model: SettingsViewModel
@@ -203,11 +208,12 @@ public struct SettingsView: View {
}
struct FootnoteText {
static let forAdvanced = try! AttributedString(
static let forAdvanced = try? AttributedString(
markdown: """
**WARNING:** These settings are intended for internal debug purposes **only**. \
Changing these will disrupt access to your Firezone resources.
""")
"""
)
}
public init(favorites: Favorites, model: SettingsViewModel) {
@@ -342,7 +348,7 @@ public struct SettingsView: View {
prompt: Text(PlaceholderText.logFilter)
)
Text(FootnoteText.forAdvanced)
Text(FootnoteText.forAdvanced ?? "")
.foregroundStyle(.secondary)
HStack(spacing: 30) {
@@ -445,7 +451,7 @@ public struct SettingsView: View {
}
},
header: { Text("Advanced Settings") },
footer: { Text(FootnoteText.forAdvanced) }
footer: { Text(FootnoteText.forAdvanced ?? "") }
)
}
Spacer()
@@ -633,7 +639,7 @@ public struct SettingsView: View {
await MainActor.run {
alert.messageText = "Error exporting logs: \(error.localizedDescription)"
alert.alertStyle = .critical
let _ = alert.runModal()
_ = alert.runModal()
}
}

View File

@@ -24,70 +24,86 @@ class UpdateChecker {
private var timer: Timer?
private let notificationAdapter: NotificationAdapter = NotificationAdapter()
private let versionCheckUrl: URL = URL(string: "https://www.firezone.dev/api/releases")!
private let marketingVersion = SemVerString.from(string: Bundle.main.infoDictionary!["CFBundleShortVersionString"] as! String)!
private let versionCheckUrl: URL
private let marketingVersion: SemanticVersion
@Published public var updateAvailable: Bool = false
init() {
startCheckingForUpdates()
guard let versionCheckUrl = URL(string: "https://www.firezone.dev/api/releases"),
let versionString = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String,
let marketingVersion = try? SemanticVersion(versionString)
else {
fatalError("Should be able to initialize the UpdateChecker")
}
self.versionCheckUrl = versionCheckUrl
self.marketingVersion = marketingVersion
startCheckingForUpdates()
}
private func startCheckingForUpdates() {
timer = Timer.scheduledTimer(timeInterval: 6 * 60 * 60, target: self, selector: #selector(checkForUpdates), userInfo: nil, repeats: true)
checkForUpdates()
}
private func startCheckingForUpdates() {
timer = Timer.scheduledTimer(
timeInterval: 6 * 60 * 60,
target: self,
selector: #selector(checkForUpdates),
userInfo: nil,
repeats: true
)
checkForUpdates()
}
deinit {
timer?.invalidate()
}
deinit {
timer?.invalidate()
}
@objc private func checkForUpdates() {
let task = URLSession.shared.dataTask(with: versionCheckUrl) { [weak self] data, response, error in
guard let self = self else { return }
@objc private func checkForUpdates() {
let task = URLSession.shared.dataTask(with: versionCheckUrl) { [weak self] data, _, error in
guard let self = self else { return }
if let error = error as NSError?,
error.domain == NSURLErrorDomain,
[
NSURLErrorTimedOut,
NSURLErrorCannotFindHost,
NSURLErrorCannotConnectToHost,
NSURLErrorNetworkConnectionLost,
NSURLErrorDNSLookupFailed,
NSURLErrorNotConnectedToInternet
].contains(error.code) // Don't capture transient errors
{
Log.warning("\(#function): Update check failed: \(error)")
if let error = error as NSError?,
error.domain == NSURLErrorDomain,
[
NSURLErrorTimedOut,
NSURLErrorCannotFindHost,
NSURLErrorCannotConnectToHost,
NSURLErrorNetworkConnectionLost,
NSURLErrorDNSLookupFailed,
NSURLErrorNotConnectedToInternet
].contains(error.code) { // Don't capture transient errors
Log.warning("\(#function): Update check failed: \(error)")
return
} else if let error = error {
Log.error(error)
return
} else if let error = error {
Log.error(error)
return
}
return
}
guard let versionInfo = VersionInfo.from(data: data) else {
let attemptedVersion = String(data: data ?? Data(), encoding: .utf8) ?? ""
Log.error(UpdateError.invalidVersion(attemptedVersion))
guard let data = data,
let versions = try? JSONDecoder().decode([String: String].self, from: data),
let versionString = versions["apple"],
let latestVersion = try? SemanticVersion(versionString)
else {
Log.error(UpdateError.invalidVersion("data was invalid or 'apple' key not found"))
return
}
return
}
let latestVersion = versionInfo.apple
if latestVersion > marketingVersion {
self.updateAvailable = true
if latestVersion > marketingVersion {
self.updateAvailable = true
if let lastDismissedVersion = getLastDismissedVersion(), lastDismissedVersion >= latestVersion {
return
}
self.notificationAdapter.showUpdateNotification(version: latestVersion)
}
if let lastDismissedVersion = getLastDismissedVersion(), lastDismissedVersion >= latestVersion {
return
}
task.resume()
}
self.notificationAdapter.showUpdateNotification(version: latestVersion)
}
}
task.resume()
}
static func downloadURL() -> URL {
if BundleHelper.isAppStore() {
@@ -99,8 +115,8 @@ class UpdateChecker {
}
private class NotificationAdapter: NSObject, UNUserNotificationCenterDelegate {
private var lastNotifiedVersion: SemVerString?
private var lastDismissedVersion: SemVerString?
private var lastNotifiedVersion: SemanticVersion?
private var lastDismissedVersion: SemanticVersion?
static let notificationIdentifier = "UPDATE_CATEGORY"
static let dismissIdentifier = "DISMISS_ACTION"
@@ -114,9 +130,9 @@ private class NotificationAdapter: NSObject, UNUserNotificationCenterDelegate {
options: [])
let notificationCategory = UNNotificationCategory(identifier: NotificationAdapter.notificationIdentifier,
actions: [dismissAction],
intentIdentifiers: [],
options: [])
actions: [dismissAction],
intentIdentifiers: [],
options: [])
notificationCenter.setNotificationCategories([notificationCategory])
@@ -137,8 +153,7 @@ private class NotificationAdapter: NSObject, UNUserNotificationCenterDelegate {
}
func showUpdateNotification(version: SemVerString) {
func showUpdateNotification(version: SemanticVersion) {
let content = UNMutableNotificationContent()
lastNotifiedVersion = version
content.title = "Update Firezone"
@@ -146,58 +161,57 @@ private class NotificationAdapter: NSObject, UNUserNotificationCenterDelegate {
content.sound = .default
content.categoryIdentifier = NotificationAdapter.notificationIdentifier
let request = UNNotificationRequest(
identifier: UUID().uuidString,
content: content,
trigger: UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
)
content: content,
trigger: UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
)
UNUserNotificationCenter.current().add(request) { error in
if let error = error {
Log.error(error)
}
}
}
func userNotificationCenter(_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void) {
if response.actionIdentifier == NotificationAdapter.dismissIdentifier {
try? setLastDismissedVersion(version: lastNotifiedVersion!)
return
}
if response.actionIdentifier == NotificationAdapter.dismissIdentifier {
setLastDismissedVersion(version: lastNotifiedVersion!)
return
}
Task.detached {
NSWorkspace.shared.open(UpdateChecker.downloadURL())
}
completionHandler()
completionHandler()
}
func applicationDidFinishLaunching(_ notification: Notification) {
UNUserNotificationCenter.current().delegate = self
UNUserNotificationCenter.current().delegate = self
}
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
// Show the notification even when the app is in the foreground
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions
) -> Void) {
// Show the notification even when the app is in the foreground
completionHandler([.badge, .banner, .sound])
}
}
let lastDismissedVersionKey = "lastDismissedVersion"
private let lastDismissedVersionKey = "lastDismissedVersion"
private func setLastDismissedVersion(version: SemVerString) throws {
guard let data = version.versionString().data(using: .utf8) else { return }
UserDefaults.standard.setValue(String(data: data, encoding: .utf8), forKey: lastDismissedVersionKey)
private func setLastDismissedVersion(version: SemanticVersion) {
UserDefaults.standard.setValue(version, forKey: lastDismissedVersionKey)
}
private func getLastDismissedVersion() -> SemVerString? {
guard let versionString = UserDefaults.standard.string(forKey: lastDismissedVersionKey) else { return nil }
return SemVerString.from(string: versionString)
private func getLastDismissedVersion() -> SemanticVersion? {
return UserDefaults.standard.object(forKey: lastDismissedVersionKey) as? SemanticVersion
}
#endif

View File

@@ -9,7 +9,7 @@
import SwiftUI
#if os(iOS)
struct iOSNavigationView<Content: View>: View {
struct iOSNavigationView<Content: View>: View { // swiftlint:disable:this type_name
@State private var isSettingsPresented = false
@ObservedObject var model: AppViewModel
@Environment(\.openURL) var openURL
@@ -26,7 +26,7 @@ struct iOSNavigationView<Content: View>: View {
NavigationView {
content
.navigationBarTitleDisplayMode(.inline)
.navigationBarItems(leading: AuthMenu, trailing: SettingsButton)
.navigationBarItems(leading: authMenu, trailing: settingsButton)
.alert(
item: $errorHandler.currentAlert,
content: { alert in
@@ -46,57 +46,72 @@ struct iOSNavigationView<Content: View>: View {
.navigationViewStyle(StackNavigationViewStyle())
}
private var SettingsButton: some View {
Button(action: {
isSettingsPresented = true
}) {
Label("Settings", systemImage: "gear")
}
private var settingsButton: some View {
Button(
action: {
isSettingsPresented = true
},
label: {
Label("Settings", systemImage: "gear")
}
)
.disabled(model.status == .invalid)
}
private var AuthMenu: some View {
private var authMenu: some View {
Menu {
if model.status == .connected {
Text("Signed in as \(model.store.actorName ?? "Unknown user")")
Button(action: {
signOutButtonTapped()
}) {
Label("Sign out", systemImage: "rectangle.portrait.and.arrow.right")
}
Button(
action: {
signOutButtonTapped()
},
label: {
Label("Sign out", systemImage: "rectangle.portrait.and.arrow.right")
}
)
} else {
Button(action: {
Task.detached {
do {
try await WebAuthSession.signIn(store: model.store)
} catch {
Log.error(error)
Button(
action: {
Task.detached {
do {
try await WebAuthSession.signIn(store: model.store)
} catch {
Log.error(error)
await MainActor.run {
self.errorHandler.handle(
ErrorAlert(
title: "Error signing in",
error: error
await MainActor.run {
self.errorHandler.handle(
ErrorAlert(
title: "Error signing in",
error: error
)
)
)
}
}
}
},
label: {
Label("Sign in", systemImage: "person.crop.circle.fill.badge.plus")
}
}) {
Label("Sign in", systemImage: "person.crop.circle.fill.badge.plus")
}
)
}
Divider()
Button(action: {
openURL(URL(string: "https://www.firezone.dev/support?utm_source=ios-client")!)
}) {
Label("Support...", systemImage: "safari")
}
Button(action: {
openURL(URL(string: "https://www.firezone.dev/kb?utm_source=ios=client")!)
}) {
Label("Documentation...", systemImage: "safari")
}
Button(
action: {
openURL(URL(string: "https://www.firezone.dev/support?utm_source=ios-client")!)
},
label: {
Label("Support...", systemImage: "safari")
}
)
Button(
action: {
openURL(URL(string: "https://www.firezone.dev/kb?utm_source=ios=client")!)
},
label: {
Label("Documentation...", systemImage: "safari")
}
)
} label: {
Image(systemName: "person.circle")
}

View File

@@ -11,14 +11,13 @@ import AppKit
import NetworkExtension
@MainActor
struct macOSAlert {
struct macOSAlert { // swiftlint:disable:this type_name
static func show(for error: Error) {
if let error = error as? OSSystemExtensionError,
// Expected in normal operation
error.code != .requestCanceled,
error.code != .requestSuperseded,
error.code != .authorizationRequired
{
error.code != .authorizationRequired {
alert(message(for: error))
}
@@ -31,7 +30,7 @@ struct macOSAlert {
let alert = NSAlert()
alert.messageText = messageText
alert.alertStyle = .critical
let _ = alert.runModal()
_ = alert.runModal()
}
// NEVPNError
@@ -87,6 +86,7 @@ struct macOSAlert {
}
// OSSystemExtensionError
// swiftlint:disable:next cyclomatic_complexity function_body_length
private static func message(for error: OSSystemExtensionError) -> String {
return {
switch error.code {
@@ -171,18 +171,18 @@ struct macOSAlert {
// Code 11
case .requestCanceled:
// This will happen if the user cancels
fallthrough
return "\(error)"
// Code 12
case .requestSuperseded:
// This will happen if the user repeatedly clicks "Enable ..."
fallthrough
return "\(error)"
// Code 13
case .authorizationRequired:
// This happens the first time we try to install the system extension.
// The user is prompted but we still get this.
fallthrough
return "\(error)"
@unknown default:
return "\(error)"

View File

@@ -1,8 +1,13 @@
import CryptoKit
//
// Adapter.swift
// (c) 2024 Firezone, Inc.
// LICENSE: Apache-2.0
//
// TODO: Refactor to fix file length
// swiftlint:disable file_length
import CryptoKit
import FirezoneKit
import Foundation
import NetworkExtension
@@ -54,9 +59,9 @@ class Adapter {
private var networkMonitor: NWPathMonitor?
/// Used to avoid path update callback cycles on iOS
#if os(iOS)
private var gateways: [Network.NWEndpoint] = []
#endif
#if os(iOS)
private var gateways: [Network.NWEndpoint] = []
#endif
#if os(macOS)
/// Used for finding system DNS resolvers on macOS when network conditions have changed.
@@ -67,7 +72,8 @@ class Adapter {
private var lastFetchedResolvers: [String] = []
/// Remembers the last _relevant_ path update.
/// A path update is considered relevant if certain properties change that require us to reset connlib's network state.
/// A path update is considered relevant if certain properties change that require us to reset connlib's
/// network state.
private var lastRelevantPath: Network.NWPath?
/// Private queue used to ensure consistent ordering among path update and connlib callbacks
@@ -140,10 +146,8 @@ class Adapter {
interfaceName: path.availableInterfaces.first?.name)
if self.lastFetchedResolvers != resolvers,
let jsonResolvers = try? String(
decoding: JSONEncoder().encode(resolvers), as: UTF8.self
).intoRustString()
{
let encoded = try? JSONEncoder().encode(resolvers),
let jsonResolvers = String(data: encoded, encoding: .utf8)?.intoRustString() {
// Update connlib DNS
session.setDns(jsonResolvers)
@@ -232,18 +236,18 @@ class Adapter {
// Grab a session pointer
let session =
try WrappedSession.connect(
apiURL,
"\(token)",
"\(id)",
"\(Telemetry.accountSlug!)",
DeviceMetadata.getDeviceName(),
DeviceMetadata.getOSVersion(),
connlibLogFolderPath,
logFilter,
callbackHandler,
String(data: jsonEncoder.encode(DeviceMetadata.deviceInfo()), encoding: .utf8)!
)
try WrappedSession.connect(
apiURL,
"\(token)",
"\(id)",
"\(Telemetry.accountSlug!)",
DeviceMetadata.getDeviceName(),
DeviceMetadata.getOSVersion(),
connlibLogFolderPath,
logFilter,
callbackHandler,
String(data: jsonEncoder.encode(DeviceMetadata.deviceInfo()), encoding: .utf8)!
)
// Start listening for network change events. The first few will be our
// tunnel interface coming up, but that's ok -- it will trigger a `set_dns`
@@ -253,9 +257,9 @@ class Adapter {
// Update state in case everything succeeded
self.state = .tunnelStarted(session: session)
} catch let error {
let msg = error as! RustString
// `toString` needed to deep copy the string and avoid a possible dangling pointer
throw AdapterError.connlibConnectError(msg.toString())
let msg = (error as? RustString)?.toString() ?? "Unknown error"
throw AdapterError.connlibConnectError(msg)
}
}
@@ -290,7 +294,7 @@ class Adapter {
// This is async to avoid blocking the main UI thread
workQueue.async { [weak self] in
guard let self = self else { return }
guard case .tunnelStarted(_) = self.state
guard case .tunnelStarted = self.state
else {
Log.debug("\(#function): Invalid state \(self.state)")
return
@@ -315,7 +319,7 @@ class Adapter {
public func setInternetResourceEnabled(_ enabled: Bool) {
workQueue.async { [weak self] in
guard let self = self else { return }
guard case .tunnelStarted(_) = self.state
guard case .tunnelStarted = self.state
else {
Log.debug("\(#function): Invalid state \(self.state)")
return
@@ -332,16 +336,20 @@ class Adapter {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
internetResource = resources().filter{ $0.isInternetResource() }.first
internetResource = resources().filter { $0.isInternetResource() }.first
var disablingResources: Set<String> = []
if let internetResource = internetResource, !internetResourceEnabled {
disablingResources.insert(internetResource.id)
}
guard let currentlyDisabled = try? JSONEncoder().encode(disablingResources),
let toSet = String(data: currentlyDisabled, encoding: .utf8)
else {
fatalError("Should be able to encode 'disablingResources'")
}
let currentlyDisabled = try! JSONEncoder().encode(disablingResources)
session.setDisabledResources(String(data: currentlyDisabled, encoding: .utf8)!)
session.setDisabledResources(toSet)
}
}
@@ -355,32 +363,36 @@ extension Adapter {
networkMonitor.start(queue: self.workQueue)
}
#if os(iOS)
private func shouldFetchSystemResolvers(path: Network.NWPath) -> Bool {
if path.gateways != gateways {
gateways = path.gateways
return true
}
return false
}
#else
private func shouldFetchSystemResolvers(path _: Network.NWPath) -> Bool {
#if os(iOS)
private func shouldFetchSystemResolvers(path: Network.NWPath) -> Bool {
if path.gateways != gateways {
gateways = path.gateways
return true
}
#endif
return false
}
#else
private func shouldFetchSystemResolvers(path _: Network.NWPath) -> Bool {
return true
}
#endif
}
// MARK: Implementing CallbackHandlerDelegate
extension Adapter: CallbackHandlerDelegate {
public func onSetInterfaceConfig(
tunnelAddressIPv4: String, tunnelAddressIPv6: String, dnsAddresses: [String], routeListv4: String, routeListv6: String
tunnelAddressIPv4: String,
tunnelAddressIPv6: String,
dnsAddresses: [String],
routeListv4: String,
routeListv6: String
) {
// This is a queued callback to ensure ordering
workQueue.async { [weak self] in
guard let self = self else { return }
guard case .tunnelStarted(_) = self.state
guard case .tunnelStarted = self.state
else {
Log.debug("\(#function): Invalid state \(self.state)")
return
@@ -392,15 +404,22 @@ extension Adapter: CallbackHandlerDelegate {
Log.log(
"\(#function): \(tunnelAddressIPv4) \(tunnelAddressIPv6) \(dnsAddresses) \(routeListv4) \(routeListv6)")
guard let data4 = routeListv4.data(using: .utf8),
let data6 = routeListv6.data(using: .utf8),
let decoded4 = try? JSONDecoder().decode([NetworkSettings.Cidr].self, from: data4),
let decoded6 = try? JSONDecoder().decode([NetworkSettings.Cidr].self, from: data6)
else {
fatalError("Could not decode route list from connlib")
}
let routes4 = decoded4.compactMap({ $0.asNEIPv4Route })
let routes6 = decoded6.compactMap({ $0.asNEIPv6Route })
networkSettings.tunnelAddressIPv4 = tunnelAddressIPv4
networkSettings.tunnelAddressIPv6 = tunnelAddressIPv6
networkSettings.dnsAddresses = dnsAddresses
networkSettings.routes4 = try! JSONDecoder().decode(
[NetworkSettings.Cidr].self, from: routeListv4.data(using: .utf8)!
).compactMap { $0.asNEIPv4Route }
networkSettings.routes6 = try! JSONDecoder().decode(
[NetworkSettings.Cidr].self, from: routeListv6.data(using: .utf8)!
).compactMap { $0.asNEIPv6Route }
networkSettings.routes4 = routes4
networkSettings.routes6 = routes6
networkSettings.apply()
}
@@ -410,7 +429,7 @@ extension Adapter: CallbackHandlerDelegate {
// This is a queued callback to ensure ordering
workQueue.async { [weak self] in
guard let self = self else { return }
guard case .tunnelStarted(_) = self.state
guard case .tunnelStarted = self.state
else {
Log.debug("Tried to call \(#function) while state is \(self.state)")
return
@@ -430,7 +449,7 @@ extension Adapter: CallbackHandlerDelegate {
// to ensure that we can clean up even if connlib exits before we are done.
workQueue.async { [weak self] in
guard let self = self else { return }
guard case .tunnelStarted(_) = self.state
guard case .tunnelStarted = self.state
else {
Log.debug("\(#function): Invalid state \(self.state)")
return
@@ -457,12 +476,12 @@ extension Adapter: CallbackHandlerDelegate {
}
private func getSystemDefaultResolvers(interfaceName: String?) -> [String] {
#if os(macOS)
let resolvers = self.systemConfigurationResolvers.getDefaultDNSServers(
interfaceName: interfaceName)
#elseif os(iOS)
let resolvers = resetToSystemDNSGettingBindResolvers()
#endif
#if os(macOS)
let resolvers = self.systemConfigurationResolvers.getDefaultDNSServers(
interfaceName: interfaceName)
#elseif os(iOS)
let resolvers = resetToSystemDNSGettingBindResolvers()
#endif
return resolvers
}
@@ -470,54 +489,54 @@ extension Adapter: CallbackHandlerDelegate {
// MARK: Getting System Resolvers on iOS
#if os(iOS)
extension Adapter {
// When the tunnel is up, we can only get the system's default resolvers
// by reading /etc/resolv.conf when matchDomains is set to a non-empty string.
// If matchDomains is an empty string, /etc/resolv.conf will contain connlib's
// sentinel, which isn't helpful to us.
private func resetToSystemDNSGettingBindResolvers() -> [String] {
guard let networkSettings = networkSettings
else {
// Network Settings hasn't been applied yet, so our sentinel isn't
// the system's resolver and we can grab the system resolvers directly.
// If we try to continue below without valid tunnel addresses assigned
// to the interface, we'll crash.
return BindResolvers().getservers().map(BindResolvers.getnameinfo)
}
var resolvers: [String] = []
// The caller is in an async context, so it's ok to block this thread here.
let semaphore = DispatchSemaphore(value: 0)
// Set tunnel's matchDomains to a dummy string that will never match any name
networkSettings.matchDomains = ["firezone-fd0020211111"]
// Call apply to populate /etc/resolv.conf with the system's default resolvers
networkSettings.apply {
guard let networkSettings = self.networkSettings else { return }
// Only now can we get the system resolvers
resolvers = BindResolvers().getservers().map(BindResolvers.getnameinfo)
// Restore connlib's DNS resolvers
networkSettings.matchDomains = [""]
networkSettings.apply { semaphore.signal() }
}
semaphore.wait()
return resolvers
extension Adapter {
// When the tunnel is up, we can only get the system's default resolvers
// by reading /etc/resolv.conf when matchDomains is set to a non-empty string.
// If matchDomains is an empty string, /etc/resolv.conf will contain connlib's
// sentinel, which isn't helpful to us.
private func resetToSystemDNSGettingBindResolvers() -> [String] {
guard let networkSettings = networkSettings
else {
// Network Settings hasn't been applied yet, so our sentinel isn't
// the system's resolver and we can grab the system resolvers directly.
// If we try to continue below without valid tunnel addresses assigned
// to the interface, we'll crash.
return BindResolvers().getservers().map(BindResolvers.getnameinfo)
}
var resolvers: [String] = []
// The caller is in an async context, so it's ok to block this thread here.
let semaphore = DispatchSemaphore(value: 0)
// Set tunnel's matchDomains to a dummy string that will never match any name
networkSettings.matchDomains = ["firezone-fd0020211111"]
// Call apply to populate /etc/resolv.conf with the system's default resolvers
networkSettings.apply {
guard let networkSettings = self.networkSettings else { return }
// Only now can we get the system resolvers
resolvers = BindResolvers().getservers().map(BindResolvers.getnameinfo)
// Restore connlib's DNS resolvers
networkSettings.matchDomains = [""]
networkSettings.apply { semaphore.signal() }
}
semaphore.wait()
return resolvers
}
}
#endif
extension Network.NWPath {
func connectivityDifferentFrom(path: Network.NWPath) -> Bool {
// We define a path as different from another if the following properties change
return path.supportsIPv4 != self.supportsIPv4 ||
path.supportsIPv6 != self.supportsIPv6 ||
path.availableInterfaces.first?.name != self.availableInterfaces.first?.name ||
// Apple provides no documentation on whether order is meaningful, so assume it isn't.
Set(self.gateways) != Set(path.gateways)
path.supportsIPv6 != self.supportsIPv6 ||
path.availableInterfaces.first?.name != self.availableInterfaces.first?.name ||
// Apple provides no documentation on whether order is meaningful, so assume it isn't.
Set(self.gateways) != Set(path.gateways)
}
}

View File

@@ -28,12 +28,12 @@ public class BindResolvers {
}
extension BindResolvers {
public static func getnameinfo(_ s: res_9_sockaddr_union) -> String {
var s = s
public static func getnameinfo(_ sock: res_9_sockaddr_union) -> String {
var sockUnion = sock
var hostBuffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
let sinlen = socklen_t(s.sin.sin_len)
let _ = withUnsafePointer(to: &s) {
let sinlen = socklen_t(sockUnion.sin.sin_len)
_ = withUnsafePointer(to: &sockUnion) {
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
Darwin.getnameinfo(
$0, sinlen,

View File

@@ -12,7 +12,7 @@ import OSLog
// shall get updated.
// This is so that the app stays buildable even when the FFI changes.
// TODO: https://github.com/chinedufn/swift-bridge/issues/150
// See https://github.com/chinedufn/swift-bridge/issues/150
extension RustString: @unchecked Sendable {}
extension RustString: Error {}
@@ -48,8 +48,11 @@ public class CallbackHandler {
IPv6 routes: \(routeListv6.toString())
""")
let dnsData = dnsAddresses.toString().data(using: .utf8)!
let dnsArray = try! JSONDecoder().decode([String].self, from: dnsData)
guard let dnsData = dnsAddresses.toString().data(using: .utf8),
let dnsArray = try? JSONDecoder().decode([String].self, from: dnsData)
else {
fatalError("Should be able to decode DNS Addresses from connlib")
}
delegate?.onSetInterfaceConfig(
tunnelAddressIPv4: tunnelAddressIPv4.toString(),

View File

@@ -39,7 +39,8 @@ class NetworkSettings {
// Set tunnel addresses and routes
let ipv4Settings = NEIPv4Settings(addresses: [tunnelAddressIPv4!], subnetMasks: ["255.255.255.255"])
// This is a hack since macos routing table ignores, for full route, any prefix smaller than 120.
// Without this, adding a full route, remove the previous default route and leaves the system with none, breaking completely IPv6 on the user's system.
// Without this, adding a full route, remove the previous default route and leaves the system with none,
// completely breaking IPv6 on the user's system.
let ipv6Settings = NEIPv6Settings(addresses: [tunnelAddressIPv6!], networkPrefixLengths: [120])
let dnsSettings = NEDNSSettings(servers: dnsAddresses)
ipv4Settings.includedRoutes = routes4
@@ -96,7 +97,7 @@ enum IPv4SubnetMaskLookup {
29: "255.255.255.248",
30: "255.255.255.252",
31: "255.255.255.254",
32: "255.255.255.255",
32: "255.255.255.255"
]
}

View File

@@ -31,6 +31,8 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
super.init()
}
// TODO: Refactor this to shorten function body
// swiftlint:disable:next function_body_length
override func startTunnel(
options: [String: NSObject]?,
completionHandler: @escaping (Error?) -> Void
@@ -84,7 +86,9 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
Telemetry.accountSlug = accountSlug
let internetResourceEnabled: Bool = if let internetResourceEnabledJSON = providerConfiguration[VPNConfigurationManagerKeys.internetResourceEnabled]?.data(using: .utf8) {
let internetResourceEnabled: Bool =
if let internetResourceEnabledJSON = providerConfiguration[
VPNConfigurationManagerKeys.internetResourceEnabled]?.data(using: .utf8) {
(try? JSONDecoder().decode(Bool.self, from: internetResourceEnabledJSON )) ?? false
} else {
false
@@ -147,10 +151,10 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
)
}
}
#if os(iOS)
// iOS notifications should be shown from the tunnel process
SessionNotification.showSignedOutNotificationiOS()
#endif
#if os(iOS)
// iOS notifications should be shown from the tunnel process
SessionNotification.showSignedOutNotificationiOS()
#endif
}
// handles both connlib-initiated and user-initiated stops
@@ -161,7 +165,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
completionHandler()
}
// TODO: It would be helpful to be able to encapsulate Errors here. To do that
// It would be helpful to be able to encapsulate Errors here. To do that
// we need to update TunnelMessage to encode/decode Result to and from Data.
override func handleAppMessage(_ message: Data, completionHandler: ((Data?) -> Void)? = nil) {
guard let tunnelMessage = try? PropertyListDecoder().decode(TunnelMessage.self, from: message) else { return }
@@ -176,8 +180,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
Log.error(error)
}
case .getResourceList(let value):
adapter?.getResourcesIfVersionDifferentFrom(hash: value) {
resourceListJSON in
adapter?.getResourcesIfVersionDifferentFrom(hash: value) { resourceListJSON in
completionHandler?(resourceListJSON?.data(using: .utf8))
}
case .clearLogs:
@@ -208,7 +211,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
func loadAndSaveFirezoneId(from options: [String: NSObject]?) -> String {
let passedId = options?["id"] as? String
let persistedId = FirezoneId.load(.Post_1_4_0)
let persistedId = FirezoneId.load(.post140)
let id = passedId ?? persistedId ?? UUID().uuidString

View File

@@ -31,20 +31,18 @@ class SystemConfigurationResolvers {
/// can fail in some circumstances to initialize, like because of allocation failures.
private var _dynamicStore: SCDynamicStore?
private var dynamicStore: SCDynamicStore? {
get {
if self._dynamicStore == nil {
guard let dynamicStore = SCDynamicStoreCreate(nil, storeName, nil, nil)
else {
let code = SCError()
Log.error(SystemConfigurationError.failedToCreateDynamicStore(code: code))
return nil
}
self._dynamicStore = dynamicStore
if self._dynamicStore == nil {
guard let dynamicStore = SCDynamicStoreCreate(nil, storeName, nil, nil)
else {
let code = SCError()
Log.error(SystemConfigurationError.failedToCreateDynamicStore(code: code))
return nil
}
return self._dynamicStore
self._dynamicStore = dynamicStore
}
return self._dynamicStore
}
// Arbitrary name for the connection to the store

View File

@@ -1,17 +0,0 @@
#!/bin/bash
##################################################
# We call this from an Xcode run script.
##################################################
set -e
if [[ "$(uname -m)" == arm64 ]]; then
export PATH="/opt/homebrew/bin:$PATH"
fi
if which swift-format >/dev/null; then
find . -name "*.swift" -not -path "./FirezoneNetworkExtension/Connlib/Generated/*" -exec xargs swift-format lint --strict \;
else
echo "warning: swift-format not installed, install with 'brew install swift-format'"
fi