diff --git a/AltStore.xcodeproj/project.pbxproj b/AltStore.xcodeproj/project.pbxproj index 4cc377ad..e168cee0 100644 --- a/AltStore.xcodeproj/project.pbxproj +++ b/AltStore.xcodeproj/project.pbxproj @@ -374,6 +374,7 @@ D55467C52A8D72C300F4CE90 /* ActiveAppsWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = D55467C42A8D72C300F4CE90 /* ActiveAppsWidget.swift */; }; D561B2EB28EF5A4F006752E4 /* AltSign-Dynamic in Frameworks */ = {isa = PBXBuildFile; productRef = D561B2EA28EF5A4F006752E4 /* AltSign-Dynamic */; }; D561B2ED28EF5A4F006752E4 /* AltSign-Dynamic in Embed Frameworks */ = {isa = PBXBuildFile; productRef = D561B2EA28EF5A4F006752E4 /* AltSign-Dynamic */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; + D56915062AD5D75B00A2B747 /* Regex+Permissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D56915052AD5D75B00A2B747 /* Regex+Permissions.swift */; }; D5708417292448DA00D42D34 /* OperatingSystemVersion+Comparable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5708416292448DA00D42D34 /* OperatingSystemVersion+Comparable.swift */; }; D570841A2924680D00D42D34 /* OperatingSystemVersion+Comparable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5708416292448DA00D42D34 /* OperatingSystemVersion+Comparable.swift */; }; D571ADD02A02FC7200B24B63 /* ALTAppPermission.swift in Sources */ = {isa = PBXBuildFile; fileRef = D571ADCF2A02FC7200B24B63 /* ALTAppPermission.swift */; }; @@ -1035,6 +1036,7 @@ D552B1D72A042A740066216F /* AppPermissionsCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPermissionsCard.swift; sourceTree = ""; }; D55467B12A8D5E2600F4CE90 /* AppShortcuts.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppShortcuts.swift; sourceTree = ""; }; D55467C42A8D72C300F4CE90 /* ActiveAppsWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveAppsWidget.swift; sourceTree = ""; }; + D56915052AD5D75B00A2B747 /* Regex+Permissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Regex+Permissions.swift"; sourceTree = ""; }; D5708416292448DA00D42D34 /* OperatingSystemVersion+Comparable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OperatingSystemVersion+Comparable.swift"; sourceTree = ""; }; D571ADCD2A02FA7400B24B63 /* SourceError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceError.swift; sourceTree = ""; }; D571ADCF2A02FC7200B24B63 /* ALTAppPermission.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ALTAppPermission.swift; sourceTree = ""; }; @@ -2013,6 +2015,7 @@ D5927D6529DCC89000D6898E /* UINavigationBarAppearance+TintColor.swift */, D54058BA2A1D8FE3008CCC58 /* UIColor+AltStore.swift */, D5FB28EB2ADDF68D00A1C337 /* UIFontDescriptor+Bold.swift */, + D56915052AD5D75B00A2B747 /* Regex+Permissions.swift */, ); path = Extensions; sourceTree = ""; @@ -3177,6 +3180,8 @@ BFF0B6982322CAB8007A79E1 /* InstructionsViewController.swift in Sources */, BF9ABA4522DCFF43008935CF /* BrowseViewController.swift in Sources */, D5935AED29C39DE300C157EF /* SourceComponents.swift in Sources */, + D56915062AD5D75B00A2B747 /* Regex+Permissions.swift in Sources */, + D5935AED29C39DE300C157EF /* SourceDetailsComponents.swift in Sources */, D5A0537329B91DB400997551 /* SourceDetailContentViewController.swift in Sources */, BF770E5422BC044E002A40FE /* OperationContexts.swift in Sources */, BFD2478C2284C4C300981D42 /* AppIconImageView.swift in Sources */, diff --git a/AltStore/Extensions/Regex+Permissions.swift b/AltStore/Extensions/Regex+Permissions.swift new file mode 100644 index 00000000..18232937 --- /dev/null +++ b/AltStore/Extensions/Regex+Permissions.swift @@ -0,0 +1,31 @@ +// +// Regex+Permissions.swift +// AltStore +// +// Created by Riley Testut on 10/10/23. +// Copyright © 2023 Riley Testut. All rights reserved. +// + +import RegexBuilder + +@available(iOS 16, *) +extension Regex where Output == (Substring, Substring) +{ + static var privacyPermission: some RegexComponent<(Substring, Substring)> { + Regex { + Optionally { + "NS" + } + + // Capture permission "name" + Capture { + OneOrMore(.anyGraphemeCluster) + } + + "UsageDescription" + + // Optional suffix + Optionally(OneOrMore(.anyGraphemeCluster)) + } + } +} diff --git a/AltStore/Operations/VerifyAppOperation.swift b/AltStore/Operations/VerifyAppOperation.swift index 0094a8a5..1acae721 100644 --- a/AltStore/Operations/VerifyAppOperation.swift +++ b/AltStore/Operations/VerifyAppOperation.swift @@ -294,41 +294,23 @@ private extension VerifyAppOperation // Privacy - let allPrivacyPermissions: Set - if #available(iOS 16, *) - { - let regex = Regex { - "NS" - - // Capture permission "name" - Capture { - OneOrMore(.anyGraphemeCluster) + let allPrivacyPermissions = ([app] + app.appExtensions).flatMap { (app) in + let permissions = app.bundle.infoDictionary?.keys.compactMap { key -> ALTAppPrivacyPermission? in + if #available(iOS 16, *) + { + guard key.wholeMatch(of: Regex.privacyPermission) != nil else { return nil } + } + else + { + guard key.contains("UsageDescription") else { return nil } } - "UsageDescription" - - // Optional suffix - Optionally(OneOrMore(.anyGraphemeCluster)) - } + let permission = ALTAppPrivacyPermission(rawValue: key) + return permission + } ?? [] - let privacyPermissions = ([app] + app.appExtensions).flatMap { (app) in - let permissions = app.bundle.infoDictionary?.keys.compactMap { key -> ALTAppPrivacyPermission? in - guard let match = key.wholeMatch(of: regex) else { return nil } - - let permission = ALTAppPrivacyPermission(rawValue: String(match.1)) - return permission - } ?? [] - - return permissions - } - - allPrivacyPermissions = Set(privacyPermissions) + return permissions } - else - { - allPrivacyPermissions = [] - } - // Verify permissions. let sourcePermissions: Set = Set(await $storeApp.perform { $0.permissions.map { AnyHashable($0.permission) } }) @@ -336,8 +318,37 @@ private extension VerifyAppOperation // To pass: EVERY permission in localPermissions must also appear in sourcePermissions. // If there is a single missing permission, throw error. - let missingPermissions: [any ALTAppPermission] = localPermissions.filter { !sourcePermissions.contains(AnyHashable($0)) } - guard missingPermissions.isEmpty else { throw VerificationError.undeclaredPermissions(missingPermissions, app: app) } + let missingPermissions: [any ALTAppPermission] = localPermissions.filter { permission in + if sourcePermissions.contains(AnyHashable(permission)) + { + // `permission` exists in source, so return false. + return false + } + else if permission.type == .privacy + { + guard #available(iOS 16, *) else { + // Assume all privacy permissions _are_ included in source on pre-iOS 16 devices. + return false + } + + // Special-handling for legacy privacy permissions. + if let match = permission.rawValue.firstMatch(of: Regex.privacyPermission), + case let legacyPermission = ALTAppPrivacyPermission(rawValue: String(match.1)), + sourcePermissions.contains(AnyHashable(legacyPermission)) + { + // The legacy name of this permission exists in the source, so return false. + return false + } + } + + // Source doesn't contain permission or its legacy name, so assume it is missing. + return true + } + + guard missingPermissions.isEmpty else { + // There is at least one undeclared permission, so throw error. + throw VerificationError.undeclaredPermissions(missingPermissions, app: app) + } return localPermissions } diff --git a/AltStoreCore/Model/AppPermission.swift b/AltStoreCore/Model/AppPermission.swift index 612d73c6..909684ee 100644 --- a/AltStoreCore/Model/AppPermission.swift +++ b/AltStoreCore/Model/AppPermission.swift @@ -73,7 +73,7 @@ public extension ALTAppPermissionType } @objc(AppPermission) @dynamicMemberLookup -public class AppPermission: NSManagedObject, Decodable, Fetchable +public class AppPermission: NSManagedObject, Fetchable { /* Properties */ @NSManaged public var type: ALTAppPermissionType @@ -108,37 +108,13 @@ public class AppPermission: NSManagedObject, Decodable, Fetchable super.init(entity: entity, insertInto: context) } - private enum CodingKeys: String, CodingKey + convenience init(permission: String, usageDescription: String?, type: ALTAppPermissionType, context: NSManagedObjectContext) { - case name - case usageDescription - } - - public required init(from decoder: Decoder) throws - { - guard let context = decoder.managedObjectContext else { preconditionFailure("Decoder must have non-nil NSManagedObjectContext.") } + self.init(entity: AppPermission.entity(), insertInto: context) - super.init(entity: AppPermission.entity(), insertInto: context) - - do - { - let container = try decoder.container(keyedBy: CodingKeys.self) - - self._permission = try container.decode(String.self, forKey: .name) - self.usageDescription = try container.decodeIfPresent(String.self, forKey: .usageDescription) - - // Will be updated from StoreApp. - self.type = .unknown - } - catch - { - if let context = self.managedObjectContext - { - context.delete(self) - } - - throw error - } + self._permission = permission + self.usageDescription = usageDescription + self.type = type } } @@ -160,3 +136,113 @@ public extension AppPermission } } } + +private struct AnyDecodable: Decodable +{ + init(from decoder: Decoder) throws + { + } +} + +internal struct AppPermissions: Decodable +{ + var entitlements: [AppPermission] = [] + var privacy: [AppPermission] = [] + + private enum CodingKeys: String, CodingKey, Decodable + { + case entitlements + case privacy + + // Legacy + case name + case usageDescription + } + + init(from decoder: Decoder) throws + { + guard let context = decoder.managedObjectContext else { preconditionFailure("Decoder must have non-nil NSManagedObjectContext.") } + + let container = try decoder.container(keyedBy: CodingKeys.self) + self.entitlements = try self.parseEntitlements(from: container, into: context) + self.privacy = try self.parsePrivacyPermissions(from: container, into: context) + } + + private func parseEntitlements(from container: KeyedDecodingContainer, into context: NSManagedObjectContext) throws -> [AppPermission] + { + guard container.contains(.entitlements) else { return [] } + + do + { + do + { + // Legacy + // Must parse as [String: String], NOT [CodingKeys: String], to avoid incorrect DecodingError.typeMismatch error. + let rawEntitlements = try container.decode([[String: String]].self, forKey: .entitlements) + + let entitlements = try rawEntitlements.compactMap { (dictionary) -> AppPermission? in + guard let name = dictionary[CodingKeys.name.rawValue] else { + let context = DecodingError.Context(codingPath: container.codingPath, debugDescription: "Legacy entitlements must have `name` key.") + throw DecodingError.keyNotFound(CodingKeys.name, context) + } + + let entitlement = AppPermission(permission: name, usageDescription: nil, type: .entitlement, context: context) + return entitlement + } + + return entitlements + } + catch DecodingError.typeMismatch + { + // Detailed + // AnyDecodable ensures we're forward-compatible with any values we may later require for entitlement permissions. + let rawEntitlements = try container.decode([String: AnyDecodable?].self, forKey: .entitlements) + + let entitlements = rawEntitlements.map { AppPermission(permission: $0.key, usageDescription: nil, type: .entitlement, context: context) } + return entitlements + } + } + catch DecodingError.typeMismatch + { + // Default + let rawEntitlements = try container.decode([String].self, forKey: .entitlements) + + let entitlements = rawEntitlements.map { AppPermission(permission: $0, usageDescription: nil, type: .entitlement, context: context) } + return entitlements + } + } + + private func parsePrivacyPermissions(from container: KeyedDecodingContainer, into context: NSManagedObjectContext) throws -> [AppPermission] + { + guard container.contains(.privacy) else { return [] } + + do + { + // Legacy + // Must parse as [String: String], NOT [CodingKeys: String], to avoid incorrect DecodingError.typeMismatch error. + let rawPermissions = try container.decode([[String: String]].self, forKey: .privacy) + + let permissions = try rawPermissions.compactMap { (dictionary) -> AppPermission? in + guard let name = dictionary[CodingKeys.name.rawValue] else { + let context = DecodingError.Context(codingPath: container.codingPath, debugDescription: "Legacy privacy permissions must have `name` key.") + throw DecodingError.keyNotFound(CodingKeys.name, context) + } + + let usageDescription = dictionary[CodingKeys.usageDescription.rawValue] + + let permission = AppPermission(permission: name, usageDescription: usageDescription, type: .privacy, context: context) + return permission + } + + return permissions + } + catch DecodingError.typeMismatch + { + // Default + let rawPermissions = try container.decode([String: String?].self, forKey: .privacy) + + let permissions = rawPermissions.map { AppPermission(permission: $0, usageDescription: $1, type: .privacy, context: context) } + return permissions + } + } +} diff --git a/AltStoreCore/Model/StoreApp.swift b/AltStoreCore/Model/StoreApp.swift index 2b5cf4a2..0ec2e56c 100644 --- a/AltStoreCore/Model/StoreApp.swift +++ b/AltStoreCore/Model/StoreApp.swift @@ -23,12 +23,6 @@ public extension StoreApp #endif static let dolphinAppID = "me.oatmealdome.dolphinios-njb" - - private struct AppPermissions: Decodable - { - var entitlements: [AppPermission]? - var privacy: [AppPermission]? - } } @objc @@ -299,10 +293,7 @@ public class StoreApp: NSManagedObject, Decodable, Fetchable if let appPermissions = try container.decodeIfPresent(AppPermissions.self, forKey: .permissions) { - appPermissions.entitlements?.forEach { $0.type = .entitlement } - appPermissions.privacy?.forEach { $0.type = .privacy } - - let allPermissions = (appPermissions.entitlements ?? []) + (appPermissions.privacy ?? []) + let allPermissions = appPermissions.entitlements + appPermissions.privacy for permission in allPermissions { permission.appBundleID = self.bundleIdentifier