From 30c03aad423eba22c7e7aa56003a0e275ac634e4 Mon Sep 17 00:00:00 2001 From: mahee96 <47920326+mahee96@users.noreply.github.com> Date: Mon, 2 Feb 2026 08:44:49 +0530 Subject: [PATCH] feature: added a prompt before installing to customize appId --- AltStore/Managing Apps/AppManager.swift | 196 ++++++++++++++++-- .../FetchProvisioningProfilesOperation.swift | 26 ++- AltStore/Operations/ResignAppOperation.swift | 21 +- AltStore/Operations/VerifyAppOperation.swift | 7 +- 4 files changed, 212 insertions(+), 38 deletions(-) diff --git a/AltStore/Managing Apps/AppManager.swift b/AltStore/Managing Apps/AppManager.swift index 80161c79..a4c9cd80 100644 --- a/AltStore/Managing Apps/AppManager.swift +++ b/AltStore/Managing Apps/AppManager.swift @@ -8,7 +8,6 @@ import Foundation import UIKit -import SwiftUI import UserNotifications import MobileCoreServices import Intents @@ -705,9 +704,36 @@ extension AppManager } } - let operation = AppOperation.install(app) - self.perform([operation], presentingViewController: presentingViewController, group: group) + Task{ + var app: AppProtocol = app + // ---- Preflight bundle ID resolution ---- + if let presentingViewController { + let originalBundleID = app.bundleIdentifier + + let resolution = await self.resolveBundleID( + initial: originalBundleID, + presentingViewController: presentingViewController + ) + + switch resolution { + case .cancelled: + completionHandler(.failure(OperationError.cancelled)) + group.progress.cancel() + + case .resolved(let newBundleID): + app = AnyApp( + name: app.name, + bundleIdentifier: newBundleID, + url: app.url, + storeApp: app.storeApp + ) + } + } + + await self.perform([.install(app)], presentingViewController: presentingViewController, group: group) + + } return group } @@ -732,10 +758,11 @@ extension AppManager } } - let operation = AppOperation.update(appVersion) - assert(operation.app as AnyObject !== installedApp) // Make sure we never accidentally "update" to already installed app. + assert(appVersion as AnyObject !== installedApp) // Make sure we never accidentally "update" to already installed app. - self.perform([operation], presentingViewController: presentingViewController, group: group) + Task{ + await self.perform([.update(appVersion)], presentingViewController: presentingViewController, group: group) + } return group.progress } @@ -745,16 +772,20 @@ extension AppManager { let group = group ?? RefreshGroup() - let operations = installedApps.map { AppOperation.refresh($0) } - return self.perform(operations, presentingViewController: presentingViewController, group: group) + Task{ + await self.perform(installedApps.map { .refresh($0) }, presentingViewController: presentingViewController, group: group) + } + + return group } func activate(_ installedApp: InstalledApp, presentingViewController: UIViewController?, completionHandler: @escaping (Result) -> Void) { let group = RefreshGroup() - let operation = AppOperation.activate(installedApp) - self.perform([operation], presentingViewController: presentingViewController, group: group) + Task{ + await self.perform([.activate(installedApp)], presentingViewController: presentingViewController, group: group) + } group.completionHandler = { (results) in do @@ -812,8 +843,9 @@ extension AppManager } } - let operation = AppOperation.deactivate(installedApp) - self.perform([operation], presentingViewController: presentingViewController, group: group) + Task{ + await self.perform([.deactivate(installedApp)], presentingViewController: presentingViewController, group: group) + } } } @@ -837,8 +869,9 @@ extension AppManager } } - let operation = AppOperation.backup(installedApp) - self.perform([operation], presentingViewController: presentingViewController, group: group) + Task{ + await self.perform([.backup(installedApp)], presentingViewController: presentingViewController, group: group) + } } func restore(_ installedApp: InstalledApp, presentingViewController: UIViewController?, completionHandler: @escaping (Result) -> Void) @@ -863,8 +896,9 @@ extension AppManager } } - let operation = AppOperation.restore(installedApp) - self.perform([operation], presentingViewController: presentingViewController, group: group) + Task{ + await self.perform([.restore(installedApp)], presentingViewController: presentingViewController, group: group) + } } func remove(_ installedApp: InstalledApp, completionHandler: @escaping (Result) -> Void) @@ -1091,7 +1125,7 @@ private extension AppManager } @discardableResult - private func perform(_ operations: [AppOperation], presentingViewController: UIViewController?, group: RefreshGroup) -> RefreshGroup + private func perform(_ operations: [AppOperation], presentingViewController: UIViewController?, group: RefreshGroup) async -> RefreshGroup { let operations = operations.filter { self.progress(for: $0) == nil || self.progress(for: $0)?.isCancelled == true } @@ -1226,7 +1260,7 @@ private extension AppManager { let progress = Progress.discreteProgress(totalUnitCount: 100) - let context = context ?? InstallAppOperationContext(bundleIdentifier: app.bundleIdentifier, authenticatedContext: group.context) + let context = InstallAppOperationContext(bundleIdentifier: app.bundleIdentifier, authenticatedContext: group.context) assert(context.authenticatedContext === group.context) context.beginInstallationHandler = { (installedApp) in @@ -1304,7 +1338,7 @@ private extension AppManager /* Verify App */ let permissionsMode = UserDefaults.shared.permissionCheckingDisabled ? .none : permissionReviewMode - let verifyOperation = VerifyAppOperation(permissionsMode: permissionsMode, context: context) + let verifyOperation = VerifyAppOperation(permissionsMode: permissionsMode, context: context, customBundleId: app.bundleIdentifier) verifyOperation.resultHandler = { (result) in do { @@ -1457,7 +1491,7 @@ private extension AppManager let patchAppURL = URL(string: patchAppLink) else { throw OperationError.invalidApp } - let patchApp = AnyApp(name: app.name, bundleIdentifier: app.bundleIdentifier, url: patchAppURL, storeApp: nil) + let patchApp = AnyApp(name: app.name, bundleIdentifier: context.bundleIdentifier, url: patchAppURL, storeApp: nil) DispatchQueue.main.async { let storyboard = UIStoryboard(name: "PatchApp", bundle: nil) @@ -1479,7 +1513,7 @@ private extension AppManager presentingViewController?.dismiss(animated: true, completion: nil) } } - presentingViewController.present(navigationController, animated: true, completion: nil) + presentingViewController.present(navigationController, animated: true, completion: nil) } } catch @@ -2274,3 +2308,123 @@ private extension AppManager } } } + +private enum BundleIDAlertKeys { + static var okAction: UInt8 = 0 +} + +private func _isValidBundleID(_ value: String) -> Bool { + let pattern = #"^[A-Za-z][A-Za-z0-9\-]*(\.[A-Za-z0-9\-]+)+$"# + return value.range(of: pattern, options: .regularExpression) != nil +} + +private extension UIResponder { + @objc func _validateBundleIDText(_ sender: UITextField) { + let isValid = sender.text.map(_isValidBundleID) ?? false + + sender.backgroundColor = + isValid || sender.text?.isEmpty == true + ? .clear + : UIColor.systemRed.withAlphaComponent(0.2) + + if + let alert = sender.superview?.superview as? UIAlertController, + let okAction = objc_getAssociatedObject(alert, &BundleIDAlertKeys.okAction) as? UIAlertAction + { + okAction.isEnabled = isValid + } + } +} + + + +private extension AppManager { + + func _presentBundleIDOverrideDialog( + bundleIdentifier: String, + presentingViewController: UIViewController, + completion: @escaping (BundleIDResolution) -> Void + ) { + let alert = self._makeBundleIDOverrideAlert( + initialBundleID: bundleIdentifier, + completion: completion + ) + + presentingViewController.present(alert, animated: true) + } + + func _makeBundleIDOverrideAlert( + initialBundleID: String, + completion: @escaping (BundleIDResolution) -> Void + ) -> UIAlertController { + + let alert = UIAlertController( + title: NSLocalizedString("Override Bundle Identifier", comment: ""), + message: nil, + preferredStyle: .alert + ) + + var okAction: UIAlertAction! + + alert.addTextField { textField in + textField.text = initialBundleID + textField.autocapitalizationType = .none + textField.autocorrectionType = .no + textField.addTarget( + nil, + action: #selector(UIResponder._validateBundleIDText(_:)), + for: .editingChanged + ) + } + + okAction = UIAlertAction(title: NSLocalizedString("OK", comment: ""), style: .default) { _ in + completion(.resolved(alert.textFields?.first?.text ?? initialBundleID)) + } + + okAction.isEnabled = _isValidBundleID(initialBundleID) + + let cancelAction = UIAlertAction(title: NSLocalizedString("Cancel", comment: ""), style: .cancel) { _ in + completion(.cancelled) + } + + alert.addAction(cancelAction) + alert.addAction(okAction) + + objc_setAssociatedObject( + alert, + &BundleIDAlertKeys.okAction, + okAction, + .OBJC_ASSOCIATION_ASSIGN + ) + + return alert + } +} + + +// ---- Part 1: Add async resolver ---- + +private extension AppManager { + + enum BundleIDResolution { + case resolved(String) + case cancelled + } + + @MainActor + func resolveBundleID( + initial: String, + presentingViewController: UIViewController + ) async -> BundleIDResolution { + + await withCheckedContinuation { continuation in + let alert = self._makeBundleIDOverrideAlert( + initialBundleID: initial + ) { result in + continuation.resume(returning: result) + } + + presentingViewController.present(alert, animated: true) + } + } +} diff --git a/AltStore/Operations/FetchProvisioningProfilesOperation.swift b/AltStore/Operations/FetchProvisioningProfilesOperation.swift index 90106cb6..862e4018 100644 --- a/AltStore/Operations/FetchProvisioningProfilesOperation.swift +++ b/AltStore/Operations/FetchProvisioningProfilesOperation.swift @@ -54,7 +54,8 @@ class FetchProvisioningProfilesOperation: ResultOperation<[String: ALTProvisioni Logger.sideload.notice("Fetching provisioning profiles for app \(self.context.bundleIdentifier, privacy: .public)...") self.progress.totalUnitCount = Int64(1 + app.appExtensions.count) - + let effectiveBundleId = self.context.bundleIdentifier + self.prepareProvisioningProfile(for: app, parentApp: nil, team: team, session: session) { (result) in do { @@ -62,7 +63,7 @@ class FetchProvisioningProfilesOperation: ResultOperation<[String: ALTProvisioni let profile = try result.get() - var profiles = [app.bundleIdentifier: profile] + var profiles = [effectiveBundleId: profile] var error: Error? let dispatchGroup = DispatchGroup() @@ -220,19 +221,30 @@ extension FetchProvisioningProfilesOperation // Or, if the app _is_ installed but with a different team, we need to create a new // bundle identifier anyway to prevent collisions with the previous team. let parentBundleID = parentApp?.bundleIdentifier ?? app.bundleIdentifier + let effectiveParentBundleID = self.context.bundleIdentifier + let updatedParentBundleID: String - + if app.isAltStoreApp { // Use legacy bundle ID format for AltStore (and its extensions). - updatedParentBundleID = parentBundleID + "." + team.identifier // Append just team identifier to make it harder to track. + updatedParentBundleID = effectiveParentBundleID + "." + team.identifier // Append just team identifier to make it harder to track. } else { - updatedParentBundleID = parentBundleID + "." + team.identifier // Append just team identifier to make it harder to track. + updatedParentBundleID = effectiveParentBundleID + "." + team.identifier // Append just team identifier to make it harder to track. + } + + if let parentApp = parentApp, + app.bundleIdentifier.hasPrefix(parentBundleID + ".") + { + let suffix = String(app.bundleIdentifier.dropFirst(parentBundleID.count)) + bundleID = updatedParentBundleID + suffix + } + else + { + bundleID = updatedParentBundleID } - - bundleID = app.bundleIdentifier.replacingOccurrences(of: parentBundleID, with: updatedParentBundleID) } let preferredName: String diff --git a/AltStore/Operations/ResignAppOperation.swift b/AltStore/Operations/ResignAppOperation.swift index 0bfd2a96..6d5ca496 100644 --- a/AltStore/Operations/ResignAppOperation.swift +++ b/AltStore/Operations/ResignAppOperation.swift @@ -55,6 +55,7 @@ final class ResignAppOperation: ResultOperation let prepareAppProgress = Progress.discreteProgress(totalUnitCount: 2) self.progress.addChild(prepareAppProgress, withPendingUnitCount: 3) + let effectiveBundleId = self.context.bundleIdentifier let prepareAppBundleProgress = self.prepareAppBundle(for: app, profiles: profiles, appexBundleIds: context.appexBundleIds ?? [:]) { (result) in guard let appBundleURL = self.process(result) else { return } @@ -65,7 +66,13 @@ final class ResignAppOperation: ResultOperation // Finish do { - let destinationURL = InstalledApp.refreshedIPAURL(for: app) + let updatedApp = AnyApp( + name: app.name, + bundleIdentifier: effectiveBundleId, + url: app.fileURL, + storeApp: app.storeApp + ) + let destinationURL = InstalledApp.refreshedIPAURL(for: updatedApp) try FileManager.default.copyItem(at: resignedURL, to: destinationURL, shouldReplace: true) print("Successfully resigned app to \(destinationURL.absoluteString)") @@ -110,15 +117,13 @@ private extension ResignAppOperation func prepareAppBundle(for app: ALTApplication, profiles: [String: ALTProvisioningProfile], appexBundleIds: [String: String], completionHandler: @escaping (Result) -> Void) -> Progress { let progress = Progress.discreteProgress(totalUnitCount: 1) - - let bundleIdentifier = app.bundleIdentifier + let openURL = InstalledApp.openAppURL(for: app) - let fileURL = app.fileURL - + let identifier = context.bundleIdentifier + func prepare(_ bundle: Bundle, additionalInfoDictionaryValues: [String: Any] = [:]) throws { - guard let identifier = bundle.bundleIdentifier else { throw ALTError(.missingAppBundle) } guard let profile = context.useMainProfile ? profiles.values.first : profiles[identifier] else { throw ALTError(.missingProvisioningProfile) } guard var infoDictionary = bundle.completeInfoDictionary else { throw ALTError(.missingInfoPlist) } @@ -189,7 +194,7 @@ private extension ResignAppOperation var allURLSchemes = infoDictionary[Bundle.Info.urlTypes] as? [[String: Any]] ?? [] let altstoreURLScheme = ["CFBundleTypeRole": "Editor", - "CFBundleURLName": bundleIdentifier, + "CFBundleURLName": identifier, "CFBundleURLSchemes": [openURL.scheme!]] as [String : Any] allURLSchemes.append(altstoreURLScheme) @@ -198,7 +203,7 @@ private extension ResignAppOperation if app.isAltStoreApp { guard let udid = fetch_udid()?.toString() as? String else { throw OperationError.unknownUDID } - guard let pairingFileString = Bundle.main.object(forInfoDictionaryKey: Bundle.Info.devicePairingString) as? String else { throw OperationError.unknownUDID } + guard Bundle.main.object(forInfoDictionaryKey: Bundle.Info.devicePairingString) is String else { throw OperationError.unknownUDID } additionalValues[Bundle.Info.devicePairingString] = "" additionalValues[Bundle.Info.deviceID] = udid additionalValues[Bundle.Info.serverID] = UserDefaults.standard.preferredServerID diff --git a/AltStore/Operations/VerifyAppOperation.swift b/AltStore/Operations/VerifyAppOperation.swift index cf741287..7e8772d7 100644 --- a/AltStore/Operations/VerifyAppOperation.swift +++ b/AltStore/Operations/VerifyAppOperation.swift @@ -38,11 +38,13 @@ final class VerifyAppOperation: ResultOperation { let permissionsMode: PermissionReviewMode let context: InstallAppOperationContext + var customBundleId: String? - init(permissionsMode: PermissionReviewMode, context: InstallAppOperationContext) + init(permissionsMode: PermissionReviewMode, context: InstallAppOperationContext, customBundleId: String? = nil) { self.permissionsMode = permissionsMode self.context = context + self.customBundleId = customBundleId super.init() } @@ -65,7 +67,8 @@ final class VerifyAppOperation: ResultOperation } if !["ny.litritt.ignited", "com.litritt.ignited"].contains(where: { $0 == app.bundleIdentifier }) { - guard app.bundleIdentifier == self.context.bundleIdentifier else { + let bundleId = customBundleId ?? app.bundleIdentifier + guard bundleId == self.context.bundleIdentifier else { throw VerificationError.mismatchedBundleIdentifiers(sourceBundleID: self.context.bundleIdentifier, app: app) } }