Reorganize Core Data containers (#1017)

Before anything, remove any code related to App Group containers from
tvOS target because they are not available. Include the beta receipt
override, it's broken for that reason.

In short:

- Store all Core Data containers locally. Do not use the App Group for
Core Data for consistency across platforms.
- Store logs in the App Group on iOS/macOS, but locally on tvOS (see
`urlForCaches`).

Then, rather than one container per model, merge models into:

- Local: Providers
- Remote: Profiles + Preferences (now in the same CloudKit container)

Reuse the remote model for backups too.

This change is safe because:

- Local profiles are stored via Network Extension in the keychain, not
Core Data
- Remote profiles are re-imported via CloudKit sync
- Providers are re-downloaded on first use
- Preferences are lost, but they are "cheap" data
- Profile backups are lost, but they were hidden anyway
This commit is contained in:
Davide 2024-12-15 20:20:33 +01:00 committed by GitHub
parent 1751c94891
commit ffb8829f4f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 107 additions and 77 deletions

View File

@ -49,10 +49,7 @@ let package = Package(
), ),
.library( .library(
name: "TunnelLibrary", name: "TunnelLibrary",
targets: [ targets: ["CommonLibrary"]
"AppDataPreferences",
"CommonLibrary"
]
), ),
.library( .library(
name: "UIAccessibility", name: "UIAccessibility",

View File

@ -24,14 +24,8 @@
// //
import AppData import AppData
import CoreData
import Foundation import Foundation
extension AppData { extension AppData {
public static var cdPreferencesModel: NSManagedObjectModel { public static let preferencesBundle: Bundle = .module
guard let model: NSManagedObjectModel = .mergedModel(from: [.module]) else {
fatalError("Unable to build Core Data model (Preferences v3)")
}
return model
}
} }

View File

@ -24,16 +24,8 @@
// //
import AppData import AppData
import CoreData
import Foundation import Foundation
extension AppData { extension AppData {
public static let profilesBundle: Bundle = .module
@MainActor
public static let cdProfilesModel: NSManagedObjectModel = {
guard let model: NSManagedObjectModel = .mergedModel(from: [.module]) else {
fatalError("Unable to build Core Data model (Profiles v3)")
}
return model
}()
} }

View File

@ -24,16 +24,8 @@
// //
import AppData import AppData
import CoreData
import Foundation import Foundation
extension AppData { extension AppData {
public static let providersBundle: Bundle = .module
@MainActor
public static let cdProvidersModel: NSManagedObjectModel = {
guard let model: NSManagedObjectModel = .mergedModel(from: [.module]) else {
fatalError("Unable to build Core Data model (Providers v3)")
}
return model
}()
} }

View File

@ -30,28 +30,44 @@ import PassepartoutKit
extension BundleConfiguration { extension BundleConfiguration {
public static var urlForAppLog: URL { public static var urlForAppLog: URL {
urlForGroupCaches.appending(path: Constants.shared.log.appPath) urlForCaches.appending(path: Constants.shared.log.appPath)
} }
public static var urlForTunnelLog: URL { public static var urlForTunnelLog: URL {
urlForGroupCaches.appending(path: Constants.shared.log.tunnelPath) urlForCaches.appending(path: Constants.shared.log.tunnelPath)
} }
public static var urlForBetaReceipt: URL { public static var urlForBetaReceipt: URL? {
urlForGroupCaches.appending(path: Constants.shared.tunnel.betaReceiptPath) #if os(iOS)
urlForCaches.appending(path: Constants.shared.tunnel.betaReceiptPath)
#else
nil
#endif
} }
} }
// App Group container is not available on tvOS (#1007)
#if !os(tvOS)
extension BundleConfiguration { extension BundleConfiguration {
public static var urlForGroupCaches: URL { public static var urlForCaches: URL {
let url = appGroupURL.appending(components: "Library", "Caches") let url = appGroupURL.appending(components: "Library", "Caches")
try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) do {
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
} catch {
pp_log(.app, .fault, "Unable to create group caches directory: \(error)")
}
return url return url
} }
public static var urlForGroupDocuments: URL { public static var urlForDocuments: URL {
let url = appGroupURL.appending(components: "Library", "Documents") let url = appGroupURL.appending(components: "Library", "Documents")
try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) do {
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
} catch {
pp_log(.app, .fault, "Unable to create group documents directory: \(error)")
}
return url return url
} }
} }
@ -66,3 +82,27 @@ private extension BundleConfiguration {
return url return url
} }
} }
#else
extension BundleConfiguration {
public static var urlForCaches: URL {
do {
return try FileManager.default.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
} catch {
pp_log(.app, .fault, "Unable to create user documents directory: \(error)")
return URL(fileURLWithPath: NSTemporaryDirectory())
}
}
public static var urlForDocuments: URL {
do {
return try FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
} catch {
pp_log(.app, .fault, "Unable to create user documents directory: \(error)")
return URL(fileURLWithPath: NSTemporaryDirectory())
}
}
}
#endif

View File

@ -34,8 +34,6 @@ extension BundleConfiguration {
case cloudKitId case cloudKitId
case cloudKitPreferencesId
case userLevel case userLevel
case groupId case groupId

View File

@ -28,13 +28,11 @@ import PassepartoutKit
public struct Constants: Decodable, Sendable { public struct Constants: Decodable, Sendable {
public struct Containers: Decodable, Sendable { public struct Containers: Decodable, Sendable {
public let localProfiles: String public let local: String
public let remoteProfiles: String public let remote: String
public let providers: String public let backup: String
public let preferences: String
public let legacyV2: String public let legacyV2: String

View File

@ -1,9 +1,9 @@
{ {
"bundleKey": "AppConfig", "bundleKey": "AppConfig",
"containers": { "containers": {
"localProfiles": "Profiles-v3", "local": "Local",
"remoteProfiles": "Profiles-v3.remote", "remote": "Remote",
"providers": "Providers-v3", "backup": "Backup",
"preferences": "Preferences-v3", "preferences": "Preferences-v3",
"legacyV2": "Profiles", "legacyV2": "Profiles",
"legacyV2TV": "SharedProfiles" "legacyV2TV": "SharedProfiles"

View File

@ -46,7 +46,7 @@ public final class AppContext: ObservableObject, Sendable {
public let tunnel: ExtendedTunnel public let tunnel: ExtendedTunnel
private let tunnelReceiptURL: URL private let tunnelReceiptURL: URL?
private var launchTask: Task<Void, Error>? private var launchTask: Task<Void, Error>?
@ -62,7 +62,7 @@ public final class AppContext: ObservableObject, Sendable {
preferencesManager: PreferencesManager, preferencesManager: PreferencesManager,
registry: Registry, registry: Registry,
tunnel: ExtendedTunnel, tunnel: ExtendedTunnel,
tunnelReceiptURL: URL tunnelReceiptURL: URL?
) { ) {
self.iapManager = iapManager self.iapManager = iapManager
self.migrationManager = migrationManager self.migrationManager = migrationManager
@ -128,7 +128,8 @@ private extension AppContext {
.store(in: &subscriptions) .store(in: &subscriptions)
// copy release receipt to tunnel for TestFlight eligibility (once is enough, it won't change) // copy release receipt to tunnel for TestFlight eligibility (once is enough, it won't change)
if let appReceiptURL = Bundle.main.appStoreProductionReceiptURL { if let tunnelReceiptURL,
let appReceiptURL = Bundle.main.appStoreProductionReceiptURL {
do { do {
pp_log(.App.iap, .info, "\tCopy release receipt to tunnel...") pp_log(.App.iap, .info, "\tCopy release receipt to tunnel...")
try? FileManager.default.removeItem(at: tunnelReceiptURL) try? FileManager.default.removeItem(at: tunnelReceiptURL)

View File

@ -85,7 +85,7 @@
</CommandLineArgument> </CommandLineArgument>
<CommandLineArgument <CommandLineArgument
argument = "-pp_ui_testing" argument = "-pp_ui_testing"
isEnabled = "YES"> isEnabled = "NO">
</CommandLineArgument> </CommandLineArgument>
<CommandLineArgument <CommandLineArgument
argument = "-pp_fake_migration" argument = "-pp_fake_migration"

View File

@ -9,7 +9,6 @@
<key>com.apple.developer.icloud-container-identifiers</key> <key>com.apple.developer.icloud-container-identifiers</key>
<array> <array>
<string>$(CFG_CLOUDKIT_ID)</string> <string>$(CFG_CLOUDKIT_ID)</string>
<string>$(CFG_CLOUDKIT_PREFERENCES_ID)</string>
<string>$(CFG_LEGACY_V2_CLOUDKIT_ID)</string> <string>$(CFG_LEGACY_V2_CLOUDKIT_ID)</string>
<string>$(CFG_LEGACY_V2_TV_CLOUDKIT_ID)</string> <string>$(CFG_LEGACY_V2_TV_CLOUDKIT_ID)</string>
</array> </array>

View File

@ -8,8 +8,6 @@
<string>$(CFG_APP_STORE_ID)</string> <string>$(CFG_APP_STORE_ID)</string>
<key>cloudKitId</key> <key>cloudKitId</key>
<string>$(CFG_CLOUDKIT_ID)</string> <string>$(CFG_CLOUDKIT_ID)</string>
<key>cloudKitPreferencesId</key>
<string>$(CFG_CLOUDKIT_PREFERENCES_ID)</string>
<key>groupId</key> <key>groupId</key>
<string>$(CFG_GROUP_ID)</string> <string>$(CFG_GROUP_ID)</string>
<key>iapBundlePrefix</key> <key>iapBundlePrefix</key>

View File

@ -29,6 +29,7 @@ import AppDataProfiles
import AppDataProviders import AppDataProviders
import CommonLibrary import CommonLibrary
import CommonUtils import CommonUtils
import CoreData
import Foundation import Foundation
import LegacyV2 import LegacyV2
import PassepartoutKit import PassepartoutKit
@ -39,6 +40,18 @@ extension AppContext {
static let shared: AppContext = { static let shared: AppContext = {
let dependencies: Dependencies = .shared let dependencies: Dependencies = .shared
guard let cdRemoteModel = NSManagedObjectModel.mergedModel(from: [
AppData.profilesBundle,
AppData.preferencesBundle
]) else {
fatalError("Unable to load remote model")
}
guard let cdLocalModel = NSManagedObjectModel.mergedModel(from: [
AppData.providersBundle
]) else {
fatalError("Unable to load local model")
}
let iapManager = IAPManager( let iapManager = IAPManager(
customUserLevel: dependencies.customUserLevel, customUserLevel: dependencies.customUserLevel,
inAppHelper: dependencies.simulatedAppProductHelper(), inAppHelper: dependencies.simulatedAppProductHelper(),
@ -54,8 +67,8 @@ extension AppContext {
let remoteRepositoryBlock: (Bool) -> ProfileRepository = { let remoteRepositoryBlock: (Bool) -> ProfileRepository = {
let remoteStore = CoreDataPersistentStore( let remoteStore = CoreDataPersistentStore(
logger: dependencies.coreDataLogger(), logger: dependencies.coreDataLogger(),
containerName: Constants.shared.containers.remoteProfiles, containerName: Constants.shared.containers.remote,
model: AppData.cdProfilesModel, model: cdRemoteModel,
cloudKitIdentifier: $0 ? BundleConfiguration.mainString(for: .cloudKitId) : nil, cloudKitIdentifier: $0 ? BundleConfiguration.mainString(for: .cloudKitId) : nil,
author: nil author: nil
) )
@ -71,8 +84,13 @@ extension AppContext {
) )
} }
return ProfileManager( return ProfileManager(
repository: dependencies.mainProfileRepository(environment: tunnelEnvironment), repository: dependencies.mainProfileRepository(
backupRepository: dependencies.backupProfileRepository(), model: cdRemoteModel,
environment: tunnelEnvironment
),
backupRepository: dependencies.backupProfileRepository(
model: cdRemoteModel
),
remoteRepositoryBlock: remoteRepositoryBlock, remoteRepositoryBlock: remoteRepositoryBlock,
mirrorsRemoteRepository: dependencies.mirrorsRemoteRepository, mirrorsRemoteRepository: dependencies.mirrorsRemoteRepository,
processor: processor processor: processor
@ -89,8 +107,8 @@ extension AppContext {
let providerManager: ProviderManager = { let providerManager: ProviderManager = {
let store = CoreDataPersistentStore( let store = CoreDataPersistentStore(
logger: dependencies.coreDataLogger(), logger: dependencies.coreDataLogger(),
containerName: Constants.shared.containers.providers, containerName: Constants.shared.containers.local,
model: AppData.cdProvidersModel, model: cdLocalModel,
cloudKitIdentifier: nil, cloudKitIdentifier: nil,
author: nil author: nil
) )
@ -126,9 +144,9 @@ extension AppContext {
let preferencesManager: PreferencesManager = { let preferencesManager: PreferencesManager = {
let preferencesStore = CoreDataPersistentStore( let preferencesStore = CoreDataPersistentStore(
logger: dependencies.coreDataLogger(), logger: dependencies.coreDataLogger(),
containerName: Constants.shared.containers.preferences, containerName: Constants.shared.containers.remote,
model: AppData.cdPreferencesModel, model: cdRemoteModel,
cloudKitIdentifier: BundleConfiguration.mainString(for: .cloudKitPreferencesId), cloudKitIdentifier: BundleConfiguration.mainString(for: .cloudKitId),
author: nil author: nil
) )
return PreferencesManager( return PreferencesManager(
@ -207,11 +225,11 @@ private extension Dependencies {
FakeTunnelStrategy(environment: environment, dataCountInterval: 1000) FakeTunnelStrategy(environment: environment, dataCountInterval: 1000)
} }
func mainProfileRepository(environment: TunnelEnvironment) -> ProfileRepository { func mainProfileRepository(model: NSManagedObjectModel, environment: TunnelEnvironment) -> ProfileRepository {
coreDataProfileRepository(observingResults: true) coreDataProfileRepository(model: model, observingResults: true)
} }
func backupProfileRepository() -> ProfileRepository? { func backupProfileRepository(model: NSManagedObjectModel) -> ProfileRepository? {
nil nil
} }
} }
@ -225,12 +243,12 @@ private extension Dependencies {
neStrategy(environment: environment) neStrategy(environment: environment)
} }
func mainProfileRepository(environment: TunnelEnvironment) -> ProfileRepository { func mainProfileRepository(model: NSManagedObjectModel, environment: TunnelEnvironment) -> ProfileRepository {
neProfileRepository(environment: environment) neProfileRepository(environment: environment)
} }
func backupProfileRepository() -> ProfileRepository? { func backupProfileRepository(model: NSManagedObjectModel) -> ProfileRepository? {
coreDataProfileRepository(observingResults: false) coreDataProfileRepository(model: model, observingResults: false)
} }
} }
@ -261,11 +279,11 @@ private extension Dependencies {
) )
} }
func coreDataProfileRepository(observingResults: Bool) -> ProfileRepository { func coreDataProfileRepository(model: NSManagedObjectModel, observingResults: Bool) -> ProfileRepository {
let store = CoreDataPersistentStore( let store = CoreDataPersistentStore(
logger: coreDataLogger(), logger: coreDataLogger(),
containerName: Constants.shared.containers.localProfiles, containerName: Constants.shared.containers.backup,
model: AppData.cdProfilesModel, model: model,
cloudKitIdentifier: nil, cloudKitIdentifier: nil,
author: nil author: nil
) )

View File

@ -47,7 +47,6 @@ CFG_LOGIN_ITEM_ID = $(CFG_APP_ID).LoginItem
CFG_TUNNEL_ID = $(CFG_APP_ID).Tunnel CFG_TUNNEL_ID = $(CFG_APP_ID).Tunnel
CFG_CLOUDKIT_ID = $(CFG_CLOUDKIT_ROOT).v3 CFG_CLOUDKIT_ID = $(CFG_CLOUDKIT_ROOT).v3
CFG_CLOUDKIT_PREFERENCES_ID = $(CFG_CLOUDKIT_ROOT).v3.Preferences
CFG_LEGACY_V2_CLOUDKIT_ID = $(CFG_CLOUDKIT_ROOT) CFG_LEGACY_V2_CLOUDKIT_ID = $(CFG_CLOUDKIT_ROOT)
CFG_LEGACY_V2_TV_CLOUDKIT_ID = $(CFG_CLOUDKIT_ROOT).Shared CFG_LEGACY_V2_TV_CLOUDKIT_ID = $(CFG_CLOUDKIT_ROOT).Shared

View File

@ -51,11 +51,17 @@ private extension Dependencies {
func tunnelReceiptReader() -> AppReceiptReader { func tunnelReceiptReader() -> AppReceiptReader {
FallbackReceiptReader( FallbackReceiptReader(
main: StoreKitReceiptReader(), main: StoreKitReceiptReader(),
beta: KvittoReceiptReader(url: betaReceiptURL) beta: betaReceiptURL.map {
KvittoReceiptReader(url: $0)
}
) )
} }
var betaReceiptURL: URL { var betaReceiptURL: URL? {
#if !os(tvOS)
BundleConfiguration.urlForBetaReceipt // copied by AppContext.onLaunch BundleConfiguration.urlForBetaReceipt // copied by AppContext.onLaunch
#else
nil
#endif
} }
} }

View File

@ -10,8 +10,6 @@
<string>$(CFG_KEYCHAIN_GROUP_ID)</string> <string>$(CFG_KEYCHAIN_GROUP_ID)</string>
<key>tunnelId</key> <key>tunnelId</key>
<string>$(CFG_TUNNEL_ID)</string> <string>$(CFG_TUNNEL_ID)</string>
<key>cloudKitPreferencesId</key>
<string>$(CFG_CLOUDKIT_PREFERENCES_ID)</string>
</dict> </dict>
<key>NSExtension</key> <key>NSExtension</key>
<dict> <dict>