Split views into extensions (#321)
Nothing but moving code around to reorganize views into the following sections (MARK): - Properties/Body - Subviews - Actions
This commit is contained in:
parent
7198150f00
commit
d7ebcb23ba
|
@ -26,8 +26,6 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct AboutView: View {
|
struct AboutView: View {
|
||||||
// private let appName = Unlocalized.appName
|
|
||||||
|
|
||||||
private let versionString = Constants.Global.appVersionString
|
private let versionString = Constants.Global.appVersionString
|
||||||
|
|
||||||
private let redditURL = Constants.URLs.subreddit
|
private let redditURL = Constants.URLs.subreddit
|
||||||
|
@ -52,11 +50,15 @@ struct AboutView: View {
|
||||||
supportSection
|
supportSection
|
||||||
webSection
|
webSection
|
||||||
githubSection
|
githubSection
|
||||||
}.themeSecondaryView()
|
}.navigationTitle(L10n.About.title)
|
||||||
.navigationTitle(L10n.About.title)
|
.themeSecondaryView()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var infoSection: some View {
|
// MARK: -
|
||||||
|
|
||||||
|
private extension AboutView {
|
||||||
|
var infoSection: some View {
|
||||||
Section {
|
Section {
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
VersionView()
|
VersionView()
|
||||||
|
@ -70,7 +72,7 @@ struct AboutView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var supportSection: some View {
|
var supportSection: some View {
|
||||||
Section {
|
Section {
|
||||||
Button(L10n.About.Items.JoinCommunity.caption) {
|
Button(L10n.About.Items.JoinCommunity.caption) {
|
||||||
URL.open(redditURL)
|
URL.open(redditURL)
|
||||||
|
@ -82,7 +84,7 @@ struct AboutView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var webSection: some View {
|
var webSection: some View {
|
||||||
Section {
|
Section {
|
||||||
Button(L10n.About.Items.Website.caption) {
|
Button(L10n.About.Items.Website.caption) {
|
||||||
URL.open(homeURL)
|
URL.open(homeURL)
|
||||||
|
@ -101,7 +103,7 @@ struct AboutView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var githubSection: some View {
|
var githubSection: some View {
|
||||||
Section {
|
Section {
|
||||||
Button(Unlocalized.About.readme) {
|
Button(Unlocalized.About.readme) {
|
||||||
URL.open(readmeURL)
|
URL.open(readmeURL)
|
||||||
|
@ -115,13 +117,15 @@ struct AboutView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension AboutView {
|
// MARK: -
|
||||||
private func shareOnTwitter() {
|
|
||||||
|
private extension AboutView {
|
||||||
|
func shareOnTwitter() {
|
||||||
let url = Unlocalized.Social.twitterIntent(withMessage: shareMessage)
|
let url = Unlocalized.Social.twitterIntent(withMessage: shareMessage)
|
||||||
URL.open(url)
|
URL.open(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func submitReview() {
|
func submitReview() {
|
||||||
let reviewURL = Reviewer.urlForReview(withAppId: Constants.App.appStoreId)
|
let reviewURL = Reviewer.urlForReview(withAppId: Constants.App.appStoreId)
|
||||||
URL.open(reviewURL)
|
URL.open(reviewURL)
|
||||||
}
|
}
|
||||||
|
|
|
@ -58,7 +58,7 @@ struct AccountView: View {
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
List {
|
List {
|
||||||
// TODO: interactive, re-enable after fixing
|
// TODO: interactive, re-enable after fixing
|
||||||
// Section {
|
// Section {
|
||||||
// // TODO: interactive, l10n
|
// // TODO: interactive, l10n
|
||||||
// themeTextPicker(L10n.Global.Strings.authentication, selection: $liveAccount.authenticationMethod ?? .persistent, values: [
|
// themeTextPicker(L10n.Global.Strings.authentication, selection: $liveAccount.authenticationMethod ?? .persistent, values: [
|
||||||
|
@ -83,7 +83,7 @@ struct AccountView: View {
|
||||||
.withLeadingText(L10n.Account.Items.Password.caption)
|
.withLeadingText(L10n.Account.Items.Password.caption)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: interactive, scan QR code
|
// TODO: interactive, scan QR code
|
||||||
case .totp:
|
case .totp:
|
||||||
themeSecureField(L10n.Account.Items.Password.placeholder, text: $liveAccount.password, contentType: .oneTimeCode)
|
themeSecureField(L10n.Account.Items.Password.placeholder, text: $liveAccount.password, contentType: .oneTimeCode)
|
||||||
.withLeadingText(L10n.Account.Items.Seed.caption)
|
.withLeadingText(L10n.Account.Items.Seed.caption)
|
||||||
|
@ -102,8 +102,7 @@ struct AccountView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}.navigationTitle(L10n.Account.title)
|
}.toolbar {
|
||||||
.toolbar {
|
|
||||||
CopySavingButton(
|
CopySavingButton(
|
||||||
original: $account,
|
original: $account,
|
||||||
copy: $liveAccount,
|
copy: $liveAccount,
|
||||||
|
@ -112,25 +111,21 @@ struct AccountView: View {
|
||||||
saveAnyway: saveAnyway,
|
saveAnyway: saveAnyway,
|
||||||
onSave: onSave
|
onSave: onSave
|
||||||
)
|
)
|
||||||
}
|
}.navigationTitle(L10n.Account.title)
|
||||||
}
|
|
||||||
|
|
||||||
private func openGuidanceURL(_ url: URL) {
|
|
||||||
URL.open(url)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Provider
|
// MARK: -
|
||||||
|
|
||||||
extension AccountView {
|
private extension AccountView {
|
||||||
private var usernamePlaceholder: String? {
|
var usernamePlaceholder: String? {
|
||||||
guard let name = providerName else {
|
guard let name = providerName else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return providerManager.defaultUsername(name, vpnProtocol: vpnProtocol)
|
return providerManager.defaultUsername(name, vpnProtocol: vpnProtocol)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var metadata: ProviderMetadata? {
|
var metadata: ProviderMetadata? {
|
||||||
guard let name = providerName else {
|
guard let name = providerName else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
@ -152,3 +147,11 @@ private extension Profile.Account.AuthenticationMethod {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: -
|
||||||
|
|
||||||
|
private extension AccountView {
|
||||||
|
func openGuidanceURL(_ url: URL) {
|
||||||
|
URL.open(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -42,10 +42,6 @@ extension AddHostView {
|
||||||
|
|
||||||
@State private var isEnteringCredentials = false
|
@State private var isEnteringCredentials = false
|
||||||
|
|
||||||
private var isComplete: Bool {
|
|
||||||
!viewModel.processedProfile.isPlaceholder
|
|
||||||
}
|
|
||||||
|
|
||||||
init(
|
init(
|
||||||
url: URL,
|
url: URL,
|
||||||
deletingURLOnSuccess: Bool,
|
deletingURLOnSuccess: Bool,
|
||||||
|
@ -84,123 +80,136 @@ extension AddHostView {
|
||||||
.navigationTitle(L10n.AddProfile.Shared.title)
|
.navigationTitle(L10n.AddProfile.Shared.title)
|
||||||
.themeSecondaryView()
|
.themeSecondaryView()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
// MARK: -
|
||||||
private var mainView: some View {
|
|
||||||
AddProfileView.ProfileNameSection(
|
|
||||||
profileName: $viewModel.profileName,
|
|
||||||
errorMessage: viewModel.errorMessage
|
|
||||||
) {
|
|
||||||
processProfile(replacingExisting: false)
|
|
||||||
}.onAppear {
|
|
||||||
viewModel.presetName(withURL: url)
|
|
||||||
}.disabled(isComplete)
|
|
||||||
|
|
||||||
if !isComplete {
|
private extension AddHostView.NameView {
|
||||||
if viewModel.requiresPassphrase {
|
|
||||||
encryptionSection
|
@ViewBuilder
|
||||||
}
|
var mainView: some View {
|
||||||
let headers = profileManager.headers.sorted()
|
AddProfileView.ProfileNameSection(
|
||||||
if !headers.isEmpty {
|
profileName: $viewModel.profileName,
|
||||||
AddProfileView.ExistingProfilesSection(
|
errorMessage: viewModel.errorMessage
|
||||||
headers: headers,
|
) {
|
||||||
profileName: $viewModel.profileName
|
processProfile(replacingExisting: false)
|
||||||
)
|
}.onAppear {
|
||||||
}
|
viewModel.presetName(withURL: url)
|
||||||
} else {
|
}.disabled(isComplete)
|
||||||
completeSection
|
|
||||||
|
if !isComplete {
|
||||||
|
if viewModel.requiresPassphrase {
|
||||||
|
encryptionSection
|
||||||
}
|
}
|
||||||
}
|
let headers = profileManager.headers.sorted()
|
||||||
|
if !headers.isEmpty {
|
||||||
private var encryptionSection: some View {
|
AddProfileView.ExistingProfilesSection(
|
||||||
Section {
|
headers: headers,
|
||||||
SecureField(L10n.AddProfile.Host.Sections.Encryption.footer, text: $viewModel.encryptionPassphrase) {
|
profileName: $viewModel.profileName
|
||||||
processProfile(replacingExisting: false)
|
|
||||||
}
|
|
||||||
} header: {
|
|
||||||
Text(L10n.Global.Strings.encryption)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var completeSection: some View {
|
|
||||||
Section {
|
|
||||||
Text(Unlocalized.Network.url)
|
|
||||||
.withTrailingText(url.lastPathComponent)
|
|
||||||
viewModel.processedProfile.vpnProtocols.first.map {
|
|
||||||
Text(L10n.Global.Strings.protocol)
|
|
||||||
.withTrailingText($0.description)
|
|
||||||
}
|
|
||||||
} header: {
|
|
||||||
Text(L10n.AddProfile.Shared.title)
|
|
||||||
} footer: {
|
|
||||||
themeErrorMessage(viewModel.errorMessage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var hiddenAccountLink: some View {
|
|
||||||
NavigationLink("", isActive: $isEnteringCredentials) {
|
|
||||||
AddProfileView.AccountWrapperView(
|
|
||||||
profile: $viewModel.processedProfile,
|
|
||||||
bindings: bindings
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
completeSection
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var nextString: String {
|
var encryptionSection: some View {
|
||||||
if !viewModel.processedProfile.isPlaceholder {
|
Section {
|
||||||
return viewModel.processedProfile.requiresCredentials ? L10n.Global.Strings.next : L10n.Global.Strings.save
|
SecureField(L10n.AddProfile.Host.Sections.Encryption.footer, text: $viewModel.encryptionPassphrase) {
|
||||||
} else {
|
processProfile(replacingExisting: false)
|
||||||
return L10n.Global.Strings.next
|
|
||||||
}
|
}
|
||||||
|
} header: {
|
||||||
|
Text(L10n.Global.Strings.encryption)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func requestResourcePermissions() {
|
var completeSection: some View {
|
||||||
_ = url.startAccessingSecurityScopedResource()
|
Section {
|
||||||
}
|
Text(Unlocalized.Network.url)
|
||||||
|
.withTrailingText(url.lastPathComponent)
|
||||||
private func dropResourcePermissions() {
|
viewModel.processedProfile.vpnProtocols.first.map {
|
||||||
url.stopAccessingSecurityScopedResource()
|
Text(L10n.Global.Strings.protocol)
|
||||||
}
|
.withTrailingText($0.description)
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private func alertOverwriteActions() -> some View {
|
|
||||||
Button(role: .destructive) {
|
|
||||||
processProfile(replacingExisting: true)
|
|
||||||
} label: {
|
|
||||||
Text(L10n.Global.Strings.ok)
|
|
||||||
}
|
|
||||||
Button(role: .cancel) {
|
|
||||||
} label: {
|
|
||||||
Text(L10n.Global.Strings.cancel)
|
|
||||||
}
|
}
|
||||||
|
} header: {
|
||||||
|
Text(L10n.AddProfile.Shared.title)
|
||||||
|
} footer: {
|
||||||
|
themeErrorMessage(viewModel.errorMessage)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func alertOverwriteMessage() -> some View {
|
var hiddenAccountLink: some View {
|
||||||
Text(L10n.AddProfile.Shared.Alerts.Overwrite.message)
|
NavigationLink("", isActive: $isEnteringCredentials) {
|
||||||
}
|
AddProfileView.AccountWrapperView(
|
||||||
|
profile: $viewModel.processedProfile,
|
||||||
private func processProfile(replacingExisting: Bool) {
|
bindings: bindings
|
||||||
viewModel.processURL(
|
|
||||||
url,
|
|
||||||
with: profileManager,
|
|
||||||
replacingExisting: replacingExisting,
|
|
||||||
deletingURLOnSuccess: deletingURLOnSuccess
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func saveProfile() {
|
var nextString: String {
|
||||||
let result = viewModel.addProcessedProfile(to: profileManager)
|
if !viewModel.processedProfile.isPlaceholder {
|
||||||
guard result else {
|
return viewModel.processedProfile.requiresCredentials ? L10n.Global.Strings.next : L10n.Global.Strings.save
|
||||||
return
|
} else {
|
||||||
}
|
return L10n.Global.Strings.next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let profile = viewModel.processedProfile
|
@ViewBuilder
|
||||||
if profile.requiresCredentials {
|
func alertOverwriteActions() -> some View {
|
||||||
isEnteringCredentials = true
|
Button(role: .destructive) {
|
||||||
} else {
|
processProfile(replacingExisting: true)
|
||||||
bindings.isPresented = false
|
} label: {
|
||||||
profileManager.didCreateProfile.send(profile)
|
Text(L10n.Global.Strings.ok)
|
||||||
}
|
}
|
||||||
|
Button(role: .cancel) {
|
||||||
|
} label: {
|
||||||
|
Text(L10n.Global.Strings.cancel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func alertOverwriteMessage() -> some View {
|
||||||
|
Text(L10n.AddProfile.Shared.Alerts.Overwrite.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
var isComplete: Bool {
|
||||||
|
!viewModel.processedProfile.isPlaceholder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: -
|
||||||
|
|
||||||
|
private extension AddHostView.NameView {
|
||||||
|
func requestResourcePermissions() {
|
||||||
|
_ = url.startAccessingSecurityScopedResource()
|
||||||
|
}
|
||||||
|
|
||||||
|
func dropResourcePermissions() {
|
||||||
|
url.stopAccessingSecurityScopedResource()
|
||||||
|
}
|
||||||
|
|
||||||
|
func processProfile(replacingExisting: Bool) {
|
||||||
|
viewModel.processURL(
|
||||||
|
url,
|
||||||
|
with: profileManager,
|
||||||
|
replacingExisting: replacingExisting,
|
||||||
|
deletingURLOnSuccess: deletingURLOnSuccess
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveProfile() {
|
||||||
|
let result = viewModel.addProcessedProfile(to: profileManager)
|
||||||
|
guard result else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let profile = viewModel.processedProfile
|
||||||
|
if profile.requiresCredentials {
|
||||||
|
isEnteringCredentials = true
|
||||||
|
} else {
|
||||||
|
bindings.isPresented = false
|
||||||
|
profileManager.didCreateProfile.send(profile)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -61,11 +61,11 @@ struct AddProfileMenu: View {
|
||||||
Button(action: presentHostFileImporter) {
|
Button(action: presentHostFileImporter) {
|
||||||
Label(L10n.Menu.Contextual.AddProfile.fromFiles, systemImage: themeHostFilesImage)
|
Label(L10n.Menu.Contextual.AddProfile.fromFiles, systemImage: themeHostFilesImage)
|
||||||
}
|
}
|
||||||
// Button {
|
// Button {
|
||||||
// // TODO: add profile from text
|
// // TODO: add profile from text
|
||||||
// } label: {
|
// } label: {
|
||||||
// Label(L10n.Organizer.Menus.AddProfile.fromText, systemImage: themeHostTextImage)
|
// Label(L10n.Organizer.Menus.AddProfile.fromText, systemImage: themeHostTextImage)
|
||||||
// }
|
// }
|
||||||
if let urls = importedURLs, !urls.isEmpty {
|
if let urls = importedURLs, !urls.isEmpty {
|
||||||
Divider()
|
Divider()
|
||||||
ForEach(urls, id: \.absoluteString, content: importedURLRow)
|
ForEach(urls, id: \.absoluteString, content: importedURLRow)
|
||||||
|
@ -74,9 +74,14 @@ struct AddProfileMenu: View {
|
||||||
themeAddMenuImage.asSystemImage
|
themeAddMenuImage.asSystemImage
|
||||||
}.sheet(item: $modalType, content: presentedModal)
|
}.sheet(item: $modalType, content: presentedModal)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: -
|
||||||
|
|
||||||
|
private extension AddProfileMenu {
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func presentedModal(_ modalType: ModalType) -> some View {
|
func presentedModal(_ modalType: ModalType) -> some View {
|
||||||
switch modalType {
|
switch modalType {
|
||||||
case .addProvider:
|
case .addProvider:
|
||||||
NavigationView {
|
NavigationView {
|
||||||
|
@ -100,7 +105,7 @@ struct AddProfileMenu: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var isModalPresented: Binding<Bool> {
|
var isModalPresented: Binding<Bool> {
|
||||||
.init {
|
.init {
|
||||||
modalType != nil
|
modalType != nil
|
||||||
} set: {
|
} set: {
|
||||||
|
@ -110,13 +115,13 @@ struct AddProfileMenu: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func importedURLRow(_ url: URL) -> some View {
|
func importedURLRow(_ url: URL) -> some View {
|
||||||
Button(L10n.Menu.Contextual.AddProfile.imported(url.lastPathComponent)) {
|
Button(L10n.Menu.Contextual.AddProfile.imported(url.lastPathComponent)) {
|
||||||
presentAddHost(withURL: url, deletingURLOnSuccess: true)
|
presentAddHost(withURL: url, deletingURLOnSuccess: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var importedURLs: [URL]? {
|
var importedURLs: [URL]? {
|
||||||
do {
|
do {
|
||||||
let url = FileManager.default.userURL(for: .documentDirectory, appending: nil)
|
let url = FileManager.default.userURL(for: .documentDirectory, appending: nil)
|
||||||
let list = try FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil)
|
let list = try FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil)
|
||||||
|
@ -129,16 +134,18 @@ struct AddProfileMenu: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension AddProfileMenu {
|
// MARK: -
|
||||||
private func presentAddProvider() {
|
|
||||||
|
private extension AddProfileMenu {
|
||||||
|
func presentAddProvider() {
|
||||||
modalType = .addProvider
|
modalType = .addProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
private func presentAddHost(withURL url: URL, deletingURLOnSuccess: Bool) {
|
func presentAddHost(withURL url: URL, deletingURLOnSuccess: Bool) {
|
||||||
modalType = .addHost(url, deletingURLOnSuccess)
|
modalType = .addHost(url, deletingURLOnSuccess)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func presentHostFileImporter() {
|
func presentHostFileImporter() {
|
||||||
|
|
||||||
// XXX: iOS bug, hack around crappy bug when dismissing by swiping down
|
// XXX: iOS bug, hack around crappy bug when dismissing by swiping down
|
||||||
//
|
//
|
||||||
|
|
|
@ -84,50 +84,58 @@ extension AddProviderView {
|
||||||
message: alertOverwriteMessage
|
message: alertOverwriteMessage
|
||||||
).navigationTitle(providerMetadata.fullName)
|
).navigationTitle(providerMetadata.fullName)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var hiddenAccountLink: some View {
|
// MARK: -
|
||||||
NavigationLink("", isActive: $isEnteringCredentials) {
|
|
||||||
AddProfileView.AccountWrapperView(
|
|
||||||
profile: $profile,
|
|
||||||
bindings: bindings
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
private extension AddProviderView.NameView {
|
||||||
private func alertOverwriteActions() -> some View {
|
var hiddenAccountLink: some View {
|
||||||
Button(role: .destructive) {
|
NavigationLink("", isActive: $isEnteringCredentials) {
|
||||||
saveProfile(replacingExisting: true)
|
AddProfileView.AccountWrapperView(
|
||||||
} label: {
|
profile: $profile,
|
||||||
Text(L10n.Global.Strings.ok)
|
bindings: bindings
|
||||||
}
|
|
||||||
Button(role: .cancel) {
|
|
||||||
} label: {
|
|
||||||
Text(L10n.Global.Strings.cancel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func alertOverwriteMessage() -> some View {
|
|
||||||
Text(L10n.AddProfile.Shared.Alerts.Overwrite.message)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func saveProfile(replacingExisting: Bool) {
|
|
||||||
let addedProfile = viewModel.addProfile(
|
|
||||||
profile,
|
|
||||||
to: profileManager,
|
|
||||||
replacingExisting: replacingExisting
|
|
||||||
)
|
)
|
||||||
guard let addedProfile = addedProfile else {
|
}
|
||||||
return
|
}
|
||||||
}
|
|
||||||
profile = addedProfile
|
|
||||||
|
|
||||||
if profile.requiresCredentials {
|
@ViewBuilder
|
||||||
isEnteringCredentials = true
|
func alertOverwriteActions() -> some View {
|
||||||
} else {
|
Button(role: .destructive) {
|
||||||
bindings.isPresented = false
|
saveProfile(replacingExisting: true)
|
||||||
profileManager.didCreateProfile.send(profile)
|
} label: {
|
||||||
}
|
Text(L10n.Global.Strings.ok)
|
||||||
|
}
|
||||||
|
Button(role: .cancel) {
|
||||||
|
} label: {
|
||||||
|
Text(L10n.Global.Strings.cancel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func alertOverwriteMessage() -> some View {
|
||||||
|
Text(L10n.AddProfile.Shared.Alerts.Overwrite.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: -
|
||||||
|
|
||||||
|
private extension AddProviderView.NameView {
|
||||||
|
func saveProfile(replacingExisting: Bool) {
|
||||||
|
let addedProfile = viewModel.addProfile(
|
||||||
|
profile,
|
||||||
|
to: profileManager,
|
||||||
|
replacingExisting: replacingExisting
|
||||||
|
)
|
||||||
|
guard let addedProfile = addedProfile else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
profile = addedProfile
|
||||||
|
|
||||||
|
if profile.requiresCredentials {
|
||||||
|
isEnteringCredentials = true
|
||||||
|
} else {
|
||||||
|
bindings.isPresented = false
|
||||||
|
profileManager.didCreateProfile.send(profile)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -41,23 +41,6 @@ struct AddProviderView: View {
|
||||||
self.bindings = bindings
|
self.bindings = bindings
|
||||||
}
|
}
|
||||||
|
|
||||||
private var providers: [ProviderMetadata] {
|
|
||||||
providerManager.allProviders()
|
|
||||||
.filter {
|
|
||||||
$0.supportedVPNProtocols.contains(viewModel.selectedVPNProtocol)
|
|
||||||
}.sorted()
|
|
||||||
}
|
|
||||||
|
|
||||||
private var availableVPNProtocols: [VPNProtocolType] {
|
|
||||||
var protos: Set<VPNProtocolType> = []
|
|
||||||
providers.forEach {
|
|
||||||
$0.supportedVPNProtocols.forEach {
|
|
||||||
protos.insert($0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return protos.sorted()
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
ForEach(providers, id: \.navigationId, content: hiddenProviderLink)
|
ForEach(providers, id: \.navigationId, content: hiddenProviderLink)
|
||||||
|
@ -80,10 +63,14 @@ struct AddProviderView: View {
|
||||||
PaywallView(isPresented: $viewModel.isPaywallPresented)
|
PaywallView(isPresented: $viewModel.isPaywallPresented)
|
||||||
}.themeGlobal()
|
}.themeGlobal()
|
||||||
}.navigationTitle(L10n.AddProfile.Shared.title)
|
}.navigationTitle(L10n.AddProfile.Shared.title)
|
||||||
.themeSecondaryView()
|
.themeSecondaryView()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var mainSection: some View {
|
// MARK: -
|
||||||
|
|
||||||
|
private extension AddProviderView {
|
||||||
|
var mainSection: some View {
|
||||||
Section {
|
Section {
|
||||||
let protos = availableVPNProtocols
|
let protos = availableVPNProtocols
|
||||||
if !protos.isEmpty {
|
if !protos.isEmpty {
|
||||||
|
@ -100,7 +87,7 @@ struct AddProviderView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var providersSection: some View {
|
var providersSection: some View {
|
||||||
Section {
|
Section {
|
||||||
ForEach(providers, content: providerRow)
|
ForEach(providers, content: providerRow)
|
||||||
} footer: {
|
} footer: {
|
||||||
|
@ -108,7 +95,7 @@ struct AddProviderView: View {
|
||||||
}.disabled(viewModel.isFetchingAnyProvider)
|
}.disabled(viewModel.isFetchingAnyProvider)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func providerRow(_ metadata: ProviderMetadata) -> some View {
|
func providerRow(_ metadata: ProviderMetadata) -> some View {
|
||||||
Button {
|
Button {
|
||||||
presentOrPurchaseProvider(metadata)
|
presentOrPurchaseProvider(metadata)
|
||||||
} label: {
|
} label: {
|
||||||
|
@ -116,7 +103,7 @@ struct AddProviderView: View {
|
||||||
}.withTrailingProgress(when: viewModel.isFetchingProvider(metadata.name))
|
}.withTrailingProgress(when: viewModel.isFetchingProvider(metadata.name))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func hiddenProviderLink(_ metadata: ProviderMetadata) -> some View {
|
func hiddenProviderLink(_ metadata: ProviderMetadata) -> some View {
|
||||||
NavigationLink("", tag: metadata, selection: $viewModel.selectedProvider) {
|
NavigationLink("", tag: metadata, selection: $viewModel.selectedProvider) {
|
||||||
NameView(
|
NameView(
|
||||||
profile: $viewModel.pendingProfile,
|
profile: $viewModel.pendingProfile,
|
||||||
|
@ -126,33 +113,28 @@ struct AddProviderView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var updateListButton: some View {
|
var updateListButton: some View {
|
||||||
Button(L10n.AddProfile.Provider.Items.updateList) {
|
Button(L10n.AddProfile.Provider.Items.updateList) {
|
||||||
viewModel.updateIndex(providerManager)
|
viewModel.updateIndex(providerManager)
|
||||||
}.withTrailingProgress(when: viewModel.isUpdatingIndex)
|
}.withTrailingProgress(when: viewModel.isUpdatingIndex)
|
||||||
.disabled(viewModel.isUpdatingIndex)
|
.disabled(viewModel.isUpdatingIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
// eligibility: select or purchase provider
|
var providers: [ProviderMetadata] {
|
||||||
private func presentOrPurchaseProvider(_ metadata: ProviderMetadata) {
|
providerManager.allProviders()
|
||||||
guard productManager.isEligible(forProvider: metadata.name) else {
|
.filter {
|
||||||
viewModel.presentPaywall()
|
$0.supportedVPNProtocols.contains(viewModel.selectedVPNProtocol)
|
||||||
return
|
}.sorted()
|
||||||
|
}
|
||||||
|
|
||||||
|
var availableVPNProtocols: [VPNProtocolType] {
|
||||||
|
var protos: Set<VPNProtocolType> = []
|
||||||
|
providers.forEach {
|
||||||
|
$0.supportedVPNProtocols.forEach {
|
||||||
|
protos.insert($0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
viewModel.selectProvider(metadata, providerManager)
|
return protos.sorted()
|
||||||
}
|
|
||||||
|
|
||||||
private func onErrorMessage(_ message: String?, _ scrollProxy: ScrollViewProxy) {
|
|
||||||
guard message != nil else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
scrollToErrorMessage(scrollProxy)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension AddProviderView {
|
|
||||||
private func scrollToErrorMessage(_ proxy: ScrollViewProxy) {
|
|
||||||
proxy.maybeScrollTo(providers.last?.id, animated: true)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -161,3 +143,28 @@ private extension ProviderMetadata {
|
||||||
"navigation.\(name)"
|
"navigation.\(name)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: -
|
||||||
|
|
||||||
|
private extension AddProviderView {
|
||||||
|
|
||||||
|
// eligibility: select or purchase provider
|
||||||
|
func presentOrPurchaseProvider(_ metadata: ProviderMetadata) {
|
||||||
|
guard productManager.isEligible(forProvider: metadata.name) else {
|
||||||
|
viewModel.presentPaywall()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
viewModel.selectProvider(metadata, providerManager)
|
||||||
|
}
|
||||||
|
|
||||||
|
func onErrorMessage(_ message: String?, _ scrollProxy: ScrollViewProxy) {
|
||||||
|
guard message != nil else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scrollToErrorMessage(scrollProxy)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scrollToErrorMessage(_ proxy: ScrollViewProxy) {
|
||||||
|
proxy.maybeScrollTo(providers.last?.id, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -89,8 +89,12 @@ struct DebugLogView: View {
|
||||||
.navigationTitle(title)
|
.navigationTitle(title)
|
||||||
.themeDebugLogStyle()
|
.themeDebugLogStyle()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var contentView: some View {
|
// MARK: -
|
||||||
|
|
||||||
|
private extension DebugLogView {
|
||||||
|
var contentView: some View {
|
||||||
LazyVStack {
|
LazyVStack {
|
||||||
ForEach(logLines.indices, id: \.self) {
|
ForEach(logLines.indices, id: \.self) {
|
||||||
Text(logLines[$0])
|
Text(logLines[$0])
|
||||||
|
@ -100,32 +104,11 @@ struct DebugLogView: View {
|
||||||
// TODO: layout, a slight padding would be nice, but it glitches on first touch
|
// TODO: layout, a slight padding would be nice, but it glitches on first touch
|
||||||
}
|
}
|
||||||
|
|
||||||
private func refreshLog(scrollingToLatestWith scrollProxy: ScrollViewProxy?) {
|
func sharingActivityView() -> some View {
|
||||||
logLines = url.trailingLines(bytes: maxBytes)
|
|
||||||
if let scrollProxy = scrollProxy {
|
|
||||||
scrollToLatestUpdate(scrollProxy)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func refreshLog(_: Date) {
|
|
||||||
refreshLog(scrollingToLatestWith: nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension DebugLogView {
|
|
||||||
private func shareDebugLog() {
|
|
||||||
guard !logLines.isEmpty else {
|
|
||||||
assertionFailure("Log is empty, why could it share?")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
isSharing = true
|
|
||||||
}
|
|
||||||
|
|
||||||
private func sharingActivityView() -> some View {
|
|
||||||
ActivityView(activityItems: sharingItems)
|
ActivityView(activityItems: sharingItems)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var sharingItems: [Any] {
|
var sharingItems: [Any] {
|
||||||
let raw = logLines.joined(separator: "\n")
|
let raw = logLines.joined(separator: "\n")
|
||||||
let data = DebugLog(content: raw)
|
let data = DebugLog(content: raw)
|
||||||
.decoratedData(appName, appVersion)
|
.decoratedData(appName, appVersion)
|
||||||
|
@ -143,8 +126,29 @@ extension DebugLogView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension DebugLogView {
|
// MARK: -
|
||||||
private func copyDebugLog() {
|
|
||||||
|
private extension DebugLogView {
|
||||||
|
func refreshLog(_: Date) {
|
||||||
|
refreshLog(scrollingToLatestWith: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshLog(scrollingToLatestWith scrollProxy: ScrollViewProxy?) {
|
||||||
|
logLines = url.trailingLines(bytes: maxBytes)
|
||||||
|
if let scrollProxy = scrollProxy {
|
||||||
|
scrollToLatestUpdate(scrollProxy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func shareDebugLog() {
|
||||||
|
guard !logLines.isEmpty else {
|
||||||
|
assertionFailure("Log is empty, why could it share?")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isSharing = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyDebugLog() {
|
||||||
guard !logLines.isEmpty else {
|
guard !logLines.isEmpty else {
|
||||||
assertionFailure("Log is empty, why could it copy?")
|
assertionFailure("Log is empty, why could it copy?")
|
||||||
return
|
return
|
||||||
|
@ -155,10 +159,8 @@ extension DebugLogView {
|
||||||
|
|
||||||
Utils.copyToPasteboard(content)
|
Utils.copyToPasteboard(content)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
extension DebugLogView {
|
func scrollToLatestUpdate(_ proxy: ScrollViewProxy) {
|
||||||
private func scrollToLatestUpdate(_ proxy: ScrollViewProxy) {
|
|
||||||
proxy.maybeScrollTo(logLines.count - 1, anchor: .bottomLeading)
|
proxy.maybeScrollTo(logLines.count - 1, anchor: .bottomLeading)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -47,10 +47,6 @@ extension DiagnosticsView {
|
||||||
|
|
||||||
private let providerName: ProviderName?
|
private let providerName: ProviderName?
|
||||||
|
|
||||||
private var isEligibleForFeedback: Bool {
|
|
||||||
productManager.isEligibleForFeedback()
|
|
||||||
}
|
|
||||||
|
|
||||||
@State private var isReportingIssue = false
|
@State private var isReportingIssue = false
|
||||||
|
|
||||||
@State private var isAlertPresented = false
|
@State private var isAlertPresented = false
|
||||||
|
@ -85,75 +81,77 @@ extension DiagnosticsView {
|
||||||
message: alertMessage
|
message: alertMessage
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func alertActions(_ alertType: AlertType) -> some View {
|
|
||||||
Button(role: .cancel) {
|
|
||||||
} label: {
|
|
||||||
Text(L10n.Global.Strings.ok)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func alertMessage(_ alertType: AlertType) -> some View {
|
|
||||||
switch alertType {
|
|
||||||
case .emailNotConfigured:
|
|
||||||
return Text(L10n.Global.Messages.emailNotConfigured)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var serverConfigurationSection: some View {
|
|
||||||
Section {
|
|
||||||
let cfg = currentServerConfiguration
|
|
||||||
NavigationLink(L10n.Diagnostics.Items.ServerConfiguration.caption) {
|
|
||||||
cfg.map {
|
|
||||||
EndpointAdvancedView.OpenVPNView(
|
|
||||||
builder: .constant($0),
|
|
||||||
isReadonly: true,
|
|
||||||
isServerPushed: true
|
|
||||||
).navigationTitle(L10n.Diagnostics.Items.ServerConfiguration.caption)
|
|
||||||
}
|
|
||||||
}.disabled(cfg == nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var debugLogSection: some View {
|
|
||||||
Section {
|
|
||||||
DebugLogSection(appLogURL: appLogURL, tunnelLogURL: tunnelLogURL)
|
|
||||||
Toggle(L10n.Diagnostics.Items.MasksPrivateData.caption, isOn: $vpnManager.masksPrivateData)
|
|
||||||
} header: {
|
|
||||||
Text(L10n.DebugLog.title)
|
|
||||||
} footer: {
|
|
||||||
Text(L10n.Diagnostics.Sections.DebugLog.footer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var issueReporterSection: some View {
|
|
||||||
Section {
|
|
||||||
Button(L10n.Diagnostics.Items.ReportIssue.caption, action: presentReportIssue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func reportIssueView() -> some View {
|
|
||||||
let logURL = vpnManager.debugLogURL(forProtocol: vpnProtocol)
|
|
||||||
var metadata: ProviderMetadata?
|
|
||||||
var lastUpdate: Date?
|
|
||||||
if let name = providerName {
|
|
||||||
metadata = providerManager.provider(withName: name)
|
|
||||||
lastUpdate = providerManager.lastUpdate(name, vpnProtocol: vpnProtocol)
|
|
||||||
}
|
|
||||||
|
|
||||||
return ReportIssueView(
|
|
||||||
isPresented: $isReportingIssue,
|
|
||||||
vpnProtocol: vpnProtocol,
|
|
||||||
logURL: logURL,
|
|
||||||
providerMetadata: metadata,
|
|
||||||
lastUpdate: lastUpdate
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension DiagnosticsView.OpenVPNView {
|
// MARK: -
|
||||||
private var currentServerConfiguration: OpenVPN.ConfigurationBuilder? {
|
|
||||||
|
private extension DiagnosticsView.OpenVPNView {
|
||||||
|
func alertActions(_ alertType: AlertType) -> some View {
|
||||||
|
Button(role: .cancel) {
|
||||||
|
} label: {
|
||||||
|
Text(L10n.Global.Strings.ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func alertMessage(_ alertType: AlertType) -> some View {
|
||||||
|
switch alertType {
|
||||||
|
case .emailNotConfigured:
|
||||||
|
return Text(L10n.Global.Messages.emailNotConfigured)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var serverConfigurationSection: some View {
|
||||||
|
Section {
|
||||||
|
let cfg = currentServerConfiguration
|
||||||
|
NavigationLink(L10n.Diagnostics.Items.ServerConfiguration.caption) {
|
||||||
|
cfg.map {
|
||||||
|
EndpointAdvancedView.OpenVPNView(
|
||||||
|
builder: .constant($0),
|
||||||
|
isReadonly: true,
|
||||||
|
isServerPushed: true
|
||||||
|
).navigationTitle(L10n.Diagnostics.Items.ServerConfiguration.caption)
|
||||||
|
}
|
||||||
|
}.disabled(cfg == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var debugLogSection: some View {
|
||||||
|
Section {
|
||||||
|
DiagnosticsView.DebugLogSection(appLogURL: appLogURL, tunnelLogURL: tunnelLogURL)
|
||||||
|
Toggle(L10n.Diagnostics.Items.MasksPrivateData.caption, isOn: $vpnManager.masksPrivateData)
|
||||||
|
} header: {
|
||||||
|
Text(L10n.DebugLog.title)
|
||||||
|
} footer: {
|
||||||
|
Text(L10n.Diagnostics.Sections.DebugLog.footer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var issueReporterSection: some View {
|
||||||
|
Section {
|
||||||
|
Button(L10n.Diagnostics.Items.ReportIssue.caption, action: presentReportIssue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func reportIssueView() -> some View {
|
||||||
|
let logURL = vpnManager.debugLogURL(forProtocol: vpnProtocol)
|
||||||
|
var metadata: ProviderMetadata?
|
||||||
|
var lastUpdate: Date?
|
||||||
|
if let name = providerName {
|
||||||
|
metadata = providerManager.provider(withName: name)
|
||||||
|
lastUpdate = providerManager.lastUpdate(name, vpnProtocol: vpnProtocol)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ReportIssueView(
|
||||||
|
isPresented: $isReportingIssue,
|
||||||
|
vpnProtocol: vpnProtocol,
|
||||||
|
logURL: logURL,
|
||||||
|
providerMetadata: metadata,
|
||||||
|
lastUpdate: lastUpdate
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentServerConfiguration: OpenVPN.ConfigurationBuilder? {
|
||||||
guard currentVPNState.vpnStatus == .connected else {
|
guard currentVPNState.vpnStatus == .connected else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
@ -164,17 +162,23 @@ extension DiagnosticsView.OpenVPNView {
|
||||||
return cfg.builder(withFallbacks: false)
|
return cfg.builder(withFallbacks: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var appLogURL: URL? {
|
var appLogURL: URL? {
|
||||||
Passepartout.shared.logger.logFile
|
Passepartout.shared.logger.logFile
|
||||||
}
|
}
|
||||||
|
|
||||||
private var tunnelLogURL: URL? {
|
var tunnelLogURL: URL? {
|
||||||
vpnManager.debugLogURL(forProtocol: vpnProtocol)
|
vpnManager.debugLogURL(forProtocol: vpnProtocol)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var isEligibleForFeedback: Bool {
|
||||||
|
productManager.isEligibleForFeedback()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension DiagnosticsView.OpenVPNView {
|
// MARK: -
|
||||||
private func presentReportIssue() {
|
|
||||||
|
private extension DiagnosticsView.OpenVPNView {
|
||||||
|
func presentReportIssue() {
|
||||||
guard MailComposerView.canSendMail() else {
|
guard MailComposerView.canSendMail() else {
|
||||||
openReportIssueMailTo()
|
openReportIssueMailTo()
|
||||||
return
|
return
|
||||||
|
@ -182,7 +186,7 @@ extension DiagnosticsView.OpenVPNView {
|
||||||
isReportingIssue = true
|
isReportingIssue = true
|
||||||
}
|
}
|
||||||
|
|
||||||
private func openReportIssueMailTo() {
|
func openReportIssueMailTo() {
|
||||||
let V = Unlocalized.Issues.self
|
let V = Unlocalized.Issues.self
|
||||||
let body = V.body(V.template, DebugLog(content: "--").decoratedString())
|
let body = V.body(V.template, DebugLog(content: "--").decoratedString())
|
||||||
|
|
||||||
|
|
|
@ -50,12 +50,14 @@ extension DiagnosticsView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension DiagnosticsView.WireGuardView {
|
// MARK: -
|
||||||
private var appLogURL: URL? {
|
|
||||||
|
private extension DiagnosticsView.WireGuardView {
|
||||||
|
var appLogURL: URL? {
|
||||||
Passepartout.shared.logger.logFile
|
Passepartout.shared.logger.logFile
|
||||||
}
|
}
|
||||||
|
|
||||||
private var tunnelLogURL: URL? {
|
var tunnelLogURL: URL? {
|
||||||
vpnManager.debugLogURL(forProtocol: .wireGuard)
|
vpnManager.debugLogURL(forProtocol: .wireGuard)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -60,33 +60,37 @@ extension DiagnosticsView {
|
||||||
appLink
|
appLink
|
||||||
tunnelLink
|
tunnelLink
|
||||||
}
|
}
|
||||||
|
}
|
||||||
private var appLink: some View {
|
}
|
||||||
navigationLink(
|
|
||||||
withTitle: L10n.Diagnostics.Items.AppLog.title,
|
// MARK: -
|
||||||
url: appLogURL,
|
|
||||||
refreshInterval: nil
|
private extension DiagnosticsView.DebugLogSection {
|
||||||
)
|
var appLink: some View {
|
||||||
}
|
navigationLink(
|
||||||
|
withTitle: L10n.Diagnostics.Items.AppLog.title,
|
||||||
private var tunnelLink: some View {
|
url: appLogURL,
|
||||||
navigationLink(
|
refreshInterval: nil
|
||||||
withTitle: Unlocalized.VPN.vpn,
|
)
|
||||||
url: tunnelLogURL,
|
}
|
||||||
refreshInterval: refreshInterval
|
|
||||||
)
|
var tunnelLink: some View {
|
||||||
}
|
navigationLink(
|
||||||
|
withTitle: Unlocalized.VPN.vpn,
|
||||||
private func navigationLink(withTitle title: String, url: URL?, refreshInterval: TimeInterval?) -> some View {
|
url: tunnelLogURL,
|
||||||
NavigationLink(title) {
|
refreshInterval: refreshInterval
|
||||||
url.map {
|
)
|
||||||
DebugLogView(
|
}
|
||||||
title: title,
|
|
||||||
url: $0,
|
func navigationLink(withTitle title: String, url: URL?, refreshInterval: TimeInterval?) -> some View {
|
||||||
refreshInterval: refreshInterval
|
NavigationLink(title) {
|
||||||
)
|
url.map {
|
||||||
}
|
DebugLogView(
|
||||||
}.disabled(url == nil)
|
title: title,
|
||||||
}
|
url: $0,
|
||||||
|
refreshInterval: refreshInterval
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.disabled(url == nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -76,8 +76,12 @@ struct DonateView: View {
|
||||||
}
|
}
|
||||||
}.themeAnimation(on: productManager.isRefreshingProducts)
|
}.themeAnimation(on: productManager.isRefreshingProducts)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func alertActions(_ alertType: AlertType) -> some View {
|
// MARK: -
|
||||||
|
|
||||||
|
private extension DonateView {
|
||||||
|
func alertActions(_ alertType: AlertType) -> some View {
|
||||||
switch alertType {
|
switch alertType {
|
||||||
case .thankYou:
|
case .thankYou:
|
||||||
return Button(role: .cancel) {
|
return Button(role: .cancel) {
|
||||||
|
@ -87,14 +91,14 @@ struct DonateView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func alertMessage(_ alertType: AlertType) -> some View {
|
func alertMessage(_ alertType: AlertType) -> some View {
|
||||||
switch alertType {
|
switch alertType {
|
||||||
case .thankYou:
|
case .thankYou:
|
||||||
return Text(L10n.Donate.Alerts.Purchase.Success.message)
|
return Text(L10n.Donate.Alerts.Purchase.Success.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var productsSection: some View {
|
var productsSection: some View {
|
||||||
Section {
|
Section {
|
||||||
if !productManager.isRefreshingProducts {
|
if !productManager.isRefreshingProducts {
|
||||||
ForEach(productManager.donations, id: \.productIdentifier, content: productRow)
|
ForEach(productManager.donations, id: \.productIdentifier, content: productRow)
|
||||||
|
@ -109,7 +113,7 @@ struct DonateView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func productRow(_ product: SKProduct) -> some View {
|
func productRow(_ product: SKProduct) -> some View {
|
||||||
HStack {
|
HStack {
|
||||||
Button(product.localizedTitle) {
|
Button(product.localizedTitle) {
|
||||||
purchaseProduct(product)
|
purchaseProduct(product)
|
||||||
|
@ -127,13 +131,27 @@ struct DonateView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension DonateView {
|
private extension ProductManager {
|
||||||
private func purchaseProduct(_ product: SKProduct) {
|
var donations: [SKProduct] {
|
||||||
|
products.filter { product in
|
||||||
|
LocalProduct.allDonations.contains {
|
||||||
|
$0.matchesStoreKitProduct(product)
|
||||||
|
}
|
||||||
|
}.sorted {
|
||||||
|
$0.price.decimalValue < $1.price.decimalValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: -
|
||||||
|
|
||||||
|
private extension DonateView {
|
||||||
|
func purchaseProduct(_ product: SKProduct) {
|
||||||
pendingDonationIdentifier = product.productIdentifier
|
pendingDonationIdentifier = product.productIdentifier
|
||||||
productManager.purchase(product, completionHandler: handlePurchaseResult)
|
productManager.purchase(product, completionHandler: handlePurchaseResult)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handlePurchaseResult(_ result: Result<InAppPurchaseResult, Error>) {
|
func handlePurchaseResult(_ result: Result<InAppPurchaseResult, Error>) {
|
||||||
switch result {
|
switch result {
|
||||||
case .success(let value):
|
case .success(let value):
|
||||||
if case .done = value {
|
if case .done = value {
|
||||||
|
@ -152,15 +170,3 @@ extension DonateView {
|
||||||
pendingDonationIdentifier = nil
|
pendingDonationIdentifier = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension ProductManager {
|
|
||||||
var donations: [SKProduct] {
|
|
||||||
products.filter { product in
|
|
||||||
LocalProduct.allDonations.contains {
|
|
||||||
$0.matchesStoreKitProduct(product)
|
|
||||||
}
|
|
||||||
}.sorted {
|
|
||||||
$0.price.decimalValue < $1.price.decimalValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -67,8 +67,10 @@ extension EndpointAdvancedView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension EndpointAdvancedView.OpenVPNView {
|
// MARK: -
|
||||||
private func pullSection(configuration: OpenVPN.Configuration) -> some View {
|
|
||||||
|
private extension EndpointAdvancedView.OpenVPNView {
|
||||||
|
func pullSection(configuration: OpenVPN.Configuration) -> some View {
|
||||||
configuration.pullMask.map { mask in
|
configuration.pullMask.map { mask in
|
||||||
Section {
|
Section {
|
||||||
ForEach(mask, id: \.self) {
|
ForEach(mask, id: \.self) {
|
||||||
|
@ -80,7 +82,7 @@ extension EndpointAdvancedView.OpenVPNView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var ipv4Section: some View {
|
var ipv4Section: some View {
|
||||||
Section {
|
Section {
|
||||||
if let settings = builder.ipv4 {
|
if let settings = builder.ipv4 {
|
||||||
themeLongContentLinkDefault(
|
themeLongContentLinkDefault(
|
||||||
|
@ -105,7 +107,7 @@ extension EndpointAdvancedView.OpenVPNView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var ipv6Section: some View {
|
var ipv6Section: some View {
|
||||||
Section {
|
Section {
|
||||||
if let settings = builder.ipv6 {
|
if let settings = builder.ipv6 {
|
||||||
themeLongContentLinkDefault(
|
themeLongContentLinkDefault(
|
||||||
|
@ -130,7 +132,7 @@ extension EndpointAdvancedView.OpenVPNView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func communicationSection(configuration: OpenVPN.Configuration) -> some View {
|
func communicationSection(configuration: OpenVPN.Configuration) -> some View {
|
||||||
configuration.communicationSettings.map { settings in
|
configuration.communicationSettings.map { settings in
|
||||||
Section {
|
Section {
|
||||||
settings.cipher.map {
|
settings.cipher.map {
|
||||||
|
@ -157,7 +159,7 @@ extension EndpointAdvancedView.OpenVPNView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var communicationEditableSection: some View {
|
var communicationEditableSection: some View {
|
||||||
Section {
|
Section {
|
||||||
themeTextPicker(
|
themeTextPicker(
|
||||||
L10n.Endpoint.Advanced.Openvpn.Items.Cipher.caption,
|
L10n.Endpoint.Advanced.Openvpn.Items.Cipher.caption,
|
||||||
|
@ -186,7 +188,7 @@ extension EndpointAdvancedView.OpenVPNView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func compressionSection(configuration: OpenVPN.Configuration) -> some View {
|
func compressionSection(configuration: OpenVPN.Configuration) -> some View {
|
||||||
configuration.compressionSettings.map { settings in
|
configuration.compressionSettings.map { settings in
|
||||||
Section {
|
Section {
|
||||||
settings.framing.map {
|
settings.framing.map {
|
||||||
|
@ -203,7 +205,7 @@ extension EndpointAdvancedView.OpenVPNView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var compressionEditableSection: some View {
|
var compressionEditableSection: some View {
|
||||||
Section {
|
Section {
|
||||||
themeTextPicker(
|
themeTextPicker(
|
||||||
L10n.Endpoint.Advanced.Openvpn.Items.CompressionFraming.caption,
|
L10n.Endpoint.Advanced.Openvpn.Items.CompressionFraming.caption,
|
||||||
|
@ -222,7 +224,7 @@ extension EndpointAdvancedView.OpenVPNView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func dnsSection(configuration: OpenVPN.Configuration) -> some View {
|
func dnsSection(configuration: OpenVPN.Configuration) -> some View {
|
||||||
configuration.dnsSettings.map { settings in
|
configuration.dnsSettings.map { settings in
|
||||||
Section {
|
Section {
|
||||||
ForEach(settings.servers, id: \.self) {
|
ForEach(settings.servers, id: \.self) {
|
||||||
|
@ -239,7 +241,7 @@ extension EndpointAdvancedView.OpenVPNView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func proxySection(configuration: OpenVPN.Configuration) -> some View {
|
func proxySection(configuration: OpenVPN.Configuration) -> some View {
|
||||||
configuration.proxySettings.map { settings in
|
configuration.proxySettings.map { settings in
|
||||||
Section {
|
Section {
|
||||||
settings.proxy.map {
|
settings.proxy.map {
|
||||||
|
@ -260,7 +262,7 @@ extension EndpointAdvancedView.OpenVPNView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var tlsSection: some View {
|
var tlsSection: some View {
|
||||||
Section {
|
Section {
|
||||||
builder.ca.map { ca in
|
builder.ca.map { ca in
|
||||||
themeLongContentLink(
|
themeLongContentLink(
|
||||||
|
@ -294,7 +296,7 @@ extension EndpointAdvancedView.OpenVPNView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func otherSection(configuration: OpenVPN.Configuration) -> some View {
|
func otherSection(configuration: OpenVPN.Configuration) -> some View {
|
||||||
configuration.otherSettings.map { settings in
|
configuration.otherSettings.map { settings in
|
||||||
Section {
|
Section {
|
||||||
settings.keepAlive.map {
|
settings.keepAlive.map {
|
||||||
|
|
|
@ -44,8 +44,10 @@ extension EndpointAdvancedView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension EndpointAdvancedView.WireGuardView {
|
// MARK: -
|
||||||
private var keySection: some View {
|
|
||||||
|
private extension EndpointAdvancedView.WireGuardView {
|
||||||
|
var keySection: some View {
|
||||||
Section {
|
Section {
|
||||||
themeLongContentLink(L10n.Global.Strings.privateKey, content: .constant(builder.privateKey))
|
themeLongContentLink(L10n.Global.Strings.privateKey, content: .constant(builder.privateKey))
|
||||||
themeLongContentLink(L10n.Global.Strings.publicKey, content: .constant(builder.publicKey))
|
themeLongContentLink(L10n.Global.Strings.publicKey, content: .constant(builder.publicKey))
|
||||||
|
@ -54,7 +56,7 @@ extension EndpointAdvancedView.WireGuardView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var addressesSection: some View {
|
var addressesSection: some View {
|
||||||
Section {
|
Section {
|
||||||
ForEach(builder.addresses, id: \.self, content: Text.init)
|
ForEach(builder.addresses, id: \.self, content: Text.init)
|
||||||
} header: {
|
} header: {
|
||||||
|
@ -62,7 +64,7 @@ extension EndpointAdvancedView.WireGuardView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func dnsSection(configuration: WireGuard.Configuration) -> some View {
|
func dnsSection(configuration: WireGuard.Configuration) -> some View {
|
||||||
configuration.dnsSettings.map { settings in
|
configuration.dnsSettings.map { settings in
|
||||||
Section {
|
Section {
|
||||||
ForEach(settings.servers, id: \.self) {
|
ForEach(settings.servers, id: \.self) {
|
||||||
|
@ -79,7 +81,7 @@ extension EndpointAdvancedView.WireGuardView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var mtuSection: some View {
|
var mtuSection: some View {
|
||||||
builder.mtu.map { mtu in
|
builder.mtu.map { mtu in
|
||||||
Section {
|
Section {
|
||||||
Text(Unlocalized.Network.mtu)
|
Text(Unlocalized.Network.mtu)
|
||||||
|
|
|
@ -39,10 +39,6 @@ extension EndpointView {
|
||||||
|
|
||||||
@Binding private var customEndpoint: Endpoint?
|
@Binding private var customEndpoint: Endpoint?
|
||||||
|
|
||||||
private var isConfigurationReadonly: Bool {
|
|
||||||
currentProfile.value.isProvider
|
|
||||||
}
|
|
||||||
|
|
||||||
@State private var isFirstAppearance = true
|
@State private var isFirstAppearance = true
|
||||||
|
|
||||||
@State private var isAutomatic = false
|
@State private var isAutomatic = false
|
||||||
|
@ -129,14 +125,16 @@ extension EndpointView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension EndpointView.OpenVPNView {
|
// MARK: -
|
||||||
private var mainSection: some View {
|
|
||||||
|
private extension EndpointView.OpenVPNView {
|
||||||
|
var mainSection: some View {
|
||||||
Section {
|
Section {
|
||||||
Toggle(L10n.Global.Strings.automatic, isOn: $isAutomatic.themeAnimation())
|
Toggle(L10n.Global.Strings.automatic, isOn: $isAutomatic.themeAnimation())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var filtersSection: some View {
|
var filtersSection: some View {
|
||||||
Section {
|
Section {
|
||||||
themeTextPicker(
|
themeTextPicker(
|
||||||
L10n.Global.Strings.protocol,
|
L10n.Global.Strings.protocol,
|
||||||
|
@ -153,7 +151,7 @@ extension EndpointView.OpenVPNView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var addressesSection: some View {
|
var addressesSection: some View {
|
||||||
Section {
|
Section {
|
||||||
filteredRemotes.map {
|
filteredRemotes.map {
|
||||||
ForEach($0, content: button(forEndpoint:))
|
ForEach($0, content: button(forEndpoint:))
|
||||||
|
@ -163,7 +161,7 @@ extension EndpointView.OpenVPNView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var advancedSection: some View {
|
var advancedSection: some View {
|
||||||
Section {
|
Section {
|
||||||
let caption = L10n.Endpoint.Advanced.title
|
let caption = L10n.Endpoint.Advanced.title
|
||||||
NavigationLink(caption) {
|
NavigationLink(caption) {
|
||||||
|
@ -176,7 +174,7 @@ extension EndpointView.OpenVPNView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func button(forEndpoint endpoint: Endpoint?) -> some View {
|
func button(forEndpoint endpoint: Endpoint?) -> some View {
|
||||||
Button {
|
Button {
|
||||||
customEndpoint = endpoint
|
customEndpoint = endpoint
|
||||||
presentationMode.wrappedValue.dismiss()
|
presentationMode.wrappedValue.dismiss()
|
||||||
|
@ -185,56 +183,12 @@ extension EndpointView.OpenVPNView {
|
||||||
}.withTrailingCheckmark(when: customEndpoint == endpoint)
|
}.withTrailingCheckmark(when: customEndpoint == endpoint)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func text(forEndpoint endpoint: Endpoint?) -> some View {
|
func text(forEndpoint endpoint: Endpoint?) -> some View {
|
||||||
Text(endpoint?.address ?? L10n.Global.Strings.automatic)
|
Text(endpoint?.address ?? L10n.Global.Strings.automatic)
|
||||||
.themeLongTextStyle()
|
.themeLongTextStyle()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
extension EndpointView.OpenVPNView {
|
var availableSocketTypes: [SocketType] {
|
||||||
private func onToggleAutomatic(_ value: Bool) {
|
|
||||||
if value {
|
|
||||||
guard customEndpoint != nil else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
customEndpoint = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func preselectFilters(once: Bool) {
|
|
||||||
guard !once || isFirstAppearance else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
isFirstAppearance = false
|
|
||||||
|
|
||||||
if let customEndpoint = customEndpoint {
|
|
||||||
isAutomatic = false
|
|
||||||
selectedSocketType = customEndpoint.proto.socketType
|
|
||||||
selectedPort = customEndpoint.proto.port
|
|
||||||
} else {
|
|
||||||
isAutomatic = true
|
|
||||||
guard let socketType = availableSocketTypes.first else {
|
|
||||||
assertionFailure("No socket types, empty remotes?")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
selectedSocketType = socketType
|
|
||||||
preselectPort(forSocketType: socketType)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func preselectPort(forSocketType socketType: SocketType) {
|
|
||||||
let supported = allPorts(forSocketType: socketType)
|
|
||||||
guard !supported.contains(selectedPort) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard let port = supported.first else {
|
|
||||||
assertionFailure("No ports, empty remotes?")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
selectedPort = port
|
|
||||||
}
|
|
||||||
|
|
||||||
private var availableSocketTypes: [SocketType] {
|
|
||||||
guard let remotes = builder.remotes else {
|
guard let remotes = builder.remotes else {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
@ -256,7 +210,7 @@ extension EndpointView.OpenVPNView {
|
||||||
return availableTypes
|
return availableTypes
|
||||||
}
|
}
|
||||||
|
|
||||||
private func allPorts(forSocketType socketType: SocketType) -> [UInt16] {
|
func allPorts(forSocketType socketType: SocketType) -> [UInt16] {
|
||||||
guard let remotes = builder.remotes else {
|
guard let remotes = builder.remotes else {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
@ -266,15 +220,63 @@ extension EndpointView.OpenVPNView {
|
||||||
return Array(allPorts).sorted()
|
return Array(allPorts).sorted()
|
||||||
}
|
}
|
||||||
|
|
||||||
private var filteredRemotes: [Endpoint]? {
|
var filteredRemotes: [Endpoint]? {
|
||||||
builder.remotes?.filter {
|
builder.remotes?.filter {
|
||||||
$0.proto.socketType == selectedSocketType && $0.proto.port == selectedPort
|
$0.proto.socketType == selectedSocketType && $0.proto.port == selectedPort
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var isConfigurationReadonly: Bool {
|
||||||
|
currentProfile.value.isProvider
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension EndpointView.OpenVPNView {
|
// MARK: -
|
||||||
private func scrollToCustomEndpoint(_ proxy: ScrollViewProxy) {
|
|
||||||
|
private extension EndpointView.OpenVPNView {
|
||||||
|
func onToggleAutomatic(_ value: Bool) {
|
||||||
|
if value {
|
||||||
|
guard customEndpoint != nil else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
customEndpoint = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func preselectFilters(once: Bool) {
|
||||||
|
guard !once || isFirstAppearance else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isFirstAppearance = false
|
||||||
|
|
||||||
|
if let customEndpoint = customEndpoint {
|
||||||
|
isAutomatic = false
|
||||||
|
selectedSocketType = customEndpoint.proto.socketType
|
||||||
|
selectedPort = customEndpoint.proto.port
|
||||||
|
} else {
|
||||||
|
isAutomatic = true
|
||||||
|
guard let socketType = availableSocketTypes.first else {
|
||||||
|
assertionFailure("No socket types, empty remotes?")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
selectedSocketType = socketType
|
||||||
|
preselectPort(forSocketType: socketType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func preselectPort(forSocketType socketType: SocketType) {
|
||||||
|
let supported = allPorts(forSocketType: socketType)
|
||||||
|
guard !supported.contains(selectedPort) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let port = supported.first else {
|
||||||
|
assertionFailure("No ports, empty remotes?")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
selectedPort = port
|
||||||
|
}
|
||||||
|
|
||||||
|
func scrollToCustomEndpoint(_ proxy: ScrollViewProxy) {
|
||||||
proxy.maybeScrollTo(customEndpoint?.id)
|
proxy.maybeScrollTo(customEndpoint?.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -88,8 +88,10 @@ extension EndpointView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension EndpointView.WireGuardView {
|
// MARK: -
|
||||||
private var peersSections: some View {
|
|
||||||
|
private extension EndpointView.WireGuardView {
|
||||||
|
var peersSections: some View {
|
||||||
|
|
||||||
// TODO: WireGuard, make peers editable
|
// TODO: WireGuard, make peers editable
|
||||||
// if !isReadonly {
|
// if !isReadonly {
|
||||||
|
@ -103,7 +105,7 @@ extension EndpointView.WireGuardView {
|
||||||
// }
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func section(forPeerAt peerIndex: Int) -> some View {
|
func section(forPeerAt peerIndex: Int) -> some View {
|
||||||
Section {
|
Section {
|
||||||
themeLongContentLink(
|
themeLongContentLink(
|
||||||
L10n.Global.Strings.publicKey,
|
L10n.Global.Strings.publicKey,
|
||||||
|
@ -132,7 +134,7 @@ extension EndpointView.WireGuardView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var advancedSection: some View {
|
var advancedSection: some View {
|
||||||
Section {
|
Section {
|
||||||
let caption = L10n.Endpoint.Advanced.title
|
let caption = L10n.Endpoint.Advanced.title
|
||||||
NavigationLink(caption) {
|
NavigationLink(caption) {
|
||||||
|
|
|
@ -64,8 +64,12 @@ struct InteractiveConnectionView: View {
|
||||||
}
|
}
|
||||||
}.navigationTitle(profile.header.name)
|
}.navigationTitle(profile.header.name)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func saveAccount() {
|
// MARK: -
|
||||||
|
|
||||||
|
private extension InteractiveConnectionView {
|
||||||
|
func saveAccount() {
|
||||||
Task {
|
Task {
|
||||||
try? await vpnManager.connect(with: profile.id, newPassword: password)
|
try? await vpnManager.connect(with: profile.id, newPassword: password)
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,10 +29,6 @@ import SwiftUI
|
||||||
struct NetworkSettingsView: View {
|
struct NetworkSettingsView: View {
|
||||||
@ObservedObject private var currentProfile: ObservableProfile
|
@ObservedObject private var currentProfile: ObservableProfile
|
||||||
|
|
||||||
private var vpnProtocol: VPNProtocolType {
|
|
||||||
currentProfile.value.currentVPNProtocol
|
|
||||||
}
|
|
||||||
|
|
||||||
@State private var settings = Profile.NetworkSettings()
|
@State private var settings = Profile.NetworkSettings()
|
||||||
|
|
||||||
init(currentProfile: ObservableProfile) {
|
init(currentProfile: ObservableProfile) {
|
||||||
|
@ -64,36 +60,14 @@ struct NetworkSettingsView: View {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// EditButton()
|
|
||||||
// .disabled(!isAnythingManual)
|
|
||||||
|
|
||||||
private var isAnythingManual: Bool {
|
|
||||||
// if settings.gateway.choice == .manual {
|
|
||||||
// return true
|
|
||||||
// }
|
|
||||||
if settings.dns.choice == .manual {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if settings.proxy.choice == .manual {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
// if settings.mtu.choice == .manual {
|
|
||||||
// return true
|
|
||||||
// }
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
private func mapNotEmpty(elements: [IdentifiableString]) -> [IdentifiableString] {
|
|
||||||
elements
|
|
||||||
.filter { !$0.string.isEmpty }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: -
|
||||||
|
|
||||||
// MARK: Gateway
|
// MARK: Gateway
|
||||||
|
|
||||||
extension NetworkSettingsView {
|
private extension NetworkSettingsView {
|
||||||
private var gatewayView: some View {
|
var gatewayView: some View {
|
||||||
Section {
|
Section {
|
||||||
Toggle(L10n.Global.Strings.automatic, isOn: $settings.isAutomaticGateway.themeAnimation())
|
Toggle(L10n.Global.Strings.automatic, isOn: $settings.isAutomaticGateway.themeAnimation())
|
||||||
|
|
||||||
|
@ -109,10 +83,10 @@ extension NetworkSettingsView {
|
||||||
|
|
||||||
// MARK: DNS
|
// MARK: DNS
|
||||||
|
|
||||||
extension NetworkSettingsView {
|
private extension NetworkSettingsView {
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var dnsView: some View {
|
var dnsView: some View {
|
||||||
Section {
|
Section {
|
||||||
Toggle(L10n.Global.Strings.automatic, isOn: $settings.isAutomaticDNS.themeAnimation())
|
Toggle(L10n.Global.Strings.automatic, isOn: $settings.isAutomaticDNS.themeAnimation())
|
||||||
|
|
||||||
|
@ -148,17 +122,17 @@ extension NetworkSettingsView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var dnsManualHTTPSRow: some View {
|
var dnsManualHTTPSRow: some View {
|
||||||
TextField(Unlocalized.Placeholders.dohURL, text: $settings.dns.dnsHTTPSURL.toString())
|
TextField(Unlocalized.Placeholders.dohURL, text: $settings.dns.dnsHTTPSURL.toString())
|
||||||
.themeValidURL(settings.dns.dnsHTTPSURL?.absoluteString)
|
.themeValidURL(settings.dns.dnsHTTPSURL?.absoluteString)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var dnsManualTLSRow: some View {
|
var dnsManualTLSRow: some View {
|
||||||
TextField(Unlocalized.Placeholders.dotServerName, text: $settings.dns.dnsTLSServerName ?? "")
|
TextField(Unlocalized.Placeholders.dotServerName, text: $settings.dns.dnsTLSServerName ?? "")
|
||||||
.themeValidDNSOverTLSServerName(settings.dns.dnsTLSServerName)
|
.themeValidDNSOverTLSServerName(settings.dns.dnsTLSServerName)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var dnsManualServers: some View {
|
var dnsManualServers: some View {
|
||||||
Section {
|
Section {
|
||||||
EditableTextList(
|
EditableTextList(
|
||||||
elements: $settings.dns.dnsServers ?? [],
|
elements: $settings.dns.dnsServers ?? [],
|
||||||
|
@ -179,12 +153,12 @@ extension NetworkSettingsView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var dnsManualDomainRow: some View {
|
var dnsManualDomainRow: some View {
|
||||||
TextField(L10n.Global.Strings.domain, text: $settings.dns.dnsDomain ?? "")
|
TextField(L10n.Global.Strings.domain, text: $settings.dns.dnsDomain ?? "")
|
||||||
.themeValidDomainName(settings.dns.dnsDomain)
|
.themeValidDomainName(settings.dns.dnsDomain)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var dnsManualSearchDomains: some View {
|
var dnsManualSearchDomains: some View {
|
||||||
Section {
|
Section {
|
||||||
EditableTextList(
|
EditableTextList(
|
||||||
elements: $settings.dns.dnsSearchDomains ?? [],
|
elements: $settings.dns.dnsSearchDomains ?? [],
|
||||||
|
@ -208,10 +182,10 @@ extension NetworkSettingsView {
|
||||||
|
|
||||||
// MARK: Proxy
|
// MARK: Proxy
|
||||||
|
|
||||||
extension NetworkSettingsView {
|
private extension NetworkSettingsView {
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var proxyView: some View {
|
var proxyView: some View {
|
||||||
Section {
|
Section {
|
||||||
Toggle(L10n.Global.Strings.automatic, isOn: $settings.isAutomaticProxy.themeAnimation())
|
Toggle(L10n.Global.Strings.automatic, isOn: $settings.isAutomaticProxy.themeAnimation())
|
||||||
|
|
||||||
|
@ -249,7 +223,7 @@ extension NetworkSettingsView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var proxyManualBypassDomains: some View {
|
var proxyManualBypassDomains: some View {
|
||||||
Section {
|
Section {
|
||||||
EditableTextList(
|
EditableTextList(
|
||||||
elements: $settings.proxy.proxyBypassDomains ?? [],
|
elements: $settings.proxy.proxyBypassDomains ?? [],
|
||||||
|
@ -273,8 +247,8 @@ extension NetworkSettingsView {
|
||||||
|
|
||||||
// MARK: MTU
|
// MARK: MTU
|
||||||
|
|
||||||
extension NetworkSettingsView {
|
private extension NetworkSettingsView {
|
||||||
private var mtuView: some View {
|
var mtuView: some View {
|
||||||
Section {
|
Section {
|
||||||
Toggle(L10n.Global.Strings.automatic, isOn: $settings.isAutomaticMTU.themeAnimation())
|
Toggle(L10n.Global.Strings.automatic, isOn: $settings.isAutomaticMTU.themeAnimation())
|
||||||
|
|
||||||
|
@ -291,3 +265,35 @@ extension NetworkSettingsView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: Global
|
||||||
|
|
||||||
|
private extension NetworkSettingsView {
|
||||||
|
var vpnProtocol: VPNProtocolType {
|
||||||
|
currentProfile.value.currentVPNProtocol
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditButton()
|
||||||
|
// .disabled(!isAnythingManual)
|
||||||
|
|
||||||
|
var isAnythingManual: Bool {
|
||||||
|
// if settings.gateway.choice == .manual {
|
||||||
|
// return true
|
||||||
|
// }
|
||||||
|
if settings.dns.choice == .manual {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if settings.proxy.choice == .manual {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// if settings.mtu.choice == .manual {
|
||||||
|
// return true
|
||||||
|
// }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapNotEmpty(elements: [IdentifiableString]) -> [IdentifiableString] {
|
||||||
|
elements
|
||||||
|
.filter { !$0.string.isEmpty }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -44,47 +44,40 @@ extension OnDemandView {
|
||||||
Text(L10n.Global.Strings.add)
|
Text(L10n.Global.Strings.add)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func mapElements(elements: [IdentifiableString]) -> [IdentifiableString] {
|
// MARK: -
|
||||||
elements
|
|
||||||
.filter { !$0.string.isEmpty }
|
|
||||||
.sorted { $0.string.lowercased() < $1.string.lowercased() }
|
|
||||||
}
|
|
||||||
|
|
||||||
private func ssidRow(callback: EditableTextFieldCallback) -> some View {
|
private extension OnDemandView.SSIDList {
|
||||||
Group {
|
func mapElements(elements: [IdentifiableString]) -> [IdentifiableString] {
|
||||||
if callback.isNewElement {
|
elements
|
||||||
|
.filter { !$0.string.isEmpty }
|
||||||
|
.sorted { $0.string.lowercased() < $1.string.lowercased() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func ssidRow(callback: EditableTextFieldCallback) -> some View {
|
||||||
|
Group {
|
||||||
|
if callback.isNewElement {
|
||||||
|
ssidField(callback: callback)
|
||||||
|
} else {
|
||||||
|
Toggle(isOn: isSSIDOn(callback.text.wrappedValue)) {
|
||||||
ssidField(callback: callback)
|
ssidField(callback: callback)
|
||||||
} else {
|
|
||||||
Toggle(isOn: isSSIDOn(callback.text.wrappedValue)) {
|
|
||||||
ssidField(callback: callback)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func ssidField(callback: EditableTextFieldCallback) -> some View {
|
|
||||||
TextField(
|
|
||||||
Unlocalized.Network.ssid,
|
|
||||||
text: callback.text,
|
|
||||||
onEditingChanged: callback.onEditingChanged,
|
|
||||||
onCommit: callback.onCommit
|
|
||||||
).themeValidSSID(callback.text.wrappedValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func requestSSID(_ text: Binding<String>) {
|
|
||||||
Task { @MainActor in
|
|
||||||
let ssid = try await reader.currentSSID()
|
|
||||||
if !withSSIDs.keys.contains(ssid) {
|
|
||||||
text.wrappedValue = ssid
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
extension OnDemandView.SSIDList {
|
func ssidField(callback: EditableTextFieldCallback) -> some View {
|
||||||
private var allSSIDs: Binding<[String]> {
|
TextField(
|
||||||
|
Unlocalized.Network.ssid,
|
||||||
|
text: callback.text,
|
||||||
|
onEditingChanged: callback.onEditingChanged,
|
||||||
|
onCommit: callback.onCommit
|
||||||
|
).themeValidSSID(callback.text.wrappedValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
var allSSIDs: Binding<[String]> {
|
||||||
.init {
|
.init {
|
||||||
Array(withSSIDs.keys)
|
Array(withSSIDs.keys)
|
||||||
} set: { newValue in
|
} set: { newValue in
|
||||||
|
@ -104,7 +97,7 @@ extension OnDemandView.SSIDList {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var onSSIDs: Binding<Set<String>> {
|
var onSSIDs: Binding<Set<String>> {
|
||||||
.init {
|
.init {
|
||||||
Set(withSSIDs.filter {
|
Set(withSSIDs.filter {
|
||||||
$0.value
|
$0.value
|
||||||
|
@ -130,7 +123,7 @@ extension OnDemandView.SSIDList {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func isSSIDOn(_ ssid: String) -> Binding<Bool> {
|
func isSSIDOn(_ ssid: String) -> Binding<Bool> {
|
||||||
.init {
|
.init {
|
||||||
withSSIDs[ssid] ?? false
|
withSSIDs[ssid] ?? false
|
||||||
} set: {
|
} set: {
|
||||||
|
@ -138,3 +131,16 @@ extension OnDemandView.SSIDList {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: -
|
||||||
|
|
||||||
|
private extension OnDemandView.SSIDList {
|
||||||
|
func requestSSID(_ text: Binding<String>) {
|
||||||
|
Task { @MainActor in
|
||||||
|
let ssid = try await reader.currentSSID()
|
||||||
|
if !withSSIDs.keys.contains(ssid) {
|
||||||
|
text.wrappedValue = ssid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -31,10 +31,6 @@ struct OnDemandView: View {
|
||||||
|
|
||||||
@ObservedObject private var currentProfile: ObservableProfile
|
@ObservedObject private var currentProfile: ObservableProfile
|
||||||
|
|
||||||
private var isEligibleForSiri: Bool {
|
|
||||||
productManager.isEligible(forFeature: .siriShortcuts)
|
|
||||||
}
|
|
||||||
|
|
||||||
@State private var onDemand = Profile.OnDemand()
|
@State private var onDemand = Profile.OnDemand()
|
||||||
|
|
||||||
init(currentProfile: ObservableProfile) {
|
init(currentProfile: ObservableProfile) {
|
||||||
|
@ -66,21 +62,23 @@ struct OnDemandView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension OnDemandView {
|
// MARK: -
|
||||||
private var enabledView: some View {
|
|
||||||
|
private extension OnDemandView {
|
||||||
|
var enabledView: some View {
|
||||||
Section {
|
Section {
|
||||||
Toggle(L10n.Global.Strings.enabled, isOn: $onDemand.isEnabled.themeAnimation())
|
Toggle(L10n.Global.Strings.enabled, isOn: $onDemand.isEnabled.themeAnimation())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var mainView: some View {
|
var mainView: some View {
|
||||||
if Utils.hasCellularData() {
|
if Utils.hasCellularData() {
|
||||||
Section {
|
Section {
|
||||||
Toggle(L10n.OnDemand.Items.Mobile.caption, isOn: $onDemand.withMobileNetwork)
|
Toggle(L10n.OnDemand.Items.Mobile.caption, isOn: $onDemand.withMobileNetwork)
|
||||||
} header: {
|
} header: {
|
||||||
// TODO: on-demand, restore when "trusted networks" -> "on-demand"
|
// TODO: on-demand, restore when "trusted networks" -> "on-demand"
|
||||||
// Text(L10n.Profile.Sections.Trusted.header)
|
// Text(L10n.Profile.Sections.Trusted.header)
|
||||||
}
|
}
|
||||||
Section {
|
Section {
|
||||||
SSIDList(withSSIDs: $onDemand.withSSIDs)
|
SSIDList(withSSIDs: $onDemand.withSSIDs)
|
||||||
|
@ -90,7 +88,7 @@ extension OnDemandView {
|
||||||
Toggle(L10n.OnDemand.Items.Ethernet.caption, isOn: $onDemand.withEthernetNetwork)
|
Toggle(L10n.OnDemand.Items.Ethernet.caption, isOn: $onDemand.withEthernetNetwork)
|
||||||
} header: {
|
} header: {
|
||||||
// TODO: on-demand, restore when "trusted networks" -> "on-demand"
|
// TODO: on-demand, restore when "trusted networks" -> "on-demand"
|
||||||
// Text(L10n.Profile.Sections.Trusted.header)
|
// Text(L10n.Profile.Sections.Trusted.header)
|
||||||
}
|
}
|
||||||
Section {
|
Section {
|
||||||
SSIDList(withSSIDs: $onDemand.withSSIDs)
|
SSIDList(withSSIDs: $onDemand.withSSIDs)
|
||||||
|
@ -100,7 +98,7 @@ extension OnDemandView {
|
||||||
SSIDList(withSSIDs: $onDemand.withSSIDs)
|
SSIDList(withSSIDs: $onDemand.withSSIDs)
|
||||||
} header: {
|
} header: {
|
||||||
// TODO: on-demand, restore when "trusted networks" -> "on-demand"
|
// TODO: on-demand, restore when "trusted networks" -> "on-demand"
|
||||||
// Text(L10n.Profile.Sections.Trusted.header)
|
// Text(L10n.Profile.Sections.Trusted.header)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Section {
|
Section {
|
||||||
|
@ -110,8 +108,17 @@ extension OnDemandView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var isEligibleForSiri: Bool {
|
||||||
|
productManager.isEligible(forFeature: .siriShortcuts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: -
|
||||||
|
|
||||||
|
private extension OnDemandView {
|
||||||
|
|
||||||
// eligibility: donate intents if eligible for Siri
|
// eligibility: donate intents if eligible for Siri
|
||||||
private func donateMobileIntent(_ isEnabled: Bool) {
|
func donateMobileIntent(_ isEnabled: Bool) {
|
||||||
guard isEligibleForSiri else {
|
guard isEligibleForSiri else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -120,7 +127,7 @@ extension OnDemandView {
|
||||||
}
|
}
|
||||||
|
|
||||||
// eligibility: donate intents if eligible for Siri
|
// eligibility: donate intents if eligible for Siri
|
||||||
private func donateNetworkIntents(_: [String: Bool]) {
|
func donateNetworkIntents(_: [String: Bool]) {
|
||||||
guard isEligibleForSiri else {
|
guard isEligibleForSiri else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -34,21 +34,6 @@ extension OrganizerView {
|
||||||
|
|
||||||
@Binding private var modalType: ModalType?
|
@Binding private var modalType: ModalType?
|
||||||
|
|
||||||
private var interactiveProfile: Binding<Profile?> {
|
|
||||||
.init {
|
|
||||||
if case .interactiveAccount(let profile) = modalType {
|
|
||||||
return profile
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
} set: {
|
|
||||||
if let profile = $0 {
|
|
||||||
modalType = .interactiveAccount(profile: profile)
|
|
||||||
} else {
|
|
||||||
modalType = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
init(profile: Profile, isActiveProfile: Bool, modalType: Binding<ModalType?>) {
|
init(profile: Profile, isActiveProfile: Bool, modalType: Binding<ModalType?>) {
|
||||||
self.profile = profile
|
self.profile = profile
|
||||||
self.isActiveProfile = isActiveProfile
|
self.isActiveProfile = isActiveProfile
|
||||||
|
@ -77,3 +62,22 @@ extension OrganizerView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: -
|
||||||
|
|
||||||
|
private extension OrganizerView.ProfileRow {
|
||||||
|
var interactiveProfile: Binding<Profile?> {
|
||||||
|
.init {
|
||||||
|
if case .interactiveAccount(let profile) = modalType {
|
||||||
|
return profile
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
} set: {
|
||||||
|
if let profile = $0 {
|
||||||
|
modalType = .interactiveAccount(profile: profile)
|
||||||
|
} else {
|
||||||
|
modalType = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -49,87 +49,6 @@ extension OrganizerView {
|
||||||
profileManager.currentProfileId = $0.id
|
profileManager.currentProfileId = $0.id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var mainView: some View {
|
|
||||||
List {
|
|
||||||
if profileManager.hasProfiles {
|
|
||||||
|
|
||||||
// FIXME: iPad multitasking, navigation binding does not clear on pop
|
|
||||||
// - if listStyle is different than .sidebar
|
|
||||||
// - if listStyle is .sidebar but List has no Section
|
|
||||||
if themeIsiPadMultitasking {
|
|
||||||
Section {
|
|
||||||
profilesView
|
|
||||||
} header: {
|
|
||||||
Text(L10n.Global.Strings.profiles)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
profilesView
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.themeAnimation(on: profileManager.headers)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var profilesView: some View {
|
|
||||||
ForEach(sortedProfiles, content: profileRow(forProfile:))
|
|
||||||
.onDelete(perform: removeProfiles)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var emptyView: some View {
|
|
||||||
VStack {
|
|
||||||
Text(L10n.Organizer.Empty.noProfiles)
|
|
||||||
.themeInformativeTextStyle()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func profileRow(forProfile profile: Profile) -> some View {
|
|
||||||
NavigationLink(tag: profile.id, selection: $profileManager.currentProfileId) {
|
|
||||||
ProfileView()
|
|
||||||
} label: {
|
|
||||||
profileLabel(forProfile: profile)
|
|
||||||
}.contextMenu {
|
|
||||||
ProfileContextMenu(header: profile.header)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func profileLabel(forProfile profile: Profile) -> some View {
|
|
||||||
ProfileRow(
|
|
||||||
profile: profile,
|
|
||||||
isActiveProfile: profileManager.isActiveProfile(profile.id),
|
|
||||||
modalType: $modalType
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var sortedProfiles: [Profile] {
|
|
||||||
profileManager.profiles
|
|
||||||
.sorted()
|
|
||||||
// .sorted {
|
|
||||||
// if profileManager.isActiveProfile($0.id) {
|
|
||||||
// return true
|
|
||||||
// } else if profileManager.isActiveProfile($1.id) {
|
|
||||||
// return false
|
|
||||||
// } else {
|
|
||||||
// return $0 < $1
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
|
|
||||||
private func removeProfiles(at offsets: IndexSet) {
|
|
||||||
let currentHeaders = sortedProfiles
|
|
||||||
var toDelete: [UUID] = []
|
|
||||||
offsets.forEach {
|
|
||||||
toDelete.append(currentHeaders[$0].id)
|
|
||||||
}
|
|
||||||
withAnimation {
|
|
||||||
profileManager.removeProfiles(withIds: toDelete)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func performMigrationsIfNeeded() {
|
|
||||||
Task { @MainActor in
|
|
||||||
UpgradeManager.shared.doMigrations(profileManager)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -154,26 +73,117 @@ extension OrganizerView {
|
||||||
duplicateButton
|
duplicateButton
|
||||||
deleteButton
|
deleteButton
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var reconnectButton: some View {
|
// MARK: -
|
||||||
ProfileView.ReconnectButton()
|
|
||||||
}
|
|
||||||
|
|
||||||
private var duplicateButton: some View {
|
private extension OrganizerView.ProfilesList {
|
||||||
ProfileView.DuplicateButton(
|
var mainView: some View {
|
||||||
header: header,
|
List {
|
||||||
setAsCurrent: false
|
if profileManager.hasProfiles {
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var deleteButton: some View {
|
// FIXME: iPad multitasking, navigation binding does not clear on pop
|
||||||
DestructiveButton {
|
// - if listStyle is different than .sidebar
|
||||||
withAnimation {
|
// - if listStyle is .sidebar but List has no Section
|
||||||
profileManager.removeProfiles(withIds: [header.id])
|
if themeIsiPadMultitasking {
|
||||||
|
Section {
|
||||||
|
profilesView
|
||||||
|
} header: {
|
||||||
|
Text(L10n.Global.Strings.profiles)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
profilesView
|
||||||
}
|
}
|
||||||
} label: {
|
|
||||||
Label(L10n.Global.Strings.delete, systemImage: themeDeleteImage)
|
|
||||||
}
|
}
|
||||||
|
}.themeAnimation(on: profileManager.headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
var profilesView: some View {
|
||||||
|
ForEach(sortedProfiles, content: profileRow(forProfile:))
|
||||||
|
.onDelete(perform: removeProfiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
var emptyView: some View {
|
||||||
|
VStack {
|
||||||
|
Text(L10n.Organizer.Empty.noProfiles)
|
||||||
|
.themeInformativeTextStyle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func profileRow(forProfile profile: Profile) -> some View {
|
||||||
|
NavigationLink(tag: profile.id, selection: $profileManager.currentProfileId) {
|
||||||
|
ProfileView()
|
||||||
|
} label: {
|
||||||
|
profileLabel(forProfile: profile)
|
||||||
|
}.contextMenu {
|
||||||
|
OrganizerView.ProfileContextMenu(header: profile.header)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func profileLabel(forProfile profile: Profile) -> some View {
|
||||||
|
OrganizerView.ProfileRow(
|
||||||
|
profile: profile,
|
||||||
|
isActiveProfile: profileManager.isActiveProfile(profile.id),
|
||||||
|
modalType: $modalType
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var sortedProfiles: [Profile] {
|
||||||
|
profileManager.profiles
|
||||||
|
.sorted()
|
||||||
|
// .sorted {
|
||||||
|
// if profileManager.isActiveProfile($0.id) {
|
||||||
|
// return true
|
||||||
|
// } else if profileManager.isActiveProfile($1.id) {
|
||||||
|
// return false
|
||||||
|
// } else {
|
||||||
|
// return $0 < $1
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension OrganizerView.ProfileContextMenu {
|
||||||
|
var reconnectButton: some View {
|
||||||
|
ProfileView.ReconnectButton()
|
||||||
|
}
|
||||||
|
|
||||||
|
var duplicateButton: some View {
|
||||||
|
ProfileView.DuplicateButton(
|
||||||
|
header: header,
|
||||||
|
setAsCurrent: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var deleteButton: some View {
|
||||||
|
DestructiveButton {
|
||||||
|
withAnimation {
|
||||||
|
profileManager.removeProfiles(withIds: [header.id])
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label(L10n.Global.Strings.delete, systemImage: themeDeleteImage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: -
|
||||||
|
|
||||||
|
private extension OrganizerView.ProfilesList {
|
||||||
|
func removeProfiles(at offsets: IndexSet) {
|
||||||
|
let currentHeaders = sortedProfiles
|
||||||
|
var toDelete: [UUID] = []
|
||||||
|
offsets.forEach {
|
||||||
|
toDelete.append(currentHeaders[$0].id)
|
||||||
|
}
|
||||||
|
withAnimation {
|
||||||
|
profileManager.removeProfiles(withIds: toDelete)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func performMigrationsIfNeeded() {
|
||||||
|
Task { @MainActor in
|
||||||
|
UpgradeManager.shared.doMigrations(profileManager)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -51,31 +51,36 @@ extension OrganizerView {
|
||||||
.hidden()
|
.hidden()
|
||||||
.onAppear(perform: onAppear)
|
.onAppear(perform: onAppear)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
// MARK: -
|
||||||
private func onAppear() {
|
|
||||||
guard didHandleSubreddit else {
|
|
||||||
alertType = .subscribeReddit
|
|
||||||
isAlertPresented = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
private extension OrganizerView.SceneView {
|
||||||
// FIXME: iPad portrait/compact, loading current profile adds ProfileView() twice
|
|
||||||
//
|
@MainActor
|
||||||
// - from MainView
|
func onAppear() {
|
||||||
// - from NavigationLink destination in OrganizerView
|
guard didHandleSubreddit else {
|
||||||
//
|
alertType = .subscribeReddit
|
||||||
// can notice becase "Back" needs to be tapped twice to show sidebar
|
isAlertPresented = true
|
||||||
// workaround: set active profile but do not load as current (prevents NavigationLink activation)
|
return
|
||||||
//
|
}
|
||||||
guard isFirstLaunch else {
|
|
||||||
return
|
//
|
||||||
}
|
// FIXME: iPad portrait/compact, loading current profile adds ProfileView() twice
|
||||||
isFirstLaunch = false
|
//
|
||||||
if themeIdiom != .phone && !themeIsiPadPortrait, let activeProfileId = ProfileManager.shared.activeProfileId {
|
// - from MainView
|
||||||
ProfileManager.shared.currentProfileId = activeProfileId
|
// - from NavigationLink destination in OrganizerView
|
||||||
}
|
//
|
||||||
|
// can notice becase "Back" needs to be tapped twice to show sidebar
|
||||||
|
// workaround: set active profile but do not load as current (prevents NavigationLink activation)
|
||||||
|
//
|
||||||
|
guard isFirstLaunch else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isFirstLaunch = false
|
||||||
|
if themeIdiom != .phone && !themeIsiPadPortrait, let activeProfileId = ProfileManager.shared.activeProfileId {
|
||||||
|
ProfileManager.shared.currentProfileId = activeProfileId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -97,42 +97,21 @@ struct OrganizerView: View {
|
||||||
).onOpenURL(perform: onOpenURL)
|
).onOpenURL(perform: onOpenURL)
|
||||||
.themePrimaryView()
|
.themePrimaryView()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var hiddenSceneView: some View {
|
// MARK: -
|
||||||
|
|
||||||
|
private extension OrganizerView {
|
||||||
|
var hiddenSceneView: some View {
|
||||||
SceneView(
|
SceneView(
|
||||||
isAlertPresented: $isAlertPresented,
|
isAlertPresented: $isAlertPresented,
|
||||||
alertType: $alertType,
|
alertType: $alertType,
|
||||||
didHandleSubreddit: $didHandleSubreddit
|
didHandleSubreddit: $didHandleSubreddit
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
extension OrganizerView {
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func onHostFileImporterResult(_ result: Result<[URL], Error>) {
|
|
||||||
switch result {
|
|
||||||
case .success(let urls):
|
|
||||||
guard let url = urls.first else {
|
|
||||||
assertionFailure("Empty URLs from file importer?")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
Task {
|
|
||||||
await Task.maybeWait(forMilliseconds: Constants.Delays.xxxPresentFileImporter)
|
|
||||||
addProfileModalType = .addHost(url, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
case .failure(let error):
|
|
||||||
ErrorHandler.shared.handle(error, title: L10n.Menu.Contextual.AddProfile.fromFiles)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func onOpenURL(_ url: URL) {
|
|
||||||
addProfileModalType = .addHost(url, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func presentedModal(_ modalType: ModalType) -> some View {
|
func presentedModal(_ modalType: ModalType) -> some View {
|
||||||
switch modalType {
|
switch modalType {
|
||||||
case .interactiveAccount(let profile):
|
case .interactiveAccount(let profile):
|
||||||
NavigationView {
|
NavigationView {
|
||||||
|
@ -141,7 +120,7 @@ extension OrganizerView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func alertActions(_ alertType: AlertType) -> some View {
|
func alertActions(_ alertType: AlertType) -> some View {
|
||||||
switch alertType {
|
switch alertType {
|
||||||
case .subscribeReddit:
|
case .subscribeReddit:
|
||||||
return Group {
|
return Group {
|
||||||
|
@ -158,10 +137,37 @@ extension OrganizerView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func alertMessage(_ alertType: AlertType) -> some View {
|
func alertMessage(_ alertType: AlertType) -> some View {
|
||||||
switch alertType {
|
switch alertType {
|
||||||
case .subscribeReddit:
|
case .subscribeReddit:
|
||||||
return Text(L10n.Organizer.Alerts.Reddit.message)
|
return Text(L10n.Organizer.Alerts.Reddit.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: -
|
||||||
|
|
||||||
|
private extension OrganizerView {
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func onHostFileImporterResult(_ result: Result<[URL], Error>) {
|
||||||
|
switch result {
|
||||||
|
case .success(let urls):
|
||||||
|
guard let url = urls.first else {
|
||||||
|
assertionFailure("Empty URLs from file importer?")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Task {
|
||||||
|
await Task.maybeWait(forMilliseconds: Constants.Delays.xxxPresentFileImporter)
|
||||||
|
addProfileModalType = .addHost(url, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
case .failure(let error):
|
||||||
|
ErrorHandler.shared.handle(error, title: L10n.Menu.Contextual.AddProfile.fromFiles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func onOpenURL(_ url: URL) {
|
||||||
|
addProfileModalType = .addHost(url, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -35,8 +35,6 @@ extension PaywallView {
|
||||||
case restoring
|
case restoring
|
||||||
}
|
}
|
||||||
|
|
||||||
private typealias RowModel = (product: SKProduct, extra: String?)
|
|
||||||
|
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
|
||||||
@ObservedObject private var productManager: ProductManager
|
@ObservedObject private var productManager: ProductManager
|
||||||
|
@ -68,47 +66,186 @@ extension PaywallView {
|
||||||
}
|
}
|
||||||
}.themeAnimation(on: productManager.isRefreshingProducts)
|
}.themeAnimation(on: productManager.isRefreshingProducts)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var productsSection: some View {
|
private struct PurchaseRow: View {
|
||||||
Section {
|
var product: SKProduct?
|
||||||
if !productManager.isRefreshingProducts {
|
|
||||||
ForEach(productRowModels, id: \.product.productIdentifier, content: productRow)
|
let title: String
|
||||||
} else {
|
|
||||||
ProgressView()
|
let extra: String?
|
||||||
|
|
||||||
|
let action: () -> Void
|
||||||
|
|
||||||
|
let purchaseState: PaywallView.PurchaseView.PurchaseState?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
actionButton
|
||||||
|
.padding(.bottom, 5)
|
||||||
|
|
||||||
|
extra.map {
|
||||||
|
Text($0)
|
||||||
|
.frame(maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
}.padding([.top, .bottom])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private typealias RowModel = (product: SKProduct, extra: String?)
|
||||||
|
|
||||||
|
// MARK: -
|
||||||
|
|
||||||
|
private extension PaywallView.PurchaseView {
|
||||||
|
var productsSection: some View {
|
||||||
|
Section {
|
||||||
|
if !productManager.isRefreshingProducts {
|
||||||
|
ForEach(productRowModels, id: \.product.productIdentifier, content: productRow)
|
||||||
|
} else {
|
||||||
|
ProgressView()
|
||||||
|
}
|
||||||
|
restoreRow
|
||||||
|
} header: {
|
||||||
|
Text(L10n.Paywall.title)
|
||||||
|
} footer: {
|
||||||
|
Text(L10n.Paywall.Sections.Products.footer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func productRow(_ model: RowModel) -> some View {
|
||||||
|
PurchaseRow(
|
||||||
|
product: model.product,
|
||||||
|
title: model.product.localizedTitle,
|
||||||
|
extra: model.extra,
|
||||||
|
action: {
|
||||||
|
purchaseProduct(model.product)
|
||||||
|
},
|
||||||
|
purchaseState: purchaseState
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var restoreRow: some View {
|
||||||
|
PurchaseRow(
|
||||||
|
title: L10n.Paywall.Items.Restore.title,
|
||||||
|
extra: L10n.Paywall.Items.Restore.description,
|
||||||
|
action: restorePurchases,
|
||||||
|
purchaseState: purchaseState
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension PaywallView.PurchaseView {
|
||||||
|
var skFeature: SKProduct? {
|
||||||
|
guard let feature = feature else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return productManager.product(withIdentifier: feature)
|
||||||
|
}
|
||||||
|
|
||||||
|
var skPlatformVersion: SKProduct? {
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
productManager.product(withIdentifier: .fullVersion_macOS)
|
||||||
|
#else
|
||||||
|
productManager.product(withIdentifier: .fullVersion_iOS)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
// hide full version if already bought the other platform version
|
||||||
|
var skFullVersion: SKProduct? {
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
guard !productManager.hasPurchased(.fullVersion_iOS) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
guard !productManager.hasPurchased(.fullVersion_macOS) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
return productManager.product(withIdentifier: .fullVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
var platformVersionExtra: [String] {
|
||||||
|
productManager.featureProducts(excluding: [
|
||||||
|
.fullVersion,
|
||||||
|
.fullVersion_iOS,
|
||||||
|
.fullVersion_macOS
|
||||||
|
]).map {
|
||||||
|
$0.localizedTitle
|
||||||
|
}.sorted {
|
||||||
|
$0.lowercased() < $1.lowercased()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var fullVersionExtra: [String] {
|
||||||
|
productManager.featureProducts(including: [
|
||||||
|
.fullVersion_iOS,
|
||||||
|
.fullVersion_macOS
|
||||||
|
]).map {
|
||||||
|
$0.localizedTitle
|
||||||
|
}.sorted {
|
||||||
|
$0.lowercased() < $1.lowercased()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var productRowModels: [RowModel] {
|
||||||
|
var models: [RowModel] = []
|
||||||
|
skPlatformVersion.map {
|
||||||
|
let extra = platformVersionExtra.joined(separator: "\n")
|
||||||
|
models.append(($0, extra))
|
||||||
|
}
|
||||||
|
skFullVersion.map {
|
||||||
|
let extra = fullVersionExtra.joined(separator: "\n")
|
||||||
|
models.append(($0, extra))
|
||||||
|
}
|
||||||
|
skFeature.map {
|
||||||
|
models.append(($0, nil))
|
||||||
|
}
|
||||||
|
return models
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension PurchaseRow {
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
var actionButton: some View {
|
||||||
|
if let product = product {
|
||||||
|
purchaseButton(product)
|
||||||
|
} else {
|
||||||
|
restoreButton
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func purchaseButton(_ product: SKProduct) -> some View {
|
||||||
|
HStack {
|
||||||
|
Button(title, action: action)
|
||||||
|
Spacer()
|
||||||
|
if case .purchasing(let pending) = purchaseState, pending.productIdentifier == product.productIdentifier {
|
||||||
|
ProgressView()
|
||||||
|
} else {
|
||||||
|
product.localizedPrice.map {
|
||||||
|
Text($0)
|
||||||
|
.themeSecondaryTextStyle()
|
||||||
}
|
}
|
||||||
restoreRow
|
|
||||||
} header: {
|
|
||||||
Text(L10n.Paywall.title)
|
|
||||||
} footer: {
|
|
||||||
Text(L10n.Paywall.Sections.Products.footer)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func productRow(_ model: RowModel) -> some View {
|
var restoreButton: some View {
|
||||||
PurchaseRow(
|
HStack {
|
||||||
product: model.product,
|
Button(title, action: action)
|
||||||
title: model.product.localizedTitle,
|
Spacer()
|
||||||
extra: model.extra,
|
if case .restoring = purchaseState {
|
||||||
action: {
|
ProgressView()
|
||||||
purchaseProduct(model.product)
|
}
|
||||||
},
|
|
||||||
purchaseState: purchaseState
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var restoreRow: some View {
|
|
||||||
PurchaseRow(
|
|
||||||
title: L10n.Paywall.Items.Restore.title,
|
|
||||||
extra: L10n.Paywall.Items.Restore.description,
|
|
||||||
action: restorePurchases,
|
|
||||||
purchaseState: purchaseState
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension PaywallView.PurchaseView {
|
// MARK: -
|
||||||
private func purchaseProduct(_ product: SKProduct) {
|
|
||||||
|
private extension PaywallView.PurchaseView {
|
||||||
|
func purchaseProduct(_ product: SKProduct) {
|
||||||
purchaseState = .purchasing(product)
|
purchaseState = .purchasing(product)
|
||||||
|
|
||||||
productManager.purchase(product) {
|
productManager.purchase(product) {
|
||||||
|
@ -135,7 +272,7 @@ extension PaywallView.PurchaseView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func restorePurchases() {
|
func restorePurchases() {
|
||||||
purchaseState = .restoring
|
purchaseState = .restoring
|
||||||
|
|
||||||
productManager.restorePurchases {
|
productManager.restorePurchases {
|
||||||
|
@ -154,131 +291,3 @@ extension PaywallView.PurchaseView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension PaywallView.PurchaseView {
|
|
||||||
private var skFeature: SKProduct? {
|
|
||||||
guard let feature = feature else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return productManager.product(withIdentifier: feature)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var skPlatformVersion: SKProduct? {
|
|
||||||
#if targetEnvironment(macCatalyst)
|
|
||||||
productManager.product(withIdentifier: .fullVersion_macOS)
|
|
||||||
#else
|
|
||||||
productManager.product(withIdentifier: .fullVersion_iOS)
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
// hide full version if already bought the other platform version
|
|
||||||
private var skFullVersion: SKProduct? {
|
|
||||||
#if targetEnvironment(macCatalyst)
|
|
||||||
guard !productManager.hasPurchased(.fullVersion_iOS) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
guard !productManager.hasPurchased(.fullVersion_macOS) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
return productManager.product(withIdentifier: .fullVersion)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var platformVersionExtra: [String] {
|
|
||||||
productManager.featureProducts(excluding: [
|
|
||||||
.fullVersion,
|
|
||||||
.fullVersion_iOS,
|
|
||||||
.fullVersion_macOS
|
|
||||||
]).map {
|
|
||||||
$0.localizedTitle
|
|
||||||
}.sorted {
|
|
||||||
$0.lowercased() < $1.lowercased()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var fullVersionExtra: [String] {
|
|
||||||
productManager.featureProducts(including: [
|
|
||||||
.fullVersion_iOS,
|
|
||||||
.fullVersion_macOS
|
|
||||||
]).map {
|
|
||||||
$0.localizedTitle
|
|
||||||
}.sorted {
|
|
||||||
$0.lowercased() < $1.lowercased()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var productRowModels: [RowModel] {
|
|
||||||
var models: [RowModel] = []
|
|
||||||
skPlatformVersion.map {
|
|
||||||
let extra = platformVersionExtra.joined(separator: "\n")
|
|
||||||
models.append(($0, extra))
|
|
||||||
}
|
|
||||||
skFullVersion.map {
|
|
||||||
let extra = fullVersionExtra.joined(separator: "\n")
|
|
||||||
models.append(($0, extra))
|
|
||||||
}
|
|
||||||
skFeature.map {
|
|
||||||
models.append(($0, nil))
|
|
||||||
}
|
|
||||||
return models
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct PurchaseRow: View {
|
|
||||||
var product: SKProduct?
|
|
||||||
|
|
||||||
let title: String
|
|
||||||
|
|
||||||
let extra: String?
|
|
||||||
|
|
||||||
let action: () -> Void
|
|
||||||
|
|
||||||
let purchaseState: PaywallView.PurchaseView.PurchaseState?
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(alignment: .leading) {
|
|
||||||
actionButton
|
|
||||||
.padding(.bottom, 5)
|
|
||||||
|
|
||||||
extra.map {
|
|
||||||
Text($0)
|
|
||||||
.frame(maxHeight: .infinity)
|
|
||||||
}
|
|
||||||
}.padding([.top, .bottom])
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var actionButton: some View {
|
|
||||||
if let product = product {
|
|
||||||
purchaseButton(product)
|
|
||||||
} else {
|
|
||||||
restoreButton
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func purchaseButton(_ product: SKProduct) -> some View {
|
|
||||||
HStack {
|
|
||||||
Button(title, action: action)
|
|
||||||
Spacer()
|
|
||||||
if case .purchasing(let pending) = purchaseState, pending.productIdentifier == product.productIdentifier {
|
|
||||||
ProgressView()
|
|
||||||
} else {
|
|
||||||
product.localizedPrice.map {
|
|
||||||
Text($0)
|
|
||||||
.themeSecondaryTextStyle()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var restoreButton: some View {
|
|
||||||
HStack {
|
|
||||||
Button(title, action: action)
|
|
||||||
Spacer()
|
|
||||||
if case .restoring = purchaseState {
|
|
||||||
ProgressView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -34,14 +34,6 @@ extension ProfileView {
|
||||||
|
|
||||||
@Binding private var modalType: ModalType?
|
@Binding private var modalType: ModalType?
|
||||||
|
|
||||||
private var isEligibleForNetworkSettings: Bool {
|
|
||||||
productManager.isEligible(forFeature: .networkSettings)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var isEligibleForTrustedNetworks: Bool {
|
|
||||||
productManager.isEligible(forFeature: .trustedNetworks)
|
|
||||||
}
|
|
||||||
|
|
||||||
init(currentProfile: ObservableProfile, modalType: Binding<ModalType?>) {
|
init(currentProfile: ObservableProfile, modalType: Binding<ModalType?>) {
|
||||||
productManager = .shared
|
productManager = .shared
|
||||||
self.currentProfile = currentProfile
|
self.currentProfile = currentProfile
|
||||||
|
@ -111,13 +103,25 @@ extension ProfileView {
|
||||||
Text(L10n.Global.Strings.configuration)
|
Text(L10n.Global.Strings.configuration)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
private var networkSettingsRow: some View {
|
}
|
||||||
Label(L10n.NetworkSettings.title, systemImage: themeNetworkSettingsImage)
|
|
||||||
}
|
// MARK: -
|
||||||
|
|
||||||
private var onDemandRow: some View {
|
private extension ProfileView.ConfigurationSection {
|
||||||
Label(L10n.OnDemand.title, systemImage: themeOnDemandImage)
|
var networkSettingsRow: some View {
|
||||||
}
|
Label(L10n.NetworkSettings.title, systemImage: themeNetworkSettingsImage)
|
||||||
|
}
|
||||||
|
|
||||||
|
var onDemandRow: some View {
|
||||||
|
Label(L10n.OnDemand.title, systemImage: themeOnDemandImage)
|
||||||
|
}
|
||||||
|
|
||||||
|
var isEligibleForNetworkSettings: Bool {
|
||||||
|
productManager.isEligible(forFeature: .networkSettings)
|
||||||
|
}
|
||||||
|
|
||||||
|
var isEligibleForTrustedNetworks: Bool {
|
||||||
|
productManager.isEligible(forFeature: .trustedNetworks)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -46,10 +46,6 @@ extension ProfileView {
|
||||||
|
|
||||||
@ObservedObject private var currentProfile: ObservableProfile
|
@ObservedObject private var currentProfile: ObservableProfile
|
||||||
|
|
||||||
private var header: Profile.Header {
|
|
||||||
currentProfile.value.header
|
|
||||||
}
|
|
||||||
|
|
||||||
@Binding private var modalType: ModalType?
|
@Binding private var modalType: ModalType?
|
||||||
|
|
||||||
@State private var isAlertPresented = false
|
@State private var isAlertPresented = false
|
||||||
|
@ -78,94 +74,6 @@ extension ProfileView {
|
||||||
message: alertMessage
|
message: alertMessage
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var mainView: some View {
|
|
||||||
Menu {
|
|
||||||
ReconnectButton()
|
|
||||||
ShortcutsButton(
|
|
||||||
modalType: $modalType
|
|
||||||
)
|
|
||||||
Divider()
|
|
||||||
RenameButton(
|
|
||||||
modalType: $modalType
|
|
||||||
)
|
|
||||||
DuplicateButton(
|
|
||||||
header: header,
|
|
||||||
setAsCurrent: true
|
|
||||||
)
|
|
||||||
uninstallVPNButton
|
|
||||||
Divider()
|
|
||||||
deleteProfileButton
|
|
||||||
} label: {
|
|
||||||
themeSettingsMenuImage.asSystemImage
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func alertActions(_ alertType: AlertType) -> some View {
|
|
||||||
switch alertType {
|
|
||||||
case .uninstallVPN:
|
|
||||||
return Group {
|
|
||||||
Button(role: .destructive, action: uninstallVPN) {
|
|
||||||
Text(uninstallVPNTitle)
|
|
||||||
}
|
|
||||||
Button(role: .cancel) {
|
|
||||||
} label: {
|
|
||||||
Text(L10n.Global.Strings.cancel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case .deleteProfile:
|
|
||||||
return Group {
|
|
||||||
Button(role: .destructive, action: removeProfile) {
|
|
||||||
Text(deleteProfileTitle)
|
|
||||||
}
|
|
||||||
Button(role: .cancel) {
|
|
||||||
} label: {
|
|
||||||
Text(L10n.Global.Strings.cancel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func alertMessage(_ alertType: AlertType) -> some View {
|
|
||||||
switch alertType {
|
|
||||||
case .uninstallVPN:
|
|
||||||
return Text(L10n.Profile.Alerts.UninstallVpn.message)
|
|
||||||
|
|
||||||
case .deleteProfile:
|
|
||||||
return Text(L10n.Organizer.Alerts.RemoveProfile.message(header.name))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var uninstallVPNButton: some View {
|
|
||||||
Button {
|
|
||||||
alertType = .uninstallVPN
|
|
||||||
isAlertPresented = true
|
|
||||||
} label: {
|
|
||||||
Label(uninstallVPNTitle, systemImage: themeUninstallImage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var deleteProfileButton: some View {
|
|
||||||
DestructiveButton {
|
|
||||||
alertType = .deleteProfile
|
|
||||||
isAlertPresented = true
|
|
||||||
} label: {
|
|
||||||
Label(deleteProfileTitle, systemImage: themeDeleteImage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func uninstallVPN() {
|
|
||||||
Task { @MainActor in
|
|
||||||
await vpnManager.uninstall()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func removeProfile() {
|
|
||||||
withAnimation {
|
|
||||||
profileManager.removeProfiles(withIds: [header.id])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -198,10 +106,6 @@ extension ProfileView {
|
||||||
_modalType = modalType
|
_modalType = modalType
|
||||||
}
|
}
|
||||||
|
|
||||||
private var isEligibleForSiri: Bool {
|
|
||||||
productManager.isEligible(forFeature: .siriShortcuts)
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button {
|
Button {
|
||||||
presentShortcutsOrPaywall()
|
presentShortcutsOrPaywall()
|
||||||
|
@ -209,16 +113,6 @@ extension ProfileView {
|
||||||
Label(Unlocalized.Other.siri, systemImage: themeShortcutsImage)
|
Label(Unlocalized.Other.siri, systemImage: themeShortcutsImage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func presentShortcutsOrPaywall() {
|
|
||||||
|
|
||||||
// eligibility: enter Siri shortcuts or present paywall
|
|
||||||
if isEligibleForSiri {
|
|
||||||
modalType = .shortcuts
|
|
||||||
} else {
|
|
||||||
modalType = .paywallShortcuts
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct RenameButton: View {
|
struct RenameButton: View {
|
||||||
|
@ -257,9 +151,129 @@ extension ProfileView {
|
||||||
Label(L10n.Global.Strings.duplicate, systemImage: themeDuplicateImage)
|
Label(L10n.Global.Strings.duplicate, systemImage: themeDuplicateImage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func duplicateProfile(withId id: UUID) {
|
// MARK: -
|
||||||
profileManager.duplicateProfile(withId: id, setAsCurrent: setAsCurrent)
|
|
||||||
|
private extension ProfileView.MainMenu {
|
||||||
|
var header: Profile.Header {
|
||||||
|
currentProfile.value.header
|
||||||
|
}
|
||||||
|
|
||||||
|
var mainView: some View {
|
||||||
|
Menu {
|
||||||
|
ProfileView.ReconnectButton()
|
||||||
|
ProfileView.ShortcutsButton(
|
||||||
|
modalType: $modalType
|
||||||
|
)
|
||||||
|
Divider()
|
||||||
|
ProfileView.RenameButton(
|
||||||
|
modalType: $modalType
|
||||||
|
)
|
||||||
|
ProfileView.DuplicateButton(
|
||||||
|
header: header,
|
||||||
|
setAsCurrent: true
|
||||||
|
)
|
||||||
|
uninstallVPNButton
|
||||||
|
Divider()
|
||||||
|
deleteProfileButton
|
||||||
|
} label: {
|
||||||
|
themeSettingsMenuImage.asSystemImage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func alertActions(_ alertType: AlertType) -> some View {
|
||||||
|
switch alertType {
|
||||||
|
case .uninstallVPN:
|
||||||
|
return Group {
|
||||||
|
Button(role: .destructive, action: uninstallVPN) {
|
||||||
|
Text(uninstallVPNTitle)
|
||||||
|
}
|
||||||
|
Button(role: .cancel) {
|
||||||
|
} label: {
|
||||||
|
Text(L10n.Global.Strings.cancel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case .deleteProfile:
|
||||||
|
return Group {
|
||||||
|
Button(role: .destructive, action: removeProfile) {
|
||||||
|
Text(deleteProfileTitle)
|
||||||
|
}
|
||||||
|
Button(role: .cancel) {
|
||||||
|
} label: {
|
||||||
|
Text(L10n.Global.Strings.cancel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func alertMessage(_ alertType: AlertType) -> some View {
|
||||||
|
switch alertType {
|
||||||
|
case .uninstallVPN:
|
||||||
|
return Text(L10n.Profile.Alerts.UninstallVpn.message)
|
||||||
|
|
||||||
|
case .deleteProfile:
|
||||||
|
return Text(L10n.Organizer.Alerts.RemoveProfile.message(header.name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var uninstallVPNButton: some View {
|
||||||
|
Button {
|
||||||
|
alertType = .uninstallVPN
|
||||||
|
isAlertPresented = true
|
||||||
|
} label: {
|
||||||
|
Label(uninstallVPNTitle, systemImage: themeUninstallImage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var deleteProfileButton: some View {
|
||||||
|
DestructiveButton {
|
||||||
|
alertType = .deleteProfile
|
||||||
|
isAlertPresented = true
|
||||||
|
} label: {
|
||||||
|
Label(deleteProfileTitle, systemImage: themeDeleteImage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private extension ProfileView.ShortcutsButton {
|
||||||
|
var isEligibleForSiri: Bool {
|
||||||
|
productManager.isEligible(forFeature: .siriShortcuts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: -
|
||||||
|
|
||||||
|
private extension ProfileView.MainMenu {
|
||||||
|
func uninstallVPN() {
|
||||||
|
Task { @MainActor in
|
||||||
|
await vpnManager.uninstall()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeProfile() {
|
||||||
|
withAnimation {
|
||||||
|
profileManager.removeProfiles(withIds: [header.id])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension ProfileView.ShortcutsButton {
|
||||||
|
func presentShortcutsOrPaywall() {
|
||||||
|
|
||||||
|
// eligibility: enter Siri shortcuts or present paywall
|
||||||
|
if isEligibleForSiri {
|
||||||
|
modalType = .shortcuts
|
||||||
|
} else {
|
||||||
|
modalType = .paywallShortcuts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension ProfileView.DuplicateButton {
|
||||||
|
func duplicateProfile(withId id: UUID) {
|
||||||
|
profileManager.duplicateProfile(withId: id, setAsCurrent: setAsCurrent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -32,14 +32,14 @@ extension ProfileView {
|
||||||
|
|
||||||
@ObservedObject private var currentProfile: ObservableProfile
|
@ObservedObject private var currentProfile: ObservableProfile
|
||||||
|
|
||||||
var profile: Profile {
|
|
||||||
currentProfile.value
|
|
||||||
}
|
|
||||||
|
|
||||||
@State private var isProviderLocationPresented = false
|
@State private var isProviderLocationPresented = false
|
||||||
|
|
||||||
@State private var isRefreshingInfrastructure = false
|
@State private var isRefreshingInfrastructure = false
|
||||||
|
|
||||||
|
var profile: Profile {
|
||||||
|
currentProfile.value
|
||||||
|
}
|
||||||
|
|
||||||
init(currentProfile: ObservableProfile) {
|
init(currentProfile: ObservableProfile) {
|
||||||
providerManager = .shared
|
providerManager = .shared
|
||||||
self.currentProfile = currentProfile
|
self.currentProfile = currentProfile
|
||||||
|
@ -55,102 +55,111 @@ extension ProfileView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
// MARK: -
|
||||||
private var mainView: some View {
|
|
||||||
Section {
|
private extension ProfileView.ProviderSection {
|
||||||
NavigationLink(isActive: $isProviderLocationPresented) {
|
|
||||||
ProviderLocationView(
|
@ViewBuilder
|
||||||
currentProfile: currentProfile,
|
var mainView: some View {
|
||||||
isEditable: true,
|
Section {
|
||||||
isPresented: $isProviderLocationPresented
|
NavigationLink(isActive: $isProviderLocationPresented) {
|
||||||
)
|
ProviderLocationView(
|
||||||
} label: {
|
currentProfile: currentProfile,
|
||||||
HStack {
|
isEditable: true,
|
||||||
Label(L10n.Provider.Location.title, systemImage: themeProviderLocationImage)
|
isPresented: $isProviderLocationPresented
|
||||||
Spacer()
|
|
||||||
currentProviderCountryImage
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} header: {
|
|
||||||
currentProviderFullName.map(Text.init)
|
|
||||||
} footer: {
|
|
||||||
currentProviderServerDescription.map(Text.init)
|
|
||||||
}
|
|
||||||
Section {
|
|
||||||
Toggle(
|
|
||||||
L10n.Profile.Items.RandomizesServer.caption,
|
|
||||||
isOn: $currentProfile.value.providerRandomizesServer ?? false
|
|
||||||
)
|
)
|
||||||
Toggle(
|
} label: {
|
||||||
L10n.Profile.Items.VpnResolvesHostname.caption,
|
HStack {
|
||||||
isOn: $currentProfile.value.networkSettings.resolvesHostname
|
Label(L10n.Provider.Location.title, systemImage: themeProviderLocationImage)
|
||||||
)
|
Spacer()
|
||||||
} footer: {
|
currentProviderCountryImage
|
||||||
Text(L10n.Profile.Sections.VpnResolvesHostname.footer)
|
|
||||||
.xxxThemeTruncation()
|
|
||||||
}
|
|
||||||
Section {
|
|
||||||
NavigationLink {
|
|
||||||
ProviderPresetView(currentProfile: currentProfile)
|
|
||||||
} label: {
|
|
||||||
Label(L10n.Provider.Preset.title, systemImage: themeProviderPresetImage)
|
|
||||||
.withTrailingText(currentProviderPreset)
|
|
||||||
}
|
|
||||||
Button(action: refreshInfrastructure) {
|
|
||||||
Text(L10n.Profile.Items.Provider.Refresh.caption)
|
|
||||||
}.withTrailingProgress(when: isRefreshingInfrastructure)
|
|
||||||
} footer: {
|
|
||||||
lastInfrastructureUpdate.map {
|
|
||||||
Text(L10n.Profile.Sections.ProviderInfrastructure.footer($0))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} header: {
|
||||||
|
currentProviderFullName.map(Text.init)
|
||||||
|
} footer: {
|
||||||
|
currentProviderServerDescription.map(Text.init)
|
||||||
}
|
}
|
||||||
|
Section {
|
||||||
private var currentProviderFullName: String? {
|
Toggle(
|
||||||
guard let name = profile.header.providerName else {
|
L10n.Profile.Items.RandomizesServer.caption,
|
||||||
assertionFailure("Provider name accessed but profile is not a provider (isPlaceholder? \(profile.isPlaceholder))")
|
isOn: $currentProfile.value.providerRandomizesServer ?? false
|
||||||
return nil
|
)
|
||||||
|
Toggle(
|
||||||
|
L10n.Profile.Items.VpnResolvesHostname.caption,
|
||||||
|
isOn: $currentProfile.value.networkSettings.resolvesHostname
|
||||||
|
)
|
||||||
|
} footer: {
|
||||||
|
Text(L10n.Profile.Sections.VpnResolvesHostname.footer)
|
||||||
|
.xxxThemeTruncation()
|
||||||
|
}
|
||||||
|
Section {
|
||||||
|
NavigationLink {
|
||||||
|
ProviderPresetView(currentProfile: currentProfile)
|
||||||
|
} label: {
|
||||||
|
Label(L10n.Provider.Preset.title, systemImage: themeProviderPresetImage)
|
||||||
|
.withTrailingText(currentProviderPreset)
|
||||||
}
|
}
|
||||||
guard let metadata = providerManager.provider(withName: name) else {
|
Button(action: refreshInfrastructure) {
|
||||||
assertionFailure("Provider metadata not found")
|
Text(L10n.Profile.Items.Provider.Refresh.caption)
|
||||||
return nil
|
}.withTrailingProgress(when: isRefreshingInfrastructure)
|
||||||
}
|
} footer: {
|
||||||
return metadata.fullName
|
lastInfrastructureUpdate.map {
|
||||||
}
|
Text(L10n.Profile.Sections.ProviderInfrastructure.footer($0))
|
||||||
|
|
||||||
private var currentProviderServerDescription: String? {
|
|
||||||
guard let server = profile.providerServer(providerManager) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if currentProfile.value.providerRandomizesServer ?? false {
|
|
||||||
return server.localizedCountry(withCategory: true)
|
|
||||||
} else {
|
|
||||||
return server.localizedLongDescription(withCategory: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var currentProviderCountryImage: Image? {
|
|
||||||
guard let code = profile.providerServer(providerManager)?.countryCode else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return themeAssetsCountryImage(code).asAssetImage
|
|
||||||
}
|
|
||||||
|
|
||||||
private var currentProviderPreset: String? {
|
|
||||||
providerManager.localizedPreset(forProfile: profile)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var lastInfrastructureUpdate: String? {
|
|
||||||
providerManager.localizedInfrastructureUpdate(forProfile: profile)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func refreshInfrastructure() {
|
|
||||||
isRefreshingInfrastructure = true
|
|
||||||
Task { @MainActor in
|
|
||||||
try? await providerManager.fetchRemoteProviderPublisher(forProfile: profile).async()
|
|
||||||
isRefreshingInfrastructure = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var currentProviderFullName: String? {
|
||||||
|
guard let name = profile.header.providerName else {
|
||||||
|
assertionFailure("Provider name accessed but profile is not a provider (isPlaceholder? \(profile.isPlaceholder))")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
guard let metadata = providerManager.provider(withName: name) else {
|
||||||
|
assertionFailure("Provider metadata not found")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return metadata.fullName
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentProviderServerDescription: String? {
|
||||||
|
guard let server = profile.providerServer(providerManager) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if currentProfile.value.providerRandomizesServer ?? false {
|
||||||
|
return server.localizedCountry(withCategory: true)
|
||||||
|
} else {
|
||||||
|
return server.localizedLongDescription(withCategory: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentProviderCountryImage: Image? {
|
||||||
|
guard let code = profile.providerServer(providerManager)?.countryCode else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return themeAssetsCountryImage(code).asAssetImage
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentProviderPreset: String? {
|
||||||
|
providerManager.localizedPreset(forProfile: profile)
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastInfrastructureUpdate: String? {
|
||||||
|
providerManager.localizedInfrastructureUpdate(forProfile: profile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: -
|
||||||
|
|
||||||
|
private extension ProfileView.ProviderSection {
|
||||||
|
func refreshInfrastructure() {
|
||||||
|
isRefreshingInfrastructure = true
|
||||||
|
Task { @MainActor in
|
||||||
|
try? await providerManager.fetchRemoteProviderPublisher(forProfile: profile).async()
|
||||||
|
isRefreshingInfrastructure = false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -66,51 +66,60 @@ extension ProfileView {
|
||||||
message: alertOverwriteMessage
|
message: alertOverwriteMessage
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
@ViewBuilder
|
}
|
||||||
private func alertOverwriteActions() -> some View {
|
|
||||||
Button(role: .destructive) {
|
// MARK: -
|
||||||
commitRenaming(force: true)
|
|
||||||
} label: {
|
private extension ProfileView.RenameView {
|
||||||
Text(L10n.Global.Strings.ok)
|
|
||||||
}
|
@ViewBuilder
|
||||||
Button(role: .cancel) {
|
func alertOverwriteActions() -> some View {
|
||||||
} label: {
|
Button(role: .destructive) {
|
||||||
Text(L10n.Global.Strings.cancel)
|
commitRenaming(force: true)
|
||||||
}
|
} label: {
|
||||||
}
|
Text(L10n.Global.Strings.ok)
|
||||||
|
}
|
||||||
private func alertOverwriteMessage() -> some View {
|
Button(role: .cancel) {
|
||||||
Text(L10n.AddProfile.Shared.Alerts.Overwrite.message)
|
} label: {
|
||||||
}
|
Text(L10n.Global.Strings.cancel)
|
||||||
|
}
|
||||||
private func loadCurrentName() {
|
}
|
||||||
newName = currentProfile.value.header.name
|
|
||||||
}
|
func alertOverwriteMessage() -> some View {
|
||||||
|
Text(L10n.AddProfile.Shared.Alerts.Overwrite.message)
|
||||||
private func commitRenaming() {
|
}
|
||||||
commitRenaming(force: false)
|
}
|
||||||
}
|
|
||||||
|
// MARK: -
|
||||||
private func commitRenaming(force: Bool) {
|
|
||||||
let name = newName.stripped
|
private extension ProfileView.RenameView {
|
||||||
|
func loadCurrentName() {
|
||||||
guard !name.isEmpty else {
|
newName = currentProfile.value.header.name
|
||||||
return
|
}
|
||||||
}
|
|
||||||
guard name != currentProfile.value.header.name else {
|
func commitRenaming() {
|
||||||
presentationMode.wrappedValue.dismiss()
|
commitRenaming(force: false)
|
||||||
return
|
}
|
||||||
}
|
|
||||||
guard force || !profileManager.isExistingProfile(withName: name) else {
|
func commitRenaming(force: Bool) {
|
||||||
isOverwritingExistingProfile = true
|
let name = newName.stripped
|
||||||
return
|
|
||||||
}
|
guard !name.isEmpty else {
|
||||||
|
return
|
||||||
let renamed = currentProfile.value.renamed(to: name)
|
}
|
||||||
profileManager.saveProfile(renamed, isActive: nil)
|
guard name != currentProfile.value.header.name else {
|
||||||
|
presentationMode.wrappedValue.dismiss()
|
||||||
presentationMode.wrappedValue.dismiss()
|
return
|
||||||
}
|
}
|
||||||
|
guard force || !profileManager.isExistingProfile(withName: name) else {
|
||||||
|
isOverwritingExistingProfile = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let renamed = currentProfile.value.renamed(to: name)
|
||||||
|
profileManager.saveProfile(renamed, isActive: nil)
|
||||||
|
|
||||||
|
presentationMode.wrappedValue.dismiss()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -34,18 +34,6 @@ extension ProfileView {
|
||||||
|
|
||||||
@Binding private var modalType: ModalType?
|
@Binding private var modalType: ModalType?
|
||||||
|
|
||||||
private var interactiveProfile: Binding<Profile?> {
|
|
||||||
.init {
|
|
||||||
modalType == .interactiveAccount ? profile : nil
|
|
||||||
} set: {
|
|
||||||
modalType = $0 != nil ? .interactiveAccount : nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var isActiveProfile: Bool {
|
|
||||||
profileManager.isActiveProfile(profile.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
init(profile: Profile, modalType: Binding<ModalType?>) {
|
init(profile: Profile, modalType: Binding<ModalType?>) {
|
||||||
profileManager = .shared
|
profileManager = .shared
|
||||||
self.profile = profile
|
self.profile = profile
|
||||||
|
@ -63,22 +51,38 @@ extension ProfileView {
|
||||||
.xxxThemeTruncation()
|
.xxxThemeTruncation()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var toggleView: some View {
|
// MARK: -
|
||||||
VPNToggle(
|
|
||||||
profile: profile,
|
private extension ProfileView.VPNSection {
|
||||||
interactiveProfile: interactiveProfile,
|
var interactiveProfile: Binding<Profile?> {
|
||||||
rateLimit: Constants.RateLimit.vpnToggle
|
.init {
|
||||||
)
|
modalType == .interactiveAccount ? profile : nil
|
||||||
|
} set: {
|
||||||
|
modalType = $0 != nil ? .interactiveAccount : nil
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var statusView: some View {
|
var isActiveProfile: Bool {
|
||||||
HStack {
|
profileManager.isActiveProfile(profile.id)
|
||||||
Text(L10n.Profile.Items.ConnectionStatus.caption)
|
}
|
||||||
Spacer()
|
|
||||||
VPNStatusText(isActiveProfile: isActiveProfile)
|
var toggleView: some View {
|
||||||
.themeSecondaryTextStyle()
|
VPNToggle(
|
||||||
}
|
profile: profile,
|
||||||
|
interactiveProfile: interactiveProfile,
|
||||||
|
rateLimit: Constants.RateLimit.vpnToggle
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var statusView: some View {
|
||||||
|
HStack {
|
||||||
|
Text(L10n.Profile.Items.ConnectionStatus.caption)
|
||||||
|
Spacer()
|
||||||
|
VPNStatusText(isActiveProfile: isActiveProfile)
|
||||||
|
.themeSecondaryTextStyle()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -47,14 +47,6 @@ struct ProfileView: View {
|
||||||
|
|
||||||
@ObservedObject private var currentProfile: ObservableProfile
|
@ObservedObject private var currentProfile: ObservableProfile
|
||||||
|
|
||||||
private var isLoading: Bool {
|
|
||||||
currentProfile.isLoading
|
|
||||||
}
|
|
||||||
|
|
||||||
private var isExisting: Bool {
|
|
||||||
!currentProfile.value.isPlaceholder
|
|
||||||
}
|
|
||||||
|
|
||||||
@State private var modalType: ModalType?
|
@State private var modalType: ModalType?
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
@ -83,12 +75,24 @@ struct ProfileView: View {
|
||||||
.navigationTitle(title)
|
.navigationTitle(title)
|
||||||
.themeSecondaryView()
|
.themeSecondaryView()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var title: String {
|
// MARK: -
|
||||||
|
|
||||||
|
private extension ProfileView {
|
||||||
|
var isLoading: Bool {
|
||||||
|
currentProfile.isLoading
|
||||||
|
}
|
||||||
|
|
||||||
|
var isExisting: Bool {
|
||||||
|
!currentProfile.value.isPlaceholder
|
||||||
|
}
|
||||||
|
|
||||||
|
var title: String {
|
||||||
currentProfile.name
|
currentProfile.name
|
||||||
}
|
}
|
||||||
|
|
||||||
private var mainView: some View {
|
var mainView: some View {
|
||||||
List {
|
List {
|
||||||
if !isLoading {
|
if !isLoading {
|
||||||
VPNSection(
|
VPNSection(
|
||||||
|
@ -109,7 +113,7 @@ struct ProfileView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func presentedModal(_ modalType: ModalType) -> some View {
|
func presentedModal(_ modalType: ModalType) -> some View {
|
||||||
switch modalType {
|
switch modalType {
|
||||||
case .interactiveAccount:
|
case .interactiveAccount:
|
||||||
NavigationView {
|
NavigationView {
|
||||||
|
|
|
@ -31,35 +31,16 @@ struct ProviderLocationView: View, ProviderProfileAvailability {
|
||||||
|
|
||||||
@ObservedObject private var currentProfile: ObservableProfile
|
@ObservedObject private var currentProfile: ObservableProfile
|
||||||
|
|
||||||
var profile: Profile {
|
|
||||||
currentProfile.value
|
|
||||||
}
|
|
||||||
|
|
||||||
private let isEditable: Bool
|
private let isEditable: Bool
|
||||||
|
|
||||||
private var providerName: ProviderName {
|
|
||||||
guard let name = currentProfile.value.header.providerName else {
|
|
||||||
assertionFailure("Not a provider")
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
|
|
||||||
private var vpnProtocol: VPNProtocolType {
|
|
||||||
currentProfile.value.currentVPNProtocol
|
|
||||||
}
|
|
||||||
|
|
||||||
@Binding private var selectedServer: ProviderServer?
|
@Binding private var selectedServer: ProviderServer?
|
||||||
|
|
||||||
@Binding private var favoriteLocationIds: Set<String>?
|
@Binding private var favoriteLocationIds: Set<String>?
|
||||||
|
|
||||||
@AppStorage(AppPreference.isShowingFavorites.key) private var isShowingFavorites = false
|
@AppStorage(AppPreference.isShowingFavorites.key) private var isShowingFavorites = false
|
||||||
|
|
||||||
private var isShowingEmptyFavorites: Bool {
|
var profile: Profile {
|
||||||
guard isShowingFavorites else {
|
currentProfile.value
|
||||||
return false
|
|
||||||
}
|
|
||||||
return favoriteLocationIds?.isEmpty ?? true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// XXX: do not escape mutating 'self', use constant providerManager
|
// XXX: do not escape mutating 'self', use constant providerManager
|
||||||
|
@ -108,139 +89,6 @@ struct ProviderLocationView: View, ProviderProfileAvailability {
|
||||||
}
|
}
|
||||||
}.navigationTitle(L10n.Provider.Location.title)
|
}.navigationTitle(L10n.Provider.Location.title)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var mainView: some View {
|
|
||||||
// FIXME: layout, restore ScrollViewReader, but content inside it is not re-rendered on isShowingFavorites
|
|
||||||
// ScrollViewReader { scrollProxy in
|
|
||||||
List {
|
|
||||||
if !isShowingEmptyFavorites {
|
|
||||||
categoriesView
|
|
||||||
} else {
|
|
||||||
emptyFavoritesSection
|
|
||||||
}
|
|
||||||
// }.onAppear {
|
|
||||||
// scrollToSelectedLocation(scrollProxy)
|
|
||||||
}
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
|
|
||||||
private var categoriesView: some View {
|
|
||||||
ForEach(categories, content: categorySection)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func categorySection(_ category: ProviderCategory) -> some View {
|
|
||||||
Section {
|
|
||||||
ForEach(filteredLocations(for: category)) { location in
|
|
||||||
if isEditable {
|
|
||||||
locationRow(location)
|
|
||||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
|
||||||
favoriteActions(location)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
locationRow(location)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} header: {
|
|
||||||
!category.name.isEmpty ? Text(category.name) : nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private func locationRow(_ location: ProviderLocation) -> some View {
|
|
||||||
if let onlyServer = location.onlyServer {
|
|
||||||
singleServerRow(location, onlyServer)
|
|
||||||
} else if profile.providerRandomizesServer ?? false {
|
|
||||||
singleServerRow(location, nil)
|
|
||||||
} else {
|
|
||||||
multipleServersRow(location)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func multipleServersRow(_ location: ProviderLocation) -> some View {
|
|
||||||
NavigationLink(destination: {
|
|
||||||
ServerListView(
|
|
||||||
location: location,
|
|
||||||
selectedServer: $selectedServer
|
|
||||||
).navigationTitle(location.localizedCountry)
|
|
||||||
}, label: {
|
|
||||||
LocationRow(
|
|
||||||
location: location,
|
|
||||||
selectedLocationId: selectedServer?.locationId
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private func singleServerRow(_ location: ProviderLocation, _ server: ProviderServer?) -> some View {
|
|
||||||
Button {
|
|
||||||
selectedServer = server ?? location.servers?.randomElement()
|
|
||||||
} label: {
|
|
||||||
LocationRow(
|
|
||||||
location: location,
|
|
||||||
selectedLocationId: selectedServer?.locationId
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var emptyFavoritesSection: some View {
|
|
||||||
Section {
|
|
||||||
} footer: {
|
|
||||||
Text(L10n.Provider.Location.Sections.EmptyFavorites.footer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(iOS 15, *)
|
|
||||||
private func favoriteActions(_ location: ProviderLocation) -> some View {
|
|
||||||
Button {
|
|
||||||
withAnimation {
|
|
||||||
toggleFavoriteLocation(location)
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
themeFavoriteActionImage(!isFavoriteLocation(location)).asSystemImage
|
|
||||||
}.themePrimaryTintStyle()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension ProviderLocationView {
|
|
||||||
private func server(withId serverId: String) -> ProviderServer? {
|
|
||||||
providerManager.server(withId: serverId)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var categories: [ProviderCategory] {
|
|
||||||
providerManager.categories(providerName, vpnProtocol: vpnProtocol)
|
|
||||||
.filter {
|
|
||||||
!filteredLocations(for: $0).isEmpty
|
|
||||||
}.sorted()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func filteredLocations(for category: ProviderCategory) -> [ProviderLocation] {
|
|
||||||
let locations: [ProviderLocation]
|
|
||||||
if isShowingFavorites {
|
|
||||||
locations = category.locations.filter {
|
|
||||||
favoriteLocationIds?.contains($0.id) ?? false
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
locations = category.locations
|
|
||||||
}
|
|
||||||
return locations.sorted()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func isFavoriteLocation(_ location: ProviderLocation) -> Bool {
|
|
||||||
favoriteLocationIds?.contains(location.id) ?? false
|
|
||||||
}
|
|
||||||
|
|
||||||
private func toggleFavoriteLocation(_ location: ProviderLocation) {
|
|
||||||
if !isFavoriteLocation(location) {
|
|
||||||
if favoriteLocationIds == nil {
|
|
||||||
favoriteLocationIds = [location.id]
|
|
||||||
} else {
|
|
||||||
favoriteLocationIds?.insert(location.id)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
favoriteLocationIds?.remove(location.id)
|
|
||||||
}
|
|
||||||
// may trigger view updates?
|
|
||||||
// pp_log.debug("New favorite locations: \(favoriteLocationIds ?? [])")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ProviderLocationView {
|
extension ProviderLocationView {
|
||||||
|
@ -293,21 +141,183 @@ extension ProviderLocationView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var servers: [ProviderServer] {
|
|
||||||
providerManager.servers(forLocation: location).sorted()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ProviderLocationView {
|
// MARK: -
|
||||||
private func scrollToSelectedLocation(_ proxy: ScrollViewProxy) {
|
|
||||||
|
private extension ProviderLocationView {
|
||||||
|
var providerName: ProviderName {
|
||||||
|
guard let name = currentProfile.value.header.providerName else {
|
||||||
|
assertionFailure("Not a provider")
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
var vpnProtocol: VPNProtocolType {
|
||||||
|
currentProfile.value.currentVPNProtocol
|
||||||
|
}
|
||||||
|
|
||||||
|
var mainView: some View {
|
||||||
|
// FIXME: layout, restore ScrollViewReader, but content inside it is not re-rendered on isShowingFavorites
|
||||||
|
// ScrollViewReader { scrollProxy in
|
||||||
|
List {
|
||||||
|
if !isShowingEmptyFavorites {
|
||||||
|
categoriesView
|
||||||
|
} else {
|
||||||
|
emptyFavoritesSection
|
||||||
|
}
|
||||||
|
// }.onAppear {
|
||||||
|
// scrollToSelectedLocation(scrollProxy)
|
||||||
|
}
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
|
var categoriesView: some View {
|
||||||
|
ForEach(categories, content: categorySection)
|
||||||
|
}
|
||||||
|
|
||||||
|
func categorySection(_ category: ProviderCategory) -> some View {
|
||||||
|
Section {
|
||||||
|
ForEach(filteredLocations(for: category)) { location in
|
||||||
|
if isEditable {
|
||||||
|
locationRow(location)
|
||||||
|
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||||
|
favoriteActions(location)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
locationRow(location)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
!category.name.isEmpty ? Text(category.name) : nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
func locationRow(_ location: ProviderLocation) -> some View {
|
||||||
|
if let onlyServer = location.onlyServer {
|
||||||
|
singleServerRow(location, onlyServer)
|
||||||
|
} else if profile.providerRandomizesServer ?? false {
|
||||||
|
singleServerRow(location, nil)
|
||||||
|
} else {
|
||||||
|
multipleServersRow(location)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func multipleServersRow(_ location: ProviderLocation) -> some View {
|
||||||
|
NavigationLink(destination: {
|
||||||
|
ServerListView(
|
||||||
|
location: location,
|
||||||
|
selectedServer: $selectedServer
|
||||||
|
).navigationTitle(location.localizedCountry)
|
||||||
|
}, label: {
|
||||||
|
LocationRow(
|
||||||
|
location: location,
|
||||||
|
selectedLocationId: selectedServer?.locationId
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func singleServerRow(_ location: ProviderLocation, _ server: ProviderServer?) -> some View {
|
||||||
|
Button {
|
||||||
|
selectedServer = server ?? location.servers?.randomElement()
|
||||||
|
} label: {
|
||||||
|
LocationRow(
|
||||||
|
location: location,
|
||||||
|
selectedLocationId: selectedServer?.locationId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS 15, *)
|
||||||
|
func favoriteActions(_ location: ProviderLocation) -> some View {
|
||||||
|
Button {
|
||||||
|
withAnimation {
|
||||||
|
toggleFavoriteLocation(location)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
themeFavoriteActionImage(!isFavoriteLocation(location)).asSystemImage
|
||||||
|
}.themePrimaryTintStyle()
|
||||||
|
}
|
||||||
|
|
||||||
|
var emptyFavoritesSection: some View {
|
||||||
|
Section {
|
||||||
|
} footer: {
|
||||||
|
Text(L10n.Provider.Location.Sections.EmptyFavorites.footer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var isShowingEmptyFavorites: Bool {
|
||||||
|
guard isShowingFavorites else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return favoriteLocationIds?.isEmpty ?? true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension ProviderLocationView {
|
||||||
|
func server(withId serverId: String) -> ProviderServer? {
|
||||||
|
providerManager.server(withId: serverId)
|
||||||
|
}
|
||||||
|
|
||||||
|
var categories: [ProviderCategory] {
|
||||||
|
providerManager.categories(providerName, vpnProtocol: vpnProtocol)
|
||||||
|
.filter {
|
||||||
|
!filteredLocations(for: $0).isEmpty
|
||||||
|
}.sorted()
|
||||||
|
}
|
||||||
|
|
||||||
|
func filteredLocations(for category: ProviderCategory) -> [ProviderLocation] {
|
||||||
|
let locations: [ProviderLocation]
|
||||||
|
if isShowingFavorites {
|
||||||
|
locations = category.locations.filter {
|
||||||
|
favoriteLocationIds?.contains($0.id) ?? false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
locations = category.locations
|
||||||
|
}
|
||||||
|
return locations.sorted()
|
||||||
|
}
|
||||||
|
|
||||||
|
func isFavoriteLocation(_ location: ProviderLocation) -> Bool {
|
||||||
|
favoriteLocationIds?.contains(location.id) ?? false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension ProviderLocationView.ServerListView {
|
||||||
|
var servers: [ProviderServer] {
|
||||||
|
providerManager.servers(forLocation: location).sorted()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: -
|
||||||
|
|
||||||
|
private extension ProviderLocationView {
|
||||||
|
func toggleFavoriteLocation(_ location: ProviderLocation) {
|
||||||
|
if !isFavoriteLocation(location) {
|
||||||
|
if favoriteLocationIds == nil {
|
||||||
|
favoriteLocationIds = [location.id]
|
||||||
|
} else {
|
||||||
|
favoriteLocationIds?.insert(location.id)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
favoriteLocationIds?.remove(location.id)
|
||||||
|
}
|
||||||
|
// may trigger view updates?
|
||||||
|
// pp_log.debug("New favorite locations: \(favoriteLocationIds ?? [])")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension ProviderLocationView {
|
||||||
|
func scrollToSelectedLocation(_ proxy: ScrollViewProxy) {
|
||||||
proxy.maybeScrollTo(selectedServer?.locationId)
|
proxy.maybeScrollTo(selectedServer?.locationId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ProviderLocationView.ServerListView {
|
private extension ProviderLocationView.ServerListView {
|
||||||
private func scrollToSelectedServer(_ proxy: ScrollViewProxy) {
|
func scrollToSelectedServer(_ proxy: ScrollViewProxy) {
|
||||||
proxy.maybeScrollTo(selectedServer?.id)
|
proxy.maybeScrollTo(selectedServer?.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -67,8 +67,12 @@ struct ProviderPresetView: View {
|
||||||
ForEach(availablePresets, id: \.id, content: presetSection)
|
ForEach(availablePresets, id: \.id, content: presetSection)
|
||||||
}.navigationTitle(L10n.Provider.Preset.title)
|
}.navigationTitle(L10n.Provider.Preset.title)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func presetSection(_ preset: ProviderServer.Preset) -> some View {
|
// MARK: -
|
||||||
|
|
||||||
|
private extension ProviderPresetView {
|
||||||
|
func presetSection(_ preset: ProviderServer.Preset) -> some View {
|
||||||
Section {
|
Section {
|
||||||
Button {
|
Button {
|
||||||
selectedPreset = preset
|
selectedPreset = preset
|
||||||
|
@ -92,13 +96,13 @@ struct ProviderPresetView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func presetSelectionRow(_ preset: ProviderServer.Preset) -> some View {
|
func presetSelectionRow(_ preset: ProviderServer.Preset) -> some View {
|
||||||
Text(preset.comment)
|
Text(preset.comment)
|
||||||
.withTrailingCheckmark(when: preset.id == selectedPreset?.id)
|
.withTrailingCheckmark(when: preset.id == selectedPreset?.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// some providers (e.g. NordVPN) have specific presets based on selected server
|
// some providers (e.g. NordVPN) have specific presets based on selected server
|
||||||
private var availablePresets: [ProviderServer.Preset] {
|
var availablePresets: [ProviderServer.Preset] {
|
||||||
server?.presets?.sorted() ?? []
|
server?.presets?.sorted() ?? []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -53,14 +53,18 @@ struct SettingsView: View {
|
||||||
}.themeSecondaryView()
|
}.themeSecondaryView()
|
||||||
.navigationTitle(L10n.Settings.title)
|
.navigationTitle(L10n.Settings.title)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var preferencesSection: some View {
|
// MARK: -
|
||||||
|
|
||||||
|
private extension SettingsView {
|
||||||
|
var preferencesSection: some View {
|
||||||
Section {
|
Section {
|
||||||
Toggle(L10n.Settings.Items.LocksInBackground.caption, isOn: $locksInBackground)
|
Toggle(L10n.Settings.Items.LocksInBackground.caption, isOn: $locksInBackground)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var aboutSection: some View {
|
var aboutSection: some View {
|
||||||
Section {
|
Section {
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
AboutView()
|
AboutView()
|
||||||
|
|
|
@ -71,32 +71,34 @@ extension ShortcutsView {
|
||||||
}
|
}
|
||||||
}.navigationTitle(L10n.Shortcuts.Add.title)
|
}.navigationTitle(L10n.Shortcuts.Add.title)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var addConnectView: some View {
|
|
||||||
Button(L10n.Shortcuts.Add.Items.Connect.caption) {
|
|
||||||
if target.isProvider {
|
|
||||||
pendingProfile.value = target
|
|
||||||
isPresentingProviderLocation = true
|
|
||||||
} else {
|
|
||||||
addConnect(target.header)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var hiddenProviderLocationLink: some View {
|
|
||||||
NavigationLink("", isActive: $isPresentingProviderLocation) {
|
|
||||||
ProviderLocationView(
|
|
||||||
currentProfile: pendingProfile,
|
|
||||||
isEditable: false,
|
|
||||||
isPresented: isProviderLocationPresented
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ShortcutsView.AddView {
|
// MARK: -
|
||||||
private var isProviderLocationPresented: Binding<Bool> {
|
|
||||||
|
private extension ShortcutsView.AddView {
|
||||||
|
var addConnectView: some View {
|
||||||
|
Button(L10n.Shortcuts.Add.Items.Connect.caption) {
|
||||||
|
if target.isProvider {
|
||||||
|
pendingProfile.value = target
|
||||||
|
isPresentingProviderLocation = true
|
||||||
|
} else {
|
||||||
|
addConnect(target.header)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var hiddenProviderLocationLink: some View {
|
||||||
|
NavigationLink("", isActive: $isPresentingProviderLocation) {
|
||||||
|
ProviderLocationView(
|
||||||
|
currentProfile: pendingProfile,
|
||||||
|
isEditable: false,
|
||||||
|
isPresented: isProviderLocationPresented
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var isProviderLocationPresented: Binding<Bool> {
|
||||||
.init {
|
.init {
|
||||||
isPresentingProviderLocation
|
isPresentingProviderLocation
|
||||||
} set: {
|
} set: {
|
||||||
|
@ -106,14 +108,18 @@ extension ShortcutsView.AddView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func addConnect(_ header: Profile.Header) {
|
// MARK: -
|
||||||
|
|
||||||
|
private extension ShortcutsView.AddView {
|
||||||
|
func addConnect(_ header: Profile.Header) {
|
||||||
pendingShortcut = INShortcut(intent: IntentDispatcher.intentConnect(
|
pendingShortcut = INShortcut(intent: IntentDispatcher.intentConnect(
|
||||||
header: header
|
header: header
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func addMoveToPendingProfile() {
|
func addMoveToPendingProfile() {
|
||||||
let header = pendingProfile.value.header
|
let header = pendingProfile.value.header
|
||||||
guard let server = pendingProfile.value.providerServer(providerManager) else {
|
guard let server = pendingProfile.value.providerServer(providerManager) else {
|
||||||
return
|
return
|
||||||
|
@ -125,31 +131,31 @@ extension ShortcutsView.AddView {
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func addEnableVPN() {
|
func addEnableVPN() {
|
||||||
addShortcut(with: IntentDispatcher.intentEnable())
|
addShortcut(with: IntentDispatcher.intentEnable())
|
||||||
}
|
}
|
||||||
|
|
||||||
private func addDisableVPN() {
|
func addDisableVPN() {
|
||||||
addShortcut(with: IntentDispatcher.intentDisable())
|
addShortcut(with: IntentDispatcher.intentDisable())
|
||||||
}
|
}
|
||||||
|
|
||||||
private func addTrustWiFi() {
|
func addTrustWiFi() {
|
||||||
addShortcut(with: IntentDispatcher.intentTrustWiFi())
|
addShortcut(with: IntentDispatcher.intentTrustWiFi())
|
||||||
}
|
}
|
||||||
|
|
||||||
private func addUntrustWiFi() {
|
func addUntrustWiFi() {
|
||||||
addShortcut(with: IntentDispatcher.intentUntrustWiFi())
|
addShortcut(with: IntentDispatcher.intentUntrustWiFi())
|
||||||
}
|
}
|
||||||
|
|
||||||
private func addTrustCellular() {
|
func addTrustCellular() {
|
||||||
addShortcut(with: IntentDispatcher.intentTrustCellular())
|
addShortcut(with: IntentDispatcher.intentTrustCellular())
|
||||||
}
|
}
|
||||||
|
|
||||||
private func addUntrustCellular() {
|
func addUntrustCellular() {
|
||||||
addShortcut(with: IntentDispatcher.intentUntrustCellular())
|
addShortcut(with: IntentDispatcher.intentUntrustCellular())
|
||||||
}
|
}
|
||||||
|
|
||||||
private func addShortcut(with intent: INIntent) {
|
func addShortcut(with intent: INIntent) {
|
||||||
guard let shortcut = INShortcut(intent: intent) else {
|
guard let shortcut = INShortcut(intent: intent) else {
|
||||||
fatalError("Unable to create INShortcut, intent '\(intent.description)' not exposed by app?")
|
fatalError("Unable to create INShortcut, intent '\(intent.description)' not exposed by app?")
|
||||||
}
|
}
|
||||||
|
|
|
@ -78,8 +78,12 @@ struct ShortcutsView: View {
|
||||||
.navigationTitle(Unlocalized.Other.siri)
|
.navigationTitle(Unlocalized.Other.siri)
|
||||||
.themeSecondaryView()
|
.themeSecondaryView()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var shortcutsSection: some View {
|
// MARK: -
|
||||||
|
|
||||||
|
private extension ShortcutsView {
|
||||||
|
var shortcutsSection: some View {
|
||||||
Section {
|
Section {
|
||||||
ForEach(relevantShortcuts, content: rowView)
|
ForEach(relevantShortcuts, content: rowView)
|
||||||
} header: {
|
} header: {
|
||||||
|
@ -87,13 +91,13 @@ struct ShortcutsView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var relevantShortcuts: [Shortcut] {
|
var relevantShortcuts: [Shortcut] {
|
||||||
intentsManager.shortcuts.values.filter {
|
intentsManager.shortcuts.values.filter {
|
||||||
$0.isRelevant(to: target)
|
$0.isRelevant(to: target)
|
||||||
}.sorted()
|
}.sorted()
|
||||||
}
|
}
|
||||||
|
|
||||||
private var addSection: some View {
|
var addSection: some View {
|
||||||
Section {
|
Section {
|
||||||
NavigationLink(isActive: $isNavigationPresented) {
|
NavigationLink(isActive: $isNavigationPresented) {
|
||||||
AddView(
|
AddView(
|
||||||
|
@ -109,7 +113,7 @@ struct ShortcutsView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func presentedModal(_ modalType: ModalType) -> some View {
|
func presentedModal(_ modalType: ModalType) -> some View {
|
||||||
switch modalType {
|
switch modalType {
|
||||||
case .edit(let shortcut):
|
case .edit(let shortcut):
|
||||||
IntentEditView(
|
IntentEditView(
|
||||||
|
@ -125,7 +129,7 @@ struct ShortcutsView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func rowView(forShortcut vs: Shortcut) -> some View {
|
func rowView(forShortcut vs: Shortcut) -> some View {
|
||||||
Button {
|
Button {
|
||||||
presentEditShortcut(vs)
|
presentEditShortcut(vs)
|
||||||
} label: {
|
} label: {
|
||||||
|
@ -133,7 +137,7 @@ struct ShortcutsView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var delegatingPendingShortcut: Binding<INShortcut?> {
|
var delegatingPendingShortcut: Binding<INShortcut?> {
|
||||||
.init {
|
.init {
|
||||||
pendingShortcut
|
pendingShortcut
|
||||||
} set: {
|
} set: {
|
||||||
|
@ -143,15 +147,6 @@ struct ShortcutsView: View {
|
||||||
presentAddShortcut(pendingShortcut)
|
presentAddShortcut(pendingShortcut)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func presentEditShortcut(_ shortcut: Shortcut) {
|
|
||||||
modalType = .edit(shortcut: shortcut)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func presentAddShortcut(_ shortcut: INShortcut) {
|
|
||||||
isNavigationPresented = false
|
|
||||||
modalType = .add(shortcut: shortcut)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension Shortcut {
|
private extension Shortcut {
|
||||||
|
@ -168,3 +163,16 @@ private extension Shortcut {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: -
|
||||||
|
|
||||||
|
private extension ShortcutsView {
|
||||||
|
func presentEditShortcut(_ shortcut: Shortcut) {
|
||||||
|
modalType = .edit(shortcut: shortcut)
|
||||||
|
}
|
||||||
|
|
||||||
|
func presentAddShortcut(_ shortcut: INShortcut) {
|
||||||
|
isNavigationPresented = false
|
||||||
|
modalType = .add(shortcut: shortcut)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -39,8 +39,12 @@ struct VPNStatusText: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Text(statusText)
|
Text(statusText)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var statusText: String {
|
// MARK: -
|
||||||
|
|
||||||
|
private extension VPNStatusText {
|
||||||
|
var statusText: String {
|
||||||
currentVPNState.localizedStatusDescription(
|
currentVPNState.localizedStatusDescription(
|
||||||
isActiveProfile: isActiveProfile,
|
isActiveProfile: isActiveProfile,
|
||||||
withErrors: true,
|
withErrors: true,
|
||||||
|
|
|
@ -41,34 +41,6 @@ struct VPNToggle: View {
|
||||||
|
|
||||||
private let rateLimit: Int
|
private let rateLimit: Int
|
||||||
|
|
||||||
private var isEnabled: Binding<Bool> {
|
|
||||||
.init {
|
|
||||||
isActiveProfile && currentVPNState.isEnabled && !shouldPromptForAccount
|
|
||||||
} set: { newValue in
|
|
||||||
guard !shouldPromptForAccount else {
|
|
||||||
interactiveProfile = profile
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard newValue else {
|
|
||||||
disableVPN()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
enableVPN()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var isActiveProfile: Bool {
|
|
||||||
profileManager.isActiveProfile(profile.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var shouldPromptForAccount: Bool {
|
|
||||||
profile.account.authenticationMethod == .interactive && (currentVPNState.vpnStatus == .disconnecting || currentVPNState.vpnStatus == .disconnected)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var isEligibleForSiri: Bool {
|
|
||||||
productManager.isEligible(forFeature: .siriShortcuts)
|
|
||||||
}
|
|
||||||
|
|
||||||
@State private var canToggle = true
|
@State private var canToggle = true
|
||||||
|
|
||||||
init(profile: Profile, interactiveProfile: Binding<Profile?>, rateLimit: Int) {
|
init(profile: Profile, interactiveProfile: Binding<Profile?>, rateLimit: Int) {
|
||||||
|
@ -86,8 +58,44 @@ struct VPNToggle: View {
|
||||||
.disabled(!canToggle)
|
.disabled(!canToggle)
|
||||||
.themeAnimation(on: currentVPNState.isEnabled)
|
.themeAnimation(on: currentVPNState.isEnabled)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func enableVPN() {
|
// MARK: -
|
||||||
|
|
||||||
|
private extension VPNToggle {
|
||||||
|
var isEnabled: Binding<Bool> {
|
||||||
|
.init {
|
||||||
|
isActiveProfile && currentVPNState.isEnabled && !shouldPromptForAccount
|
||||||
|
} set: { newValue in
|
||||||
|
guard !shouldPromptForAccount else {
|
||||||
|
interactiveProfile = profile
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard newValue else {
|
||||||
|
disableVPN()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
enableVPN()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var isActiveProfile: Bool {
|
||||||
|
profileManager.isActiveProfile(profile.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
var shouldPromptForAccount: Bool {
|
||||||
|
profile.account.authenticationMethod == .interactive && (currentVPNState.vpnStatus == .disconnecting || currentVPNState.vpnStatus == .disconnected)
|
||||||
|
}
|
||||||
|
|
||||||
|
var isEligibleForSiri: Bool {
|
||||||
|
productManager.isEligible(forFeature: .siriShortcuts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: -
|
||||||
|
|
||||||
|
private extension VPNToggle {
|
||||||
|
func enableVPN() {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
canToggle = false
|
canToggle = false
|
||||||
await Task.maybeWait(forMilliseconds: rateLimit)
|
await Task.maybeWait(forMilliseconds: rateLimit)
|
||||||
|
@ -104,7 +112,7 @@ struct VPNToggle: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func disableVPN() {
|
func disableVPN() {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
canToggle = false
|
canToggle = false
|
||||||
await vpnManager.disable()
|
await vpnManager.disable()
|
||||||
|
@ -112,7 +120,7 @@ struct VPNToggle: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func donateIntents(withProfile profile: Profile) {
|
func donateIntents(withProfile profile: Profile) {
|
||||||
|
|
||||||
// eligibility: donate intents if eligible for Siri
|
// eligibility: donate intents if eligible for Siri
|
||||||
guard isEligibleForSiri else {
|
guard isEligibleForSiri else {
|
||||||
|
|
Loading…
Reference in New Issue