From 5119cc20d56d429398f2566ab6affaaddc25ae1c Mon Sep 17 00:00:00 2001 From: Davide Date: Sun, 3 Nov 2024 23:42:17 +0100 Subject: [PATCH] Implement TV profile sharing (#808) Add profile attribute `isAvailableForTV` and set specific behavior to: - Observe shared profiles and delete locally when unshared - Only keep locally those profiles with the TV attribute enabled - Add toggle in UI --- .../CDProfileRepositoryV3.swift | 2 + .../AppDataProfiles/Domain/CDProfileV3.swift | 1 + .../ProfilesV3.xcdatamodel/contents | 3 +- .../AppUIMain/Views/App/ProfileRowView.swift | 49 ++++++++++++++----- .../Views/Profile/StorageSection.swift | 18 +++++++ .../Views/Profile/ProfileListView.swift | 6 ++- .../Business/ProfileManager.swift | 4 ++ .../Domain/ProfileAttributes.swift | 8 ++- .../UILibrary/Business/ProfileEditor.swift | 9 ++++ .../UILibrary/L10n/Strings+Unlocalized.swift | 2 + .../UILibrary/L10n/SwiftGen+Strings.swift | 10 ++++ .../Resources/en.lproj/Localizable.strings | 2 + .../UILibrary/Theme/Theme+ImageName.swift | 2 + Passepartout/Shared/Shared+App.swift | 16 +++++- 14 files changed, 115 insertions(+), 17 deletions(-) diff --git a/Passepartout/Library/Sources/AppDataProfiles/CDProfileRepositoryV3.swift b/Passepartout/Library/Sources/AppDataProfiles/CDProfileRepositoryV3.swift index bd60ce33..c8b4a564 100644 --- a/Passepartout/Library/Sources/AppDataProfiles/CDProfileRepositoryV3.swift +++ b/Passepartout/Library/Sources/AppDataProfiles/CDProfileRepositoryV3.swift @@ -70,6 +70,7 @@ private extension AppData { let profile = try registry.decodedProfile(from: encoded, with: coder) var builder = profile.builder() builder.attributes = ProfileAttributes( + isAvailableForTV: cdEntity.isAvailableForTV?.boolValue ?? false, lastUpdate: cdEntity.lastUpdate, fingerprint: cdEntity.fingerprint ) @@ -91,6 +92,7 @@ private extension AppData { cdProfile.encoded = encoded let attributes = profile.attributes + cdProfile.isAvailableForTV = attributes.isAvailableForTV.map(NSNumber.init(value:)) cdProfile.lastUpdate = attributes.lastUpdate cdProfile.fingerprint = attributes.fingerprint diff --git a/Passepartout/Library/Sources/AppDataProfiles/Domain/CDProfileV3.swift b/Passepartout/Library/Sources/AppDataProfiles/Domain/CDProfileV3.swift index 00e06329..7cb81b0a 100644 --- a/Passepartout/Library/Sources/AppDataProfiles/Domain/CDProfileV3.swift +++ b/Passepartout/Library/Sources/AppDataProfiles/Domain/CDProfileV3.swift @@ -35,6 +35,7 @@ final class CDProfileV3: NSManagedObject { @NSManaged var uuid: UUID? @NSManaged var name: String? @NSManaged var encoded: String? + @NSManaged var isAvailableForTV: NSNumber? @NSManaged var lastUpdate: Date? @NSManaged var fingerprint: UUID? } diff --git a/Passepartout/Library/Sources/AppDataProfiles/Profiles.xcdatamodeld/ProfilesV3.xcdatamodel/contents b/Passepartout/Library/Sources/AppDataProfiles/Profiles.xcdatamodeld/ProfilesV3.xcdatamodel/contents index 539add0a..3074c765 100644 --- a/Passepartout/Library/Sources/AppDataProfiles/Profiles.xcdatamodeld/ProfilesV3.xcdatamodel/contents +++ b/Passepartout/Library/Sources/AppDataProfiles/Profiles.xcdatamodeld/ProfilesV3.xcdatamodel/contents @@ -3,8 +3,9 @@ + - \ No newline at end of file + diff --git a/Passepartout/Library/Sources/AppUIMain/Views/App/ProfileRowView.swift b/Passepartout/Library/Sources/AppUIMain/Views/App/ProfileRowView.swift index 65a35420..a4013617 100644 --- a/Passepartout/Library/Sources/AppUIMain/Views/App/ProfileRowView.swift +++ b/Passepartout/Library/Sources/AppUIMain/Views/App/ProfileRowView.swift @@ -64,9 +64,7 @@ struct ProfileRowView: View, Routable { } Spacer() HStack(spacing: 10.0) { - if isShared { - sharingView - } + attributesView ProfileInfoButton(header: header) { flow?.onEditProfile($0) } @@ -76,11 +74,9 @@ struct ProfileRowView: View, Routable { } } -private extension ProfileRowView { - var isShared: Bool { - profileManager.isRemotelyShared(profileWithId: header.id) - } +// MARK: - Layout +private extension ProfileRowView { var markerView: some View { ThemeImage(header.id == nextProfileId ? .pending : statusImage) .opacity(header.id == nextProfileId || header.id == tunnel.currentProfile?.id ? 1.0 : 0.0) @@ -107,12 +103,6 @@ private extension ProfileRowView { .foregroundStyle(.primary) } - var sharingView: some View { - ThemeImage(.cloud) - .foregroundStyle(.secondary) - .help(Strings.Modules.General.Rows.icloudSharing) - } - var statusImage: Theme.ImageName { switch tunnel.connectionStatus { case .active: @@ -126,3 +116,36 @@ private extension ProfileRowView { } } } + +// MARK: - Attributes + +private extension ProfileRowView { + var attributesView: some View { + Group { + if isTV { + tvImage + } else if isShared { + sharedImage + } + } + .foregroundStyle(.secondary) + } + + var isShared: Bool { + profileManager.isRemotelyShared(profileWithId: header.id) + } + + var isTV: Bool { + isShared && profileManager.isAvailableForTV(profileWithId: header.id) + } + + var sharedImage: some View { + ThemeImage(.cloud) + .help(Strings.Modules.General.Rows.icloudSharing) + } + + var tvImage: some View { + ThemeImage(.tv) + .help(Strings.Modules.General.Rows.appleTv(Strings.Unlocalized.appleTV)) + } +} diff --git a/Passepartout/Library/Sources/AppUIMain/Views/Profile/StorageSection.swift b/Passepartout/Library/Sources/AppUIMain/Views/Profile/StorageSection.swift index 5a51b0bb..85dd5423 100644 --- a/Passepartout/Library/Sources/AppUIMain/Views/Profile/StorageSection.swift +++ b/Passepartout/Library/Sources/AppUIMain/Views/Profile/StorageSection.swift @@ -41,6 +41,7 @@ struct StorageSection: View { debugChanges() return Group { sharingToggle + tvToggle ThemeCopiableText( title: Strings.Unlocalized.uuid, value: profileEditor.profile.id, @@ -75,6 +76,23 @@ private extension StorageSection { Toggle(Strings.Modules.General.Rows.icloudSharing, isOn: $profileEditor.isShared) } } + + @ViewBuilder + var tvToggle: some View { + switch iapManager.paywallReason(forFeature: .appleTV) { + case .purchase(let appFeature): + Button(Strings.Modules.General.Rows.AppleTv.purchase(Strings.Unlocalized.appleTV)) { + paywallReason = .purchase(appFeature) + } + + case .restricted: + EmptyView() + + default: + Toggle(Strings.Modules.General.Rows.appleTv(Strings.Unlocalized.appleTV), isOn: $profileEditor.isAvailableForTV) + .disabled(!profileEditor.isShared) + } + } } #Preview { diff --git a/Passepartout/Library/Sources/AppUITV/Views/Profile/ProfileListView.swift b/Passepartout/Library/Sources/AppUITV/Views/Profile/ProfileListView.swift index a4553ce8..b71f9cc3 100644 --- a/Passepartout/Library/Sources/AppUITV/Views/Profile/ProfileListView.swift +++ b/Passepartout/Library/Sources/AppUITV/Views/Profile/ProfileListView.swift @@ -48,7 +48,7 @@ struct ProfileListView: View { var body: some View { List { Section { - ForEach(profileManager.headers, id: \.id, content: toggleButton(for:)) + ForEach(headers, id: \.id, content: toggleButton(for:)) } header: { Text(Strings.Views.Profiles.Folders.default) } @@ -59,6 +59,10 @@ struct ProfileListView: View { } private extension ProfileListView { + var headers: [ProfileHeader] { + profileManager.headers + } + func toggleButton(for header: ProfileHeader) -> some View { TunnelToggleButton( tunnel: tunnel, diff --git a/Passepartout/Library/Sources/CommonLibrary/Business/ProfileManager.swift b/Passepartout/Library/Sources/CommonLibrary/Business/ProfileManager.swift index 6cb43034..4d44e5bd 100644 --- a/Passepartout/Library/Sources/CommonLibrary/Business/ProfileManager.swift +++ b/Passepartout/Library/Sources/CommonLibrary/Business/ProfileManager.swift @@ -208,6 +208,10 @@ extension ProfileManager { allRemoteProfiles.keys.contains(profileId) } + public func isAvailableForTV(profileWithId profileId: Profile.ID) -> Bool { + profile(withId: profileId)?.attributes.isAvailableForTV == true + } + public func eraseRemotelySharedProfiles() async throws { pp_log(.app, .notice, "Erase remotely shared profiles...") try await remoteRepository?.removeProfiles(withIds: Array(allRemoteProfiles.keys)) diff --git a/Passepartout/Library/Sources/CommonLibrary/Domain/ProfileAttributes.swift b/Passepartout/Library/Sources/CommonLibrary/Domain/ProfileAttributes.swift index 187a2e16..5c5852c3 100644 --- a/Passepartout/Library/Sources/CommonLibrary/Domain/ProfileAttributes.swift +++ b/Passepartout/Library/Sources/CommonLibrary/Domain/ProfileAttributes.swift @@ -28,22 +28,28 @@ import Foundation import PassepartoutKit public struct ProfileAttributes: Hashable, Codable { + public var isAvailableForTV: Bool? + public var lastUpdate: Date? public var fingerprint: UUID? - public init() { + public init(isAvailableForTV: Bool? = false) { + self.isAvailableForTV = isAvailableForTV } public init( + isAvailableForTV: Bool?, lastUpdate: Date?, fingerprint: UUID? ) { + self.isAvailableForTV = isAvailableForTV self.lastUpdate = lastUpdate self.fingerprint = fingerprint } } +// FIXME: #570, test user info encoding/decoding with JSONSerialization extension ProfileAttributes: ProfileUserInfoTransformable { public var userInfo: [String: AnyHashable]? { do { diff --git a/Passepartout/Library/Sources/UILibrary/Business/ProfileEditor.swift b/Passepartout/Library/Sources/UILibrary/Business/ProfileEditor.swift index 12bd0c8d..cce95294 100644 --- a/Passepartout/Library/Sources/UILibrary/Business/ProfileEditor.swift +++ b/Passepartout/Library/Sources/UILibrary/Business/ProfileEditor.swift @@ -101,6 +101,15 @@ extension ProfileEditor { editableProfile = newValue } } + + public var isAvailableForTV: Bool { + get { + editableProfile.attributes.isAvailableForTV == true + } + set { + editableProfile.attributes.isAvailableForTV = newValue + } + } } extension ProfileEditor { diff --git a/Passepartout/Library/Sources/UILibrary/L10n/Strings+Unlocalized.swift b/Passepartout/Library/Sources/UILibrary/L10n/Strings+Unlocalized.swift index c0d0bfb3..9d14158b 100644 --- a/Passepartout/Library/Sources/UILibrary/L10n/Strings+Unlocalized.swift +++ b/Passepartout/Library/Sources/UILibrary/L10n/Strings+Unlocalized.swift @@ -96,6 +96,8 @@ extension Strings { public static let appName = "Passepartout" + public static let appleTV = "Apple TV" + public static let ca = "CA" public static let dns = "DNS" diff --git a/Passepartout/Library/Sources/UILibrary/L10n/SwiftGen+Strings.swift b/Passepartout/Library/Sources/UILibrary/L10n/SwiftGen+Strings.swift index 0e2ec97b..ef71dcd2 100644 --- a/Passepartout/Library/Sources/UILibrary/L10n/SwiftGen+Strings.swift +++ b/Passepartout/Library/Sources/UILibrary/L10n/SwiftGen+Strings.swift @@ -333,10 +333,20 @@ public enum Strings { } public enum General { public enum Rows { + /// Shared on %@ + public static func appleTv(_ p1: Any) -> String { + return Strings.tr("Localizable", "modules.general.rows.apple_tv", String(describing: p1), fallback: "Shared on %@") + } /// Shared on iCloud public static let icloudSharing = Strings.tr("Localizable", "modules.general.rows.icloud_sharing", fallback: "Shared on iCloud") /// Import from file... public static let importFromFile = Strings.tr("Localizable", "modules.general.rows.import_from_file", fallback: "Import from file...") + public enum AppleTv { + /// Share on %@ + public static func purchase(_ p1: Any) -> String { + return Strings.tr("Localizable", "modules.general.rows.apple_tv.purchase", String(describing: p1), fallback: "Share on %@") + } + } public enum IcloudSharing { /// Share on iCloud public static let purchase = Strings.tr("Localizable", "modules.general.rows.icloud_sharing.purchase", fallback: "Share on iCloud") diff --git a/Passepartout/Library/Sources/UILibrary/Resources/en.lproj/Localizable.strings b/Passepartout/Library/Sources/UILibrary/Resources/en.lproj/Localizable.strings index e93d9abd..665ff739 100644 --- a/Passepartout/Library/Sources/UILibrary/Resources/en.lproj/Localizable.strings +++ b/Passepartout/Library/Sources/UILibrary/Resources/en.lproj/Localizable.strings @@ -171,6 +171,7 @@ "modules.general.sections.storage.footer" = "Profiles are stored to iCloud encrypted."; "modules.general.rows.icloud_sharing" = "Shared on iCloud"; +"modules.general.rows.apple_tv" = "Shared on %@"; "modules.general.rows.import_from_file" = "Import from file..."; "modules.dns.servers.add" = "Add address"; @@ -251,6 +252,7 @@ // MARK: - Paywalls "modules.general.rows.icloud_sharing.purchase" = "Share on iCloud"; +"modules.general.rows.apple_tv.purchase" = "Share on %@"; "modules.on_demand.purchase" = "Add on-demand rules"; "modules.openvpn.credentials.interactive.purchase" = "Log in interactively"; "providers.picker.purchase" = "Add more providers"; diff --git a/Passepartout/Library/Sources/UILibrary/Theme/Theme+ImageName.swift b/Passepartout/Library/Sources/UILibrary/Theme/Theme+ImageName.swift index 120418b5..d0ab1942 100644 --- a/Passepartout/Library/Sources/UILibrary/Theme/Theme+ImageName.swift +++ b/Passepartout/Library/Sources/UILibrary/Theme/Theme+ImageName.swift @@ -61,6 +61,7 @@ extension Theme { case tunnelRestart case tunnelToggle case tunnelUninstall + case tv } } @@ -102,6 +103,7 @@ extension Theme.ImageName { case .tunnelRestart: return "arrow.clockwise" case .tunnelToggle: return "power" case .tunnelUninstall: return "arrow.uturn.down" + case .tv: return "tv" } } } diff --git a/Passepartout/Shared/Shared+App.swift b/Passepartout/Shared/Shared+App.swift index 38132f41..d253cccb 100644 --- a/Passepartout/Shared/Shared+App.swift +++ b/Passepartout/Shared/Shared+App.swift @@ -69,9 +69,23 @@ extension ProfileManager { return ProfileManager( repository: mainProfileRepository, backupRepository: backupProfileRepository, - remoteRepository: remoteRepository + remoteRepository: remoteRepository, + deletingRemotely: deletingRemotely, + isIncluded: isProfileIncluded ) }() + +#if os(tvOS) + private static let deletingRemotely = true + + private static let isProfileIncluded: (Profile) -> Bool = { + $0.attributes.isAvailableForTV == true + } +#else + private static let deletingRemotely = false + + private static let isProfileIncluded: ((Profile) -> Bool)? = nil +#endif } // MARK: -