From ee410605e871eae3b394816bcae0a43d0f0c9f24 Mon Sep 17 00:00:00 2001 From: Riley Testut Date: Fri, 12 May 2023 18:26:24 -0500 Subject: [PATCH] =?UTF-8?q?Verifies=20downloaded=20app=E2=80=99s=20permiss?= =?UTF-8?q?ions=20match=20source?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renames source JSON permissions key to “appPermissions” in order to preserve backwards compatibility, since we’ve changed the schema for permissions. --- AltStore.xcodeproj/project.pbxproj | 20 ++-- .../App Detail/AppContentViewController.swift | 13 ++- .../PermissionPopoverViewController.swift | 2 +- AltStore/Operations/VerificationError.swift | 55 +++++++++ AltStore/Operations/VerifyAppOperation.swift | 92 +++++++++++++++ AltStoreCore/AltStoreCore.h | 2 +- .../AltStore 12.xcdatamodel/contents | 13 ++- AltStoreCore/Model/AppPermission.swift | 66 +++++++++-- AltStoreCore/Model/MergePolicy.swift | 107 +++++++++++------- AltStoreCore/Model/StoreApp.swift | 32 +++++- AltStoreCore/Protocols/ALTAppPermission.swift | 89 +++++++++++++++ AltStoreCore/Types/ALTAppPermission.h | 28 ----- AltStoreCore/Types/ALTAppPermission.m | 27 ----- AltStoreCore/Types/ALTAppPermissions.h | 18 +++ AltStoreCore/Types/ALTAppPermissions.m | 14 +++ Shared/Extensions/Bundle+AltStore.swift | 1 + 16 files changed, 452 insertions(+), 127 deletions(-) create mode 100644 AltStoreCore/Protocols/ALTAppPermission.swift delete mode 100644 AltStoreCore/Types/ALTAppPermission.h delete mode 100644 AltStoreCore/Types/ALTAppPermission.m create mode 100644 AltStoreCore/Types/ALTAppPermissions.h create mode 100644 AltStoreCore/Types/ALTAppPermissions.m diff --git a/AltStore.xcodeproj/project.pbxproj b/AltStore.xcodeproj/project.pbxproj index 402d7d62..0096a398 100644 --- a/AltStore.xcodeproj/project.pbxproj +++ b/AltStore.xcodeproj/project.pbxproj @@ -179,10 +179,10 @@ BF66EE852501AE50007EE018 /* AltStoreCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BF66EE7E2501AE50007EE018 /* AltStoreCore.framework */; }; BF66EE862501AE50007EE018 /* AltStoreCore.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = BF66EE7E2501AE50007EE018 /* AltStoreCore.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; BF66EE8C2501AEB2007EE018 /* Keychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF66EE8B2501AEB1007EE018 /* Keychain.swift */; }; - BF66EE942501AEBC007EE018 /* ALTAppPermission.h in Headers */ = {isa = PBXBuildFile; fileRef = BF66EE8E2501AEBC007EE018 /* ALTAppPermission.h */; settings = {ATTRIBUTES = (Public, ); }; }; + BF66EE942501AEBC007EE018 /* ALTAppPermissions.h in Headers */ = {isa = PBXBuildFile; fileRef = BF66EE8E2501AEBC007EE018 /* ALTAppPermissions.h */; settings = {ATTRIBUTES = (Public, ); }; }; BF66EE952501AEBC007EE018 /* ALTSourceUserInfoKey.h in Headers */ = {isa = PBXBuildFile; fileRef = BF66EE8F2501AEBC007EE018 /* ALTSourceUserInfoKey.h */; settings = {ATTRIBUTES = (Public, ); }; }; BF66EE962501AEBC007EE018 /* ALTPatreonBenefitType.m in Sources */ = {isa = PBXBuildFile; fileRef = BF66EE902501AEBC007EE018 /* ALTPatreonBenefitType.m */; }; - BF66EE972501AEBC007EE018 /* ALTAppPermission.m in Sources */ = {isa = PBXBuildFile; fileRef = BF66EE912501AEBC007EE018 /* ALTAppPermission.m */; }; + BF66EE972501AEBC007EE018 /* ALTAppPermissions.m in Sources */ = {isa = PBXBuildFile; fileRef = BF66EE912501AEBC007EE018 /* ALTAppPermissions.m */; }; BF66EE982501AEBC007EE018 /* ALTPatreonBenefitType.h in Headers */ = {isa = PBXBuildFile; fileRef = BF66EE922501AEBC007EE018 /* ALTPatreonBenefitType.h */; settings = {ATTRIBUTES = (Public, ); }; }; BF66EE992501AEBC007EE018 /* ALTSourceUserInfoKey.m in Sources */ = {isa = PBXBuildFile; fileRef = BF66EE932501AEBC007EE018 /* ALTSourceUserInfoKey.m */; }; BF66EE9D2501AEC1007EE018 /* AppProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF66EE9B2501AEC1007EE018 /* AppProtocol.swift */; }; @@ -357,6 +357,7 @@ D561B2ED28EF5A4F006752E4 /* AltSign-Dynamic in Embed Frameworks */ = {isa = PBXBuildFile; productRef = D561B2EA28EF5A4F006752E4 /* AltSign-Dynamic */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; 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 */; }; D5728CA72A0D79D30014E73C /* OptionalProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5728CA62A0D79D30014E73C /* OptionalProtocol.swift */; }; D57968CB29CB99EF00539069 /* VibrantButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D57968CA29CB99EF00539069 /* VibrantButton.swift */; }; D57DF638271E32F000677701 /* PatchApp.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D57DF637271E32F000677701 /* PatchApp.storyboard */; }; @@ -749,10 +750,10 @@ BF66EE802501AE50007EE018 /* AltStoreCore.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AltStoreCore.h; sourceTree = ""; }; BF66EE812501AE50007EE018 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; BF66EE8B2501AEB1007EE018 /* Keychain.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Keychain.swift; sourceTree = ""; }; - BF66EE8E2501AEBC007EE018 /* ALTAppPermission.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ALTAppPermission.h; sourceTree = ""; }; + BF66EE8E2501AEBC007EE018 /* ALTAppPermissions.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ALTAppPermissions.h; sourceTree = ""; }; BF66EE8F2501AEBC007EE018 /* ALTSourceUserInfoKey.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ALTSourceUserInfoKey.h; sourceTree = ""; }; BF66EE902501AEBC007EE018 /* ALTPatreonBenefitType.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ALTPatreonBenefitType.m; sourceTree = ""; }; - BF66EE912501AEBC007EE018 /* ALTAppPermission.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ALTAppPermission.m; sourceTree = ""; }; + BF66EE912501AEBC007EE018 /* ALTAppPermissions.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ALTAppPermissions.m; sourceTree = ""; }; BF66EE922501AEBC007EE018 /* ALTPatreonBenefitType.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ALTPatreonBenefitType.h; sourceTree = ""; }; BF66EE932501AEBC007EE018 /* ALTSourceUserInfoKey.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ALTSourceUserInfoKey.m; sourceTree = ""; }; BF66EE9B2501AEC1007EE018 /* AppProtocol.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppProtocol.swift; sourceTree = ""; }; @@ -914,6 +915,7 @@ D54DED1328CBC44B008B27A0 /* ErrorLogTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorLogTableViewCell.swift; sourceTree = ""; }; D55E163528776CB000A627A1 /* ComplicationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComplicationView.swift; sourceTree = ""; }; D5708416292448DA00D42D34 /* OperatingSystemVersion+Comparable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OperatingSystemVersion+Comparable.swift"; sourceTree = ""; }; + D571ADCF2A02FC7200B24B63 /* ALTAppPermission.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ALTAppPermission.swift; sourceTree = ""; }; D5728CA62A0D79D30014E73C /* OptionalProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptionalProtocol.swift; sourceTree = ""; }; D57968CA29CB99EF00539069 /* VibrantButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VibrantButton.swift; sourceTree = ""; }; D57DF637271E32F000677701 /* PatchApp.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = PatchApp.storyboard; sourceTree = ""; }; @@ -1428,8 +1430,8 @@ children = ( BFB39B5B252BC10E00D1BE50 /* Managed.swift */, D5F48B4929CD0B67002B52A4 /* AsyncManaged.swift */, - BF66EE8E2501AEBC007EE018 /* ALTAppPermission.h */, - BF66EE912501AEBC007EE018 /* ALTAppPermission.m */, + BF66EE8E2501AEBC007EE018 /* ALTAppPermissions.h */, + BF66EE912501AEBC007EE018 /* ALTAppPermissions.m */, BF66EE922501AEBC007EE018 /* ALTPatreonBenefitType.h */, BF66EE902501AEBC007EE018 /* ALTPatreonBenefitType.m */, BF66EE8F2501AEBC007EE018 /* ALTSourceUserInfoKey.h */, @@ -1444,6 +1446,7 @@ BF66EE9B2501AEC1007EE018 /* AppProtocol.swift */, BF66EE9C2501AEC1007EE018 /* Fetchable.swift */, D5728CA62A0D79D30014E73C /* OptionalProtocol.swift */, + D571ADCF2A02FC7200B24B63 /* ALTAppPermission.swift */, ); path = Protocols; sourceTree = ""; @@ -1998,7 +2001,7 @@ BF66EE982501AEBC007EE018 /* ALTPatreonBenefitType.h in Headers */, BFAECC5F2501B0BF00528F27 /* ALTConstants.h in Headers */, BFAECC5D2501B0BF00528F27 /* ALTConnection.h in Headers */, - BF66EE942501AEBC007EE018 /* ALTAppPermission.h in Headers */, + BF66EE942501AEBC007EE018 /* ALTAppPermissions.h in Headers */, BFAECC602501B0BF00528F27 /* NSError+ALTServerError.h in Headers */, 0EE7FDC82BE8CF4800D1E390 /* ALTWrappedError.h in Headers */, BFAECC5E2501B0BF00528F27 /* CFNotificationName+AltStore.h in Headers */, @@ -2618,7 +2621,7 @@ 0E05025A2BEC83C500879B5C /* OperatingSystemVersion+Comparable.swift in Sources */, BF66EE8C2501AEB2007EE018 /* Keychain.swift in Sources */, BF66EED42501AECA007EE018 /* AltStore5ToAltStore6.xcmappingmodel in Sources */, - BF66EE972501AEBC007EE018 /* ALTAppPermission.m in Sources */, + BF66EE972501AEBC007EE018 /* ALTAppPermissions.m in Sources */, BFAECC552501B0A400528F27 /* Connection.swift in Sources */, BF66EEDA2501AECA007EE018 /* RefreshAttempt.swift in Sources */, 0E05025C2BEC947000879B5C /* String+SideStore.swift in Sources */, @@ -2654,6 +2657,7 @@ D5F99A1A28D12B1400476A16 /* StoreApp10ToStoreApp11Policy.swift in Sources */, BFAECC562501B0A400528F27 /* ALTServerError+Conveniences.swift in Sources */, BFAECC592501B0A400528F27 /* Result+Conveniences.swift in Sources */, + D571ADD02A02FC7200B24B63 /* ALTAppPermission.swift in Sources */, D5E3FB9828FDFAD90034B72C /* NSError+AltStore.swift in Sources */, BFAECC542501B0A400528F27 /* NSError+ALTServerError.m in Sources */, BF66EEE12501AECA007EE018 /* DatabaseManager.swift in Sources */, diff --git a/AltStore/App Detail/AppContentViewController.swift b/AltStore/App Detail/AppContentViewController.swift index 199f3a39..ce277f68 100644 --- a/AltStore/App Detail/AppContentViewController.swift +++ b/AltStore/App Detail/AppContentViewController.swift @@ -177,12 +177,17 @@ private extension AppContentViewController func makePermissionsDataSource() -> RSTArrayCollectionViewDataSource { - let dataSource = RSTArrayCollectionViewDataSource(items: self.app.permissions) + let dataSource = RSTArrayCollectionViewDataSource(items: Array(self.app.permissions)) dataSource.cellConfigurationHandler = { (cell, permission, indexPath) in let cell = cell as! PermissionCollectionViewCell - cell.button.setImage(permission.type.icon, for: .normal) - cell.button.tintColor = .label - cell.textLabel.text = permission.type.localizedShortName ?? permission.type.localizedName + // cell.button.setImage(permission.type.icon, for: .normal) + // cell.button.tintColor = .label + // cell.textLabel.text = permission.type.localizedShortName ?? permission.type.localizedName + + let icon = UIImage(systemName: permission.symbolName ?? "lock") + cell.button.setImage(icon, for: .normal) + + cell.textLabel.text = permission.localizedDisplayName } return dataSource diff --git a/AltStore/App Detail/PermissionPopoverViewController.swift b/AltStore/App Detail/PermissionPopoverViewController.swift index 52174a68..b029aa8e 100644 --- a/AltStore/App Detail/PermissionPopoverViewController.swift +++ b/AltStore/App Detail/PermissionPopoverViewController.swift @@ -21,7 +21,7 @@ final class PermissionPopoverViewController: UIViewController { super.viewDidLoad() - self.nameLabel.text = self.permission.type.localizedName + self.nameLabel.text = self.permission.localizedName ?? self.permission.permission.rawValue self.descriptionLabel.text = self.permission.usageDescription } } diff --git a/AltStore/Operations/VerificationError.swift b/AltStore/Operations/VerificationError.swift index 42e93c78..8d393157 100644 --- a/AltStore/Operations/VerificationError.swift +++ b/AltStore/Operations/VerificationError.swift @@ -23,6 +23,8 @@ extension VerificationError case mismatchedHash = 3 case mismatchedVersion = 4 + + case undeclaredPermissions = 6 } static func mismatchedBundleIdentifiers(sourceBundleID: String, app: ALTApplication) -> VerificationError { @@ -40,6 +42,10 @@ extension VerificationError static func mismatchedVersion(_ version: String, expectedVersion: String, app: AppProtocol) -> VerificationError { VerificationError(code: .mismatchedVersion, app: app, version: version, expectedVersion: expectedVersion) } + + static func undeclaredPermissions(_ permissions: [any ALTAppPermission], app: AppProtocol) -> VerificationError { + VerificationError(code: .undeclaredPermissions, app: app, permissions: permissions) + } } struct VerificationError: ALTLocalizedError @@ -60,6 +66,9 @@ struct VerificationError: ALTLocalizedError @UserInfoValue var version: String? @UserInfoValue var expectedVersion: String? + @UserInfoValue + var permissions: [any ALTAppPermission]? + var errorDescription: String? { //TODO: Make this automatic somehow with ALTLocalizedError guard self.errorFailure == nil else { return nil } @@ -129,6 +138,52 @@ struct VerificationError: ALTLocalizedError case .mismatchedVersion: let appName = self.$app.name ?? NSLocalizedString("the app", comment: "") return String(format: NSLocalizedString("The downloaded version of %@ does not match the version specified by the source.", comment: ""), appName) + + case .undeclaredPermissions: + let appName = self.$app.name ?? NSLocalizedString("The app", comment: "") + return String(format: NSLocalizedString("%@ requires additional permissions not specified by the source.", comment: ""), appName) + } + } + + var recoverySuggestion: String? { + switch self.code + { + case .undeclaredPermissions: + guard let permissions, !permissions.isEmpty else { return nil } + + let baseMessage = NSLocalizedString("These permissions must be declared by the source in order for AltStore to install this app:", comment: "") + + let permissionsByType = Dictionary(grouping: permissions) { $0.type } + let permissionSections = [ALTAppPermissionType.entitlement, .privacy, .backgroundMode].compactMap { (type) -> String? in + guard let permissions = permissionsByType[type] else { return nil } + + // "Privacy:" + var sectionText = "\(type.localizedName ?? type.rawValue):\n" + + // Sort permissions + join into single string. + let sortedList = permissions.map { permission -> String in + if let localizedName = permission.localizedName + { + // "Entitlement Name (com.apple.entitlement.name)" + return "\(localizedName) (\(permission.rawValue))" + } + else + { + // "com.apple.entitlement.name" + return permission.rawValue + } + } + .sorted { $0.localizedStandardCompare($1) == .orderedAscending } // Case-insensitive sorting + .joined(separator: "\n") + + sectionText += sortedList + return sectionText + } + + let recoverySuggestion = ([baseMessage] + permissionSections).joined(separator: "\n\n") + return recoverySuggestion + + default: return nil } } } diff --git a/AltStore/Operations/VerifyAppOperation.swift b/AltStore/Operations/VerifyAppOperation.swift index cd92e694..9288a1fb 100644 --- a/AltStore/Operations/VerifyAppOperation.swift +++ b/AltStore/Operations/VerifyAppOperation.swift @@ -94,6 +94,16 @@ struct VerificationError: ALTLocalizedError { } } +import RegexBuilder + +private extension ALTEntitlement +{ + static var ignoredEntitlements: Set = [ + .applicationIdentifier, + .teamIdentifier + ] +} + @objc(VerifyAppOperation) final class VerifyAppOperation: ResultOperation { @@ -146,6 +156,11 @@ final class VerifyAppOperation: ResultOperation try await self.verifyHash(of: app, at: ipaURL, matches: appVersion) try await self.verifyDownloadedVersion(of: app, matches: appVersion) + if let storeApp = await self.context.$appVersion.app + { + try await self.verifyPermissions(of: app, match: storeApp) + } + self.finish(.success(())) } catch @@ -183,4 +198,81 @@ private extension VerifyAppOperation guard version == app.version else { throw VerificationError.mismatchedVersion(app.version, expectedVersion: version, app: app) } } + + @discardableResult + func verifyPermissions(of app: ALTApplication, @AsyncManaged match storeApp: StoreApp) async throws -> [any ALTAppPermission] + { + // Entitlements + var allEntitlements = Set(app.entitlements.keys) + for appExtension in app.appExtensions + { + allEntitlements.formUnion(appExtension.entitlements.keys) + } + + // Filter out ignored entitlements. + allEntitlements = allEntitlements.filter { !ALTEntitlement.ignoredEntitlements.contains($0) } + + + // Background Modes + // App extensions can't have background modes, so don't need to worry about them. + let allBackgroundModes: Set + if let backgroundModes = app.bundle.infoDictionary?[Bundle.Info.backgroundModes] as? [String] + { + let backgroundModes = backgroundModes.lazy.map { ALTAppBackgroundMode($0) } + allBackgroundModes = Set(backgroundModes) + } + else + { + allBackgroundModes = [] + } + + + // Privacy + let allPrivacyPermissions: Set + if #available(iOS 16, *) + { + let regex = Regex { + "NS" + + // Capture permission "name" + Capture { + OneOrMore(.anyGraphemeCluster) + } + + "UsageDescription" + + // Optional suffix + Optionally(OneOrMore(.anyGraphemeCluster)) + } + + 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) + } + else + { + allPrivacyPermissions = [] + } + + + // Verify permissions. + let sourcePermissions: Set = Set(await $storeApp.perform { $0.permissions.map { AnyHashable($0.permission) } }) + let localPermissions: [any ALTAppPermission] = Array(allEntitlements) + Array(allBackgroundModes) + Array(allPrivacyPermissions) + + // 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) } + + return localPermissions + } } diff --git a/AltStoreCore/AltStoreCore.h b/AltStoreCore/AltStoreCore.h index 02ddf3b1..c3a5a398 100644 --- a/AltStoreCore/AltStoreCore.h +++ b/AltStoreCore/AltStoreCore.h @@ -16,7 +16,7 @@ FOUNDATION_EXPORT const unsigned char AltStoreCoreVersionString[]; // In this header, you should import all the public headers of your framework using statements like #import -#import +#import #import #import diff --git a/AltStoreCore/Model/AltStore.xcdatamodeld/AltStore 12.xcdatamodel/contents b/AltStoreCore/Model/AltStore.xcdatamodeld/AltStore 12.xcdatamodel/contents index afcec23c..580a76da 100644 --- a/AltStoreCore/Model/AltStore.xcdatamodeld/AltStore 12.xcdatamodel/contents +++ b/AltStoreCore/Model/AltStore.xcdatamodeld/AltStore 12.xcdatamodel/contents @@ -27,9 +27,18 @@ + + - + + + + + + + + @@ -191,7 +200,7 @@ - + diff --git a/AltStoreCore/Model/AppPermission.swift b/AltStoreCore/Model/AppPermission.swift index 2763f95c..fa30c7c5 100644 --- a/AltStoreCore/Model/AppPermission.swift +++ b/AltStoreCore/Model/AppPermission.swift @@ -9,6 +9,7 @@ import CoreData import UIKit +import AltSign public extension ALTAppPermissionType { var localizedShortName: String? { @@ -71,15 +72,29 @@ public extension ALTAppPermissionType } } -@objc(AppPermission) +@objc(AppPermission) @dynamicMemberLookup public class AppPermission: NSManagedObject, Decodable, Fetchable { /* Properties */ @NSManaged public var type: ALTAppPermissionType - @NSManaged public var usageDescription: String + @NSManaged public var usageDescription: String? + + @nonobjc public var permission: any ALTAppPermission { + switch self.type + { + case .entitlement: return ALTEntitlement(rawValue: self._permission) + case .privacy: return ALTAppPrivacyPermission(rawValue: self._permission) + case .backgroundMode: return ALTAppBackgroundMode(rawValue: self._permission) + default: return UnknownAppPermission(rawValue: self._permission) + } + } + @NSManaged @objc(permission) private var _permission: String + + // Set by StoreApp. + @NSManaged public var appBundleID: String? /* Relationships */ - @NSManaged public private(set) var app: StoreApp! + @NSManaged public internal(set) var app: StoreApp? private override init(entity: NSEntityDescription, insertInto context: NSManagedObjectContext?) { @@ -88,7 +103,10 @@ public class AppPermission: NSManagedObject, Decodable, Fetchable private enum CodingKeys: String, CodingKey { - case type + case entitlement + case privacyType = "privacy" + case backgroundMode = "background" + case usageDescription } @@ -101,10 +119,33 @@ public class AppPermission: NSManagedObject, Decodable, Fetchable do { let container = try decoder.container(keyedBy: CodingKeys.self) - self.usageDescription = try container.decode(String.self, forKey: .usageDescription) - let rawType = try container.decode(String.self, forKey: .type) - self.type = ALTAppPermissionType(rawValue: rawType) + self.usageDescription = try container.decodeIfPresent(String.self, forKey: .usageDescription) + + if let entitlement = try container.decodeIfPresent(String.self, forKey: .entitlement) + { + self._permission = entitlement + self.type = .entitlement + } + else if let privacyType = try container.decodeIfPresent(String.self, forKey: .privacyType) + { + self._permission = privacyType + self.type = .privacy + } + else if let backgroundMode = try container.decodeIfPresent(String.self, forKey: .backgroundMode) + { + self._permission = backgroundMode + self.type = .backgroundMode + } + else + { + self._permission = "" + self.type = .unknown + + // We don't want to save any unknown permissions, but can't throw error + // without making the entire decoding fail, so just delete self instead. + context.delete(self) + } } catch { @@ -125,3 +166,14 @@ public extension AppPermission return NSFetchRequest(entityName: "AppPermission") } } + +// @dynamicMemberLookup +public extension AppPermission +{ + // Convenience for accessing .permission properties. + subscript(dynamicMember keyPath: KeyPath) -> T { + get { + return self.permission[keyPath: keyPath] + } + } +} diff --git a/AltStoreCore/Model/MergePolicy.swift b/AltStoreCore/Model/MergePolicy.swift index 65895dec..f4b0b327 100644 --- a/AltStoreCore/Model/MergePolicy.swift +++ b/AltStoreCore/Model/MergePolicy.swift @@ -19,10 +19,12 @@ extension MergeError case noVersions case incorrectVersionOrder + case incorrectPermissions } static func noVersions(for app: StoreApp) -> MergeError { .init(code: .noVersions, appName: app.name, appBundleID: app.bundleIdentifier, sourceID: app.sourceIdentifier) } static func incorrectVersionOrder(for app: StoreApp) -> MergeError { .init(code: .incorrectVersionOrder, appName: app.name, appBundleID: app.bundleIdentifier, sourceID: app.sourceIdentifier) } + static func incorrectPermissions(for app: StoreApp) -> MergeError { .init(code: .incorrectPermissions, appName: app.name, appBundleID: app.bundleIdentifier, sourceID: app.sourceIdentifier) } } public struct MergeError: ALTLocalizedError @@ -57,6 +59,15 @@ public struct MergeError: ALTLocalizedError } return String(format: NSLocalizedString("The cached versions for %@ do not match the source.", comment: ""), appName) + + case .incorrectPermissions: + var appName = NSLocalizedString("one or more apps", comment: "") + if let name = self.appName, let bundleID = self.appBundleID + { + appName = name + " (\(bundleID))" + } + + return String(format: NSLocalizedString("The cached permissions for %@ do not match the source.", comment: ""), appName) } } @@ -134,8 +145,8 @@ open class MergePolicy: RSTRelationshipPreservingMergePolicy if let previousApp = conflict.conflictingObjects.first(where: { !$0.isInserted }) as? StoreApp { - // Delete previous permissions (same as below). - for permission in previousApp.permissions + // Delete previous permissions (different than below). + for case let permission as AppPermission in previousApp._permissions where permission.app == nil { permission.managedObjectContext?.delete(permission) } @@ -171,6 +182,8 @@ open class MergePolicy: RSTRelationshipPreservingMergePolicy } var sortedVersionsByGlobalAppID = [String: NSOrderedSet]() + var permissionsByGlobalAppID = [String: Set]() + var featuredAppIDsBySourceID = [String: [String]]() for conflict in conflicts @@ -178,28 +191,28 @@ open class MergePolicy: RSTRelationshipPreservingMergePolicy switch conflict.databaseObject { case let databaseObject as StoreApp: - // Delete previous permissions - for permission in databaseObject.permissions + guard let contextApp = conflict.conflictingObjects.first as? StoreApp else { break } + + // Permissions + let contextPermissions = Set(contextApp._permissions.lazy.compactMap { $0 as? AppPermission }.map { AnyHashable($0.permission) }) + for case let databasePermission as AppPermission in databaseObject._permissions where !contextPermissions.contains(AnyHashable(databasePermission.permission)) { - permission.managedObjectContext?.delete(permission) + // Permission does NOT exist in context, so delete existing databasePermission. + databasePermission.managedObjectContext?.delete(databasePermission) } - if let contextApp = conflict.conflictingObjects.first as? StoreApp + // Versions + let contextVersions = NSOrderedSet(array: contextApp._versions.lazy.compactMap { $0 as? AppVersion }.map { $0.version }) + for case let databaseVersion as AppVersion in databaseObject._versions where !contextVersions.contains(databaseVersion.version) { - let contextVersions = NSOrderedSet(array: contextApp._versions.lazy.compactMap { $0 as? AppVersion }.map { $0.version }) - - for case let appVersion as AppVersion in databaseObject._versions where !contextVersions.contains(appVersion.version) - { - // Version # does NOT exist in context, so delete existing appVersion. - appVersion.managedObjectContext?.delete(appVersion) - } - - if let globallyUniqueID = contextApp.globallyUniqueID - { - // Core Data _normally_ preserves the correct ordering of versions when merging, - // but just in case we cache the order and reorder the versions post-merge if needed. - sortedVersionsByGlobalAppID[globallyUniqueID] = contextVersions - } + // Version # does NOT exist in context, so delete existing databaseVersion. + databaseVersion.managedObjectContext?.delete(databaseVersion) + } + + if let globallyUniqueID = contextApp.globallyUniqueID + { + permissionsByGlobalAppID[globallyUniqueID] = contextPermissions + sortedVersionsByGlobalAppID[globallyUniqueID] = contextVersions } case let databaseObject as Source: @@ -244,36 +257,44 @@ open class MergePolicy: RSTRelationshipPreservingMergePolicy case let databaseObject as StoreApp: do { - let appVersions: [AppVersion] + var appVersions = databaseObject.versions - if let globallyUniqueID = databaseObject.globallyUniqueID, - let sortedAppVersions = sortedVersionsByGlobalAppID[globallyUniqueID], - let sortedAppVersionsArray = sortedAppVersions.array as? [String], - case let databaseVersions = databaseObject.versions.map({ $0.version }), - databaseVersions != sortedAppVersionsArray + if let globallyUniqueID = databaseObject.globallyUniqueID { - // databaseObject.versions post-merge doesn't match contextApp.versions pre-merge, so attempt to fix by re-sorting. - - let fixedAppVersions = databaseObject.versions.sorted { (versionA, versionB) in - let indexA = sortedAppVersions.index(of: versionA.version) - let indexB = sortedAppVersions.index(of: versionB.version) - return indexA < indexB + // Permissions + if let appPermissions = permissionsByGlobalAppID[globallyUniqueID], + case let databasePermissions = Set(databaseObject.permissions.map({ AnyHashable($0.permission) })), + databasePermissions != appPermissions + { + // Sorting order doesn't matter, but elements themselves don't match so throw error. + throw MergeError.incorrectPermissions(for: databaseObject) } - let appVersionValues = fixedAppVersions.map { $0.version } - guard appVersionValues == sortedAppVersionsArray else { - // fixedAppVersions still doesn't match source's versions, so throw MergeError. - throw MergeError.incorrectVersionOrder(for: databaseObject) + // App versions + if let sortedAppVersions = sortedVersionsByGlobalAppID[globallyUniqueID], + let sortedAppVersionsArray = sortedAppVersions.array as? [String], + case let databaseVersions = databaseObject.versions.map({ $0.version }), + databaseVersions != sortedAppVersionsArray + { + // databaseObject.versions post-merge doesn't match contextApp.versions pre-merge, so attempt to fix by re-sorting. + + let fixedAppVersions = databaseObject.versions.sorted { (versionA, versionB) in + let indexA = sortedAppVersions.index(of: versionA.version) + let indexB = sortedAppVersions.index(of: versionB.version) + return indexA < indexB + } + + let appVersionValues = fixedAppVersions.map { $0.version } + guard appVersionValues == sortedAppVersionsArray else { + // fixedAppVersions still doesn't match source's versions, so throw MergeError. + throw MergeError.incorrectVersionOrder(for: databaseObject) + } + + appVersions = fixedAppVersions } - - appVersions = fixedAppVersions - } - else - { - appVersions = databaseObject.versions } - // Update versions post-merging to make sure latestSupportedVersion is correct. + // Always update versions post-merging to make sure latestSupportedVersion is correct. try databaseObject.setVersions(appVersions) } catch diff --git a/AltStoreCore/Model/StoreApp.swift b/AltStoreCore/Model/StoreApp.swift index beeb3282..8a629f89 100644 --- a/AltStoreCore/Model/StoreApp.swift +++ b/AltStoreCore/Model/StoreApp.swift @@ -146,8 +146,6 @@ public class StoreApp: NSManagedObject, Decodable, Fetchable @NSManaged @objc(source) public var _source: Source? @NSManaged public internal(set) var featuringSource: Source? - @NSManaged @objc(permissions) public var _permissions: NSOrderedSet - @NSManaged @objc(latestVersion) public private(set) var latestSupportedVersion: AppVersion? @NSManaged @objc(versions) public private(set) var _versions: NSOrderedSet @@ -163,9 +161,10 @@ public class StoreApp: NSManagedObject, Decodable, Fetchable } } - @nonobjc public var permissions: [AppPermission] { - return self._permissions.array as! [AppPermission] + @nonobjc public var permissions: Set { + return self._permissions as! Set } + @NSManaged @objc(permissions) internal private(set) var _permissions: NSSet // Use NSSet to avoid eagerly fetching values. @nonobjc public var versions: [AppVersion] { return self._versions.array as! [AppVersion] @@ -216,7 +215,7 @@ public class StoreApp: NSManagedObject, Decodable, Fetchable case platformURLs case tintColor case subtitle - case permissions + case permissions = "appPermissions" case size case isBeta = "beta" case versions @@ -287,7 +286,11 @@ public class StoreApp: NSManagedObject, Decodable, Fetchable self.isBeta = try container.decodeIfPresent(Bool.self, forKey: .isBeta) ?? false let permissions = try container.decodeIfPresent([AppPermission].self, forKey: .permissions) ?? [] - self._permissions = NSOrderedSet(array: permissions) + for permission in permissions + { + permission.appBundleID = self.bundleIdentifier + } + self._permissions = NSSet(array: permissions) if let versions = try container.decodeIfPresent([AppVersion].self, forKey: .versions) { @@ -367,6 +370,23 @@ internal extension StoreApp self._downloadURL = latestVersion.downloadURL self._size = Int32(latestVersion.size) } + + func setPermissions(_ permissions: Set) + { + for case let permission as AppPermission in self._permissions + { + if permissions.contains(permission) + { + permission.app = self + } + else + { + permission.app = nil + } + } + + self._permissions = permissions as NSSet + } } public extension StoreApp diff --git a/AltStoreCore/Protocols/ALTAppPermission.swift b/AltStoreCore/Protocols/ALTAppPermission.swift new file mode 100644 index 00000000..8a83afef --- /dev/null +++ b/AltStoreCore/Protocols/ALTAppPermission.swift @@ -0,0 +1,89 @@ +// +// ALTAppPermission.swift +// AltStoreCore +// +// Created by Riley Testut on 5/3/23. +// Copyright © 2023 Riley Testut. All rights reserved. +// + +import AltSign + +public extension ALTAppPermissionType +{ + var localizedName: String? { + switch self + { + case .unknown: return NSLocalizedString("Permission", comment: "") + case .entitlement: return NSLocalizedString("Entitlement", comment: "") + case .privacy: return NSLocalizedString("Privacy Permission", comment: "") + case .backgroundMode: return NSLocalizedString("Background Mode", comment: "") + default: return nil + } + } +} + +public protocol ALTAppPermission: RawRepresentable, Hashable +{ + var type: ALTAppPermissionType { get } + var symbolName: String? { get } + + var localizedName: String? { get } + var localizedDisplayName: String { get } // Default implementation +} + +public extension ALTAppPermission +{ + var localizedDisplayName: String { + return self.localizedName ?? self.rawValue + } + + func isEqual(_ permission: any ALTAppPermission) -> Bool + { + guard let permission = permission as? Self else { return false } + return self == permission + } + + static func ==(lhs: Self, rhs: any ALTAppPermission) -> Bool + { + return lhs.isEqual(rhs) + } +} + +public struct UnknownAppPermission: ALTAppPermission +{ + public var type: ALTAppPermissionType { .unknown } + public var symbolName: String? { nil } + + public var localizedName: String? { nil } + + public var rawValue: String + + public init(rawValue: String) + { + self.rawValue = rawValue + } +} + +extension ALTEntitlement: ALTAppPermission +{ + public var type: ALTAppPermissionType { .entitlement } + public var symbolName: String? { nil } + + public var localizedName: String? { nil } +} + +extension ALTAppPrivacyPermission: ALTAppPermission +{ + public var type: ALTAppPermissionType { .privacy } + public var symbolName: String? { nil } + + public var localizedName: String? { nil } +} + +extension ALTAppBackgroundMode: ALTAppPermission +{ + public var type: ALTAppPermissionType { .backgroundMode } + public var symbolName: String? { nil } + + public var localizedName: String? { nil } +} diff --git a/AltStoreCore/Types/ALTAppPermission.h b/AltStoreCore/Types/ALTAppPermission.h deleted file mode 100644 index d84e616c..00000000 --- a/AltStoreCore/Types/ALTAppPermission.h +++ /dev/null @@ -1,28 +0,0 @@ -// -// ALTAppPermission.h -// AltStore -// -// Created by Riley Testut on 7/23/19. -// Copyright © 2019 Riley Testut. All rights reserved. -// - -#import - -typedef NSString *ALTAppPermissionType NS_TYPED_EXTENSIBLE_ENUM; -extern ALTAppPermissionType const ALTAppPermissionTypePhotos; -extern ALTAppPermissionType const ALTAppPermissionTypeCamera; -extern ALTAppPermissionType const ALTAppPermissionTypeLocation; -extern ALTAppPermissionType const ALTAppPermissionTypeContacts; -extern ALTAppPermissionType const ALTAppPermissionTypeReminders; -extern ALTAppPermissionType const ALTAppPermissionTypeAppleMusic; -extern ALTAppPermissionType const ALTAppPermissionTypeMicrophone; -extern ALTAppPermissionType const ALTAppPermissionTypeSpeechRecognition; -extern ALTAppPermissionType const ALTAppPermissionTypeBackgroundAudio; -extern ALTAppPermissionType const ALTAppPermissionTypeBackgroundFetch; -extern ALTAppPermissionType const ALTAppPermissionTypeBluetooth; -extern ALTAppPermissionType const ALTAppPermissionTypeNetwork; -extern ALTAppPermissionType const ALTAppPermissionTypeCalendars; -extern ALTAppPermissionType const ALTAppPermissionTypeTouchID; -extern ALTAppPermissionType const ALTAppPermissionTypeFaceID; -extern ALTAppPermissionType const ALTAppPermissionTypeSiri; -extern ALTAppPermissionType const ALTAppPermissionTypeMotion; diff --git a/AltStoreCore/Types/ALTAppPermission.m b/AltStoreCore/Types/ALTAppPermission.m deleted file mode 100644 index a67e536d..00000000 --- a/AltStoreCore/Types/ALTAppPermission.m +++ /dev/null @@ -1,27 +0,0 @@ -// -// ALTAppPermission.m -// AltStore -// -// Created by Riley Testut on 7/23/19. -// Copyright © 2019 Riley Testut. All rights reserved. -// - -#import "ALTAppPermission.h" - -ALTAppPermissionType const ALTAppPermissionTypePhotos = @"photos"; -ALTAppPermissionType const ALTAppPermissionTypeCamera = @"camera"; -ALTAppPermissionType const ALTAppPermissionTypeLocation = @"location"; -ALTAppPermissionType const ALTAppPermissionTypeContacts = @"contacts"; -ALTAppPermissionType const ALTAppPermissionTypeReminders = @"reminders"; -ALTAppPermissionType const ALTAppPermissionTypeAppleMusic = @"music"; -ALTAppPermissionType const ALTAppPermissionTypeMicrophone = @"microphone"; -ALTAppPermissionType const ALTAppPermissionTypeSpeechRecognition = @"speech-recognition"; -ALTAppPermissionType const ALTAppPermissionTypeBackgroundAudio = @"background-audio"; -ALTAppPermissionType const ALTAppPermissionTypeBackgroundFetch = @"background-fetch"; -ALTAppPermissionType const ALTAppPermissionTypeBluetooth = @"bluetooth"; -ALTAppPermissionType const ALTAppPermissionTypeNetwork = @"network"; -ALTAppPermissionType const ALTAppPermissionTypeCalendars = @"calendars"; -ALTAppPermissionType const ALTAppPermissionTypeTouchID = @"touchid"; -ALTAppPermissionType const ALTAppPermissionTypeFaceID = @"faceid"; -ALTAppPermissionType const ALTAppPermissionTypeSiri = @"siri"; -ALTAppPermissionType const ALTAppPermissionTypeMotion = @"motion"; diff --git a/AltStoreCore/Types/ALTAppPermissions.h b/AltStoreCore/Types/ALTAppPermissions.h new file mode 100644 index 00000000..eafb3176 --- /dev/null +++ b/AltStoreCore/Types/ALTAppPermissions.h @@ -0,0 +1,18 @@ +// +// ALTAppPermissions.h +// AltStore +// +// Created by Riley Testut on 7/23/19. +// Copyright © 2019 Riley Testut. All rights reserved. +// + +#import + +typedef NSString *ALTAppPermissionType NS_TYPED_EXTENSIBLE_ENUM; +extern ALTAppPermissionType const ALTAppPermissionTypeUnknown; +extern ALTAppPermissionType const ALTAppPermissionTypeEntitlement; +extern ALTAppPermissionType const ALTAppPermissionTypePrivacy; +extern ALTAppPermissionType const ALTAppPermissionTypeBackgroundMode; + +typedef NSString *ALTAppPrivacyPermission NS_TYPED_EXTENSIBLE_ENUM; +typedef NSString *ALTAppBackgroundMode NS_TYPED_EXTENSIBLE_ENUM; diff --git a/AltStoreCore/Types/ALTAppPermissions.m b/AltStoreCore/Types/ALTAppPermissions.m new file mode 100644 index 00000000..0b9ff5d8 --- /dev/null +++ b/AltStoreCore/Types/ALTAppPermissions.m @@ -0,0 +1,14 @@ +// +// ALTAppPermissions.m +// AltStore +// +// Created by Riley Testut on 7/23/19. +// Copyright © 2019 Riley Testut. All rights reserved. +// + +#import "ALTAppPermissions.h" + +ALTAppPermissionType const ALTAppPermissionTypeUnknown = @"unknown"; +ALTAppPermissionType const ALTAppPermissionTypeEntitlement = @"entitlement"; +ALTAppPermissionType const ALTAppPermissionTypePrivacy = @"privacy"; +ALTAppPermissionType const ALTAppPermissionTypeBackgroundMode = @"background"; diff --git a/Shared/Extensions/Bundle+AltStore.swift b/Shared/Extensions/Bundle+AltStore.swift index 13463bea..91508a82 100644 --- a/Shared/Extensions/Bundle+AltStore.swift +++ b/Shared/Extensions/Bundle+AltStore.swift @@ -22,6 +22,7 @@ public extension Bundle public static let devicePairingString = "ALTPairingFile" public static let urlTypes = "CFBundleURLTypes" public static let exportedUTIs = "UTExportedTypeDeclarations" + public static let backgroundModes = "UIBackgroundModes" public static let untetherURL = "ALTFugu14UntetherURL" public static let untetherRequired = "ALTFugu14UntetherRequired"