Replace active modules count with a description (#915)

To get access to modules, try to avoid full Profile objects. Instead,
replace the coupled ProfileHeader occurrences with a new intermediary
ProfilePreview everywhere.

This way, a ProfileProcessor can inject the localized modules
descriptions from above with the preview() method.
This commit is contained in:
Davide 2024-11-22 12:52:51 +01:00 committed by GitHub
parent a2f246454d
commit 4b4fca8344
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 229 additions and 145 deletions

View File

@ -124,7 +124,7 @@ private extension InstalledProfileView {
ProfileContextMenu( ProfileContextMenu(
profileManager: profileManager, profileManager: profileManager,
tunnel: tunnel, tunnel: tunnel,
header: (profile ?? .mock).header(), preview: .init(profile ?? .mock),
interactiveManager: interactiveManager, interactiveManager: interactiveManager,
errorHandler: errorHandler, errorHandler: errorHandler,
isInstalledProfile: true, isInstalledProfile: true,
@ -324,7 +324,7 @@ private struct ContentView: View {
style: .full, style: .full,
profileManager: .mock, profileManager: .mock,
tunnel: .mock, tunnel: .mock,
header: Profile.mock.header(), preview: .init(.mock),
interactiveManager: InteractiveManager(), interactiveManager: InteractiveManager(),
errorHandler: .default(), errorHandler: .default(),
nextProfileId: .constant(nil), nextProfileId: .constant(nil),

View File

@ -24,7 +24,6 @@
// //
import CommonLibrary import CommonLibrary
import PassepartoutKit
import SwiftUI import SwiftUI
struct ProfileCardView: View { struct ProfileCardView: View {
@ -36,21 +35,22 @@ struct ProfileCardView: View {
let style: Style let style: Style
let header: ProfileHeader let preview: ProfilePreview
var body: some View { var body: some View {
switch style { switch style {
case .compact: case .compact:
Text(header.name) Text(preview.name)
.themeTruncating() .themeTruncating()
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
case .full: case .full:
VStack(alignment: .leading) { VStack(alignment: .leading) {
Text(header.name) Text(preview.name)
.font(.headline) .font(.headline)
.themeTruncating() .themeTruncating()
Text(Strings.Views.Profiles.Rows.modules(header.modules.count))
Text(preview.subtitle ?? Strings.Views.Profiles.Rows.noModules)
.font(.subheadline) .font(.subheadline)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
@ -66,13 +66,13 @@ struct ProfileCardView: View {
Section { Section {
ProfileCardView( ProfileCardView(
style: .compact, style: .compact,
header: Profile.mock.header() preview: .init(.mock)
) )
} }
Section { Section {
ProfileCardView( ProfileCardView(
style: .full, style: .full,
header: Profile.mock.header() preview: .init(.mock)
) )
} }
} }

View File

@ -136,7 +136,7 @@ private struct ContainerModifier: ViewModifier {
profileManager.search(byName: $0) profileManager.search(byName: $0)
} }
.themeAnimation(on: profileManager.isReady, category: .profiles) .themeAnimation(on: profileManager.isReady, category: .profiles)
.themeAnimation(on: profileManager.headers, category: .profiles) .themeAnimation(on: profileManager.previews, category: .profiles)
} }
} }

View File

@ -33,7 +33,7 @@ struct ProfileContextMenu: View, Routable {
let tunnel: ExtendedTunnel let tunnel: ExtendedTunnel
let header: ProfileHeader let preview: ProfilePreview
let interactiveManager: InteractiveManager let interactiveManager: InteractiveManager
@ -60,7 +60,7 @@ struct ProfileContextMenu: View, Routable {
@MainActor @MainActor
private extension ProfileContextMenu { private extension ProfileContextMenu {
var profile: Profile? { var profile: Profile? {
profileManager.profile(withId: header.id) profileManager.profile(withId: preview.id)
} }
var tunnelToggleButton: some View { var tunnelToggleButton: some View {
@ -111,7 +111,7 @@ private extension ProfileContextMenu {
var profileEditButton: some View { var profileEditButton: some View {
Button { Button {
flow?.onEditProfile(header) flow?.onEditProfile(preview)
} label: { } label: {
ThemeImageLabel(Strings.Global.edit.withTrailingDots, .profileEdit) ThemeImageLabel(Strings.Global.edit.withTrailingDots, .profileEdit)
} }
@ -120,7 +120,7 @@ private extension ProfileContextMenu {
var profileDuplicateButton: some View { var profileDuplicateButton: some View {
ProfileDuplicateButton( ProfileDuplicateButton(
profileManager: profileManager, profileManager: profileManager,
header: header, preview: preview,
errorHandler: errorHandler errorHandler: errorHandler
) { ) {
ThemeImageLabel(Strings.Global.duplicate, .contextDuplicate) ThemeImageLabel(Strings.Global.duplicate, .contextDuplicate)
@ -130,7 +130,7 @@ private extension ProfileContextMenu {
var profileRemoveButton: some View { var profileRemoveButton: some View {
ProfileRemoveButton( ProfileRemoveButton(
profileManager: profileManager, profileManager: profileManager,
header: header preview: preview
) { ) {
ThemeImageLabel(Strings.Global.remove, .contextRemove) ThemeImageLabel(Strings.Global.remove, .contextRemove)
} }
@ -143,7 +143,7 @@ private extension ProfileContextMenu {
ProfileContextMenu( ProfileContextMenu(
profileManager: .mock, profileManager: .mock,
tunnel: .mock, tunnel: .mock,
header: Profile.mock.header(), preview: .init(.mock),
interactiveManager: InteractiveManager(), interactiveManager: InteractiveManager(),
errorHandler: .default(), errorHandler: .default(),
isInstalledProfile: true isInstalledProfile: true

View File

@ -25,13 +25,12 @@
import CommonLibrary import CommonLibrary
import CommonUtils import CommonUtils
import PassepartoutKit
import SwiftUI import SwiftUI
struct ProfileDuplicateButton<Label>: View where Label: View { struct ProfileDuplicateButton<Label>: View where Label: View {
let profileManager: ProfileManager let profileManager: ProfileManager
let header: ProfileHeader let preview: ProfilePreview
let errorHandler: ErrorHandler let errorHandler: ErrorHandler
@ -41,12 +40,12 @@ struct ProfileDuplicateButton<Label>: View where Label: View {
Button { Button {
Task { Task {
do { do {
try await profileManager.duplicate(profileWithId: header.id) try await profileManager.duplicate(profileWithId: preview.id)
} catch { } catch {
errorHandler.handle( errorHandler.handle(
error, error,
title: Strings.Global.duplicate, title: Strings.Global.duplicate,
message: Strings.Views.Profiles.Errors.duplicate(header.name) message: Strings.Views.Profiles.Errors.duplicate(preview.name)
) )
} }
} }

View File

@ -28,7 +28,7 @@ import Foundation
import PassepartoutKit import PassepartoutKit
struct ProfileFlow { struct ProfileFlow {
let onEditProfile: (ProfileHeader) -> Void let onEditProfile: (ProfilePreview) -> Void
let onEditProviderEntity: (Profile) -> Void let onEditProviderEntity: (Profile) -> Void

View File

@ -64,7 +64,7 @@ struct ProfileGridView: View, Routable, TunnelInstallationProviding {
.unanimated() .unanimated()
} }
LazyVGrid(columns: columns) { LazyVGrid(columns: columns) {
ForEach(allHeaders, content: profileView) ForEach(allPreviews, content: profileView)
.onDelete { offsets in .onDelete { offsets in
Task { Task {
await profileManager.removeProfiles(at: offsets) await profileManager.removeProfiles(at: offsets)
@ -88,8 +88,8 @@ struct ProfileGridView: View, Routable, TunnelInstallationProviding {
// MARK: - Subviews // MARK: - Subviews
private extension ProfileGridView { private extension ProfileGridView {
var allHeaders: [ProfileHeader] { var allPreviews: [ProfilePreview] {
profileManager.headers profileManager.previews
} }
func headerView(scrollProxy: ScrollViewProxy) -> some View { func headerView(scrollProxy: ScrollViewProxy) -> some View {
@ -108,7 +108,7 @@ private extension ProfileGridView {
ProfileContextMenu( ProfileContextMenu(
profileManager: profileManager, profileManager: profileManager,
tunnel: tunnel, tunnel: tunnel,
header: $0.header(), preview: .init($0),
interactiveManager: interactiveManager, interactiveManager: interactiveManager,
errorHandler: errorHandler, errorHandler: errorHandler,
isInstalledProfile: true, isInstalledProfile: true,
@ -118,31 +118,31 @@ private extension ProfileGridView {
} }
} }
func profileView(for header: ProfileHeader) -> some View { func profileView(for preview: ProfilePreview) -> some View {
ProfileRowView( ProfileRowView(
style: .full, style: .full,
profileManager: profileManager, profileManager: profileManager,
tunnel: tunnel, tunnel: tunnel,
header: header, preview: preview,
interactiveManager: interactiveManager, interactiveManager: interactiveManager,
errorHandler: errorHandler, errorHandler: errorHandler,
nextProfileId: $nextProfileId, nextProfileId: $nextProfileId,
withMarker: true, withMarker: true,
flow: flow flow: flow
) )
.themeGridCell(isSelected: header.id == nextProfileId ?? currentProfile?.id) .themeGridCell(isSelected: preview.id == nextProfileId ?? currentProfile?.id)
.contextMenu { .contextMenu {
ProfileContextMenu( ProfileContextMenu(
profileManager: profileManager, profileManager: profileManager,
tunnel: tunnel, tunnel: tunnel,
header: header, preview: preview,
interactiveManager: interactiveManager, interactiveManager: interactiveManager,
errorHandler: errorHandler, errorHandler: errorHandler,
isInstalledProfile: false, isInstalledProfile: false,
flow: flow flow: flow
) )
} }
.id(header.id) .id(preview.id)
} }
} }

View File

@ -23,17 +23,17 @@
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>. // along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
// //
import PassepartoutKit import CommonLibrary
import SwiftUI import SwiftUI
struct ProfileInfoButton: View { struct ProfileInfoButton: View {
let header: ProfileHeader let preview: ProfilePreview
let onEdit: (ProfileHeader) -> Void let onEdit: (ProfilePreview) -> Void
var body: some View { var body: some View {
Button { Button {
onEdit(header) onEdit(preview)
} label: { } label: {
ThemeImage(.info) ThemeImage(.info)
} }

View File

@ -63,7 +63,7 @@ struct ProfileListView: View, Routable, TunnelInstallationProviding {
.unanimated() .unanimated()
} }
Group { Group {
ForEach(allHeaders, content: profileView) ForEach(allPreviews, content: profileView)
.onDelete { offsets in .onDelete { offsets in
Task { Task {
await profileManager.removeProfiles(at: offsets) await profileManager.removeProfiles(at: offsets)
@ -78,8 +78,8 @@ struct ProfileListView: View, Routable, TunnelInstallationProviding {
} }
private extension ProfileListView { private extension ProfileListView {
var allHeaders: [ProfileHeader] { var allPreviews: [ProfilePreview] {
profileManager.headers profileManager.previews
} }
func headerView(scrollProxy: ScrollViewProxy) -> some View { func headerView(scrollProxy: ScrollViewProxy) -> some View {
@ -98,7 +98,7 @@ private extension ProfileListView {
ProfileContextMenu( ProfileContextMenu(
profileManager: profileManager, profileManager: profileManager,
tunnel: tunnel, tunnel: tunnel,
header: $0.header(), preview: .init($0),
interactiveManager: interactiveManager, interactiveManager: interactiveManager,
errorHandler: errorHandler, errorHandler: errorHandler,
isInstalledProfile: true, isInstalledProfile: true,
@ -108,12 +108,12 @@ private extension ProfileListView {
} }
} }
func profileView(for header: ProfileHeader) -> some View { func profileView(for preview: ProfilePreview) -> some View {
ProfileRowView( ProfileRowView(
style: cardStyle, style: cardStyle,
profileManager: profileManager, profileManager: profileManager,
tunnel: tunnel, tunnel: tunnel,
header: header, preview: preview,
interactiveManager: interactiveManager, interactiveManager: interactiveManager,
errorHandler: errorHandler, errorHandler: errorHandler,
nextProfileId: $nextProfileId, nextProfileId: $nextProfileId,
@ -124,14 +124,14 @@ private extension ProfileListView {
ProfileContextMenu( ProfileContextMenu(
profileManager: profileManager, profileManager: profileManager,
tunnel: tunnel, tunnel: tunnel,
header: header, preview: preview,
interactiveManager: interactiveManager, interactiveManager: interactiveManager,
errorHandler: errorHandler, errorHandler: errorHandler,
isInstalledProfile: false, isInstalledProfile: false,
flow: flow flow: flow
) )
} }
.id(header.id) .id(preview.id)
} }
} }

View File

@ -24,20 +24,19 @@
// //
import CommonLibrary import CommonLibrary
import PassepartoutKit
import SwiftUI import SwiftUI
struct ProfileRemoveButton<Label>: View where Label: View { struct ProfileRemoveButton<Label>: View where Label: View {
let profileManager: ProfileManager let profileManager: ProfileManager
let header: ProfileHeader let preview: ProfilePreview
let label: () -> Label let label: () -> Label
var body: some View { var body: some View {
Button(role: .destructive) { Button(role: .destructive) {
Task { Task {
await profileManager.remove(withId: header.id) await profileManager.remove(withId: preview.id)
} }
} label: { } label: {
label() label()

View File

@ -40,7 +40,7 @@ struct ProfileRowView: View, Routable {
let tunnel: ExtendedTunnel let tunnel: ExtendedTunnel
let header: ProfileHeader let preview: ProfilePreview
let interactiveManager: InteractiveManager let interactiveManager: InteractiveManager
@ -67,7 +67,7 @@ struct ProfileRowView: View, Routable {
attributes: attributes, attributes: attributes,
isRemoteImportingEnabled: profileManager.isRemoteImportingEnabled isRemoteImportingEnabled: profileManager.isRemoteImportingEnabled
) )
ProfileInfoButton(header: header) { ProfileInfoButton(preview: preview) {
flow?.onEditProfile($0) flow?.onEditProfile($0)
} }
} }
@ -79,7 +79,7 @@ struct ProfileRowView: View, Routable {
// MARK: - Subviews (observing) // MARK: - Subviews (observing)
private struct MarkerView: View { private struct MarkerView: View {
let headerId: Profile.ID let profileId: Profile.ID
let nextProfileId: Profile.ID? let nextProfileId: Profile.ID?
@ -90,8 +90,8 @@ private struct MarkerView: View {
var body: some View { var body: some View {
ZStack { ZStack {
ThemeImage(headerId == nextProfileId ? .pending : tunnel.statusImageName) ThemeImage(profileId == nextProfileId ? .pending : tunnel.statusImageName)
.opaque(requiredFeatures == nil && (headerId == nextProfileId || headerId == tunnel.currentProfile?.id)) .opaque(requiredFeatures == nil && (profileId == nextProfileId || profileId == tunnel.currentProfile?.id))
if let requiredFeatures { if let requiredFeatures {
PurchaseRequiredButton(features: requiredFeatures, paywallReason: .constant(nil)) PurchaseRequiredButton(features: requiredFeatures, paywallReason: .constant(nil))
@ -102,9 +102,13 @@ private struct MarkerView: View {
} }
private extension ProfileRowView { private extension ProfileRowView {
var profile: Profile? {
profileManager.profile(withId: preview.id)
}
var markerView: some View { var markerView: some View {
MarkerView( MarkerView(
headerId: header.id, profileId: preview.id,
nextProfileId: nextProfileId, nextProfileId: nextProfileId,
tunnel: tunnel, tunnel: tunnel,
requiredFeatures: requiredFeatures requiredFeatures: requiredFeatures
@ -114,7 +118,7 @@ private extension ProfileRowView {
var cardView: some View { var cardView: some View {
TunnelToggleButton( TunnelToggleButton(
tunnel: tunnel, tunnel: tunnel,
profile: profileManager.profile(withId: header.id), profile: profile,
nextProfileId: $nextProfileId, nextProfileId: $nextProfileId,
interactiveManager: interactiveManager, interactiveManager: interactiveManager,
errorHandler: errorHandler, errorHandler: errorHandler,
@ -127,7 +131,7 @@ private extension ProfileRowView {
label: { _ in label: { _ in
ProfileCardView( ProfileCardView(
style: style, style: style,
header: header preview: preview
) )
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.contentShape(.rect) .contentShape(.rect)
@ -146,15 +150,15 @@ private extension ProfileRowView {
} }
var requiredFeatures: Set<AppFeature>? { var requiredFeatures: Set<AppFeature>? {
profileManager.requiredFeatures(forProfileWithId: header.id) profileManager.requiredFeatures(forProfileWithId: preview.id)
} }
var isShared: Bool { var isShared: Bool {
profileManager.isRemotelyShared(profileWithId: header.id) profileManager.isRemotelyShared(profileWithId: preview.id)
} }
var isTV: Bool { var isTV: Bool {
isShared && profileManager.isAvailableForTV(profileWithId: header.id) isShared && profileManager.isAvailableForTV(profileWithId: preview.id)
} }
} }
@ -169,7 +173,7 @@ private extension ProfileRowView {
style: .compact, style: .compact,
profileManager: profileManager, profileManager: profileManager,
tunnel: .mock, tunnel: .mock,
header: profile.header(), preview: .init(profile),
interactiveManager: InteractiveManager(), interactiveManager: InteractiveManager(),
errorHandler: .default(), errorHandler: .default(),
nextProfileId: .constant(nil), nextProfileId: .constant(nil),

View File

@ -80,19 +80,19 @@ private extension AppMenu {
} }
var profilesList: some View { var profilesList: some View {
ForEach(profileManager.headers, id: \.id, content: profileToggle) ForEach(profileManager.previews, id: \.id, content: profileToggle)
} }
func profileToggle(for header: ProfileHeader) -> some View { func profileToggle(for preview: ProfilePreview) -> some View {
Toggle(header.name, isOn: profileToggleBinding(for: header)) Toggle(preview.name, isOn: profileToggleBinding(for: preview))
} }
func profileToggleBinding(for header: ProfileHeader) -> Binding<Bool> { func profileToggleBinding(for preview: ProfilePreview) -> Binding<Bool> {
Binding { Binding {
header.id == tunnel.currentProfile?.id && tunnel.status != .inactive preview.id == tunnel.currentProfile?.id && tunnel.status != .inactive
} set: { isOn in } set: { isOn in
Task { Task {
guard let profile = profileManager.profile(withId: header.id) else { guard let profile = profileManager.profile(withId: preview.id) else {
return return
} }
do { do {
@ -102,7 +102,7 @@ private extension AppMenu {
try await tunnel.disconnect() try await tunnel.disconnect()
} }
} catch { } catch {
pp_log(.app, .error, "Unable to toggle profile \(header.id) from menu: \(error)") pp_log(.app, .error, "Unable to toggle profile \(preview.id) from menu: \(error)")
} }
} }
} }

View File

@ -145,7 +145,7 @@ private extension MigrateView {
model.step = .fetching model.step = .fetching
pp_log(.App.migration, .notice, "Fetch migratable profiles...") pp_log(.App.migration, .notice, "Fetch migratable profiles...")
let migratable = try await migrationManager.fetchMigratableProfiles() let migratable = try await migrationManager.fetchMigratableProfiles()
let knownIDs = Set(profileManager.headers.map(\.id)) let knownIDs = Set(profileManager.previews.map(\.id))
model.profiles = migratable.filter { model.profiles = migratable.filter {
!knownIDs.contains($0.id) !knownIDs.contains($0.id)
} }

View File

@ -99,9 +99,9 @@ private extension ActiveProfileView {
func detailView(for profile: Profile) -> some View { func detailView(for profile: Profile) -> some View {
VStack(spacing: 10) { VStack(spacing: 10) {
if let connectionModule { if let connectionType = profile.localizedDescription(optionalStyle: .connectionType) {
DetailRowView(title: Strings.Global.protocol) { DetailRowView(title: Strings.Global.protocol) {
Text(connectionModule.moduleHandler.id.name) Text(connectionType)
} }
} }
if let pair = profile.selectedProvider { if let pair = profile.selectedProvider {
@ -116,8 +116,8 @@ private extension ActiveProfileView {
} }
} }
} }
if let otherModulesList { if let otherList = profile.localizedDescription(optionalStyle: .nonConnectionTypes) {
DetailRowView(title: otherModulesList) { DetailRowView(title: otherList) {
EmptyView() EmptyView()
} }
} }
@ -167,28 +167,6 @@ private extension ActiveProfileView {
} }
} }
private extension ActiveProfileView {
var connectionModule: ConnectionModule? {
profile?.firstConnectionModule(ifActive: true)
}
var otherModules: [Module]? {
profile?
.activeModules
.filter {
!($0 is ConnectionModule)
}
.nilIfEmpty
}
var otherModulesList: String? {
otherModules?
.map(\.moduleType.localizedDescription)
.sorted()
.joined(separator: ", ")
}
}
// MARK: - // MARK: -
private extension ActiveProfileView { private extension ActiveProfileView {

View File

@ -50,7 +50,7 @@ struct ProfileListView: View {
headerView headerView
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
List { List {
ForEach(headers, id: \.id, content: toggleButton(for:)) ForEach(previews, id: \.id, content: toggleButton(for:))
} }
.listStyle(.grouped) .listStyle(.grouped)
.scrollClipDisabled() .scrollClipDisabled()
@ -63,8 +63,8 @@ struct ProfileListView: View {
} }
private extension ProfileListView { private extension ProfileListView {
var headers: [ProfileHeader] { var previews: [ProfilePreview] {
profileManager.headers profileManager.previews
} }
var headerView: some View { var headerView: some View {
@ -74,28 +74,28 @@ private extension ProfileListView {
.font(.body) .font(.body)
} }
func toggleButton(for header: ProfileHeader) -> some View { func toggleButton(for preview: ProfilePreview) -> some View {
TunnelToggleButton( TunnelToggleButton(
tunnel: tunnel, tunnel: tunnel,
profile: profileManager.profile(withId: header.id), profile: profileManager.profile(withId: preview.id),
nextProfileId: .constant(nil), nextProfileId: .constant(nil),
interactiveManager: interactiveManager, interactiveManager: interactiveManager,
errorHandler: errorHandler, errorHandler: errorHandler,
onProviderEntityRequired: onProviderEntityRequired, onProviderEntityRequired: onProviderEntityRequired,
onPurchaseRequired: onPurchaseRequired, onPurchaseRequired: onPurchaseRequired,
label: { _ in label: { _ in
toggleView(for: header) toggleView(for: preview)
} }
) )
.focused($focusedField, equals: .profile(header.id)) .focused($focusedField, equals: .profile(preview.id))
} }
func toggleView(for header: ProfileHeader) -> some View { func toggleView(for preview: ProfilePreview) -> some View {
HStack { HStack {
Text(header.name) Text(preview.name)
Spacer() Spacer()
ThemeImage(tunnel.statusImageName) ThemeImage(tunnel.statusImageName)
.opaque(header.id == tunnel.currentProfile?.id) .opaque(preview.id == tunnel.currentProfile?.id)
} }
.font(.headline) .font(.headline)
} }

View File

@ -147,9 +147,9 @@ extension ProfileManager {
!filteredProfiles.isEmpty !filteredProfiles.isEmpty
} }
public var headers: [ProfileHeader] { public var previews: [ProfilePreview] {
filteredProfiles.map { filteredProfiles.map {
$0.header() processor?.preview(from: $0) ?? ProfilePreview($0)
} }
} }

View File

@ -0,0 +1,47 @@
//
// ProfilePreview.swift
// Passepartout
//
// Created by Davide De Rosa on 11/22/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 Foundation
import PassepartoutKit
public struct ProfilePreview: Identifiable, Hashable {
public let id: Profile.ID
public let name: String
public let subtitle: String?
public init(id: Profile.ID, name: String, subtitle: String? = nil) {
self.id = id
self.name = name
self.subtitle = subtitle
}
public init(_ profile: Profile) {
id = profile.id
name = profile.name
subtitle = nil
}
}

View File

@ -24,15 +24,14 @@
// //
import Foundation import Foundation
import PassepartoutKit
public struct TunnelInstallation { public struct TunnelInstallation {
public let header: ProfileHeader public let preview: ProfilePreview
public let onDemand: Bool public let onDemand: Bool
public init(header: ProfileHeader, onDemand: Bool) { public init(preview: ProfilePreview, onDemand: Bool) {
self.header = header self.preview = preview
self.onDemand = onDemand self.onDemand = onDemand
} }
} }

View File

@ -33,6 +33,8 @@ public final class InAppProcessor: ObservableObject, Sendable {
private nonisolated let _isIncluded: (IAPManager, Profile) -> Bool private nonisolated let _isIncluded: (IAPManager, Profile) -> Bool
private nonisolated let _preview: (Profile) -> ProfilePreview
private nonisolated let _willRebuild: (IAPManager, Profile.Builder) throws -> Profile.Builder private nonisolated let _willRebuild: (IAPManager, Profile.Builder) throws -> Profile.Builder
private nonisolated let _willInstall: (IAPManager, Profile) throws -> Profile private nonisolated let _willInstall: (IAPManager, Profile) throws -> Profile
@ -43,6 +45,7 @@ public final class InAppProcessor: ObservableObject, Sendable {
iapManager: IAPManager, iapManager: IAPManager,
title: @escaping (Profile) -> String, title: @escaping (Profile) -> String,
isIncluded: @escaping (IAPManager, Profile) -> Bool, isIncluded: @escaping (IAPManager, Profile) -> Bool,
preview: @escaping (Profile) -> ProfilePreview,
willRebuild: @escaping (IAPManager, Profile.Builder) throws -> Profile.Builder, willRebuild: @escaping (IAPManager, Profile.Builder) throws -> Profile.Builder,
willInstall: @escaping (IAPManager, Profile) throws -> Profile, willInstall: @escaping (IAPManager, Profile) throws -> Profile,
verify: @escaping (IAPManager, Profile) -> Set<AppFeature>? verify: @escaping (IAPManager, Profile) -> Set<AppFeature>?
@ -50,6 +53,7 @@ public final class InAppProcessor: ObservableObject, Sendable {
self.iapManager = iapManager self.iapManager = iapManager
_title = title _title = title
_isIncluded = isIncluded _isIncluded = isIncluded
_preview = preview
_willRebuild = willRebuild _willRebuild = willRebuild
_willInstall = willInstall _willInstall = willInstall
_verify = verify _verify = verify
@ -67,6 +71,10 @@ extension InAppProcessor: ProfileProcessor {
_isIncluded(iapManager, profile) _isIncluded(iapManager, profile)
} }
public func preview(from profile: Profile) -> ProfilePreview {
_preview(profile)
}
public func willRebuild(_ builder: Profile.Builder) throws -> Profile.Builder { public func willRebuild(_ builder: Profile.Builder) throws -> Profile.Builder {
try _willRebuild(iapManager, builder) try _willRebuild(iapManager, builder)
} }

View File

@ -29,6 +29,8 @@ import PassepartoutKit
public protocol ProfileProcessor { public protocol ProfileProcessor {
func isIncluded(_ profile: Profile) -> Bool func isIncluded(_ profile: Profile) -> Bool
func preview(from profile: Profile) -> ProfilePreview
func willRebuild(_ builder: Profile.Builder) throws -> Profile.Builder func willRebuild(_ builder: Profile.Builder) throws -> Profile.Builder
func verify(_ profile: Profile) -> Set<AppFeature>? func verify(_ profile: Profile) -> Set<AppFeature>?

View File

@ -30,7 +30,7 @@ import PassepartoutKit
@MainActor @MainActor
extension ProfileManager { extension ProfileManager {
public func removeProfiles(at offsets: IndexSet) async { public func removeProfiles(at offsets: IndexSet) async {
let idsToRemove = headers let idsToRemove = previews
.enumerated() .enumerated()
.filter { .filter {
offsets.contains($0.offset) offsets.contains($0.offset)

View File

@ -33,12 +33,12 @@ extension TunnelInstallationProviding {
guard let currentProfile = tunnel.currentProfile else { guard let currentProfile = tunnel.currentProfile else {
return nil return nil
} }
guard let header = profileManager.headers.first(where: { guard let preview = profileManager.previews.first(where: {
$0.id == currentProfile.id $0.id == currentProfile.id
}) else { }) else {
return nil return nil
} }
return TunnelInstallation(header: header, onDemand: currentProfile.onDemand) return TunnelInstallation(preview: preview, onDemand: currentProfile.onDemand)
} }
public var currentProfile: Profile? { public var currentProfile: Profile? {

View File

@ -27,6 +27,42 @@ import CommonUtils
import Foundation import Foundation
import PassepartoutKit import PassepartoutKit
extension Profile: StyledOptionalLocalizableEntity {
public enum OptionalStyle {
case moduleTypes
case connectionType
case nonConnectionTypes
}
public func localizedDescription(optionalStyle: OptionalStyle) -> String? {
switch optionalStyle {
case .moduleTypes:
return activeModules
.nilIfEmpty?
.map(\.moduleType.localizedDescription)
.sorted()
.joined(separator: ", ")
case .connectionType:
return firstConnectionModule(ifActive: true)?
.moduleType
.localizedDescription
case .nonConnectionTypes:
return activeModules
.filter {
!($0 is ConnectionModule)
}
.nilIfEmpty?
.map(\.moduleType.localizedDescription)
.sorted()
.joined(separator: ", ")
}
}
}
extension TunnelStatus: LocalizableEntity { extension TunnelStatus: LocalizableEntity {
public var localizedDescription: String { public var localizedDescription: String {
let V = Strings.Entities.TunnelStatus.self let V = Strings.Entities.TunnelStatus.self

View File

@ -809,10 +809,8 @@ public enum Strings {
} }
} }
public enum Rows { public enum Rows {
/// %d modules /// No active modules
public static func modules(_ p1: Int) -> String { public static let noModules = Strings.tr("Localizable", "views.profiles.rows.no_modules", fallback: "No active modules")
return Strings.tr("Localizable", "views.profiles.rows.modules", p1, fallback: "%d modules")
}
/// Select a profile /// Select a profile
public static let notInstalled = Strings.tr("Localizable", "views.profiles.rows.not_installed", fallback: "Select a profile") public static let notInstalled = Strings.tr("Localizable", "views.profiles.rows.not_installed", fallback: "Select a profile")
} }

View File

@ -53,6 +53,9 @@ extension AppContext {
isIncluded: { _, _ in isIncluded: { _, _ in
true true
}, },
preview: {
ProfilePreview($0)
},
willRebuild: { _, builder in willRebuild: { _, builder in
builder builder
}, },

View File

@ -117,7 +117,7 @@
// MARK: - Views // MARK: - Views
"views.profiles.rows.not_installed" = "Select a profile"; "views.profiles.rows.not_installed" = "Select a profile";
"views.profiles.rows.modules" = "%d modules"; "views.profiles.rows.no_modules" = "No active modules";
"views.profiles.folders.active_profile" = "Installed profile"; "views.profiles.folders.active_profile" = "Installed profile";
"views.profiles.folders.default" = "My profiles"; "views.profiles.folders.default" = "My profiles";
"views.profiles.folders.add_profile" = "Add profile"; "views.profiles.folders.add_profile" = "Add profile";

View File

@ -47,7 +47,7 @@ extension ProfileImporterTests {
try await sut.tryImport(urls: [], profileManager: profileManager, registry: registry) try await sut.tryImport(urls: [], profileManager: profileManager, registry: registry)
XCTAssertEqual(sut.nextURL, nil) XCTAssertEqual(sut.nextURL, nil)
XCTAssertTrue(profileManager.headers.isEmpty) XCTAssertTrue(profileManager.previews.isEmpty)
} }
func test_givenURL_whenImport_thenOneProfileIsImported() async throws { func test_givenURL_whenImport_thenOneProfileIsImported() async throws {

View File

@ -47,6 +47,10 @@ final class MockProfileProcessor: ProfileProcessor {
return isIncludedBlock(profile) return isIncludedBlock(profile)
} }
func preview(from profile: Profile) -> ProfilePreview {
ProfilePreview(profile)
}
func willRebuild(_ builder: Profile.Builder) throws -> Profile.Builder { func willRebuild(_ builder: Profile.Builder) throws -> Profile.Builder {
willRebuildCount += 1 willRebuildCount += 1
return builder return builder

View File

@ -47,7 +47,7 @@ extension ProfileManagerTests {
let sut = ProfileManager(profiles: [profile]) let sut = ProfileManager(profiles: [profile])
XCTAssertFalse(sut.isReady) XCTAssertFalse(sut.isReady)
XCTAssertFalse(sut.hasProfiles) XCTAssertFalse(sut.hasProfiles)
XCTAssertTrue(sut.headers.isEmpty) XCTAssertTrue(sut.previews.isEmpty)
} }
func test_givenRepository_whenNotReady_thenHasNoProfiles() { func test_givenRepository_whenNotReady_thenHasNoProfiles() {
@ -55,7 +55,7 @@ extension ProfileManagerTests {
let sut = ProfileManager(repository: repository, remoteRepositoryBlock: nil) let sut = ProfileManager(repository: repository, remoteRepositoryBlock: nil)
XCTAssertFalse(sut.isReady) XCTAssertFalse(sut.isReady)
XCTAssertFalse(sut.hasProfiles) XCTAssertFalse(sut.hasProfiles)
XCTAssertTrue(sut.headers.isEmpty) XCTAssertTrue(sut.previews.isEmpty)
} }
func test_givenRepository_whenReady_thenHasProfiles() async throws { func test_givenRepository_whenReady_thenHasProfiles() async throws {
@ -66,7 +66,7 @@ extension ProfileManagerTests {
try await waitForReady(sut) try await waitForReady(sut)
XCTAssertTrue(sut.isReady) XCTAssertTrue(sut.isReady)
XCTAssertTrue(sut.hasProfiles) XCTAssertTrue(sut.hasProfiles)
XCTAssertEqual(sut.headers.count, 1) XCTAssertEqual(sut.previews.count, 1)
XCTAssertEqual(sut.profile(withId: profile.id), profile) XCTAssertEqual(sut.profile(withId: profile.id), profile)
} }
@ -79,15 +79,15 @@ extension ProfileManagerTests {
try await waitForReady(sut) try await waitForReady(sut)
XCTAssertTrue(sut.isReady) XCTAssertTrue(sut.isReady)
XCTAssertTrue(sut.hasProfiles) XCTAssertTrue(sut.hasProfiles)
XCTAssertEqual(sut.headers.count, 2) XCTAssertEqual(sut.previews.count, 2)
try await wait(sut) { try await wait(sut) {
$0.search(byName: "ar") $0.search(byName: "ar")
} until: { } until: {
$0.headers.count == 1 $0.previews.count == 1
} }
XCTAssertTrue(sut.isSearching) XCTAssertTrue(sut.isSearching)
let found = try XCTUnwrap(sut.headers.last) let found = try XCTUnwrap(sut.previews.last)
XCTAssertEqual(found.id, profile2.id) XCTAssertEqual(found.id, profile2.id)
} }
@ -123,8 +123,8 @@ extension ProfileManagerTests {
try await waitForReady(sut) try await waitForReady(sut)
XCTAssertTrue(sut.isReady) XCTAssertTrue(sut.isReady)
XCTAssertEqual(sut.headers.count, 1) XCTAssertEqual(sut.previews.count, 1)
XCTAssertEqual(sut.headers.first?.name, "local2") XCTAssertEqual(sut.previews.first?.name, "local2")
} }
func test_givenRepositoryAndProcessor_whenRequiredFeaturesChange_thenMustReload() async throws { func test_givenRepositoryAndProcessor_whenRequiredFeaturesChange_thenMustReload() async throws {
@ -170,7 +170,7 @@ extension ProfileManagerTests {
} until: { } until: {
$0.hasProfiles $0.hasProfiles
} }
XCTAssertEqual(sut.headers.count, 1) XCTAssertEqual(sut.previews.count, 1)
XCTAssertEqual(sut.profile(withId: profile.id), profile) XCTAssertEqual(sut.profile(withId: profile.id), profile)
} }
@ -181,7 +181,7 @@ extension ProfileManagerTests {
try await waitForReady(sut) try await waitForReady(sut)
XCTAssertTrue(sut.isReady) XCTAssertTrue(sut.isReady)
XCTAssertEqual(sut.headers.first?.id, profile.id) XCTAssertEqual(sut.previews.first?.id, profile.id)
var builder = profile.builder() var builder = profile.builder()
builder.name = "newName" builder.name = "newName"
@ -190,7 +190,7 @@ extension ProfileManagerTests {
try await wait(sut) { try await wait(sut) {
try await $0.save(renamedProfile) try await $0.save(renamedProfile)
} until: { } until: {
$0.headers.first?.name == renamedProfile.name $0.previews.first?.name == renamedProfile.name
} }
} }
@ -264,7 +264,7 @@ extension ProfileManagerTests {
} until: { } until: {
!$0.hasProfiles !$0.hasProfiles
} }
XCTAssertTrue(sut.headers.isEmpty) XCTAssertTrue(sut.previews.isEmpty)
} }
} }
@ -336,7 +336,7 @@ extension ProfileManagerTests {
let sut = ProfileManager(repository: repository, remoteRepositoryBlock: nil) let sut = ProfileManager(repository: repository, remoteRepositoryBlock: nil)
try await waitForReady(sut) try await waitForReady(sut)
XCTAssertEqual(sut.headers.count, 1) XCTAssertEqual(sut.previews.count, 1)
let newProfile = sut.new(withName: profile.name) let newProfile = sut.new(withName: profile.name)
XCTAssertEqual(newProfile.name, "example.1") XCTAssertEqual(newProfile.name, "example.1")
@ -352,22 +352,22 @@ extension ProfileManagerTests {
try await wait(sut) { try await wait(sut) {
try await $0.duplicate(profileWithId: profile.id) try await $0.duplicate(profileWithId: profile.id)
} until: { } until: {
$0.headers.count == 2 $0.previews.count == 2
} }
try await wait(sut) { try await wait(sut) {
try await $0.duplicate(profileWithId: profile.id) try await $0.duplicate(profileWithId: profile.id)
} until: { } until: {
$0.headers.count == 3 $0.previews.count == 3
} }
try await wait(sut) { try await wait(sut) {
try await $0.duplicate(profileWithId: profile.id) try await $0.duplicate(profileWithId: profile.id)
} until: { } until: {
$0.headers.count == 4 $0.previews.count == 4
} }
XCTAssertEqual(sut.headers.map(\.name), [ XCTAssertEqual(sut.previews.map(\.name), [
"example", "example",
"example.1", "example.1",
"example.2", "example.2",
@ -400,10 +400,10 @@ extension ProfileManagerTests {
try await $0.observeLocal() try await $0.observeLocal()
try await $0.observeRemote(true) try await $0.observeRemote(true)
} until: { } until: {
$0.headers.count == allProfiles.count $0.previews.count == allProfiles.count
} }
XCTAssertEqual(Set(sut.headers), Set(allProfiles.map { $0.header() })) XCTAssertEqual(Set(sut.previews), Set(allProfiles.map { ProfilePreview($0) }))
localProfiles.forEach { localProfiles.forEach {
XCTAssertFalse(sut.isRemotelyShared(profileWithId: $0.id)) XCTAssertFalse(sut.isRemotelyShared(profileWithId: $0.id))
} }
@ -437,10 +437,10 @@ extension ProfileManagerTests {
try await $0.observeLocal() try await $0.observeLocal()
try await $0.observeRemote(true) try await $0.observeRemote(true)
} until: { } until: {
$0.headers.count == 4 // unique IDs $0.previews.count == 4 // unique IDs
} }
sut.headers.forEach { sut.previews.forEach {
switch $0.id { switch $0.id {
case l1: case l1:
XCTAssertEqual($0.name, "remote1") XCTAssertEqual($0.name, "remote1")
@ -493,7 +493,7 @@ extension ProfileManagerTests {
} }
XCTAssertEqual(processor.isIncludedCount, allProfiles.count) XCTAssertEqual(processor.isIncludedCount, allProfiles.count)
XCTAssertEqual(Set(sut.headers), Set(localProfiles.map { $0.header() })) XCTAssertEqual(Set(sut.previews), Set(localProfiles.map { ProfilePreview($0) }))
localProfiles.forEach { localProfiles.forEach {
XCTAssertFalse(sut.isRemotelyShared(profileWithId: $0.id)) XCTAssertFalse(sut.isRemotelyShared(profileWithId: $0.id))
} }
@ -533,7 +533,7 @@ extension ProfileManagerTests {
didImport didImport
} }
try sut.headers.forEach { try sut.previews.forEach {
let profile = try XCTUnwrap(sut.profile(withId: $0.id)) let profile = try XCTUnwrap(sut.profile(withId: $0.id))
XCTAssertTrue(sut.isRemotelyShared(profileWithId: $0.id)) XCTAssertTrue(sut.isRemotelyShared(profileWithId: $0.id))
switch $0.id { switch $0.id {
@ -564,7 +564,7 @@ extension ProfileManagerTests {
try await $0.observeLocal() try await $0.observeLocal()
try await $0.observeRemote(true) try await $0.observeRemote(true)
} until: { } until: {
$0.headers.count == localProfiles.count $0.previews.count == localProfiles.count
} }
let r1 = UUID() let r1 = UUID()
@ -588,7 +588,7 @@ extension ProfileManagerTests {
newProfile("remote3", id: r3, fingerprint: fp3) newProfile("remote3", id: r3, fingerprint: fp3)
] ]
} until: { } until: {
$0.headers.count == 5 $0.previews.count == 5
} }
localProfiles.forEach { localProfiles.forEach {
@ -627,7 +627,7 @@ extension ProfileManagerTests {
try await $0.observeLocal() try await $0.observeLocal()
try await $0.observeRemote(true) try await $0.observeRemote(true)
} until: { } until: {
$0.headers.count == 1 $0.previews.count == 1
} }
try await wait(sut) { _ in try await wait(sut) { _ in
remoteRepository.profiles = [] remoteRepository.profiles = []
@ -635,8 +635,8 @@ extension ProfileManagerTests {
didImport didImport
} }
XCTAssertEqual(sut.headers.count, 1) XCTAssertEqual(sut.previews.count, 1)
XCTAssertEqual(sut.headers.first, profile.header()) XCTAssertEqual(sut.previews.first, ProfilePreview(profile))
} }
func test_givenRemoteRepositoryAndMirroring_whenRemoteIsDeleted_thenLocalIsDeleted() async throws { func test_givenRemoteRepositoryAndMirroring_whenRemoteIsDeleted_thenLocalIsDeleted() async throws {
@ -656,7 +656,7 @@ extension ProfileManagerTests {
try await $0.observeLocal() try await $0.observeLocal()
try await $0.observeRemote(true) try await $0.observeRemote(true)
} until: { } until: {
$0.headers.count == 1 $0.previews.count == 1
} }
try await wait(sut) { _ in try await wait(sut) { _ in
remoteRepository.profiles = [] remoteRepository.profiles = []

View File

@ -45,6 +45,13 @@ extension IAPManager {
isIncluded: { isIncluded: {
Configuration.ProfileManager.isIncluded($0, $1) Configuration.ProfileManager.isIncluded($0, $1)
}, },
preview: {
ProfilePreview(
id: $0.id,
name: $0.name,
subtitle: $0.localizedDescription(optionalStyle: .moduleTypes)
)
},
willRebuild: { _, builder in willRebuild: { _, builder in
builder builder
}, },