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:
parent
a22584c630
commit
5119cc20d5
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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?
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,8 +3,9 @@
|
||||||
<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"/>
|
||||||
</entity>
|
</entity>
|
||||||
</model>
|
</model>
|
||||||
|
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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))
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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";
|
||||||
|
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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: -
|
||||||
|
|
Loading…
Reference in New Issue