diff --git a/TrollFools.xcodeproj/project.pbxproj b/TrollFools.xcodeproj/project.pbxproj index e3435c9..8cbfdc7 100644 --- a/TrollFools.xcodeproj/project.pbxproj +++ b/TrollFools.xcodeproj/project.pbxproj @@ -65,6 +65,7 @@ 61EFA37B2D31165700159442 /* InjectorV3+Backup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61EFA37A2D31165000159442 /* InjectorV3+Backup.swift */; }; 61EFA37D2D311DA800159442 /* InjectorV3+Inject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61EFA37C2D311DA300159442 /* InjectorV3+Inject.swift */; }; 61F595622D6B42340034DD83 /* SwiftUIIntrospect-Static in Frameworks */ = {isa = PBXBuildFile; productRef = 61F595612D6B42340034DD83 /* SwiftUIIntrospect-Static */; }; + 86B80B3B2E4CE82500543291 /* LibraryManagerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86B80B3A2E4CE82500543291 /* LibraryManagerView.swift */; }; CC0D662D2D7F11A2000EADED /* ArArchiveKit in Frameworks */ = {isa = PBXBuildFile; productRef = CC0D662C2D7F11A2000EADED /* ArArchiveKit */; }; CC0D662F2D7F11AC000EADED /* ArArchiveKit in Frameworks */ = {isa = PBXBuildFile; productRef = CC0D662E2D7F11AC000EADED /* ArArchiveKit */; }; CC0D66322D7F13A9000EADED /* SWCompression in Frameworks */ = {isa = PBXBuildFile; productRef = CC0D66312D7F13A9000EADED /* SWCompression */; }; @@ -171,6 +172,7 @@ 61EFA3782D3108B700159442 /* InjectorV3+Eject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "InjectorV3+Eject.swift"; sourceTree = ""; }; 61EFA37A2D31165000159442 /* InjectorV3+Backup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "InjectorV3+Backup.swift"; sourceTree = ""; }; 61EFA37C2D311DA300159442 /* InjectorV3+Inject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "InjectorV3+Inject.swift"; sourceTree = ""; }; + 86B80B3A2E4CE82500543291 /* LibraryManagerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryManagerView.swift; sourceTree = ""; }; CC0E80FA2C54F84000B137B4 /* mv-15 */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.executable"; path = "mv-15"; sourceTree = ""; }; CC1548CF2C4A6B8200A4173E /* ct_bypass */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.executable"; path = ct_bypass; sourceTree = ""; }; CC1548D22C4A743200A4173E /* SuccessView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuccessView.swift; sourceTree = ""; }; @@ -280,6 +282,7 @@ 6124A0722CD2327E00C52253 /* View */ = { isa = PBXGroup; children = ( + 86B80B3A2E4CE82500543291 /* LibraryManagerView.swift */, 6124A0732CD2328600C52253 /* AppListCell.swift */, CCF4706D2C4A4BAB008D8197 /* AppListView.swift */, 61C7D54B2D82DBAC0064D626 /* DisclaimerView.swift */, @@ -673,6 +676,7 @@ 61EFA3732D30347A00159442 /* InjectorV3+MachO.swift in Sources */, 614C0C682D7C72AD007E9184 /* AppListSearchModel.swift in Sources */, 614C0C622D7C2EC0007E9184 /* Constants.swift in Sources */, + 86B80B3B2E4CE82500543291 /* LibraryManagerView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/TrollFools/AppListView.swift b/TrollFools/AppListView.swift index 039f9c7..61b3cb9 100644 --- a/TrollFools/AppListView.swift +++ b/TrollFools/AppListView.swift @@ -20,6 +20,7 @@ struct AppListView: View { @State var selectorOpenedURL: URLIdentifiable? = nil @State var selectedIndex: String? = nil + @State private var isLibraryManagerPresented: Bool = false @State var isWarningPresented = false @State var temporaryOpenedURL: URLIdentifiable? = nil @@ -259,6 +260,14 @@ struct AppListView: View { } } } + ToolbarItem(placement: .navigationBarLeading) { + Button { + isLibraryManagerPresented = true + } label: { + Image(systemName: "shippingbox") + } + .accessibilityLabel(NSLocalizedString("Libraries", comment: "")) + } ToolbarItem(placement: .navigationBarTrailing) { Button { appList.filter.showPatchedOnly.toggle() @@ -276,6 +285,11 @@ struct AppListView: View { .accessibilityLabel(NSLocalizedString("Show Patched Only", comment: "")) } } + .sheet(isPresented: $isLibraryManagerPresented) { + NavigationView { + LibraryManagerView() + } + } } var allAppGroup: some View { diff --git a/TrollFools/InjectorV3+Inject.swift b/TrollFools/InjectorV3+Inject.swift index f606547..ee31c77 100644 --- a/TrollFools/InjectorV3+Inject.swift +++ b/TrollFools/InjectorV3+Inject.swift @@ -8,6 +8,18 @@ import CocoaLumberjackSwift import Foundation +fileprivate var gCachedLibraryIndex: [String: InjectorV3.LibraryModuleEntry] = [:] +fileprivate var gPreparedLibraryURLs: [ObjectIdentifier: [String: URL]] = [:] +fileprivate let gLibraryAliasMap: [String: String] = [ + "ellekit": "CydiaSubstrate", + "ellekit.framework": "CydiaSubstrate", + "libellekit.dylib": "CydiaSubstrate", + "libsubstitute.dylib": "CydiaSubstrate", + "libsubstrate.dylib": "CydiaSubstrate", + "cydiasubstrate": "CydiaSubstrate", + "cydiasubstrate.framework": "CydiaSubstrate", +] + extension InjectorV3 { enum Strategy: String, CaseIterable { case lexicographic @@ -60,12 +72,29 @@ extension InjectorV3 { return } + Self.cachedLibraryIndex = [:] + Self.buildLibraryIndexIfNeeded() + try assetURLs.forEach { - try standardizeLoadCommandDylibToSubstrate($0) + try standardizeLoadCommandDylibToLocalLibrary($0) try applyCoreTrustBypass($0) } - let substrateFwkURL = try prepareSubstrate() + var allNeededKeys: Set = [] + for assetURL in assetURLs { + let machO: URL = try checkIsBundle(assetURL) ? locateExecutableInBundle(assetURL) : assetURL + let dylibs = try loadedDylibsOfMachO(machO) + for imported in dylibs { + if let (rawKey, _) = libraryKey(fromImportedPath: imported) { + let lowered = rawKey.lowercased() + let destKey = Self.libraryAliasMap[lowered] ?? rawKey + if Self.cachedLibraryIndex[destKey.lowercased()] != nil { + allNeededKeys.insert(destKey) + } + } + } + } + let preparedLibs = try prepareLibraryModulesIfNeeded(keys: allNeededKeys) guard let targetMachO = try locateAvailableMachO() else { DDLogError("All Mach-Os are protected", ddlog: logger) @@ -74,7 +103,7 @@ extension InjectorV3 { DDLogInfo("Best matched Mach-O is \(targetMachO.path)", ddlog: logger) - let resourceURLs: [URL] = [substrateFwkURL] + assetURLs + let resourceURLs: [URL] = preparedLibs + assetURLs try makeAlternate(targetMachO) do { try copyfiles(resourceURLs) @@ -105,25 +134,117 @@ extension InjectorV3 { try cmdChangeOwnerToInstalld(target, recursively: isFramework) } - // MARK: - Cydia Substrate + // MARK: - Library Replace + + fileprivate struct LibraryModuleEntry { + enum Kind { case framework, dylib } + let kind: Kind + let key: String + let zipURL: URL + } + + fileprivate static var cachedLibraryIndex: [String: LibraryModuleEntry] { + get { gCachedLibraryIndex } + set { gCachedLibraryIndex = newValue } + } + fileprivate var preparedLibraryURLs: [String: URL] { + get { gPreparedLibraryURLs[ObjectIdentifier(self)] ?? [:] } + set { gPreparedLibraryURLs[ObjectIdentifier(self)] = newValue } + } - fileprivate static let substrateZipURL = findResource(substrateFwkName, fileExtension: "zip") + fileprivate static var libraryAliasMap: [String: String] { gLibraryAliasMap } - fileprivate func prepareSubstrate() throws -> URL { - try FileManager.default.unzipItem(at: Self.substrateZipURL, to: temporaryDirectoryURL) + fileprivate static func buildLibraryIndexIfNeeded() { + if !cachedLibraryIndex.isEmpty { return } - let fwkURL = temporaryDirectoryURL.appendingPathComponent(Self.substrateFwkName) - try markBundlesAsInjected([fwkURL], privileged: false) + var index: [String: LibraryModuleEntry] = [:] - let machO = fwkURL.appendingPathComponent(Self.substrateName) + let searchRoots: [URL] = [Bundle.main.bundleURL, userLibrariesDirectoryURL] + for root in searchRoots { + if root == userLibrariesDirectoryURL { + // 确保用户库目录存在 + try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + } + guard let enumerator = FileManager.default.enumerator(at: root, includingPropertiesForKeys: [.isRegularFileKey]) else { continue } + for case let fileURL as URL in enumerator { + guard let isRegular = try? fileURL.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile, isRegular == true else { continue } + let name = fileURL.lastPathComponent + if name.hasSuffix(".framework.zip") { + let moduleName = String(name.dropLast(".framework.zip".count)) + // 用户库优先覆盖内置 + index[moduleName.lowercased()] = LibraryModuleEntry(kind: .framework, key: moduleName, zipURL: fileURL) + } else if name.hasSuffix(".dylib.zip") { + let dylibName = String(name.dropLast(".zip".count)) + index[dylibName.lowercased()] = LibraryModuleEntry(kind: .dylib, key: dylibName, zipURL: fileURL) + } + } + } - try cmdCoreTrustBypass(machO, teamID: teamID) - try cmdChangeOwnerToInstalld(fwkURL, recursively: true) + cachedLibraryIndex = index + } + + /// 用户自定义库目录:App Support//Libraries + fileprivate static var userLibrariesDirectoryURL: URL { + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + return base.appendingPathComponent(gTrollFoolsIdentifier, isDirectory: true) + .appendingPathComponent("Libraries", isDirectory: true) + } + + fileprivate func libraryKey(fromImportedPath imported: String) -> (key: String, kind: LibraryModuleEntry.Kind)? { + let lower = imported.lowercased() + if let range = lower.range(of: ".framework/") { + let prefix = lower[..) throws -> [URL] { + Self.buildLibraryIndexIfNeeded() + var prepared: [URL] = [] + for rawKey in keys { + let key = rawKey.lowercased() + guard let entry = Self.cachedLibraryIndex[key] else { continue } + if let existing = preparedLibraryURLs[key] { + prepared.append(existing) + continue + } + try FileManager.default.unzipItem(at: entry.zipURL, to: temporaryDirectoryURL) + let targetURL: URL + switch entry.kind { + case .framework: + let fwkURL = temporaryDirectoryURL.appendingPathComponent("\(entry.key).framework") + targetURL = fwkURL + try markBundlesAsInjected([fwkURL], privileged: false) + let macho = fwkURL.appendingPathComponent(entry.key) + try cmdCoreTrustBypass(macho, teamID: teamID) + try cmdChangeOwnerToInstalld(fwkURL, recursively: true) + case .dylib: + let dylibURL = temporaryDirectoryURL.appendingPathComponent(entry.key) + targetURL = dylibURL + try cmdCoreTrustBypass(dylibURL, teamID: teamID) + try cmdChangeOwnerToInstalld(dylibURL, recursively: false) + } + preparedLibraryURLs[key] = targetURL + prepared.append(targetURL) + } + return prepared } - fileprivate func standardizeLoadCommandDylibToSubstrate(_ assetURL: URL) throws { + fileprivate func standardizeLoadCommandDylibToLocalLibrary(_ assetURL: URL) throws { let machO: URL if checkIsBundle(assetURL) { machO = try locateExecutableInBundle(assetURL) @@ -132,10 +253,29 @@ extension InjectorV3 { } let dylibs = try loadedDylibsOfMachO(machO) - for dylib in dylibs { - if Self.ignoredDylibAndFrameworkNames.firstIndex(where: { dylib.lowercased().hasSuffix("/\($0)") }) != nil { - try cmdChangeLoadCommandDylib(machO, from: dylib, to: "@executable_path/Frameworks/\(Self.substrateFwkName)/\(Self.substrateName)") + var neededKeys: Set = [] + for imported in dylibs { + if let (rawKey, _) = libraryKey(fromImportedPath: imported) { + let lower = rawKey.lowercased() + let destKey = Self.libraryAliasMap[lower] ?? rawKey + if Self.cachedLibraryIndex[destKey.lowercased()] != nil { + neededKeys.insert(destKey) + } + } + } + let _ = try prepareLibraryModulesIfNeeded(keys: neededKeys) + for imported in dylibs { + guard let (rawKey, _) = libraryKey(fromImportedPath: imported) else { continue } + let destKey = Self.libraryAliasMap[rawKey.lowercased()] ?? rawKey + guard let entry = Self.cachedLibraryIndex[destKey.lowercased()] else { continue } + let newName: String + switch entry.kind { + case .framework: + newName = "@executable_path/Frameworks/\(entry.key).framework/\(entry.key)" + case .dylib: + newName = "@executable_path/Frameworks/\(entry.key)" } + try cmdChangeLoadCommandDylib(machO, from: imported, to: newName) } } diff --git a/TrollFools/LibraryManagerView.swift b/TrollFools/LibraryManagerView.swift new file mode 100644 index 0000000..2153967 --- /dev/null +++ b/TrollFools/LibraryManagerView.swift @@ -0,0 +1,228 @@ +// +// LibraryManagerView.swift +// TrollFools +// +// Created by LiBr on 8/13/25. +// + +import SwiftUI +import UniformTypeIdentifiers + +struct LibraryItem: Identifiable, Hashable { + enum Kind { case framework, dylib } + let id: String + let name: String + let kind: Kind + let isUser: Bool + let fileURL: URL +} + +struct LibraryManagerView: View { + @State private var frameworkItems: [LibraryItem] = [] + @State private var dylibItems: [LibraryItem] = [] + @State private var isImporterPresented: Bool = false + @State private var importErrorMessage: String? = nil + + var body: some View { + List { + if !frameworkItems.isEmpty { + Section(header: Text(NSLocalizedString("Frameworks", comment: "")).font(.footnote)) { + ForEach(frameworkItems) { item in + HStack { + Image(systemName: item.isUser ? "shippingbox.fill" : "shippingbox") + .foregroundColor(item.isUser ? .accentColor : .secondary) + Text(item.name) + .font(.body) + Spacer() + if item.isUser { + if #available(iOS 15.0, *) { + Button(role: .destructive) { + delete(item: item) + } label: { + Image(systemName: "trash") + } + .buttonStyle(.borderless) + .accessibilityLabel(NSLocalizedString("Delete", comment: "")) + } else { + Button(action: { + delete(item: item) + }) { + Image(systemName: "trash") + .foregroundColor(.red) + } + .buttonStyle(.borderless) + .accessibilityLabel(NSLocalizedString("Delete", comment: "")) + } + } + } + .padding(.vertical, 2) + } + } + } + + if !dylibItems.isEmpty { + Section(header: Text(NSLocalizedString("Dynamic Libraries", comment: "")).font(.footnote)) { + ForEach(dylibItems) { item in + HStack { + Image(systemName: item.isUser ? "puzzlepiece.extension.fill" : "puzzlepiece.extension") + .foregroundColor(item.isUser ? .accentColor : .secondary) + Text(item.name) + .font(.body) + Spacer() + if item.isUser { + if #available(iOS 15.0, *) { + Button(role: .destructive) { + delete(item: item) + } label: { + Image(systemName: "trash") + } + .buttonStyle(.borderless) + .accessibilityLabel(NSLocalizedString("Delete", comment: "")) + } else { + Button(action: { + delete(item: item) + }) { + Image(systemName: "trash") + .foregroundColor(.red) + } + .buttonStyle(.borderless) + .accessibilityLabel(NSLocalizedString("Delete", comment: "")) + } + } + } + .padding(.vertical, 2) + } + } + } + + if frameworkItems.isEmpty && dylibItems.isEmpty { + Section { + } footer: { + Text(NSLocalizedString("No third-party libraries found in the app bundle.", comment: "")) + .font(.footnote) + .foregroundColor(.secondary) + } + } + } + .listStyle(.insetGrouped) + .navigationTitle(NSLocalizedString("Libraries", comment: "")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + reload() + } label: { + Image(systemName: "arrow.clockwise") + } + .accessibilityLabel(NSLocalizedString("Reload", comment: "")) + } + ToolbarItem(placement: .navigationBarLeading) { + Button { + isImporterPresented = true + } label: { + Image(systemName: "plus") + } + .accessibilityLabel(NSLocalizedString("Add Library", comment: "")) + } + } + .onAppear { + reload() + } + .fileImporter(isPresented: $isImporterPresented, allowedContentTypes: [.zip]) { result in + switch result { + case let .success(url): + handleImport(url: url) + case let .failure(error): + importErrorMessage = error.localizedDescription + } + } + .alert(isPresented: Binding(get: { importErrorMessage != nil }, set: { if !$0 { importErrorMessage = nil } })) { + Alert(title: Text(NSLocalizedString("Import Failed", comment: "")), message: Text(importErrorMessage ?? ""), dismissButton: .default(Text(NSLocalizedString("OK", comment: "")))) + } + } + + private func delete(item: LibraryItem) { + guard item.isUser else { return } + do { + try FileManager.default.removeItem(at: item.fileURL) + reload() + } catch { + importErrorMessage = error.localizedDescription + } + } + + private func reload() { + let builtinRoot = Bundle.main.bundleURL + let userRoot = userLibrariesDirectoryURL() + try? FileManager.default.createDirectory(at: userRoot, withIntermediateDirectories: true) + + var frameworks: [LibraryItem] = [] + var dylibs: [LibraryItem] = [] + + func scan(root: URL, isUser: Bool) { + guard let enumerator = FileManager.default.enumerator( + at: root, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles] + ) else { return } + for case let url as URL in enumerator { + guard let isRegular = try? url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile, isRegular == true else { continue } + let name = url.lastPathComponent + // TODO: this check is effectless. + if name.hasSuffix(".framework.zip") { + let module = String(name.dropLast(".framework.zip".count)) + let item = LibraryItem(id: "framework::\(module)", name: module, kind: .framework, isUser: isUser, fileURL: url) + frameworks.removeAll { $0.name.lowercased() == module.lowercased() } + frameworks.append(item) + } else if name.hasSuffix(".dylib.zip") { + let dylibName = String(name.dropLast(".zip".count)) + let item = LibraryItem(id: "dylib::\(dylibName)", name: dylibName, kind: .dylib, isUser: isUser, fileURL: url) + dylibs.removeAll { $0.name.lowercased() == dylibName.lowercased() } + dylibs.append(item) + } + } + } + + scan(root: builtinRoot, isUser: false) + scan(root: userRoot, isUser: true) + + frameworks.sort { $0.name.localizedStandardCompare($1.name) == .orderedAscending } + dylibs.sort { $0.name.localizedStandardCompare($1.name) == .orderedAscending } + + frameworkItems = frameworks + dylibItems = dylibs + } + + private func handleImport(url: URL) { + let destDir = userLibrariesDirectoryURL() + try? FileManager.default.createDirectory(at: destDir, withIntermediateDirectories: true) + + let fileName = url.lastPathComponent + let lower = fileName.lowercased() + let isFrameworkZip = lower.hasSuffix(".framework.zip") + let isDylibZip = lower.hasSuffix(".dylib.zip") + guard isFrameworkZip || isDylibZip else { + importErrorMessage = NSLocalizedString("Only .framework.zip or .dylib.zip is supported.", comment: "") + return + } + + let destURL = destDir.appendingPathComponent(fileName) + do { + try? FileManager.default.removeItem(at: destURL) + let access = url.startAccessingSecurityScopedResource() + defer { if access { url.stopAccessingSecurityScopedResource() } } + try FileManager.default.copyItem(at: url, to: destURL) + reload() + } catch { + importErrorMessage = error.localizedDescription + } + } + + private func userLibrariesDirectoryURL() -> URL { + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + return base.appendingPathComponent(gTrollFoolsIdentifier, isDirectory: true) + .appendingPathComponent("Libraries", isDirectory: true) + } +} + + diff --git a/TrollFools/Untitled.swift b/TrollFools/Untitled.swift new file mode 100644 index 0000000..e69de29