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
This commit is contained in:
Davide 2024-11-03 23:42:17 +01:00 committed by GitHub
parent a22584c630
commit 5119cc20d5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 115 additions and 17 deletions

View File

@ -70,6 +70,7 @@ private extension AppData {
let profile = try registry.decodedProfile(from: encoded, with: coder) let profile = try registry.decodedProfile(from: encoded, with: coder)
var builder = profile.builder() var builder = profile.builder()
builder.attributes = ProfileAttributes( builder.attributes = ProfileAttributes(
isAvailableForTV: cdEntity.isAvailableForTV?.boolValue ?? false,
lastUpdate: cdEntity.lastUpdate, lastUpdate: cdEntity.lastUpdate,
fingerprint: cdEntity.fingerprint fingerprint: cdEntity.fingerprint
) )
@ -91,6 +92,7 @@ private extension AppData {
cdProfile.encoded = encoded cdProfile.encoded = encoded
let attributes = profile.attributes let attributes = profile.attributes
cdProfile.isAvailableForTV = attributes.isAvailableForTV.map(NSNumber.init(value:))
cdProfile.lastUpdate = attributes.lastUpdate cdProfile.lastUpdate = attributes.lastUpdate
cdProfile.fingerprint = attributes.fingerprint cdProfile.fingerprint = attributes.fingerprint

View File

@ -35,6 +35,7 @@ final class CDProfileV3: NSManagedObject {
@NSManaged var uuid: UUID? @NSManaged var uuid: UUID?
@NSManaged var name: String? @NSManaged var name: String?
@NSManaged var encoded: String? @NSManaged var encoded: String?
@NSManaged var isAvailableForTV: NSNumber?
@NSManaged var lastUpdate: Date? @NSManaged var lastUpdate: Date?
@NSManaged var fingerprint: UUID? @NSManaged var fingerprint: UUID?
} }

View File

@ -3,6 +3,7 @@
<entity name="CDProfileV3" representedClassName="CDProfileV3" elementID="CDProfile" versionHashModifier="1" syncable="YES"> <entity name="CDProfileV3" representedClassName="CDProfileV3" elementID="CDProfile" versionHashModifier="1" syncable="YES">
<attribute name="encoded" optional="YES" attributeType="String" allowsCloudEncryption="YES"/> <attribute name="encoded" optional="YES" attributeType="String" allowsCloudEncryption="YES"/>
<attribute name="fingerprint" optional="YES" attributeType="UUID" usesScalarValueType="NO"/> <attribute name="fingerprint" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="isAvailableForTV" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="lastUpdate" optional="YES" attributeType="Date" usesScalarValueType="NO"/> <attribute name="lastUpdate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="name" optional="YES" attributeType="String"/> <attribute name="name" optional="YES" attributeType="String"/>
<attribute name="uuid" optional="YES" attributeType="UUID" usesScalarValueType="NO"/> <attribute name="uuid" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>

View File

@ -64,9 +64,7 @@ struct ProfileRowView: View, Routable {
} }
Spacer() Spacer()
HStack(spacing: 10.0) { HStack(spacing: 10.0) {
if isShared { attributesView
sharingView
}
ProfileInfoButton(header: header) { ProfileInfoButton(header: header) {
flow?.onEditProfile($0) flow?.onEditProfile($0)
} }
@ -76,11 +74,9 @@ struct ProfileRowView: View, Routable {
} }
} }
private extension ProfileRowView { // MARK: - Layout
var isShared: Bool {
profileManager.isRemotelyShared(profileWithId: header.id)
}
private extension ProfileRowView {
var markerView: some View { var markerView: some View {
ThemeImage(header.id == nextProfileId ? .pending : statusImage) ThemeImage(header.id == nextProfileId ? .pending : statusImage)
.opacity(header.id == nextProfileId || header.id == tunnel.currentProfile?.id ? 1.0 : 0.0) .opacity(header.id == nextProfileId || header.id == tunnel.currentProfile?.id ? 1.0 : 0.0)
@ -107,12 +103,6 @@ private extension ProfileRowView {
.foregroundStyle(.primary) .foregroundStyle(.primary)
} }
var sharingView: some View {
ThemeImage(.cloud)
.foregroundStyle(.secondary)
.help(Strings.Modules.General.Rows.icloudSharing)
}
var statusImage: Theme.ImageName { var statusImage: Theme.ImageName {
switch tunnel.connectionStatus { switch tunnel.connectionStatus {
case .active: 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))
}
}

View File

@ -41,6 +41,7 @@ struct StorageSection: View {
debugChanges() debugChanges()
return Group { return Group {
sharingToggle sharingToggle
tvToggle
ThemeCopiableText( ThemeCopiableText(
title: Strings.Unlocalized.uuid, title: Strings.Unlocalized.uuid,
value: profileEditor.profile.id, value: profileEditor.profile.id,
@ -75,6 +76,23 @@ private extension StorageSection {
Toggle(Strings.Modules.General.Rows.icloudSharing, isOn: $profileEditor.isShared) 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 { #Preview {

View File

@ -48,7 +48,7 @@ struct ProfileListView: View {
var body: some View { var body: some View {
List { List {
Section { Section {
ForEach(profileManager.headers, id: \.id, content: toggleButton(for:)) ForEach(headers, id: \.id, content: toggleButton(for:))
} header: { } header: {
Text(Strings.Views.Profiles.Folders.default) Text(Strings.Views.Profiles.Folders.default)
} }
@ -59,6 +59,10 @@ struct ProfileListView: View {
} }
private extension ProfileListView { private extension ProfileListView {
var headers: [ProfileHeader] {
profileManager.headers
}
func toggleButton(for header: ProfileHeader) -> some View { func toggleButton(for header: ProfileHeader) -> some View {
TunnelToggleButton( TunnelToggleButton(
tunnel: tunnel, tunnel: tunnel,

View File

@ -208,6 +208,10 @@ extension ProfileManager {
allRemoteProfiles.keys.contains(profileId) allRemoteProfiles.keys.contains(profileId)
} }
public func isAvailableForTV(profileWithId profileId: Profile.ID) -> Bool {
profile(withId: profileId)?.attributes.isAvailableForTV == true
}
public func eraseRemotelySharedProfiles() async throws { public func eraseRemotelySharedProfiles() async throws {
pp_log(.app, .notice, "Erase remotely shared profiles...") pp_log(.app, .notice, "Erase remotely shared profiles...")
try await remoteRepository?.removeProfiles(withIds: Array(allRemoteProfiles.keys)) try await remoteRepository?.removeProfiles(withIds: Array(allRemoteProfiles.keys))

View File

@ -28,22 +28,28 @@ import Foundation
import PassepartoutKit import PassepartoutKit
public struct ProfileAttributes: Hashable, Codable { public struct ProfileAttributes: Hashable, Codable {
public var isAvailableForTV: Bool?
public var lastUpdate: Date? public var lastUpdate: Date?
public var fingerprint: UUID? public var fingerprint: UUID?
public init() { public init(isAvailableForTV: Bool? = false) {
self.isAvailableForTV = isAvailableForTV
} }
public init( public init(
isAvailableForTV: Bool?,
lastUpdate: Date?, lastUpdate: Date?,
fingerprint: UUID? fingerprint: UUID?
) { ) {
self.isAvailableForTV = isAvailableForTV
self.lastUpdate = lastUpdate self.lastUpdate = lastUpdate
self.fingerprint = fingerprint self.fingerprint = fingerprint
} }
} }
// FIXME: #570, test user info encoding/decoding with JSONSerialization
extension ProfileAttributes: ProfileUserInfoTransformable { extension ProfileAttributes: ProfileUserInfoTransformable {
public var userInfo: [String: AnyHashable]? { public var userInfo: [String: AnyHashable]? {
do { do {

View File

@ -101,6 +101,15 @@ extension ProfileEditor {
editableProfile = newValue editableProfile = newValue
} }
} }
public var isAvailableForTV: Bool {
get {
editableProfile.attributes.isAvailableForTV == true
}
set {
editableProfile.attributes.isAvailableForTV = newValue
}
}
} }
extension ProfileEditor { extension ProfileEditor {

View File

@ -96,6 +96,8 @@ extension Strings {
public static let appName = "Passepartout" public static let appName = "Passepartout"
public static let appleTV = "Apple TV"
public static let ca = "CA" public static let ca = "CA"
public static let dns = "DNS" public static let dns = "DNS"

View File

@ -333,10 +333,20 @@ public enum Strings {
} }
public enum General { public enum General {
public enum Rows { 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 /// Shared on iCloud
public static let icloudSharing = Strings.tr("Localizable", "modules.general.rows.icloud_sharing", fallback: "Shared on iCloud") public static let icloudSharing = Strings.tr("Localizable", "modules.general.rows.icloud_sharing", fallback: "Shared on iCloud")
/// Import from file... /// Import from file...
public static let importFromFile = Strings.tr("Localizable", "modules.general.rows.import_from_file", fallback: "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 { public enum IcloudSharing {
/// Share on iCloud /// Share on iCloud
public static let purchase = Strings.tr("Localizable", "modules.general.rows.icloud_sharing.purchase", fallback: "Share on iCloud") public static let purchase = Strings.tr("Localizable", "modules.general.rows.icloud_sharing.purchase", fallback: "Share on iCloud")

View File

@ -171,6 +171,7 @@
"modules.general.sections.storage.footer" = "Profiles are stored to iCloud encrypted."; "modules.general.sections.storage.footer" = "Profiles are stored to iCloud encrypted.";
"modules.general.rows.icloud_sharing" = "Shared on iCloud"; "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.general.rows.import_from_file" = "Import from file...";
"modules.dns.servers.add" = "Add address"; "modules.dns.servers.add" = "Add address";
@ -251,6 +252,7 @@
// MARK: - Paywalls // MARK: - Paywalls
"modules.general.rows.icloud_sharing.purchase" = "Share on iCloud"; "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.on_demand.purchase" = "Add on-demand rules";
"modules.openvpn.credentials.interactive.purchase" = "Log in interactively"; "modules.openvpn.credentials.interactive.purchase" = "Log in interactively";
"providers.picker.purchase" = "Add more providers"; "providers.picker.purchase" = "Add more providers";

View File

@ -61,6 +61,7 @@ extension Theme {
case tunnelRestart case tunnelRestart
case tunnelToggle case tunnelToggle
case tunnelUninstall case tunnelUninstall
case tv
} }
} }
@ -102,6 +103,7 @@ extension Theme.ImageName {
case .tunnelRestart: return "arrow.clockwise" case .tunnelRestart: return "arrow.clockwise"
case .tunnelToggle: return "power" case .tunnelToggle: return "power"
case .tunnelUninstall: return "arrow.uturn.down" case .tunnelUninstall: return "arrow.uturn.down"
case .tv: return "tv"
} }
} }
} }

View File

@ -69,9 +69,23 @@ extension ProfileManager {
return ProfileManager( return ProfileManager(
repository: mainProfileRepository, repository: mainProfileRepository,
backupRepository: backupProfileRepository, 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: - // MARK: -