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)
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

View File

@ -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?
}

View File

@ -3,8 +3,9 @@
<entity name="CDProfileV3" representedClassName="CDProfileV3" elementID="CDProfile" versionHashModifier="1" syncable="YES">
<attribute name="encoded" optional="YES" attributeType="String" allowsCloudEncryption="YES"/>
<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="name" optional="YES" attributeType="String"/>
<attribute name="uuid" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
</entity>
</model>
</model>

View File

@ -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))
}
}

View File

@ -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 {

View File

@ -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,

View File

@ -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))

View File

@ -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 {

View File

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

View File

@ -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"

View File

@ -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")

View File

@ -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";

View File

@ -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"
}
}
}

View File

@ -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: -