mirror of
https://github.com/outbackdingo/firezone.git
synced 2026-01-27 10:18:54 +00:00
apple: Advanced settings and settings ux changes (#2540)
This is a follow-up to #2477 -- I requested the apple changes to be not included in that PR. This PR has: - A new tabbed UI for settings - Advanced settings for the dataplane configuration mentioned in #2477
This commit is contained in:
@@ -18,47 +18,85 @@ enum SettingsViewError: Error {
|
||||
public final class SettingsViewModel: ObservableObject {
|
||||
@Dependency(\.authStore) private var authStore
|
||||
|
||||
@Published var settings: Settings
|
||||
@Published var accountSettings: AccountSettings
|
||||
@Published var advancedSettings: AdvancedSettings
|
||||
|
||||
public var onSettingsSaved: () -> Void = unimplemented()
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
public init() {
|
||||
settings = Settings()
|
||||
load()
|
||||
accountSettings = AccountSettings()
|
||||
advancedSettings = AdvancedSettings.defaultValue
|
||||
loadAccountSettings()
|
||||
loadAdvancedSettings()
|
||||
}
|
||||
|
||||
func load() {
|
||||
func loadAccountSettings() {
|
||||
Task {
|
||||
authStore.tunnelStore.$tunnelAuthStatus
|
||||
.filter { $0.isInitialized }
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] tunnelAuthStatus in
|
||||
guard let self = self else { return }
|
||||
self.settings = Settings(accountId: tunnelAuthStatus.accountId() ?? "")
|
||||
self.accountSettings = AccountSettings(accountId: tunnelAuthStatus.accountId() ?? "")
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
}
|
||||
|
||||
func save() {
|
||||
func saveAccountSettings() {
|
||||
Task {
|
||||
let accountId = await authStore.loginStatus.accountId
|
||||
if accountId == settings.accountId {
|
||||
if accountId == accountSettings.accountId {
|
||||
// Not changed
|
||||
await MainActor.run {
|
||||
accountSettings.isSavedToDisk = true
|
||||
}
|
||||
return
|
||||
}
|
||||
let tunnelAuthStatus: TunnelAuthStatus = await {
|
||||
if settings.accountId.isEmpty {
|
||||
return .accountNotSetup
|
||||
} else {
|
||||
return await authStore.tunnelAuthStatusForAccount(accountId: settings.accountId)
|
||||
}
|
||||
}()
|
||||
try await authStore.tunnelStore.setAuthStatus(tunnelAuthStatus)
|
||||
onSettingsSaved()
|
||||
try await updateTunnelAuthStatus(accountId: accountSettings.accountId)
|
||||
await MainActor.run {
|
||||
accountSettings.isSavedToDisk = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func loadAdvancedSettings() {
|
||||
advancedSettings = authStore.tunnelStore.advancedSettings() ?? AdvancedSettings.defaultValue
|
||||
}
|
||||
|
||||
func saveAdvancedSettings() {
|
||||
Task {
|
||||
guard let authBaseURL = URL(string: advancedSettings.authBaseURLString) else {
|
||||
fatalError("Saved authBaseURL is invalid")
|
||||
}
|
||||
try await authStore.tunnelStore.saveAdvancedSettings(advancedSettings)
|
||||
await MainActor.run {
|
||||
advancedSettings.isSavedToDisk = true
|
||||
}
|
||||
var isChanged = false
|
||||
await authStore.setAuthBaseURL(authBaseURL, isChanged: &isChanged)
|
||||
if isChanged {
|
||||
try await updateTunnelAuthStatus(
|
||||
accountId: authStore.tunnelStore.tunnelAuthStatus.accountId() ?? "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// updateTunnelAuthStatus:
|
||||
// When the authBaseURL or the accountId changes, we should update the signed-in-ness.
|
||||
// This is done by searching the keychain for an entry with the authBaseURL+accountId
|
||||
// combination. If an entry was found, we consider that entry to mean we're logged in.
|
||||
func updateTunnelAuthStatus(accountId: String) async throws {
|
||||
let tunnelAuthStatus: TunnelAuthStatus = await {
|
||||
if accountId.isEmpty {
|
||||
return .accountNotSetup
|
||||
} else {
|
||||
return await authStore.tunnelAuthStatusForAccount(accountId: accountId)
|
||||
}
|
||||
}()
|
||||
try await authStore.tunnelStore.saveAuthStatus(tunnelAuthStatus)
|
||||
}
|
||||
}
|
||||
|
||||
public struct SettingsView: View {
|
||||
@@ -67,7 +105,6 @@ public struct SettingsView: View {
|
||||
@ObservedObject var model: SettingsViewModel
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
let teamIdAllowedCharacterSet: CharacterSet
|
||||
@State private var isExportingLogs = false
|
||||
|
||||
#if os(iOS)
|
||||
@@ -75,127 +112,337 @@ public struct SettingsView: View {
|
||||
@State private var isPresentingExportLogShareSheet = false
|
||||
#endif
|
||||
|
||||
struct PlaceholderText {
|
||||
static let accountId = "account-id"
|
||||
static let authBaseURL = "Admin portal base URL"
|
||||
static let apiURL = "Control plane WebSocket URL"
|
||||
static let logFilter = "RUST_LOG-style filter string"
|
||||
}
|
||||
|
||||
struct FootnoteText {
|
||||
static let forAccount = "Your account ID is provided by your admin"
|
||||
static let forAdvanced = try! AttributedString(
|
||||
markdown: """
|
||||
**WARNING:** These settings are intended for internal debug purposes **only**. \
|
||||
Changing these is not supported and will disrupt access to your Firezone resources.
|
||||
""")
|
||||
}
|
||||
|
||||
public init(model: SettingsViewModel) {
|
||||
self.model = model
|
||||
self.teamIdAllowedCharacterSet = {
|
||||
var pathAllowed = CharacterSet.urlPathAllowed
|
||||
pathAllowed.remove("/")
|
||||
return pathAllowed
|
||||
}()
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
#if os(iOS)
|
||||
ios
|
||||
NavigationView {
|
||||
TabView {
|
||||
accountTab
|
||||
.tabItem {
|
||||
Image(systemName: "person.3.fill")
|
||||
Text("Account")
|
||||
}
|
||||
.badge(model.accountSettings.isValid ? nil : "!")
|
||||
|
||||
advancedTab
|
||||
.tabItem {
|
||||
Image(systemName: "slider.horizontal.3")
|
||||
Text("Advanced")
|
||||
}
|
||||
.badge(model.advancedSettings.isValid ? nil : "!")
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Save") {
|
||||
self.saveSettings()
|
||||
}
|
||||
.disabled(
|
||||
(model.accountSettings.isSavedToDisk && model.advancedSettings.isSavedToDisk)
|
||||
|| !model.accountSettings.isValid
|
||||
|| !model.advancedSettings.isValid
|
||||
)
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") {
|
||||
self.loadSettings()
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
#elseif os(macOS)
|
||||
mac
|
||||
VStack {
|
||||
TabView {
|
||||
accountTab
|
||||
.tabItem {
|
||||
Text("Account")
|
||||
}
|
||||
advancedTab
|
||||
.tabItem {
|
||||
Text("Advanced")
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
.onDisappear(perform: { self.loadSettings() })
|
||||
#else
|
||||
#error("Unsupported platform")
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
private var ios: some View {
|
||||
NavigationView {
|
||||
VStack(spacing: 10) {
|
||||
form
|
||||
ExportLogsButton(isProcessing: $isExportingLogs) {
|
||||
self.isExportingLogs = true
|
||||
Task {
|
||||
self.logTempZipFileURL = try await createLogZipBundle()
|
||||
self.isPresentingExportLogShareSheet = true
|
||||
private var accountTab: some View {
|
||||
#if os(macOS)
|
||||
VStack {
|
||||
Spacer()
|
||||
Form {
|
||||
Section(
|
||||
content: {
|
||||
HStack(spacing: 15) {
|
||||
Spacer()
|
||||
Text("Account ID:")
|
||||
TextField(
|
||||
"",
|
||||
text: Binding(
|
||||
get: { model.accountSettings.accountId },
|
||||
set: { model.accountSettings.accountId = $0 }
|
||||
),
|
||||
prompt: Text(PlaceholderText.accountId)
|
||||
)
|
||||
.frame(maxWidth: 240)
|
||||
.onSubmit {
|
||||
self.model.saveAccountSettings()
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
},
|
||||
footer: {
|
||||
Text(FootnoteText.forAccount)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
)
|
||||
Button(
|
||||
"Apply",
|
||||
action: {
|
||||
self.model.saveAccountSettings()
|
||||
}
|
||||
)
|
||||
.disabled(
|
||||
model.accountSettings.isSavedToDisk
|
||||
|| !model.accountSettings.isValid
|
||||
)
|
||||
.padding(.top, 5)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
#elseif os(iOS)
|
||||
VStack {
|
||||
Form {
|
||||
Section(
|
||||
content: {
|
||||
HStack(spacing: 15) {
|
||||
Text("Account ID")
|
||||
.foregroundStyle(.secondary)
|
||||
TextField(
|
||||
PlaceholderText.accountId,
|
||||
text: Binding(
|
||||
get: { model.accountSettings.accountId },
|
||||
set: { model.accountSettings.accountId = $0 }
|
||||
)
|
||||
)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.submitLabel(.done)
|
||||
}
|
||||
},
|
||||
header: { Text("Account") },
|
||||
footer: { Text(FootnoteText.forAccount) }
|
||||
)
|
||||
}
|
||||
}
|
||||
#else
|
||||
#error("Unsupported platform")
|
||||
#endif
|
||||
}
|
||||
|
||||
private var advancedTab: some View {
|
||||
#if os(macOS)
|
||||
VStack {
|
||||
Spacer()
|
||||
HStack {
|
||||
Spacer()
|
||||
Form {
|
||||
TextField(
|
||||
"Auth Base URL:",
|
||||
text: Binding(
|
||||
get: { model.advancedSettings.authBaseURLString },
|
||||
set: { model.advancedSettings.authBaseURLString = $0 }
|
||||
),
|
||||
prompt: Text(PlaceholderText.authBaseURL)
|
||||
)
|
||||
|
||||
TextField(
|
||||
"API URL:",
|
||||
text: Binding(
|
||||
get: { model.advancedSettings.apiURLString },
|
||||
set: { model.advancedSettings.apiURLString = $0 }
|
||||
),
|
||||
prompt: Text(PlaceholderText.apiURL)
|
||||
)
|
||||
|
||||
TextField(
|
||||
"Log Filter:",
|
||||
text: Binding(
|
||||
get: { model.advancedSettings.connlibLogFilterString },
|
||||
set: { model.advancedSettings.connlibLogFilterString = $0 }
|
||||
),
|
||||
prompt: Text(PlaceholderText.logFilter)
|
||||
)
|
||||
|
||||
Text(FootnoteText.forAdvanced)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
HStack(spacing: 30) {
|
||||
Button(
|
||||
"Apply",
|
||||
action: {
|
||||
self.model.saveAdvancedSettings()
|
||||
}
|
||||
)
|
||||
.disabled(model.advancedSettings.isSavedToDisk || !model.advancedSettings.isValid)
|
||||
|
||||
Button(
|
||||
"Reset to Defaults",
|
||||
action: {
|
||||
self.restoreAdvancedSettingsToDefaults()
|
||||
}
|
||||
)
|
||||
.disabled(model.advancedSettings == AdvancedSettings.defaultValue)
|
||||
}
|
||||
.padding(.top, 5)
|
||||
}
|
||||
.sheet(isPresented: $isPresentingExportLogShareSheet) {
|
||||
if let logfileURL = self.logTempZipFileURL {
|
||||
ShareSheetView(
|
||||
localFileURL: logfileURL,
|
||||
completionHandler: {
|
||||
self.isPresentingExportLogShareSheet = false
|
||||
self.isExportingLogs = false
|
||||
self.logTempZipFileURL = nil
|
||||
})
|
||||
}
|
||||
.padding(10)
|
||||
Spacer()
|
||||
}
|
||||
Spacer()
|
||||
HStack {
|
||||
Spacer()
|
||||
ExportLogsButton(isProcessing: $isExportingLogs) {
|
||||
self.exportLogsWithSavePanelOnMac()
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Save") {
|
||||
self.saveButtonTapped()
|
||||
}
|
||||
.disabled(!isTeamIdValid(model.settings.accountId))
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") {
|
||||
self.cancelButtonTapped()
|
||||
Spacer()
|
||||
}
|
||||
#elseif os(iOS)
|
||||
VStack {
|
||||
Form {
|
||||
Section(
|
||||
content: {
|
||||
HStack(spacing: 15) {
|
||||
Text("Auth Base URL")
|
||||
.foregroundStyle(.secondary)
|
||||
TextField(
|
||||
PlaceholderText.authBaseURL,
|
||||
text: Binding(
|
||||
get: { model.advancedSettings.authBaseURLString },
|
||||
set: { model.advancedSettings.authBaseURLString = $0 }
|
||||
)
|
||||
)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.submitLabel(.done)
|
||||
}
|
||||
HStack(spacing: 15) {
|
||||
Text("API URL")
|
||||
.foregroundStyle(.secondary)
|
||||
TextField(
|
||||
PlaceholderText.apiURL,
|
||||
text: Binding(
|
||||
get: { model.advancedSettings.apiURLString },
|
||||
set: { model.advancedSettings.apiURLString = $0 }
|
||||
)
|
||||
)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.submitLabel(.done)
|
||||
}
|
||||
HStack(spacing: 15) {
|
||||
Text("Log Filter")
|
||||
.foregroundStyle(.secondary)
|
||||
TextField(
|
||||
PlaceholderText.logFilter,
|
||||
text: Binding(
|
||||
get: { model.advancedSettings.connlibLogFilterString },
|
||||
set: { model.advancedSettings.connlibLogFilterString = $0 }
|
||||
)
|
||||
)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.submitLabel(.done)
|
||||
}
|
||||
HStack {
|
||||
Spacer()
|
||||
Button(
|
||||
"Reset to Defaults",
|
||||
action: {
|
||||
self.restoreAdvancedSettingsToDefaults()
|
||||
}
|
||||
)
|
||||
.disabled(model.advancedSettings == AdvancedSettings.defaultValue)
|
||||
Spacer()
|
||||
}
|
||||
},
|
||||
header: { Text("Advanced Settings") },
|
||||
footer: { Text(FootnoteText.forAdvanced) }
|
||||
)
|
||||
Section(header: Text("Logs")) {
|
||||
HStack {
|
||||
Spacer()
|
||||
ExportLogsButton(isProcessing: $isExportingLogs) {
|
||||
self.isExportingLogs = true
|
||||
Task {
|
||||
self.logTempZipFileURL = try await createLogZipBundle()
|
||||
self.isPresentingExportLogShareSheet = true
|
||||
}
|
||||
}.sheet(isPresented: $isPresentingExportLogShareSheet) {
|
||||
if let logfileURL = self.logTempZipFileURL {
|
||||
ShareSheetView(
|
||||
localFileURL: logfileURL,
|
||||
completionHandler: {
|
||||
self.isPresentingExportLogShareSheet = false
|
||||
self.isExportingLogs = false
|
||||
self.logTempZipFileURL = nil
|
||||
})
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(macOS)
|
||||
private var mac: some View {
|
||||
VStack(spacing: 50) {
|
||||
form
|
||||
HStack(spacing: 30) {
|
||||
Button(
|
||||
"Cancel",
|
||||
action: {
|
||||
self.cancelButtonTapped()
|
||||
})
|
||||
Button(
|
||||
"Save",
|
||||
action: {
|
||||
self.saveButtonTapped()
|
||||
}
|
||||
)
|
||||
.disabled(!isTeamIdValid(model.settings.accountId))
|
||||
}
|
||||
ExportLogsButton(isProcessing: $isExportingLogs) {
|
||||
self.exportLogsWithSavePanelOnMac()
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private var form: some View {
|
||||
Form {
|
||||
Section {
|
||||
FormTextField(
|
||||
title: "Account ID:",
|
||||
baseURLString: AppInfoPlistConstants.authBaseURL.absoluteString,
|
||||
placeholder: "account-id",
|
||||
text: Binding(
|
||||
get: { model.settings.accountId },
|
||||
set: { model.settings.accountId = $0 }
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func isTeamIdValid(_ teamId: String) -> Bool {
|
||||
!teamId.isEmpty && teamId.unicodeScalars.allSatisfy { teamIdAllowedCharacterSet.contains($0) }
|
||||
}
|
||||
|
||||
func saveButtonTapped() {
|
||||
model.save()
|
||||
func saveSettings() {
|
||||
model.saveAccountSettings()
|
||||
model.saveAdvancedSettings()
|
||||
dismiss()
|
||||
}
|
||||
|
||||
func cancelButtonTapped() {
|
||||
model.load()
|
||||
func loadSettings() {
|
||||
model.loadAccountSettings()
|
||||
model.loadAdvancedSettings()
|
||||
dismiss()
|
||||
}
|
||||
|
||||
func restoreAdvancedSettingsToDefaults() {
|
||||
let defaultValue = AdvancedSettings.defaultValue
|
||||
model.advancedSettings.authBaseURLString = defaultValue.authBaseURLString
|
||||
model.advancedSettings.apiURLString = defaultValue.apiURLString
|
||||
model.advancedSettings.connlibLogFilterString = defaultValue.connlibLogFilterString
|
||||
model.saveAdvancedSettings()
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
func exportLogsWithSavePanelOnMac() {
|
||||
self.isExportingLogs = true
|
||||
@@ -302,15 +549,16 @@ struct FormTextField: View {
|
||||
|
||||
var body: some View {
|
||||
#if os(iOS)
|
||||
HStack(spacing: 15) {
|
||||
Text(title)
|
||||
VStack(spacing: 10) {
|
||||
Spacer()
|
||||
HStack(spacing: 5) {
|
||||
Text(title)
|
||||
Spacer()
|
||||
TextField(baseURLString, text: text, prompt: Text(placeholder))
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
}
|
||||
Spacer()
|
||||
TextField(baseURLString, text: text, prompt: Text(placeholder))
|
||||
.autocorrectionDisabled()
|
||||
.multilineTextAlignment(.leading)
|
||||
.foregroundColor(.secondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.textInputAutocapitalization(.never)
|
||||
}
|
||||
#else
|
||||
HStack(spacing: 30) {
|
||||
|
||||
@@ -6,6 +6,62 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
struct Settings: Codable, Hashable {
|
||||
var accountId: String = ""
|
||||
struct AccountSettings {
|
||||
var accountId: String = "" {
|
||||
didSet { if oldValue != accountId { isSavedToDisk = false } }
|
||||
}
|
||||
|
||||
var isSavedToDisk = true
|
||||
|
||||
var isValid: Bool {
|
||||
!accountId.isEmpty
|
||||
&& accountId.unicodeScalars.allSatisfy { Self.teamIdAllowedCharacterSet.contains($0) }
|
||||
}
|
||||
|
||||
static let teamIdAllowedCharacterSet: CharacterSet = {
|
||||
var pathAllowed = CharacterSet.urlPathAllowed
|
||||
pathAllowed.remove("/")
|
||||
return pathAllowed
|
||||
}()
|
||||
}
|
||||
|
||||
struct AdvancedSettings: Equatable {
|
||||
var authBaseURLString: String {
|
||||
didSet { if oldValue != authBaseURLString { isSavedToDisk = false } }
|
||||
}
|
||||
var apiURLString: String {
|
||||
didSet { if oldValue != apiURLString { isSavedToDisk = false } }
|
||||
}
|
||||
var connlibLogFilterString: String {
|
||||
didSet { if oldValue != connlibLogFilterString { isSavedToDisk = false } }
|
||||
}
|
||||
|
||||
var isSavedToDisk = true
|
||||
|
||||
var isValid: Bool {
|
||||
URL(string: authBaseURLString) != nil
|
||||
&& URL(string: apiURLString) != nil
|
||||
&& !connlibLogFilterString.isEmpty
|
||||
}
|
||||
|
||||
static let defaultValue: AdvancedSettings = {
|
||||
#if DEBUG
|
||||
AdvancedSettings(
|
||||
authBaseURLString: "https://app.firez.one/",
|
||||
apiURLString: "wss://api.firez.one/",
|
||||
connlibLogFilterString:
|
||||
"connlib_client_apple=debug,firezone_tunnel=trace,connlib_shared=debug,connlib_client_shared=debug,warn"
|
||||
)
|
||||
#else
|
||||
AdvancedSettings(
|
||||
authBaseURLString: "https://app.firezone.dev/",
|
||||
apiURLString: "wss://api.firezone.dev/",
|
||||
connlibLogFilterString:
|
||||
"connlib_client_apple=info,firezone_tunnel=info,connlib_shared=info,connlib_client_shared=info,warn"
|
||||
)
|
||||
#endif
|
||||
}()
|
||||
|
||||
// Note: To see what the connlibLogFilterString values mean, see:
|
||||
// https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ final class AuthStore: ObservableObject {
|
||||
|
||||
let tunnelStore: TunnelStore
|
||||
|
||||
public let authBaseURL: URL
|
||||
public var authBaseURL: URL
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
@Published private(set) var loginStatus: LoginStatus
|
||||
@@ -55,6 +55,10 @@ final class AuthStore: ObservableObject {
|
||||
self.authBaseURL = AppInfoPlistConstants.authBaseURL
|
||||
self.loginStatus = .uninitialized
|
||||
|
||||
Task {
|
||||
self.loginStatus = await self.getLoginStatus(from: tunnelStore.tunnelAuthStatus)
|
||||
}
|
||||
|
||||
tunnelStore.$tunnelAuthStatus
|
||||
.sink { [weak self] tunnelAuthStatus in
|
||||
guard let self = self else { return }
|
||||
@@ -71,15 +75,11 @@ final class AuthStore: ObservableObject {
|
||||
return .uninitialized
|
||||
case .accountNotSetup:
|
||||
return .signedOut(accountId: nil)
|
||||
case .signedOut(let tunnelAuthBaseURL, let tunnelAccountId):
|
||||
if self.authBaseURL == tunnelAuthBaseURL {
|
||||
return .signedOut(accountId: tunnelAccountId)
|
||||
} else {
|
||||
return .signedOut(accountId: nil)
|
||||
}
|
||||
case .signedOut(_, let tunnelAccountId):
|
||||
return .signedOut(accountId: tunnelAccountId)
|
||||
case .signedIn(let tunnelAuthBaseURL, let tunnelAccountId, let tokenReference):
|
||||
guard self.authBaseURL == tunnelAuthBaseURL else {
|
||||
return .signedOut(accountId: nil)
|
||||
return .signedOut(accountId: tunnelAccountId)
|
||||
}
|
||||
let tunnelPortalURLString = self.authURL(accountId: tunnelAccountId).absoluteString
|
||||
guard let tokenAttributes = await keychain.loadAttributes(tokenReference),
|
||||
@@ -100,7 +100,7 @@ final class AuthStore: ObservableObject {
|
||||
authURLString: portalURL.absoluteString, actorName: authResponse.actorName ?? "")
|
||||
let tokenRef = try await keychain.store(authResponse.token, attributes)
|
||||
|
||||
try await tunnelStore.setAuthStatus(
|
||||
try await tunnelStore.saveAuthStatus(
|
||||
.signedIn(authBaseURL: self.authBaseURL, accountId: accountId, tokenReference: tokenRef))
|
||||
}
|
||||
|
||||
@@ -143,4 +143,9 @@ final class AuthStore: ObservableObject {
|
||||
func authURL(accountId: String) -> URL {
|
||||
self.authBaseURL.appendingPathComponent(accountId)
|
||||
}
|
||||
|
||||
func setAuthBaseURL(_ authBaseURL: URL, isChanged: inout Bool) {
|
||||
isChanged = (self.authBaseURL == authBaseURL)
|
||||
self.authBaseURL = authBaseURL
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,17 +13,19 @@ enum TunnelStoreError: Error {
|
||||
case tunnelCouldNotBeStarted
|
||||
}
|
||||
|
||||
public struct TunnelProviderKeys {
|
||||
static let keyAuthBaseURLString = "authBaseURLString"
|
||||
static let keyAccountId = "accountId"
|
||||
public static let keyConnlibLogFilter = "connlibLogFilter"
|
||||
}
|
||||
|
||||
final class TunnelStore: ObservableObject {
|
||||
private static let logger = Logger.make(for: TunnelStore.self)
|
||||
|
||||
static let shared = TunnelStore()
|
||||
|
||||
static let keyAuthBaseURLString = "authBaseURLString"
|
||||
static let keyAccountId = "accountId"
|
||||
|
||||
@Published private var tunnel: NETunnelProviderManager?
|
||||
@Published private(set) var tunnelAuthStatus: TunnelAuthStatus = TunnelAuthStatus(
|
||||
protocolConfiguration: nil)
|
||||
@Published private(set) var tunnelAuthStatus: TunnelAuthStatus = .tunnelUninitialized
|
||||
|
||||
@Published private(set) var status: NEVPNStatus {
|
||||
didSet { TunnelStore.logger.info("status changed: \(self.status.description)") }
|
||||
@@ -41,7 +43,7 @@ final class TunnelStore: ObservableObject {
|
||||
|
||||
init() {
|
||||
self.tunnel = nil
|
||||
self.tunnelAuthStatus = TunnelAuthStatus(protocolConfiguration: nil)
|
||||
self.tunnelAuthStatus = .tunnelUninitialized
|
||||
self.status = .invalid
|
||||
|
||||
Task {
|
||||
@@ -56,12 +58,11 @@ final class TunnelStore: ObservableObject {
|
||||
if let tunnel = managers.first {
|
||||
Self.logger.log("\(#function): Tunnel already exists")
|
||||
self.tunnel = tunnel
|
||||
self.tunnelAuthStatus = TunnelAuthStatus(
|
||||
protocolConfiguration: tunnel.protocolConfiguration as? NETunnelProviderProtocol)
|
||||
self.tunnelAuthStatus = tunnel.authStatus()
|
||||
} else {
|
||||
let tunnel = NETunnelProviderManager()
|
||||
tunnel.localizedDescription = "Firezone"
|
||||
tunnel.protocolConfiguration = TunnelAuthStatus.accountNotSetup.toProtocolConfiguration()
|
||||
tunnel.protocolConfiguration = basicProviderProtocol()
|
||||
try await tunnel.saveToPreferences()
|
||||
Self.logger.log("\(#function): Tunnel created")
|
||||
self.tunnel = tunnel
|
||||
@@ -74,7 +75,7 @@ final class TunnelStore: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func setAuthStatus(_ tunnelAuthStatus: TunnelAuthStatus) async throws {
|
||||
func saveAuthStatus(_ tunnelAuthStatus: TunnelAuthStatus) async throws {
|
||||
guard let tunnel = tunnel else {
|
||||
fatalError("Tunnel not initialized yet")
|
||||
}
|
||||
@@ -84,11 +85,46 @@ final class TunnelStore: ObservableObject {
|
||||
if wasConnected {
|
||||
stop()
|
||||
}
|
||||
tunnel.protocolConfiguration = tunnelAuthStatus.toProtocolConfiguration()
|
||||
try await tunnel.saveToPreferences()
|
||||
|
||||
try await tunnel.saveAuthStatus(tunnelAuthStatus)
|
||||
self.tunnelAuthStatus = tunnelAuthStatus
|
||||
}
|
||||
|
||||
func saveAdvancedSettings(_ advancedSettings: AdvancedSettings) async throws {
|
||||
guard let tunnel = tunnel else {
|
||||
fatalError("Tunnel not initialized yet")
|
||||
}
|
||||
|
||||
let wasConnected =
|
||||
(tunnel.connection.status == .connected || tunnel.connection.status == .connecting)
|
||||
if wasConnected {
|
||||
stop()
|
||||
}
|
||||
|
||||
try await tunnel.saveAdvancedSettings(advancedSettings)
|
||||
}
|
||||
|
||||
func advancedSettings() -> AdvancedSettings? {
|
||||
guard let tunnel = tunnel else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return tunnel.advancedSettings()
|
||||
}
|
||||
|
||||
func basicProviderProtocol() -> NETunnelProviderProtocol {
|
||||
let protocolConfiguration = NETunnelProviderProtocol()
|
||||
protocolConfiguration.providerBundleIdentifier = Bundle.main.bundleIdentifier.map {
|
||||
"\($0).network-extension"
|
||||
}
|
||||
protocolConfiguration.serverAddress = AdvancedSettings.defaultValue.apiURLString
|
||||
protocolConfiguration.providerConfiguration = [
|
||||
TunnelProviderKeys.keyConnlibLogFilter:
|
||||
AdvancedSettings.defaultValue.connlibLogFilterString
|
||||
]
|
||||
return protocolConfiguration
|
||||
}
|
||||
|
||||
func start() async throws {
|
||||
guard let tunnel = tunnel else {
|
||||
Self.logger.log("\(#function): TunnelStore is not initialized")
|
||||
@@ -101,6 +137,10 @@ final class TunnelStore: ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
if tunnel.advancedSettings().connlibLogFilterString.isEmpty {
|
||||
tunnel.setConnlibLogFilter(AdvancedSettings.defaultValue.connlibLogFilterString)
|
||||
}
|
||||
|
||||
tunnel.isEnabled = true
|
||||
try await tunnel.saveToPreferences()
|
||||
try await tunnel.loadFromPreferences()
|
||||
@@ -134,7 +174,7 @@ final class TunnelStore: ObservableObject {
|
||||
session.stopTunnel()
|
||||
|
||||
if case .signedIn(let authBaseURL, let accountId, let tokenReference) = self.tunnelAuthStatus {
|
||||
try await setAuthStatus(.signedOut(authBaseURL: authBaseURL, accountId: accountId))
|
||||
try await saveAuthStatus(.signedOut(authBaseURL: authBaseURL, accountId: accountId))
|
||||
return tokenReference
|
||||
}
|
||||
|
||||
@@ -259,57 +299,6 @@ enum TunnelAuthStatus {
|
||||
}
|
||||
}
|
||||
|
||||
init(protocolConfiguration: NETunnelProviderProtocol?) {
|
||||
if let protocolConfiguration = protocolConfiguration {
|
||||
let providerConfig = protocolConfiguration.providerConfiguration
|
||||
let authBaseURL: URL? = {
|
||||
guard let urlString = providerConfig?[TunnelStore.keyAuthBaseURLString] as? String else {
|
||||
return nil
|
||||
}
|
||||
return URL(string: urlString)
|
||||
}()
|
||||
let accountId = providerConfig?[TunnelStore.keyAccountId] as? String
|
||||
let tokenRef = protocolConfiguration.passwordReference
|
||||
if let authBaseURL = authBaseURL, let accountId = accountId {
|
||||
if let tokenRef = tokenRef {
|
||||
self = .signedIn(authBaseURL: authBaseURL, accountId: accountId, tokenReference: tokenRef)
|
||||
} else {
|
||||
self = .signedOut(authBaseURL: authBaseURL, accountId: accountId)
|
||||
}
|
||||
} else {
|
||||
self = .accountNotSetup
|
||||
}
|
||||
} else {
|
||||
self = .tunnelUninitialized
|
||||
}
|
||||
}
|
||||
|
||||
func toProtocolConfiguration() -> NETunnelProviderProtocol {
|
||||
let protocolConfiguration = NETunnelProviderProtocol()
|
||||
protocolConfiguration.providerBundleIdentifier = Bundle.main.bundleIdentifier.map {
|
||||
"\($0).network-extension"
|
||||
}
|
||||
protocolConfiguration.serverAddress = AppInfoPlistConstants.controlPlaneURL.absoluteString
|
||||
|
||||
switch self {
|
||||
case .tunnelUninitialized, .accountNotSetup:
|
||||
break
|
||||
case .signedOut(let authBaseURL, let accountId):
|
||||
protocolConfiguration.providerConfiguration = [
|
||||
TunnelStore.keyAuthBaseURLString: authBaseURL.absoluteString,
|
||||
TunnelStore.keyAccountId: accountId,
|
||||
]
|
||||
case .signedIn(let authBaseURL, let accountId, let tokenReference):
|
||||
protocolConfiguration.providerConfiguration = [
|
||||
TunnelStore.keyAuthBaseURLString: authBaseURL.absoluteString,
|
||||
TunnelStore.keyAccountId: accountId,
|
||||
]
|
||||
protocolConfiguration.passwordReference = tokenReference
|
||||
}
|
||||
|
||||
return protocolConfiguration
|
||||
}
|
||||
|
||||
func accountId() -> String? {
|
||||
switch self {
|
||||
case .tunnelUninitialized, .accountNotSetup:
|
||||
@@ -338,3 +327,107 @@ extension NEVPNStatus: CustomStringConvertible {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension NETunnelProviderManager {
|
||||
func authStatus() -> TunnelAuthStatus {
|
||||
if let protocolConfiguration = protocolConfiguration as? NETunnelProviderProtocol,
|
||||
let providerConfig = protocolConfiguration.providerConfiguration
|
||||
{
|
||||
let authBaseURL: URL? = {
|
||||
guard let urlString = providerConfig[TunnelProviderKeys.keyAuthBaseURLString] as? String
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return URL(string: urlString)
|
||||
}()
|
||||
let accountId = providerConfig[TunnelProviderKeys.keyAccountId] as? String
|
||||
let tokenRef = protocolConfiguration.passwordReference
|
||||
if let authBaseURL = authBaseURL, let accountId = accountId {
|
||||
if let tokenRef = tokenRef {
|
||||
return .signedIn(authBaseURL: authBaseURL, accountId: accountId, tokenReference: tokenRef)
|
||||
} else {
|
||||
return .signedOut(authBaseURL: authBaseURL, accountId: accountId)
|
||||
}
|
||||
} else {
|
||||
return .accountNotSetup
|
||||
}
|
||||
}
|
||||
return .accountNotSetup
|
||||
}
|
||||
|
||||
func saveAuthStatus(_ authStatus: TunnelAuthStatus) async throws {
|
||||
if let protocolConfiguration = protocolConfiguration as? NETunnelProviderProtocol,
|
||||
let providerConfiguration = protocolConfiguration.providerConfiguration
|
||||
{
|
||||
var providerConfig = providerConfiguration
|
||||
|
||||
switch authStatus {
|
||||
case .tunnelUninitialized, .accountNotSetup:
|
||||
break
|
||||
case .signedOut(let authBaseURL, let accountId):
|
||||
providerConfig[TunnelProviderKeys.keyAuthBaseURLString] = authBaseURL.absoluteString
|
||||
providerConfig[TunnelProviderKeys.keyAccountId] = accountId
|
||||
case .signedIn(let authBaseURL, let accountId, let tokenReference):
|
||||
providerConfig[TunnelProviderKeys.keyAuthBaseURLString] = authBaseURL.absoluteString
|
||||
providerConfig[TunnelProviderKeys.keyAccountId] = accountId
|
||||
protocolConfiguration.passwordReference = tokenReference
|
||||
}
|
||||
|
||||
protocolConfiguration.providerConfiguration = providerConfig
|
||||
|
||||
try await saveToPreferences()
|
||||
}
|
||||
}
|
||||
|
||||
func advancedSettings() -> AdvancedSettings {
|
||||
if let protocolConfiguration = protocolConfiguration as? NETunnelProviderProtocol,
|
||||
let providerConfig = protocolConfiguration.providerConfiguration
|
||||
{
|
||||
let authBaseURLString =
|
||||
(providerConfig[TunnelProviderKeys.keyAuthBaseURLString] as? String)
|
||||
?? AdvancedSettings.defaultValue.authBaseURLString
|
||||
let logFilter =
|
||||
(providerConfig[TunnelProviderKeys.keyConnlibLogFilter] as? String)
|
||||
?? AdvancedSettings.defaultValue.connlibLogFilterString
|
||||
let apiURLString =
|
||||
protocolConfiguration.serverAddress
|
||||
?? AdvancedSettings.defaultValue.apiURLString
|
||||
|
||||
return AdvancedSettings(
|
||||
authBaseURLString: authBaseURLString,
|
||||
apiURLString: apiURLString,
|
||||
connlibLogFilterString: logFilter
|
||||
)
|
||||
}
|
||||
|
||||
return AdvancedSettings.defaultValue
|
||||
}
|
||||
|
||||
func setConnlibLogFilter(_ logFiler: String) {
|
||||
if let protocolConfiguration = protocolConfiguration as? NETunnelProviderProtocol,
|
||||
let providerConfiguration = protocolConfiguration.providerConfiguration
|
||||
{
|
||||
var providerConfig = providerConfiguration
|
||||
providerConfig[TunnelProviderKeys.keyConnlibLogFilter] = logFiler
|
||||
protocolConfiguration.providerConfiguration = providerConfig
|
||||
}
|
||||
}
|
||||
|
||||
func saveAdvancedSettings(_ advancedSettings: AdvancedSettings) async throws {
|
||||
if let protocolConfiguration = protocolConfiguration as? NETunnelProviderProtocol,
|
||||
let providerConfiguration = protocolConfiguration.providerConfiguration
|
||||
{
|
||||
var providerConfig = providerConfiguration
|
||||
|
||||
providerConfig[TunnelProviderKeys.keyAuthBaseURLString] =
|
||||
advancedSettings.authBaseURLString
|
||||
providerConfig[TunnelProviderKeys.keyConnlibLogFilter] =
|
||||
advancedSettings.connlibLogFilterString
|
||||
|
||||
protocolConfiguration.providerConfiguration = providerConfig
|
||||
protocolConfiguration.serverAddress = advancedSettings.apiURLString
|
||||
|
||||
try await saveToPreferences()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,25 +89,19 @@ public class Adapter {
|
||||
private var controlPlaneURLString: String
|
||||
private var token: String
|
||||
|
||||
// Docs on filter strings: https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html
|
||||
#if DEBUG
|
||||
private let logFilterString =
|
||||
"connlib_client_apple=debug,firezone_tunnel=trace,connlib_shared=debug,connlib_client_shared=debug,warn"
|
||||
#else
|
||||
private let logFilterString =
|
||||
"connlib_client_apple=info,firezone_tunnel=info,connlib_shared=info,connlib_client_shared=info,warn"
|
||||
#endif
|
||||
|
||||
private let logFilter: String
|
||||
private let connlibLogFolderPath: String
|
||||
|
||||
public init(
|
||||
controlPlaneURLString: String, token: String, packetTunnelProvider: NEPacketTunnelProvider
|
||||
controlPlaneURLString: String, token: String,
|
||||
logFilter: String, packetTunnelProvider: NEPacketTunnelProvider
|
||||
) {
|
||||
self.controlPlaneURLString = controlPlaneURLString
|
||||
self.token = token
|
||||
self.packetTunnelProvider = packetTunnelProvider
|
||||
self.callbackHandler = CallbackHandler()
|
||||
self.state = .stoppedTunnel
|
||||
self.logFilter = logFilter
|
||||
self.connlibLogFolderPath = SharedAccess.connlibLogFolderURL?.path ?? ""
|
||||
}
|
||||
|
||||
@@ -147,7 +141,7 @@ public class Adapter {
|
||||
self.state = .startingTunnel(
|
||||
session: try WrappedSession.connect(
|
||||
self.controlPlaneURLString, self.token, self.getDeviceId(), self.connlibLogFolderPath,
|
||||
self.logFilterString, self.callbackHandler),
|
||||
self.logFilter, self.callbackHandler),
|
||||
onStarted: completionHandler
|
||||
)
|
||||
} catch let error {
|
||||
@@ -284,7 +278,7 @@ extension Adapter {
|
||||
self.state = .startingTunnel(
|
||||
session: try WrappedSession.connect(
|
||||
controlPlaneURLString, token, self.getDeviceId(), self.connlibLogFolderPath,
|
||||
logFilterString,
|
||||
self.logFilter,
|
||||
self.callbackHandler),
|
||||
onStarted: { error in
|
||||
if let error = error {
|
||||
|
||||
@@ -40,6 +40,16 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
|
||||
return
|
||||
}
|
||||
|
||||
let providerConfig = (protocolConfiguration as? NETunnelProviderProtocol)?.providerConfiguration
|
||||
|
||||
guard let connlibLogFilter = providerConfig?[TunnelProviderKeys.keyConnlibLogFilter] as? String
|
||||
else {
|
||||
Self.logger.error("connlibLogFilter is missing")
|
||||
completionHandler(
|
||||
PacketTunnelProviderError.savedProtocolConfigurationIsInvalid("connlibLogFilter"))
|
||||
return
|
||||
}
|
||||
|
||||
Task {
|
||||
let keychain = Keychain()
|
||||
guard let token = await keychain.load(persistentRef: tokenRef) else {
|
||||
@@ -48,7 +58,8 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
|
||||
}
|
||||
|
||||
let adapter = Adapter(
|
||||
controlPlaneURLString: controlPlaneURLString, token: token, packetTunnelProvider: self)
|
||||
controlPlaneURLString: controlPlaneURLString, token: token, logFilter: connlibLogFilter,
|
||||
packetTunnelProvider: self)
|
||||
self.adapter = adapter
|
||||
do {
|
||||
try adapter.start { error in
|
||||
|
||||
Reference in New Issue
Block a user