Save provider favorite servers (#758)

Fixes #706
This commit is contained in:
Davide 2024-10-26 13:29:26 +02:00 committed by GitHub
parent 3abde3851a
commit df4e3465f5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 229 additions and 10 deletions

View File

@ -461,6 +461,8 @@ public enum Strings {
} }
/// None /// None
public static let noProvider = Strings.tr("Localizable", "providers.no_provider", fallback: "None") public static let noProvider = Strings.tr("Localizable", "providers.no_provider", fallback: "None")
/// Only favorites
public static let onlyFavorites = Strings.tr("Localizable", "providers.only_favorites", fallback: "Only favorites")
/// Refresh infrastructure /// Refresh infrastructure
public static let refreshInfrastructure = Strings.tr("Localizable", "providers.refresh_infrastructure", fallback: "Refresh infrastructure") public static let refreshInfrastructure = Strings.tr("Localizable", "providers.refresh_infrastructure", fallback: "Refresh infrastructure")
/// Select /// Select

View File

@ -228,6 +228,7 @@
"providers.no_provider" = "None"; "providers.no_provider" = "None";
"providers.select_provider" = "Select a provider"; "providers.select_provider" = "Select a provider";
"providers.select_entity" = "Select"; "providers.select_entity" = "Select";
"providers.only_favorites" = "Only favorites";
"providers.clear_filters" = "Clear filters"; "providers.clear_filters" = "Clear filters";
"providers.refresh_infrastructure" = "Refresh infrastructure"; "providers.refresh_infrastructure" = "Refresh infrastructure";
"providers.last_updated" = "Last updated on %@"; "providers.last_updated" = "Last updated on %@";

View File

@ -48,6 +48,7 @@ extension ProviderEntityViewProviding where Self: ProviderCompatibleModule, Enti
} as? VPNEntity<EntityType.Configuration> } as? VPNEntity<EntityType.Configuration>
return VPNProviderServerCoordinator( return VPNProviderServerCoordinator(
moduleId: id,
providerId: provider.id, providerId: provider.id,
selectedEntity: selectedEntity, selectedEntity: selectedEntity,
onSelect: onSelect onSelect: onSelect

View File

@ -128,6 +128,7 @@ private extension OpenVPNView {
case .providerServer: case .providerServer:
providerId.wrappedValue.map { providerId.wrappedValue.map {
VPNProviderServerView( VPNProviderServerView(
moduleId: module.id,
providerId: $0, providerId: $0,
configurationType: OpenVPN.Configuration.self, configurationType: OpenVPN.Configuration.self,
selectedEntity: providerEntity.wrappedValue, selectedEntity: providerEntity.wrappedValue,

View File

@ -24,6 +24,7 @@
// //
import AppLibrary import AppLibrary
import CommonLibrary
import PassepartoutKit import PassepartoutKit
import SwiftUI import SwiftUI
@ -35,15 +36,41 @@ struct VPNFiltersView<Configuration>: View where Configuration: ProviderConfigur
@Binding @Binding
var filters: VPNFilters var filters: VPNFilters
@Binding
var onlyShowsFavorites: Bool
let favorites: Set<String>
var body: some View { var body: some View {
debugChanges() debugChanges()
return Subview( return Subview(
filters: $filters, filters: $filters,
onlyShowsFavorites: $onlyShowsFavorites,
categories: categories, categories: categories,
countries: countries, countries: countries,
presets: presets presets: presets,
favorites: favorites
) )
.onChange(of: filters, perform: manager.applyFilters) .disabled(manager.isFiltering)
.onChange(of: filters) { filters in
Task {
await manager.applyFilters(filters)
}
}
.onChange(of: favorites) {
if onlyShowsFavorites {
filters.serverIds = $0
Task {
await manager.applyFilters(filters)
}
}
}
.onChange(of: onlyShowsFavorites) {
filters.serverIds = $0 ? favorites : nil
Task {
await manager.applyFilters(filters)
}
}
} }
} }
@ -82,12 +109,17 @@ private extension VPNFiltersView {
@Binding @Binding
var filters: VPNFilters var filters: VPNFilters
@Binding
var onlyShowsFavorites: Bool
let categories: [String] let categories: [String]
let countries: [(code: String, description: String)] let countries: [(code: String, description: String)]
let presets: [VPNPreset<Configuration>] let presets: [VPNPreset<Configuration>]
let favorites: Set<String>
var body: some View { var body: some View {
debugChanges() debugChanges()
return Form { return Form {
@ -95,6 +127,7 @@ private extension VPNFiltersView {
categoryPicker categoryPicker
countryPicker countryPicker
presetPicker presetPicker
favoritesToggle
#if os(iOS) #if os(iOS)
clearFiltersButton clearFiltersButton
.frame(maxWidth: .infinity, alignment: .center) .frame(maxWidth: .infinity, alignment: .center)
@ -144,8 +177,13 @@ private extension VPNFiltersView.Subview {
} }
} }
var favoritesToggle: some View {
Toggle(Strings.Providers.onlyFavorites, isOn: $onlyShowsFavorites)
}
var clearFiltersButton: some View { var clearFiltersButton: some View {
Button(Strings.Providers.clearFilters, role: .destructive) { Button(Strings.Providers.clearFilters, role: .destructive) {
onlyShowsFavorites = false
filters = VPNFilters() filters = VPNFilters()
} }
} }
@ -155,7 +193,9 @@ private extension VPNFiltersView.Subview {
NavigationStack { NavigationStack {
VPNFiltersView<OpenVPN.Configuration>( VPNFiltersView<OpenVPN.Configuration>(
manager: VPNProviderManager(), manager: VPNProviderManager(),
filters: .constant(VPNFilters()) filters: .constant(VPNFilters()),
onlyShowsFavorites: .constant(false),
favorites: []
) )
} }
} }

View File

@ -32,6 +32,8 @@ struct VPNProviderServerCoordinator<Configuration>: View where Configuration: Pr
@Environment(\.dismiss) @Environment(\.dismiss)
private var dismiss private var dismiss
let moduleId: UUID
let providerId: ProviderID let providerId: ProviderID
let selectedEntity: VPNEntity<Configuration>? let selectedEntity: VPNEntity<Configuration>?
@ -44,6 +46,7 @@ struct VPNProviderServerCoordinator<Configuration>: View where Configuration: Pr
var body: some View { var body: some View {
NavigationStack { NavigationStack {
VPNProviderServerView( VPNProviderServerView(
moduleId: moduleId,
providerId: providerId, providerId: providerId,
configurationType: Configuration.self, configurationType: Configuration.self,
selectedEntity: selectedEntity, selectedEntity: selectedEntity,

View File

@ -24,6 +24,7 @@
// //
import AppLibrary import AppLibrary
import CommonLibrary
import PassepartoutKit import PassepartoutKit
import SwiftUI import SwiftUI
import UtilsLibrary import UtilsLibrary
@ -33,8 +34,13 @@ struct VPNProviderServerView<Configuration>: View where Configuration: ProviderC
@EnvironmentObject @EnvironmentObject
private var providerManager: ProviderManager private var providerManager: ProviderManager
@AppStorage(AppPreference.moduleFavoriteServers.key)
var allFavorites = ModuleFavoriteServers()
var apis: [APIMapper] = API.shared var apis: [APIMapper] = API.shared
let moduleId: UUID
let providerId: ProviderID let providerId: ProviderID
let configurationType: Configuration.Type let configurationType: Configuration.Type
@ -57,6 +63,9 @@ struct VPNProviderServerView<Configuration>: View where Configuration: ProviderC
@State @State
private var filters = VPNFilters() private var filters = VPNFilters()
@State
private var onlyShowsFavorites = false
@StateObject @StateObject
private var errorHandler: ErrorHandler = .default() private var errorHandler: ErrorHandler = .default()
@ -66,6 +75,8 @@ struct VPNProviderServerView<Configuration>: View where Configuration: ProviderC
manager: manager, manager: manager,
selectedServer: selectedEntity?.server, selectedServer: selectedEntity?.server,
filters: $filters, filters: $filters,
onlyShowsFavorites: $onlyShowsFavorites,
favorites: favoritesBinding,
selectTitle: selectTitle, selectTitle: selectTitle,
onSelect: selectServer onSelect: selectServer
) )
@ -84,7 +95,7 @@ struct VPNProviderServerView<Configuration>: View where Configuration: ProviderC
} else { } else {
filters = VPNFilters() filters = VPNFilters()
} }
manager.applyFilters(filters) await manager.applyFilters(filters)
} catch { } catch {
pp_log(.app, .error, "Unable to load VPN repository: \(error)") pp_log(.app, .error, "Unable to load VPN repository: \(error)")
errorHandler.handle(error, title: Strings.Global.servers) errorHandler.handle(error, title: Strings.Global.servers)
@ -108,6 +119,14 @@ extension VPNProviderServerView {
} }
} }
var favoritesBinding: Binding<Set<String>> {
Binding {
allFavorites.servers(forModuleWithID: moduleId)
} set: {
allFavorites.setServers($0, forModuleWithID: moduleId)
}
}
func selectServer(_ server: VPNServer) { func selectServer(_ server: VPNServer) {
guard let preset = compatiblePreset(with: server) else { guard let preset = compatiblePreset(with: server) else {
pp_log(.app, .error, "Unable to find a compatible preset. Supported IDs: \(server.provider.supportedPresetIds ?? [])") pp_log(.app, .error, "Unable to find a compatible preset. Supported IDs: \(server.provider.supportedPresetIds ?? [])")
@ -124,6 +143,7 @@ extension VPNProviderServerView {
NavigationStack { NavigationStack {
VPNProviderServerView( VPNProviderServerView(
apis: [API.bundled], apis: [API.bundled],
moduleId: UUID(),
providerId: .protonvpn, providerId: .protonvpn,
configurationType: OpenVPN.Configuration.self, configurationType: OpenVPN.Configuration.self,
selectedEntity: nil, selectedEntity: nil,

View File

@ -39,6 +39,12 @@ extension VPNProviderServerView {
@Binding @Binding
var filters: VPNFilters var filters: VPNFilters
@Binding
var onlyShowsFavorites: Bool
@Binding
var favorites: Set<String>
// unused // unused
let selectTitle: String let selectTitle: String
@ -82,7 +88,11 @@ private extension VPNProviderServerView.Subview {
manager manager
.allCountryCodes .allCountryCodes
.sorted { .sorted {
$0.localizedAsRegionCode! < $1.localizedAsRegionCode! guard let region1 = $0.localizedAsRegionCode,
let region2 = $1.localizedAsRegionCode else {
return $0 < $1
}
return region1 < region2
} }
} }
@ -103,7 +113,7 @@ private extension VPNProviderServerView.Subview {
} label: { } label: {
HStack { HStack {
ThemeCountryFlag(code: code) ThemeCountryFlag(code: code)
Text(code.localizedAsRegionCode!) Text(code.localizedAsRegionCode ?? code)
} }
} }
} }
@ -114,10 +124,21 @@ private extension VPNProviderServerView.Subview {
onSelect(server) onSelect(server)
} label: { } label: {
HStack { HStack {
Text(server.hostname ?? server.serverId)
Spacer()
ThemeImage(.marked) ThemeImage(.marked)
.opacity(server.id == selectedServer?.id ? 1.0 : 0.0) .opacity(server.id == selectedServer?.id ? 1.0 : 0.0)
VStack(alignment: .leading) {
if let area = server.provider.area {
Text(area)
.font(.headline)
Text(server.provider.serverId)
.font(.subheadline)
} else {
Text(server.provider.serverId)
.font(.headline)
}
}
Spacer()
FavoriteToggle(value: server.serverId, selection: $favorites)
} }
} }
} }
@ -137,7 +158,9 @@ private extension VPNProviderServerView.Subview {
NavigationStack { NavigationStack {
VPNFiltersView( VPNFiltersView(
manager: manager, manager: manager,
filters: $filters filters: $filters,
onlyShowsFavorites: $onlyShowsFavorites,
favorites: favorites
) )
.navigationTitle(Strings.Global.filters) .navigationTitle(Strings.Global.filters)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
@ -152,6 +175,7 @@ private extension VPNProviderServerView.Subview {
NavigationStack { NavigationStack {
VPNProviderServerView( VPNProviderServerView(
apis: [API.bundled], apis: [API.bundled],
moduleId: UUID(),
providerId: .tunnelbear, providerId: .tunnelbear,
configurationType: OpenVPN.Configuration.self, configurationType: OpenVPN.Configuration.self,
selectedEntity: nil, selectedEntity: nil,

View File

@ -39,10 +39,19 @@ extension VPNProviderServerView {
@Binding @Binding
var filters: VPNFilters var filters: VPNFilters
@Binding
var onlyShowsFavorites: Bool
@Binding
var favorites: Set<String>
let selectTitle: String let selectTitle: String
let onSelect: (VPNServer) -> Void let onSelect: (VPNServer) -> Void
@State
private var hoveringServerId: String?
var body: some View { var body: some View {
VStack { VStack {
filtersView filtersView
@ -70,6 +79,15 @@ private extension VPNProviderServerView.Subview {
TableColumn(Strings.Global.address, value: \.address) TableColumn(Strings.Global.address, value: \.address)
TableColumn("") { server in
FavoriteToggle(value: server.serverId, selection: $favorites)
.opacity(favorites.contains(server.serverId) || server.serverId == hoveringServerId ? 1.0 : 0.0)
.onHover {
hoveringServerId = $0 ? server.serverId : nil
}
}
.width(20.0)
TableColumn("") { server in TableColumn("") { server in
Button { Button {
onSelect(server) onSelect(server)
@ -84,7 +102,9 @@ private extension VPNProviderServerView.Subview {
var filtersView: some View { var filtersView: some View {
VPNFiltersView( VPNFiltersView(
manager: manager, manager: manager,
filters: $filters filters: $filters,
onlyShowsFavorites: $onlyShowsFavorites,
favorites: favorites
) )
.padding() .padding()
} }
@ -96,6 +116,7 @@ private extension VPNProviderServerView.Subview {
NavigationStack { NavigationStack {
VPNProviderServerView( VPNProviderServerView(
apis: [API.bundled], apis: [API.bundled],
moduleId: UUID(),
providerId: .tunnelbear, providerId: .tunnelbear,
configurationType: OpenVPN.Configuration.self, configurationType: OpenVPN.Configuration.self,
selectedEntity: nil, selectedEntity: nil,

View File

@ -36,6 +36,8 @@ extension Theme {
case disclose case disclose
case editableSectionEdit case editableSectionEdit
case editableSectionRemove case editableSectionRemove
case favoriteOff
case favoriteOn
case filters case filters
case footerAdd case footerAdd
case hide case hide

View File

@ -89,6 +89,8 @@ public final class Theme: ObservableObject {
case .disclose: return "chevron.down" case .disclose: return "chevron.down"
case .editableSectionEdit: return "arrow.up.arrow.down" case .editableSectionEdit: return "arrow.up.arrow.down"
case .editableSectionRemove: return "trash" case .editableSectionRemove: return "trash"
case .favoriteOff: return "star"
case .favoriteOn: return "star.fill"
case .filters: return "line.3.horizontal.decrease" case .filters: return "line.3.horizontal.decrease"
case .footerAdd: return "plus.circle" case .footerAdd: return "plus.circle"
case .hide: return "eye.slash" case .hide: return "eye.slash"

View File

@ -0,0 +1,45 @@
//
// ThemeFavoriteToggle.swift
// Passepartout
//
// Created by Davide De Rosa on 10/25/24.
// Copyright (c) 2024 Davide De Rosa. All rights reserved.
//
// https://github.com/passepartoutvpn
//
// This file is part of Passepartout.
//
// Passepartout is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Passepartout is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import SwiftUI
struct FavoriteToggle<ID>: View where ID: Hashable {
let value: ID
@Binding
var selection: Set<ID>
var body: some View {
Button {
if selection.contains(value) {
selection.remove(value)
} else {
selection.insert(value)
}
} label: {
ThemeImage(selection.contains(value) ? .favoriteOn : .favoriteOff)
}
}
}

View File

@ -32,6 +32,8 @@ public enum AppPreference: String {
case logsPrivateData case logsPrivateData
case moduleFavoriteServers
case profilesLayout case profilesLayout
public var key: String { public var key: String {

View File

@ -0,0 +1,55 @@
//
// ModuleFavoriteServers.swift
// Passepartout
//
// Created by Davide De Rosa on 10/25/24.
// Copyright (c) 2024 Davide De Rosa. All rights reserved.
//
// https://github.com/passepartoutvpn
//
// This file is part of Passepartout.
//
// Passepartout is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Passepartout is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import SwiftUI
public struct ModuleFavoriteServers {
private var map: [UUID: Set<String>]
public init() {
map = [:]
}
public func servers(forModuleWithID moduleId: UUID) -> Set<String> {
map[moduleId] ?? []
}
public mutating func setServers(_ servers: Set<String>, forModuleWithID moduleId: UUID) {
map[moduleId] = servers
}
}
extension ModuleFavoriteServers: RawRepresentable {
public var rawValue: String {
(try? JSONEncoder().encode(map))?.base64EncodedString() ?? ""
}
public init?(rawValue: String) {
guard let data = Data(base64Encoded: rawValue) else {
return nil
}
map = (try? JSONDecoder().decode([UUID: Set<String>].self, from: data)) ?? [:]
}
}