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)
|
||||
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
|
||||
|
||||
|
|
|
@ -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?
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
<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"/>
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -101,6 +101,15 @@ extension ProfileEditor {
|
|||
editableProfile = newValue
|
||||
}
|
||||
}
|
||||
|
||||
public var isAvailableForTV: Bool {
|
||||
get {
|
||||
editableProfile.attributes.isAvailableForTV == true
|
||||
}
|
||||
set {
|
||||
editableProfile.attributes.isAvailableForTV = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ProfileEditor {
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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: -
|
||||
|
|
Loading…
Reference in New Issue