mirror of
https://github.com/outbackdingo/firezone.git
synced 2026-01-27 02:18:47 +00:00
feat(apple): Add Sentry reporting to Swift codebase (#7620)
Adds Sentry SDK to the Swift codebase. Currently error captures are implemented and happen during `Log.error` calls. Fixes #7560
This commit is contained in:
46
.github/workflows/sentry.yml
vendored
46
.github/workflows/sentry.yml
vendored
@@ -10,41 +10,27 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
create_gateway_sentry_release:
|
||||
if: ${{ startsWith(github.event.release.name, 'gateway') }}
|
||||
create_sentry_release:
|
||||
name: create_${{ matrix.component }}_sentry_release
|
||||
runs-on: ubuntu-22.04
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- component: gateway
|
||||
projects: gateway
|
||||
- component: gui-client
|
||||
projects: gui-client-gui gui-client-ipc-service
|
||||
- component: headless-client
|
||||
projects: headless-client
|
||||
- component: macos-client
|
||||
projects: apple-client
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: ./.github/actions/create-sentry-release
|
||||
if: ${{ startsWith(github.event.release.name, matrix.component) }}
|
||||
with:
|
||||
component: gateway
|
||||
projects: gateway
|
||||
sentry_token: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
|
||||
create_gui-client_sentry_release:
|
||||
if: ${{ startsWith(github.event.release.name, 'gui-client') }}
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: ./.github/actions/create-sentry-release
|
||||
with:
|
||||
component: gui-client
|
||||
projects: gui-client-gui gui-client-ipc-service
|
||||
sentry_token: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
|
||||
create_headless-client_sentry_release:
|
||||
if: ${{ startsWith(github.event.release.name, 'headless-client') }}
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: ./.github/actions/create-sentry-release
|
||||
with:
|
||||
component: headless-client
|
||||
projects: headless-client
|
||||
component: ${{ matrix.component }}
|
||||
projects: ${{ matrix.projects }}
|
||||
sentry_token: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
6FE93AFB2A738D7E002D278A /* NetworkSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FE93AFA2A738D7E002D278A /* NetworkSettings.swift */; };
|
||||
794C38152970A2660029F38F /* FirezoneKit in Frameworks */ = {isa = PBXBuildFile; productRef = 794C38142970A2660029F38F /* FirezoneKit */; };
|
||||
79756C6629704A7A0018E2D5 /* FirezoneKit in Frameworks */ = {isa = PBXBuildFile; productRef = 79756C6529704A7A0018E2D5 /* FirezoneKit */; };
|
||||
8D4087D52D24653B005B2BAF /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = 8D4087D42D24653B005B2BAF /* Sentry */; };
|
||||
8D4087D92D246541005B2BAF /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = 8D4087D82D246541005B2BAF /* Sentry */; };
|
||||
8D4087DD2D246651005B2BAF /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = 8D4087DC2D246651005B2BAF /* Sentry */; };
|
||||
8D41B9A52D15DD6800D16065 /* TunnelLogArchive.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D41B9A42D15DD6800D16065 /* TunnelLogArchive.swift */; };
|
||||
8D41B9A62D15DD6800D16065 /* TunnelLogArchive.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D41B9A42D15DD6800D16065 /* TunnelLogArchive.swift */; };
|
||||
8D5047F52CE6AA1E009802E9 /* libresolv.9.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 8D5047F32CE6AA1E009802E9 /* libresolv.9.tbd */; };
|
||||
@@ -129,6 +132,7 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
8D4087D52D24653B005B2BAF /* Sentry in Frameworks */,
|
||||
8DC08BD42B297B8200675F46 /* libresolv.tbd in Frameworks */,
|
||||
8DC08BCB2B296C4500675F46 /* libresolv.9.tbd in Frameworks */,
|
||||
794C38152970A2660029F38F /* FirezoneKit in Frameworks */,
|
||||
@@ -145,6 +149,7 @@
|
||||
8D5047F82CE6AA22009802E9 /* FirezoneKit in Frameworks */,
|
||||
8DC9FB852CF5A738001BCE6A /* NetworkExtension.framework in Frameworks */,
|
||||
8D5047FA2CE6AA2E009802E9 /* SystemConfiguration.framework in Frameworks */,
|
||||
8D4087D92D246541005B2BAF /* Sentry in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -154,6 +159,7 @@
|
||||
files = (
|
||||
05D3BB2128FDE9C000BC3727 /* NetworkExtension.framework in Frameworks */,
|
||||
79756C6629704A7A0018E2D5 /* FirezoneKit in Frameworks */,
|
||||
8D4087DD2D246651005B2BAF /* Sentry in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -276,6 +282,7 @@
|
||||
name = FirezoneNetworkExtensioniOS;
|
||||
packageProductDependencies = (
|
||||
794C38142970A2660029F38F /* FirezoneKit */,
|
||||
8D4087D42D24653B005B2BAF /* Sentry */,
|
||||
);
|
||||
productName = FirezoneNetworkExtensioniOS;
|
||||
productReference = 05CF1CF0290B1CEE00CF4755 /* dev.firezone.firezone.network-extension.appex */;
|
||||
@@ -298,6 +305,7 @@
|
||||
name = FirezoneNetworkExtensionmacOS;
|
||||
packageProductDependencies = (
|
||||
8D5047F72CE6AA22009802E9 /* FirezoneKit */,
|
||||
8D4087D82D246541005B2BAF /* Sentry */,
|
||||
);
|
||||
productName = FirezoneNetworkExtensionStandalonemacOS;
|
||||
productReference = 8D5047E32CE6A8F4009802E9 /* dev.firezone.firezone.network-extension.systemextension */;
|
||||
@@ -323,6 +331,7 @@
|
||||
name = Firezone;
|
||||
packageProductDependencies = (
|
||||
79756C6529704A7A0018E2D5 /* FirezoneKit */,
|
||||
8D4087DC2D246651005B2BAF /* Sentry */,
|
||||
);
|
||||
productName = Firezone;
|
||||
productReference = 8DCC021928D512AC007E12D2 /* Firezone.app */;
|
||||
@@ -361,6 +370,7 @@
|
||||
);
|
||||
mainGroup = 8DCC021028D512AC007E12D2;
|
||||
packageReferences = (
|
||||
8D4087C72D2464D6005B2BAF /* XCRemoteSwiftPackageReference "sentry-cocoa" */,
|
||||
);
|
||||
productRefGroup = 8DCC021A28D512AC007E12D2 /* Products */;
|
||||
projectDirPath = "";
|
||||
@@ -986,6 +996,17 @@
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
8D4087C72D2464D6005B2BAF /* XCRemoteSwiftPackageReference "sentry-cocoa" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/getsentry/sentry-cocoa";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 8.42.1;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
794C38142970A2660029F38F /* FirezoneKit */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
@@ -995,6 +1016,21 @@
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = FirezoneKit;
|
||||
};
|
||||
8D4087D42D24653B005B2BAF /* Sentry */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 8D4087C72D2464D6005B2BAF /* XCRemoteSwiftPackageReference "sentry-cocoa" */;
|
||||
productName = Sentry;
|
||||
};
|
||||
8D4087D82D246541005B2BAF /* Sentry */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 8D4087C72D2464D6005B2BAF /* XCRemoteSwiftPackageReference "sentry-cocoa" */;
|
||||
productName = Sentry;
|
||||
};
|
||||
8D4087DC2D246651005B2BAF /* Sentry */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 8D4087C72D2464D6005B2BAF /* XCRemoteSwiftPackageReference "sentry-cocoa" */;
|
||||
productName = Sentry;
|
||||
};
|
||||
8D5047F72CE6AA22009802E9 /* FirezoneKit */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = FirezoneKit;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "sentry-cocoa",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/getsentry/sentry-cocoa",
|
||||
"state" : {
|
||||
"revision" : "c1404acfa3a71e42aa789c6d5564da59d4377569",
|
||||
"version" : "8.42.1"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 2
|
||||
}
|
||||
@@ -18,6 +18,9 @@ struct FirezoneApp: App {
|
||||
@StateObject var store: Store
|
||||
|
||||
init() {
|
||||
// Initialize Telemetry as early as possible
|
||||
Telemetry.start()
|
||||
|
||||
let favorites = Favorites()
|
||||
let store = Store()
|
||||
_favorites = StateObject(wrappedValue: favorites)
|
||||
@@ -80,7 +83,10 @@ struct FirezoneApp: App {
|
||||
// Can be removed once all clients >= 1.4.0
|
||||
try await FirezoneId.migrate()
|
||||
|
||||
try await FirezoneId.createIfMissing()
|
||||
let id = try await FirezoneId.createIfMissing()
|
||||
|
||||
// Hydrate telemetry userId with our firezone id
|
||||
Telemetry.setFirezoneId(id.uuid.uuidString)
|
||||
}
|
||||
|
||||
if let store = store {
|
||||
|
||||
@@ -50,9 +50,10 @@ public final class Log {
|
||||
logWriter?.write(severity: .warning, message: message)
|
||||
}
|
||||
|
||||
public static func error(_ message: String) {
|
||||
self.logger.error("\(message, privacy: .public)")
|
||||
logWriter?.write(severity: .error, message: message)
|
||||
public static func error(_ err: Error) {
|
||||
self.logger.error("\(err.localizedDescription, privacy: .public)")
|
||||
logWriter?.write(severity: .error, message: err.localizedDescription)
|
||||
Telemetry.capture(err)
|
||||
}
|
||||
|
||||
// Returns the size in bytes of the provided directory, calculated by summing
|
||||
|
||||
@@ -7,6 +7,17 @@
|
||||
import Foundation
|
||||
|
||||
public struct SharedAccess {
|
||||
public enum Error: Swift.Error {
|
||||
case unableToWriteToFile(URL, Swift.Error)
|
||||
|
||||
var localizedDescription: String {
|
||||
switch self {
|
||||
case .unableToWriteToFile(let url, let error):
|
||||
return "Unable to write to \(url): \(error)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static var baseFolderURL: URL {
|
||||
guard
|
||||
let url = FileManager.default.containerURL(
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
//
|
||||
// Telemetry.swift
|
||||
// (c) 2024 Firezone, Inc.
|
||||
// LICENSE: Apache-2.0
|
||||
//
|
||||
|
||||
import Sentry
|
||||
|
||||
public enum Telemetry {
|
||||
// We can only create a new User object after Sentry is started; not retrieve
|
||||
// the existing one. So we need to collect these fields from various codepaths
|
||||
// during initialization / sign in so we can build a new User object any time
|
||||
// one of these is updated.
|
||||
private static var userId: String?
|
||||
private static var accountSlug: String?
|
||||
|
||||
public static func start() {
|
||||
SentrySDK.start { options in
|
||||
options.dsn = "https://66c71f83675f01abfffa8eb977bcbbf7@o4507971108339712.ingest.us.sentry.io/4508175177023488"
|
||||
options.environment = "entrypoint" // will be reconfigured in TunnelManager
|
||||
options.releaseName = releaseName()
|
||||
|
||||
#if DEBUG
|
||||
// https://docs.sentry.io/platforms/apple/guides/ios/configuration/options/#debug
|
||||
options.debug = true
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
public static func setEnvironmentOrClose(_ apiURLString: String) {
|
||||
var environment: String?
|
||||
|
||||
if apiURLString.starts(with: "wss://api.firezone.dev") {
|
||||
environment = "production"
|
||||
} else if apiURLString.starts(with: "wss://api.firez.one") {
|
||||
environment = "staging"
|
||||
}
|
||||
|
||||
guard let environment
|
||||
else {
|
||||
// Disable Sentry in unknown environments
|
||||
SentrySDK.close()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
SentrySDK.configureScope { configuration in
|
||||
configuration.setEnvironment(environment)
|
||||
}
|
||||
}
|
||||
|
||||
public static func capture(_ err: Error) {
|
||||
SentrySDK.capture(error: err)
|
||||
}
|
||||
|
||||
public static func setFirezoneId(_ id: String?) {
|
||||
self.userId = id
|
||||
updateUser()
|
||||
}
|
||||
|
||||
public static func setAccountSlug(_ slug: String?) {
|
||||
self.accountSlug = slug
|
||||
updateUser()
|
||||
}
|
||||
|
||||
private static func updateUser() {
|
||||
guard let userId,
|
||||
let accountSlug
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
SentrySDK.configureScope { configuration in
|
||||
// Matches the format we use in rust/telemetry/lib.rs
|
||||
let user = User(userId: userId)
|
||||
user.data = ["account_slug": accountSlug]
|
||||
|
||||
configuration.setUser(user)
|
||||
}
|
||||
}
|
||||
|
||||
private static func releaseName() -> String {
|
||||
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"]
|
||||
as? String ?? "unknown"
|
||||
|
||||
#if os(iOS)
|
||||
return "ios-appstore-\(version)"
|
||||
#else
|
||||
// Apps from the app store have a receipt file
|
||||
if let receiptURL = Bundle.main.appStoreReceiptURL,
|
||||
FileManager.default.fileExists(atPath: receiptURL.path) {
|
||||
return "macos-appstore-\(version)"
|
||||
}
|
||||
|
||||
return "macos-client-\(version)"
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -13,13 +13,29 @@ import NetworkExtension
|
||||
|
||||
enum TunnelManagerError: Error {
|
||||
case cannotSaveIfMissing
|
||||
case cannotLoad
|
||||
case decodeIPCDataFailed
|
||||
case invalidStatusChange
|
||||
|
||||
var localizedDescription: String {
|
||||
switch self {
|
||||
case .cannotSaveIfMissing:
|
||||
return "Manager doesn't seem initialized. Can't save settings."
|
||||
case .decodeIPCDataFailed:
|
||||
return "Decoding IPC data failed."
|
||||
case .invalidStatusChange:
|
||||
return "NEVPNStatusDidChange notification doesn't seem to be valid."
|
||||
case .cannotLoad:
|
||||
return "Could not load VPN configurations!"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum TunnelManagerKeys {
|
||||
static let actorName = "actorName"
|
||||
static let authBaseURL = "authBaseURL"
|
||||
static let apiURL = "apiURL"
|
||||
public static let accountSlug = "accountSlug"
|
||||
public static let logFilter = "logFilter"
|
||||
public static let internetResourceEnabled = "internetResourceEnabled"
|
||||
}
|
||||
@@ -152,7 +168,7 @@ public class TunnelManager {
|
||||
// Since our bundle ID can change (by us), find the one that's current and ignore the others.
|
||||
guard let managers = try? await NETunnelProviderManager.loadAllFromPreferences()
|
||||
else {
|
||||
Log.error("\(#function): Could not load VPN configurations!")
|
||||
Log.error(TunnelManagerError.cannotLoad)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -172,6 +188,12 @@ public class TunnelManager {
|
||||
}
|
||||
let status = manager.connection.status
|
||||
|
||||
// Configure our Telemetry environment
|
||||
Telemetry.setEnvironmentOrClose(settings.apiURL)
|
||||
Telemetry.setAccountSlug(
|
||||
providerConfiguration[TunnelManagerKeys.accountSlug]
|
||||
)
|
||||
|
||||
// Share what we found with our caller
|
||||
callback(status, settings, actorName)
|
||||
|
||||
@@ -194,16 +216,16 @@ public class TunnelManager {
|
||||
}
|
||||
}
|
||||
|
||||
func saveActorName(_ actorName: String?) async throws {
|
||||
func saveAuthResponse(_ authResponse: AuthResponse) async throws {
|
||||
guard let manager = manager,
|
||||
let protocolConfiguration = manager.protocolConfiguration as? NETunnelProviderProtocol,
|
||||
var providerConfiguration = protocolConfiguration.providerConfiguration
|
||||
else {
|
||||
Log.error("Manager doesn't seem initialized. Can't save settings.")
|
||||
throw TunnelManagerError.cannotSaveIfMissing
|
||||
}
|
||||
|
||||
providerConfiguration[TunnelManagerKeys.actorName] = actorName
|
||||
providerConfiguration[TunnelManagerKeys.actorName] = authResponse.actorName
|
||||
providerConfiguration[TunnelManagerKeys.accountSlug] = authResponse.accountSlug
|
||||
protocolConfiguration.providerConfiguration = providerConfiguration
|
||||
manager.protocolConfiguration = protocolConfiguration
|
||||
|
||||
@@ -220,7 +242,6 @@ public class TunnelManager {
|
||||
let protocolConfiguration = manager.protocolConfiguration as? NETunnelProviderProtocol,
|
||||
let providerConfiguration = protocolConfiguration.providerConfiguration as? [String: String]
|
||||
else {
|
||||
Log.error("Manager doesn't seem initialized. Can't save settings.")
|
||||
throw TunnelManagerError.cannotSaveIfMissing
|
||||
}
|
||||
|
||||
@@ -238,6 +259,9 @@ public class TunnelManager {
|
||||
|
||||
try await manager.saveToPreferences()
|
||||
try await manager.loadFromPreferences()
|
||||
|
||||
// Reconfigure our Telemetry environment in case it changed
|
||||
Telemetry.setEnvironmentOrClose(settings.apiURL)
|
||||
}
|
||||
|
||||
func start(token: String? = nil) {
|
||||
@@ -250,7 +274,7 @@ public class TunnelManager {
|
||||
do {
|
||||
try session()?.startTunnel(options: options)
|
||||
} catch {
|
||||
Log.error("Error starting tunnel: \(error)")
|
||||
Log.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,7 +285,7 @@ public class TunnelManager {
|
||||
self.session()?.stopTunnel()
|
||||
}
|
||||
} catch {
|
||||
Log.error("\(#function): \(error)")
|
||||
Log.error(error)
|
||||
}
|
||||
} else {
|
||||
session()?.stopTunnel()
|
||||
@@ -294,7 +318,7 @@ public class TunnelManager {
|
||||
callback(self.resourcesListCache)
|
||||
}
|
||||
} catch {
|
||||
Log.error("Error: sendProviderMessage: \(error)")
|
||||
Log.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,7 +374,6 @@ public class TunnelManager {
|
||||
) { data in
|
||||
guard let data = data
|
||||
else {
|
||||
Log.error("Error: \(#function): No data received")
|
||||
errorHandler(TunnelManagerError.decodeIPCDataFailed)
|
||||
|
||||
return
|
||||
@@ -360,7 +383,6 @@ public class TunnelManager {
|
||||
LogChunk.self, from: data
|
||||
)
|
||||
else {
|
||||
Log.error("Error: \(#function): Invalid data received")
|
||||
errorHandler(TunnelManagerError.decodeIPCDataFailed)
|
||||
|
||||
return
|
||||
@@ -374,7 +396,7 @@ public class TunnelManager {
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Log.error("Error: \(#function): \(error)")
|
||||
Log.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -434,7 +456,7 @@ public class TunnelManager {
|
||||
) {
|
||||
guard let session = notification.object as? NETunnelProviderSession
|
||||
else {
|
||||
Log.error("\(#function): NEVPNStatusDidChange notification doesn't seem to be valid")
|
||||
Log.error(TunnelManagerError.invalidStatusChange)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -36,25 +36,23 @@ struct AuthClient {
|
||||
|
||||
func response(url: URL?) throws -> AuthResponse {
|
||||
guard let url = url,
|
||||
let returnedState = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems?.first(where: { $0.name == "state" })?.value,
|
||||
let urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
||||
let returnedState = urlComponents.sanitizedQueryParam("state"),
|
||||
areStringsEqualConstantTime(state, returnedState),
|
||||
let fragment = URLComponents(url: url, resolvingAgainstBaseURL: false)?
|
||||
.queryItems?
|
||||
.first(where: { $0.name == "fragment" })?
|
||||
.value,
|
||||
let actorName = URLComponents(url: url, resolvingAgainstBaseURL: false)?
|
||||
.queryItems?
|
||||
.first(where: { $0.name == "actor_name" })?
|
||||
.value?
|
||||
.removingPercentEncoding?
|
||||
.replacingOccurrences(of: "+", with: " ")
|
||||
let fragment = urlComponents.sanitizedQueryParam("fragment"),
|
||||
let actorName = urlComponents.sanitizedQueryParam("actor_name"),
|
||||
let accountSlug = urlComponents.sanitizedQueryParam("account_slug")
|
||||
else {
|
||||
throw AuthClientError.invalidCallbackURL
|
||||
}
|
||||
|
||||
let token = nonce + fragment
|
||||
|
||||
return AuthResponse(token: token, actorName: actorName)
|
||||
return AuthResponse(
|
||||
actorName: actorName,
|
||||
accountSlug: accountSlug,
|
||||
token: token
|
||||
)
|
||||
}
|
||||
|
||||
private static func createRandomHexString(byteCount: Int) throws -> String {
|
||||
@@ -101,3 +99,11 @@ extension URL {
|
||||
return components.url ?? self
|
||||
}
|
||||
}
|
||||
|
||||
extension URLComponents {
|
||||
func sanitizedQueryParam(_ queryParam: String) -> String? {
|
||||
let value = self.queryItems?.first(where: { $0.name == queryParam })?.value
|
||||
|
||||
return value?.removingPercentEncoding?.replacingOccurrences(of: "+", with: " ")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,11 +10,9 @@ struct AuthResponse {
|
||||
// The user associated with this authResponse.
|
||||
let actorName: String
|
||||
|
||||
// The account slug of the account the user signed in to.
|
||||
let accountSlug: String
|
||||
|
||||
// The opaque auth token
|
||||
let token: String
|
||||
|
||||
init(token: String, actorName: String) {
|
||||
self.actorName = actorName
|
||||
self.token = token
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ public struct FirezoneId {
|
||||
kSecAttrDescription: "Firezone device id",
|
||||
]
|
||||
|
||||
private var uuid: UUID
|
||||
public var uuid: UUID
|
||||
|
||||
public init(_ uuid: UUID? = nil) {
|
||||
self.uuid = uuid ?? UUID()
|
||||
@@ -91,12 +91,17 @@ public struct FirezoneId {
|
||||
try await firezoneId.save()
|
||||
}
|
||||
|
||||
public static func createIfMissing() async throws {
|
||||
guard try await load() == nil
|
||||
else { return } // New firezone-id already saved in Keychain
|
||||
public static func createIfMissing() async throws -> FirezoneId {
|
||||
guard let id = try await load()
|
||||
else {
|
||||
let id = FirezoneId(UUID())
|
||||
try await id.save()
|
||||
|
||||
let firezoneId = FirezoneId(UUID())
|
||||
try await firezoneId.save()
|
||||
return id
|
||||
}
|
||||
|
||||
// New firezone-id already saved in Keychain
|
||||
return id
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -97,9 +97,9 @@ public class SessionNotification: NSObject {
|
||||
)
|
||||
UNUserNotificationCenter.current().add(request) { error in
|
||||
if let error = error {
|
||||
Log.error("\(#function): Error requesting notification: \(error)")
|
||||
Log.error(error)
|
||||
} else {
|
||||
Log.error("\(#function): Successfully requested notification")
|
||||
Log.debug("\(#function): Successfully requested notification")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,13 @@ struct WebAuthSession {
|
||||
return
|
||||
}
|
||||
|
||||
Task { try await store.signIn(authResponse: authResponse) }
|
||||
Task {
|
||||
do {
|
||||
try await store.signIn(authResponse: authResponse)
|
||||
} catch {
|
||||
Log.error(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apple weirdness, doesn't seem to be actually used in macOS
|
||||
|
||||
@@ -126,7 +126,7 @@ public final class Store: ObservableObject {
|
||||
DispatchQueue.main.async { self.actorName = authResponse.actorName }
|
||||
|
||||
try await TunnelManager.shared.saveSettings(settings)
|
||||
try await TunnelManager.shared.saveActorName(authResponse.actorName)
|
||||
try await TunnelManager.shared.saveAuthResponse(authResponse)
|
||||
|
||||
// Bring the tunnel up and send it a token to start
|
||||
TunnelManager.shared.start(token: authResponse.token)
|
||||
@@ -163,7 +163,7 @@ public final class Store: ObservableObject {
|
||||
try await TunnelManager.shared.saveSettings(newSettings)
|
||||
DispatchQueue.main.async { self.settings = newSettings }
|
||||
} catch {
|
||||
Log.error("\(#function): \(error)")
|
||||
Log.error(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ final class GrantVPNViewModel: ObservableObject {
|
||||
do {
|
||||
try await store.createVPNProfile()
|
||||
} catch {
|
||||
Log.error("\(#function): \(error)")
|
||||
Log.error(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,7 +293,7 @@ public final class MenuBar: NSObject, ObservableObject {
|
||||
do {
|
||||
try await model.store.createVPNProfile()
|
||||
} catch {
|
||||
Log.error("\(#function): \(error)")
|
||||
Log.error(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,13 @@ import SwiftUI
|
||||
|
||||
enum SettingsViewError: Error {
|
||||
case logFolderIsUnavailable
|
||||
|
||||
var localizedDescription: String {
|
||||
switch self {
|
||||
case .logFolderIsUnavailable:
|
||||
return "Log folder is unavailable."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -47,7 +54,7 @@ public final class SettingsViewModel: ObservableObject {
|
||||
do {
|
||||
try await store.save(settings)
|
||||
} catch {
|
||||
Log.error("Error saving settings to tunnel store: \(error)")
|
||||
Log.error(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -67,8 +74,6 @@ public final class SettingsViewModel: ObservableObject {
|
||||
Log.log("\(#function)")
|
||||
|
||||
guard let logFilesFolderURL = SharedAccess.logFolderURL else {
|
||||
Log.error("\(#function): Log folder is unavailable")
|
||||
|
||||
return "Unknown"
|
||||
}
|
||||
|
||||
@@ -90,7 +95,7 @@ public final class SettingsViewModel: ObservableObject {
|
||||
return byteCountFormatter.string(fromByteCount: Int64(totalSize))
|
||||
|
||||
} catch {
|
||||
Log.error("\(#function): \(error)")
|
||||
Log.error(error)
|
||||
|
||||
return "Unknown"
|
||||
}
|
||||
@@ -111,6 +116,17 @@ public final class SettingsViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
extension FileManager {
|
||||
enum FileManagerError: Error {
|
||||
case invalidURL(URL, Error)
|
||||
|
||||
var localizedDescription: String {
|
||||
switch self {
|
||||
case .invalidURL(let url, let error):
|
||||
return "Unable to get resource value for '\(url)': \(error)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func forEachFileUnder(
|
||||
_ dirURL: URL,
|
||||
including resourceKeys: Set<URLResourceKey>,
|
||||
@@ -135,7 +151,7 @@ extension FileManager {
|
||||
let resourceValues = try url.resourceValues(forKeys: resourceKeys)
|
||||
handler(url, resourceValues)
|
||||
} catch {
|
||||
Log.error("Unable to get resource value for '\(url)': \(error)")
|
||||
Log.error(FileManagerError.invalidURL(url, error))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -598,7 +614,7 @@ public struct SettingsView: View {
|
||||
window.contentViewController?.presentingViewController?.dismiss(self)
|
||||
}
|
||||
} catch {
|
||||
Log.error("\(#function): \(error)")
|
||||
Log.error(error)
|
||||
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Error exporting logs: \(error.localizedDescription)"
|
||||
|
||||
@@ -11,6 +11,17 @@ import UserNotifications
|
||||
import Cocoa
|
||||
|
||||
class UpdateChecker {
|
||||
enum UpdateError: Error {
|
||||
case invalidVersion(String)
|
||||
|
||||
var localizedDescription: String {
|
||||
switch self {
|
||||
case .invalidVersion(let version):
|
||||
return "Invalid version: \(version)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var timer: Timer?
|
||||
private let notificationAdapter: NotificationAdapter = NotificationAdapter()
|
||||
private let versionCheckUrl: URL = URL(string: "https://www.firezone.dev/api/releases")!
|
||||
@@ -36,12 +47,13 @@ class UpdateChecker {
|
||||
guard let self = self else { return }
|
||||
|
||||
if let error = error {
|
||||
Log.error("Error fetching version manifest: \(error)")
|
||||
Log.error(error)
|
||||
return
|
||||
}
|
||||
|
||||
guard let versionInfo = VersionInfo.from(data: data) else {
|
||||
Log.error("No data or failed to decode data")
|
||||
let attemptedVersion = String(data: data ?? Data(), encoding: .utf8) ?? ""
|
||||
Log.error(UpdateError.invalidVersion(attemptedVersion))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -89,9 +101,9 @@ private class NotificationAdapter: NSObject, UNUserNotificationCenterDelegate {
|
||||
|
||||
notificationCenter.delegate = self
|
||||
notificationCenter.requestAuthorization(options: [.sound, .badge, .alert]) { _, error in
|
||||
if let error = error {
|
||||
Log.error("Failed to request authorization for notifications: \(error)")
|
||||
}
|
||||
if let error = error {
|
||||
Log.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -114,7 +126,7 @@ private class NotificationAdapter: NSObject, UNUserNotificationCenterDelegate {
|
||||
|
||||
UNUserNotificationCenter.current().add(request) { error in
|
||||
if let error = error {
|
||||
Log.error("\(#function): Error requesting notification: \(error)")
|
||||
Log.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,16 +8,25 @@ import Foundation
|
||||
import NetworkExtension
|
||||
import OSLog
|
||||
|
||||
public enum AdapterError: Error {
|
||||
enum AdapterError: Error {
|
||||
/// Failure to perform an operation in such state.
|
||||
case invalidState
|
||||
case invalidState(AdapterState)
|
||||
|
||||
/// connlib failed to start
|
||||
case connlibConnectError(String)
|
||||
|
||||
var localizedDescription: String {
|
||||
switch self {
|
||||
case .invalidState(let state):
|
||||
return "Adapter is in an invalid state: \(state)"
|
||||
case .connlibConnectError(let error):
|
||||
return "connlib failed to start: \(error)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Enum representing internal state of the adapter
|
||||
private enum AdapterState: CustomStringConvertible {
|
||||
enum AdapterState: CustomStringConvertible {
|
||||
case tunnelStarted(session: WrappedSession)
|
||||
case tunnelStopped
|
||||
|
||||
@@ -118,15 +127,11 @@ class Adapter {
|
||||
public func start() async throws {
|
||||
Log.log("Adapter.start")
|
||||
guard case .tunnelStopped = self.state else {
|
||||
throw AdapterError.invalidState
|
||||
throw AdapterError.invalidState(self.state)
|
||||
}
|
||||
|
||||
callbackHandler.delegate = self
|
||||
|
||||
if connlibLogFolderPath.isEmpty {
|
||||
Log.error("Cannot get shared log folder for connlib")
|
||||
}
|
||||
|
||||
Log.log("Adapter.start: Starting connlib")
|
||||
do {
|
||||
let jsonEncoder = JSONEncoder()
|
||||
@@ -377,8 +382,7 @@ extension Adapter: CallbackHandlerDelegate {
|
||||
networkSettings.dnsAddresses = dnsAddresses
|
||||
networkSettings.apply()
|
||||
case .tunnelStopped:
|
||||
Log.error(
|
||||
"\(#function): Unexpected state: \(self.state)")
|
||||
Log.error(AdapterError.invalidState(self.state))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,9 +53,7 @@ class NetworkSettings {
|
||||
|
||||
packetTunnelProvider?.setTunnelNetworkSettings(tunnelNetworkSettings) { error in
|
||||
if let error = error {
|
||||
Log.error(
|
||||
"\(#function): Error occurred while applying network settings! Error: \(error.localizedDescription)"
|
||||
)
|
||||
Log.error(error)
|
||||
}
|
||||
|
||||
completionHandler?()
|
||||
|
||||
@@ -24,6 +24,13 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
|
||||
|
||||
private var logExportState: LogExportState = .idle
|
||||
|
||||
override init() {
|
||||
// Initialize Telemetry as early as possible
|
||||
Telemetry.start()
|
||||
|
||||
super.init()
|
||||
}
|
||||
|
||||
override func startTunnel(
|
||||
options: [String: NSObject]?,
|
||||
completionHandler: @escaping (Error?) -> Void
|
||||
@@ -38,7 +45,10 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
|
||||
|
||||
// The tunnel can come up without the app having been launched first, so
|
||||
// initialize the id here too.
|
||||
try await FirezoneId.createIfMissing()
|
||||
let id = try await FirezoneId.createIfMissing()
|
||||
|
||||
// Hydrate the telemetry userId with our firezone id
|
||||
Telemetry.setFirezoneId(id.uuid.uuidString)
|
||||
|
||||
let passedToken = options?["token"] as? String
|
||||
let keychainToken = try await Token.load()
|
||||
@@ -62,6 +72,9 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
|
||||
return
|
||||
}
|
||||
|
||||
// Reconfigure our Telemetry environment now that we know the API URL
|
||||
Telemetry.setEnvironmentOrClose(apiURL)
|
||||
|
||||
guard
|
||||
let providerConfiguration = (protocolConfiguration as? NETunnelProviderProtocol)?
|
||||
.providerConfiguration as? [String: String],
|
||||
@@ -73,6 +86,11 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
|
||||
return
|
||||
}
|
||||
|
||||
// Hydrate telemetry account slug
|
||||
Telemetry.setAccountSlug(
|
||||
providerConfiguration[TunnelManagerKeys.accountSlug]
|
||||
)
|
||||
|
||||
let internetResourceEnabled: Bool = if let internetResourceEnabledJSON = providerConfiguration[TunnelManagerKeys.internetResourceEnabled]?.data(using: .utf8) {
|
||||
(try? JSONDecoder().decode(Bool.self, from: internetResourceEnabledJSON )) ?? false
|
||||
} else {
|
||||
@@ -90,7 +108,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
|
||||
// `connected`.
|
||||
completionHandler(nil)
|
||||
} catch {
|
||||
Log.error("\(#function): Error! \(error)")
|
||||
Log.error(error)
|
||||
completionHandler(error)
|
||||
}
|
||||
}
|
||||
@@ -116,7 +134,11 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
|
||||
to: SharedAccess.providerStopReasonURL, atomically: true, encoding: .utf8)
|
||||
} catch {
|
||||
Log.error(
|
||||
"\(#function): Couldn't write provider stop reason to file. Notification won't work.")
|
||||
SharedAccess.Error.unableToWriteToFile(
|
||||
SharedAccess.providerStopReasonURL,
|
||||
error
|
||||
)
|
||||
)
|
||||
}
|
||||
#if os(iOS)
|
||||
// iOS notifications should be shown from the tunnel process
|
||||
@@ -155,32 +177,13 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
|
||||
getLogFolderSize(completionHandler)
|
||||
case .exportLogs:
|
||||
Task {
|
||||
guard let completionHandler
|
||||
else {
|
||||
Log.error(
|
||||
"\(#function): Need a completion handler to export logs."
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
exportLogs(completionHandler)
|
||||
exportLogs(completionHandler!)
|
||||
}
|
||||
case .consumeStopReason:
|
||||
Task {
|
||||
guard let completionHandler
|
||||
else {
|
||||
Log.error(
|
||||
"\(#function): Need a completion handler to consumeStopReason."
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
consumeStopReason(completionHandler)
|
||||
consumeStopReason(completionHandler!)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func clearLogs(_ completionHandler: ((Data?) -> Void)? = nil) {
|
||||
@@ -188,7 +191,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
|
||||
do {
|
||||
try Log.clear(in: SharedAccess.logFolderURL)
|
||||
} catch {
|
||||
Log.error("Error clearing logs: \(error)")
|
||||
Log.error(error)
|
||||
}
|
||||
|
||||
completionHandler?(nil)
|
||||
@@ -217,7 +220,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
|
||||
let chunk = try tunnelLogArchive.readChunk()
|
||||
completionHandler(chunk)
|
||||
} catch {
|
||||
Log.error("\(#function): error reading chunk: \(error)")
|
||||
Log.error(error)
|
||||
|
||||
completionHandler(nil)
|
||||
}
|
||||
@@ -232,7 +235,6 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
|
||||
guard let logFolderURL = SharedAccess.logFolderURL,
|
||||
let logFolderPath = FilePath(logFolderURL)
|
||||
else {
|
||||
Log.error("\(#function): log folder not available")
|
||||
completionHandler(nil)
|
||||
|
||||
return
|
||||
@@ -243,7 +245,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
|
||||
do {
|
||||
try tunnelLogArchive.archive()
|
||||
} catch {
|
||||
Log.error("\(#function): error archiving logs: \(error)")
|
||||
Log.error(error)
|
||||
completionHandler(nil)
|
||||
|
||||
return
|
||||
@@ -255,20 +257,17 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
|
||||
}
|
||||
|
||||
func consumeStopReason(_ completionHandler: (Data?) -> Void) {
|
||||
do {
|
||||
let data = try Data(
|
||||
contentsOf: SharedAccess.providerStopReasonURL
|
||||
)
|
||||
|
||||
try? FileManager.default
|
||||
.removeItem(at: SharedAccess.providerStopReasonURL)
|
||||
|
||||
completionHandler(data)
|
||||
} catch {
|
||||
Log.error("\(#function): error reading stop reason: \(error)")
|
||||
|
||||
guard let data = try? Data(contentsOf: SharedAccess.providerStopReasonURL)
|
||||
else {
|
||||
completionHandler(nil)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try? FileManager.default
|
||||
.removeItem(at: SharedAccess.providerStopReasonURL)
|
||||
|
||||
completionHandler(data)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,19 @@ import Foundation
|
||||
import SystemConfiguration
|
||||
|
||||
class SystemConfigurationResolvers {
|
||||
enum SystemConfigurationError: Error {
|
||||
case failedToCreateDynamicStore
|
||||
case unableToRetrieveNetworkServices
|
||||
|
||||
var localizedDescription: String {
|
||||
switch self {
|
||||
case .failedToCreateDynamicStore:
|
||||
return "Failed to create dynamic store"
|
||||
case .unableToRetrieveNetworkServices:
|
||||
return "Unable to retrieve network services"
|
||||
}
|
||||
}
|
||||
}
|
||||
private var dynamicStore: SCDynamicStore?
|
||||
|
||||
// Arbitrary name for the connection to the store
|
||||
@@ -18,7 +31,7 @@ class SystemConfigurationResolvers {
|
||||
init() {
|
||||
guard let dynamicStore = SCDynamicStoreCreate(nil, storeName, nil, nil)
|
||||
else {
|
||||
Log.error("\(#function): Failed to create dynamic store")
|
||||
Log.error(SystemConfigurationError.failedToCreateDynamicStore)
|
||||
self.dynamicStore = nil
|
||||
return
|
||||
}
|
||||
@@ -47,7 +60,7 @@ class SystemConfigurationResolvers {
|
||||
let interfaceSearchKey = "Setup:/Network/Service/.*/Interface" as CFString
|
||||
guard let services = SCDynamicStoreCopyKeyList(dynamicStore, interfaceSearchKey) as? [String]
|
||||
else {
|
||||
Log.error("\(#function): Unable to retrieve network services")
|
||||
Log.error(SystemConfigurationError.unableToRetrieveNetworkServices)
|
||||
return []
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,50 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- BEGIN Sentry -->
|
||||
<key>NSPrivacyCollectedDataTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>NSPrivacyCollectedDataType</key>
|
||||
<string>NSPrivacyCollectedDataTypeCrashData</string>
|
||||
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||
<false />
|
||||
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||
<false />
|
||||
<key>NSPrivacyCollectedDataTypePurposes</key>
|
||||
<array>
|
||||
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyCollectedDataType</key>
|
||||
<string>NSPrivacyCollectedDataTypePerformanceData</string>
|
||||
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||
<false />
|
||||
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||
<false />
|
||||
<key>NSPrivacyCollectedDataTypePurposes</key>
|
||||
<array>
|
||||
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyCollectedDataType</key>
|
||||
<string>NSPrivacyCollectedDataTypeOtherDiagnosticData</string>
|
||||
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||
<false />
|
||||
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||
<false />
|
||||
<key>NSPrivacyCollectedDataTypePurposes</key>
|
||||
<array>
|
||||
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<!-- END Sentry -->
|
||||
<key>NSPrivacyAccessedAPITypes</key>
|
||||
<array>
|
||||
<!-- BEGIN various connlib syscalls -->
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
|
||||
@@ -12,6 +54,33 @@
|
||||
<string>3B52.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
<!-- END various connlib syscalls -->
|
||||
<!-- BEGIN Sentry -->
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>CA92.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>35F9.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>C617.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
<!-- END Sentry -->
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
Reference in New Issue
Block a user