Commit 963ce3fb34
Unsigned
Layout: unified · split
DomainDig.xcodeproj/project.pbxproj +20 −20
| @@ -567,11 +567,11 @@ | ||
| 567 | 567 | BUNDLE_LOADER = "$(TEST_HOST)"; |
| 568 | 568 | CLANG_ENABLE_OBJC_WEAK = NO; |
| 569 | 569 | CODE_SIGN_STYLE = Automatic; |
| 570 | CURRENT_PROJECT_VERSION = 45; | |
| 570 | CURRENT_PROJECT_VERSION = 46; | |
| 571 | 571 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 572 | 572 | GENERATE_INFOPLIST_FILE = YES; |
| 573 | 573 | IPHONEOS_DEPLOYMENT_TARGET = 17.6; |
| 574 | MARKETING_VERSION = 5.0.0; | |
| 574 | MARKETING_VERSION = 5.0.1; | |
| 575 | 575 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDigTests; |
| 576 | 576 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 577 | 577 | SDKROOT = iphoneos; |
| @@ -714,7 +714,7 @@ | ||
| 714 | 714 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 715 | 715 | CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements; |
| 716 | 716 | CODE_SIGN_STYLE = Automatic; |
| 717 | CURRENT_PROJECT_VERSION = 45; | |
| 717 | CURRENT_PROJECT_VERSION = 46; | |
| 718 | 718 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 719 | 719 | ENABLE_PREVIEWS = YES; |
| 720 | 720 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -731,7 +731,7 @@ | ||
| 731 | 731 | "$(inherited)", |
| 732 | 732 | "@executable_path/Frameworks", |
| 733 | 733 | ); |
| 734 | MARKETING_VERSION = 5.0.0; | |
| 734 | MARKETING_VERSION = 5.0.1; | |
| 735 | 735 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; |
| 736 | 736 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 737 | 737 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -751,7 +751,7 @@ | ||
| 751 | 751 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 752 | 752 | CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements; |
| 753 | 753 | CODE_SIGN_STYLE = Automatic; |
| 754 | CURRENT_PROJECT_VERSION = 45; | |
| 754 | CURRENT_PROJECT_VERSION = 46; | |
| 755 | 755 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 756 | 756 | ENABLE_PREVIEWS = YES; |
| 757 | 757 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -768,7 +768,7 @@ | ||
| 768 | 768 | "$(inherited)", |
| 769 | 769 | "@executable_path/Frameworks", |
| 770 | 770 | ); |
| 771 | MARKETING_VERSION = 5.0.0; | |
| 771 | MARKETING_VERSION = 5.0.1; | |
| 772 | 772 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; |
| 773 | 773 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 774 | 774 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -787,7 +787,7 @@ | ||
| 787 | 787 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 788 | 788 | CODE_SIGN_ENTITLEMENTS = DomainDigWidget/DomainDigWidget.entitlements; |
| 789 | 789 | CODE_SIGN_STYLE = Automatic; |
| 790 | CURRENT_PROJECT_VERSION = 45; | |
| 790 | CURRENT_PROJECT_VERSION = 46; | |
| 791 | 791 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 792 | 792 | GENERATE_INFOPLIST_FILE = NO; |
| 793 | 793 | INFOPLIST_FILE = DomainDigWidget/Info.plist; |
| @@ -798,7 +798,7 @@ | ||
| 798 | 798 | "@executable_path/Frameworks", |
| 799 | 799 | "@executable_path/../../Frameworks", |
| 800 | 800 | ); |
| 801 | MARKETING_VERSION = 5.0.0; | |
| 801 | MARKETING_VERSION = 5.0.1; | |
| 802 | 802 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig.DomainDigWidget; |
| 803 | 803 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 804 | 804 | SKIP_INSTALL = YES; |
| @@ -816,7 +816,7 @@ | ||
| 816 | 816 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 817 | 817 | CODE_SIGN_ENTITLEMENTS = DomainDigWidget/DomainDigWidget.entitlements; |
| 818 | 818 | CODE_SIGN_STYLE = Automatic; |
| 819 | CURRENT_PROJECT_VERSION = 45; | |
| 819 | CURRENT_PROJECT_VERSION = 46; | |
| 820 | 820 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 821 | 821 | GENERATE_INFOPLIST_FILE = NO; |
| 822 | 822 | INFOPLIST_FILE = DomainDigWidget/Info.plist; |
| @@ -827,7 +827,7 @@ | ||
| 827 | 827 | "@executable_path/Frameworks", |
| 828 | 828 | "@executable_path/../../Frameworks", |
| 829 | 829 | ); |
| 830 | MARKETING_VERSION = 5.0.0; | |
| 830 | MARKETING_VERSION = 5.0.1; | |
| 831 | 831 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig.DomainDigWidget; |
| 832 | 832 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 833 | 833 | SKIP_INSTALL = YES; |
| @@ -845,7 +845,7 @@ | ||
| 845 | 845 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 846 | 846 | CODE_SIGN_ENTITLEMENTS = DomainDigShareExtension/DomainDigShareExtension.entitlements; |
| 847 | 847 | CODE_SIGN_STYLE = Automatic; |
| 848 | CURRENT_PROJECT_VERSION = 45; | |
| 848 | CURRENT_PROJECT_VERSION = 46; | |
| 849 | 849 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 850 | 850 | GENERATE_INFOPLIST_FILE = NO; |
| 851 | 851 | INFOPLIST_FILE = DomainDigShareExtension/Info.plist; |
| @@ -856,7 +856,7 @@ | ||
| 856 | 856 | "@executable_path/Frameworks", |
| 857 | 857 | "@executable_path/../../Frameworks", |
| 858 | 858 | ); |
| 859 | MARKETING_VERSION = 5.0.0; | |
| 859 | MARKETING_VERSION = 5.0.1; | |
| 860 | 860 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig.DomainDigShareExtension; |
| 861 | 861 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 862 | 862 | SKIP_INSTALL = YES; |
| @@ -874,7 +874,7 @@ | ||
| 874 | 874 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 875 | 875 | CODE_SIGN_ENTITLEMENTS = DomainDigShareExtension/DomainDigShareExtension.entitlements; |
| 876 | 876 | CODE_SIGN_STYLE = Automatic; |
| 877 | CURRENT_PROJECT_VERSION = 45; | |
| 877 | CURRENT_PROJECT_VERSION = 46; | |
| 878 | 878 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 879 | 879 | GENERATE_INFOPLIST_FILE = NO; |
| 880 | 880 | INFOPLIST_FILE = DomainDigShareExtension/Info.plist; |
| @@ -885,7 +885,7 @@ | ||
| 885 | 885 | "@executable_path/Frameworks", |
| 886 | 886 | "@executable_path/../../Frameworks", |
| 887 | 887 | ); |
| 888 | MARKETING_VERSION = 5.0.0; | |
| 888 | MARKETING_VERSION = 5.0.1; | |
| 889 | 889 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig.DomainDigShareExtension; |
| 890 | 890 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 891 | 891 | SKIP_INSTALL = YES; |
| @@ -901,11 +901,11 @@ | ||
| 901 | 901 | isa = XCBuildConfiguration; |
| 902 | 902 | buildSettings = { |
| 903 | 903 | CODE_SIGN_STYLE = Automatic; |
| 904 | CURRENT_PROJECT_VERSION = 45; | |
| 904 | CURRENT_PROJECT_VERSION = 46; | |
| 905 | 905 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 906 | 906 | GENERATE_INFOPLIST_FILE = YES; |
| 907 | 907 | IPHONEOS_DEPLOYMENT_TARGET = 17.6; |
| 908 | MARKETING_VERSION = 5.0.0; | |
| 908 | MARKETING_VERSION = 5.0.1; | |
| 909 | 909 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDigUITests; |
| 910 | 910 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 911 | 911 | SWIFT_APPROACHABLE_CONCURRENCY = YES; |
| @@ -920,11 +920,11 @@ | ||
| 920 | 920 | isa = XCBuildConfiguration; |
| 921 | 921 | buildSettings = { |
| 922 | 922 | CODE_SIGN_STYLE = Automatic; |
| 923 | CURRENT_PROJECT_VERSION = 45; | |
| 923 | CURRENT_PROJECT_VERSION = 46; | |
| 924 | 924 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 925 | 925 | GENERATE_INFOPLIST_FILE = YES; |
| 926 | 926 | IPHONEOS_DEPLOYMENT_TARGET = 17.6; |
| 927 | MARKETING_VERSION = 5.0.0; | |
| 927 | MARKETING_VERSION = 5.0.1; | |
| 928 | 928 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDigUITests; |
| 929 | 929 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 930 | 930 | SWIFT_APPROACHABLE_CONCURRENCY = YES; |
| @@ -941,11 +941,11 @@ | ||
| 941 | 941 | BUNDLE_LOADER = "$(TEST_HOST)"; |
| 942 | 942 | CLANG_ENABLE_OBJC_WEAK = NO; |
| 943 | 943 | CODE_SIGN_STYLE = Automatic; |
| 944 | CURRENT_PROJECT_VERSION = 45; | |
| 944 | CURRENT_PROJECT_VERSION = 46; | |
| 945 | 945 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 946 | 946 | GENERATE_INFOPLIST_FILE = YES; |
| 947 | 947 | IPHONEOS_DEPLOYMENT_TARGET = 17.6; |
| 948 | MARKETING_VERSION = 5.0.0; | |
| 948 | MARKETING_VERSION = 5.0.1; | |
| 949 | 949 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDigTests; |
| 950 | 950 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 951 | 951 | SDKROOT = iphoneos; |
DomainDig/AppVersion.swift +1 −1
| @@ -2,6 +2,6 @@ import Foundation | ||
| 2 | 2 | |
| 3 | 3 | enum AppVersion { |
| 4 | 4 | nonisolated static var current: String { |
| 5 | "5.0.0" | |
| 5 | "5.0.1" | |
| 6 | 6 | } |
| 7 | 7 | } |
DomainDig/OwnerAccess.swift added +34
| @@ -0,0 +1,34 @@ | ||
| 1 | import CloudKit | |
| 2 | ||
| 3 | /// Owner-only entitlement support. The app owner is identified by their CloudKit | |
| 4 | /// user-record ID — a stable, opaque per-Apple-ID value for this app's container. | |
| 5 | /// `PurchaseService` grants the owner Pro+ when the signed-in iCloud user matches, | |
| 6 | /// so the owner does not need a purchase. | |
| 7 | /// | |
| 8 | /// Publishing the record ID here is safe: it is not an Apple ID or any personal | |
| 9 | /// identifier, it is scoped to the `iCloud.net.cleberg.DomainDig` container, and | |
| 10 | /// CloudKit identity is verified server-side — another user cannot present it as | |
| 11 | /// their own. An empty value makes the allowlist inert. | |
| 12 | enum OwnerAccess { | |
| 13 | static let ownerUserRecordID = "_1c35d6a25540b3ef00023cc0425ec373" | |
| 14 | ||
| 15 | static var isConfigured: Bool { !ownerUserRecordID.isEmpty } | |
| 16 | ||
| 17 | /// The current iCloud user's record name for this app's container, or nil if | |
| 18 | /// it is unavailable (not signed into iCloud, restricted, or offline before | |
| 19 | /// the first fetch). | |
| 20 | static func currentUserRecordName() async -> String? { | |
| 21 | do { | |
| 22 | return try await CKContainer.default().userRecordID().recordName | |
| 23 | } catch { | |
| 24 | return nil | |
| 25 | } | |
| 26 | } | |
| 27 | ||
| 28 | /// True only when the allowlist is configured and the current iCloud user is | |
| 29 | /// the owner. | |
| 30 | static func isOwner() async -> Bool { | |
| 31 | guard isConfigured else { return false } | |
| 32 | return await currentUserRecordName() == ownerUserRecordID | |
| 33 | } | |
| 34 | } | |
DomainDig/PurchaseService.swift +50 −3
| @@ -34,6 +34,20 @@ final class PurchaseService { | ||
| 34 | 34 | private static let debugForceProPlusArgument = "DOMAIN_DIG_FORCE_PRO_PLUS" |
| 35 | 35 | #endif |
| 36 | 36 | |
| 37 | private static let ownerEntitlementKey = "purchase.ownerEntitlement" | |
| 38 | ||
| 39 | /// Whether the owner allowlist has confirmed this device's iCloud user as the | |
| 40 | /// owner. Persisted so the grant is instant on later launches and survives | |
| 41 | /// offline, when CloudKit cannot be reached. | |
| 42 | static var ownerEntitlementGranted: Bool { | |
| 43 | UserDefaults.standard.bool(forKey: ownerEntitlementKey) | |
| 44 | } | |
| 45 | ||
| 46 | private static var storedEntitlement: CachedEntitlement? { | |
| 47 | guard let data = UserDefaults.standard.data(forKey: entitlementCacheKey) else { return nil } | |
| 48 | return try? JSONDecoder().decode(CachedEntitlement.self, from: data) | |
| 49 | } | |
| 50 | ||
| 37 | 51 | static var cachedEntitlement: CachedEntitlement? { |
| 38 | 52 | #if DEBUG |
| 39 | 53 | if let forcedEntitlement = debugForcedEntitlement { |
| @@ -41,12 +55,17 @@ final class PurchaseService { | ||
| 41 | 55 | } |
| 42 | 56 | #endif |
| 43 | 57 | |
| 44 | guard let data = UserDefaults.standard.data(forKey: entitlementCacheKey) else { return nil } | |
| 45 | return try? JSONDecoder().decode(CachedEntitlement.self, from: data) | |
| 58 | return storedEntitlement | |
| 46 | 59 | } |
| 47 | 60 | |
| 48 | 61 | static var cachedTier: FeatureTier { |
| 49 | cachedEntitlement?.tier ?? .free | |
| 62 | #if DEBUG | |
| 63 | // A debug override wins outright so free/pro tiers remain testable on the | |
| 64 | // owner's own device. | |
| 65 | if let forcedEntitlement = debugForcedEntitlement { return forcedEntitlement.tier } | |
| 66 | #endif | |
| 67 | if ownerEntitlementGranted { return .proPlus } | |
| 68 | return storedEntitlement?.tier ?? .free | |
| 50 | 69 | } |
| 51 | 70 | |
| 52 | 71 | var products: [Product] = [] |
| @@ -64,8 +83,10 @@ final class PurchaseService { | ||
| 64 | 83 | currentTier = Self.cachedTier |
| 65 | 84 | activeProductID = Self.cachedEntitlement?.activeProductID |
| 66 | 85 | applyDebugOverrideIfNeeded() |
| 86 | applyOwnerOverrideIfNeeded() | |
| 67 | 87 | updatesTask = observeTransactionUpdates() |
| 68 | 88 | Task { |
| 89 | await resolveOwnerEntitlementIfNeeded() | |
| 69 | 90 | await refreshProducts() |
| 70 | 91 | await refreshEntitlements() |
| 71 | 92 | } |
| @@ -118,6 +139,7 @@ final class PurchaseService { | ||
| 118 | 139 | currentTier = tier(for: activeProductID) |
| 119 | 140 | persistCurrentEntitlement() |
| 120 | 141 | applyDebugOverrideIfNeeded() |
| 142 | applyOwnerOverrideIfNeeded() | |
| 121 | 143 | } |
| 122 | 144 | |
| 123 | 145 | func purchase(_ product: Product) async { |
| @@ -251,6 +273,31 @@ final class PurchaseService { | ||
| 251 | 273 | #endif |
| 252 | 274 | } |
| 253 | 275 | |
| 276 | /// Elevates the current tier to Pro+ when the device's iCloud user has been | |
| 277 | /// confirmed as the owner. Only ever elevates, and defers to a debug override | |
| 278 | /// so free/pro tiers stay testable on the owner's own device. | |
| 279 | private func applyOwnerOverrideIfNeeded() { | |
| 280 | #if DEBUG | |
| 281 | if Self.debugForcedEntitlement != nil { return } | |
| 282 | #endif | |
| 283 | guard Self.ownerEntitlementGranted else { return } | |
| 284 | currentTier = .proPlus | |
| 285 | } | |
| 286 | ||
| 287 | /// Resolves the owner allowlist against CloudKit once per launch. On a match | |
| 288 | /// it records the grant so future launches apply it synchronously and offline. | |
| 289 | private func resolveOwnerEntitlementIfNeeded() async { | |
| 290 | guard OwnerAccess.isConfigured else { return } | |
| 291 | if Self.ownerEntitlementGranted { | |
| 292 | applyOwnerOverrideIfNeeded() | |
| 293 | return | |
| 294 | } | |
| 295 | if await OwnerAccess.isOwner() { | |
| 296 | UserDefaults.standard.set(true, forKey: Self.ownerEntitlementKey) | |
| 297 | applyOwnerOverrideIfNeeded() | |
| 298 | } | |
| 299 | } | |
| 300 | ||
| 254 | 301 | private func verifiedTransaction(from result: VerificationResult<Transaction>) throws -> Transaction { |
| 255 | 302 | switch result { |
| 256 | 303 | case .verified(let transaction): |
RELEASE_ROADMAP.md +12 −1
| @@ -6,7 +6,7 @@ ports, geolocation, subdomains, availability). The next several releases invest | ||
| 6 | 6 | in *reach and surfacing* — getting that data onto more iOS surfaces and into more |
| 7 | 7 | workflows — rather than adding raw protocol checks. |
| 8 | 8 | |
| 9 | Current version: `v5.0.0`. | |
| 9 | Current version: `v5.0.1`. | |
| 10 | 10 | |
| 11 | 11 | ## v4.4.1 Patch: Release Readiness — ✅ shipped |
| 12 | 12 | |
| @@ -310,6 +310,17 @@ Release cut: `MARKETING_VERSION` 4.9.0 → 5.0.0, `CURRENT_PROJECT_VERSION` | ||
| 310 | 310 | 44 → 45, and `AppVersion.current` bumped in lockstep. App Store archive/submit is |
| 311 | 311 | the only step left, and it is a manual action outside the repo. |
| 312 | 312 | |
| 313 | ## v5.0.1 Patch: Owner entitlement — ✅ shipped | |
| 314 | ||
| 315 | - **Owner Pro+ allowlist.** `OwnerAccess` identifies the app owner by their | |
| 316 | CloudKit user-record ID (an opaque, per-Apple-ID value scoped to the app's | |
| 317 | container). `PurchaseService` resolves it against CloudKit once per launch and, | |
| 318 | on a match, grants `.proPlus` — persisted so it applies instantly and offline | |
| 319 | thereafter. It only ever elevates the tier and defers to the existing `#if | |
| 320 | DEBUG` overrides, so real purchases and free/pro testing are unaffected. | |
| 321 | - Release cut: `MARKETING_VERSION` 5.0.0 → 5.0.1, `CURRENT_PROJECT_VERSION` | |
| 322 | 45 → 46, `AppVersion.current` in lockstep. | |
| 323 | ||
| 313 | 324 | ## Cross-cutting note |
| 314 | 325 | |
| 315 | 326 | New feature surfaces (widgets, intents, extensions) each add a target and a |