From 994b2318a9efb44c906efa51b92278ebbfb1dd33 Mon Sep 17 00:00:00 2001 From: naturecodevoid <44983869+naturecodevoid@users.noreply.github.com> Date: Sun, 9 Apr 2023 13:38:44 -0700 Subject: [PATCH] feat(dev mode): add AFC file explorer and dump profiles --- AltStore/Generated/Localizations.swift | 27 +- AltStore/Operations/OperationError.swift | 91 ++-- .../Resources/en.lproj/Localizable.strings | 16 +- AltStore/View Components/FileExplorer.swift | 515 +++++++++++------- AltStore/Views/Settings/DevModeView.swift | 52 +- 5 files changed, 408 insertions(+), 293 deletions(-) diff --git a/AltStore/Generated/Localizations.swift b/AltStore/Generated/Localizations.swift index 6942a616..84d72524 100644 --- a/AltStore/Generated/Localizations.swift +++ b/AltStore/Generated/Localizations.swift @@ -11,12 +11,18 @@ import Foundation // swiftlint:disable nesting type_body_length type_name vertical_whitespace_opening_braces internal enum L10n { internal enum Action { + /// Cancel + internal static let cancel = L10n.tr("Localizable", "Action.cancel", fallback: "Cancel") /// Close internal static let close = L10n.tr("Localizable", "Action.close", fallback: "Close") /// General Actions internal static let done = L10n.tr("Localizable", "Action.done", fallback: "Done") /// Enable internal static let enable = L10n.tr("Localizable", "Action.enable", fallback: "Enable") + /// Submit + internal static let submit = L10n.tr("Localizable", "Action.submit", fallback: "Submit") + /// Try Again + internal static let tryAgain = L10n.tr("Localizable", "Action.tryAgain", fallback: "Try Again") } internal enum AddSourceView { /// Continue @@ -250,6 +256,8 @@ internal enum L10n { internal static let console = L10n.tr("Localizable", "DevModeView.console", fallback: "Console") /// Data File Explorer internal static let dataExplorer = L10n.tr("Localizable", "DevModeView.dataExplorer", fallback: "Data File Explorer") + /// Skip Resign should only be used when you have an IPA that you have self signed. Otherwise, it will break things, and might make SideStore crash (there is absolutely no error handling and everything is expected to work). + internal static let footer = L10n.tr("Localizable", "DevModeView.footer", fallback: "Skip Resign should only be used when you have an IPA that you have self signed. Otherwise, it will break things, and might make SideStore crash (there is absolutely no error handling and everything is expected to work).") /// Incorrect password. internal static let incorrectPassword = L10n.tr("Localizable", "DevModeView.incorrectPassword", fallback: "Incorrect password.") /// minimuxer debug actions @@ -260,28 +268,31 @@ internal enum L10n { /// /// You should only enable Developer Mode if you meet one of the following requirements: /// - You are a SideStore developer or contributor - /// - When getting support, you were asked to do this by a helper + /// - You were asked to do this by a helper when getting support /// - You were asked to do this when you reported a bug or helped a developer test a change /// /// **_We will not provide support if you break SideStore with Developer Mode._** - internal static let prompt = L10n.tr("Localizable", "DevModeView.prompt", fallback: "SideStore's Developer Mode gives access to a menu with some debugging actions commonly used by developers. **However, some of them can break SideStore if used in the wrong way.**\n\nYou should only enable Developer Mode if you meet one of the following requirements:\n- You are a SideStore developer or contributor\n- When getting support, you were asked to do this by a helper\n- You were asked to do this when you reported a bug or helped a developer test a change\n\n**_We will not provide support if you break SideStore with Developer Mode._**") + internal static let prompt = L10n.tr("Localizable", "DevModeView.prompt", fallback: "SideStore's Developer Mode gives access to a menu with some debugging actions commonly used by developers. **However, some of them can break SideStore if used in the wrong way.**\n\nYou should only enable Developer Mode if you meet one of the following requirements:\n- You are a SideStore developer or contributor\n- You were asked to do this by a helper when getting support\n- You were asked to do this when you reported a bug or helped a developer test a change\n\n**_We will not provide support if you break SideStore with Developer Mode._**") /// Read the text! internal static let read = L10n.tr("Localizable", "DevModeView.read", fallback: "Read the text!") /// Skip Resign internal static let skipResign = L10n.tr("Localizable", "DevModeView.skipResign", fallback: "Skip Resign") - /// Skip Resign should only be used when you have an IPA that you have self signed. Otherwise, it will break things, and might make SideStore crash (there is absolutely no error handling and everything is expected to work). Useful for debugging ApplicationVerificationError - internal static let skipResignInfo = L10n.tr("Localizable", "DevModeView.skipResignInfo", fallback: "Skip Resign should only be used when you have an IPA that you have self signed. Otherwise, it will break things, and might make SideStore crash (there is absolutely no error handling and everything is expected to work). Useful for debugging ApplicationVerificationError") /// DevModeView internal static let title = L10n.tr("Localizable", "DevModeView.title", fallback: "Developer Mode") /// Temporary File Explorer internal static let tmpExplorer = L10n.tr("Localizable", "DevModeView.tmpExplorer", fallback: "Temporary File Explorer") internal enum Minimuxer { + /// AFC File Explorer (check footer for notes) + internal static let afcExplorer = L10n.tr("Localizable", "DevModeView.Minimuxer.afcExplorer", fallback: "AFC File Explorer (check footer for notes)") /// Dump provisioning profiles to Documents directory internal static let dumpProfiles = L10n.tr("Localizable", "DevModeView.Minimuxer.dumpProfiles", fallback: "Dump provisioning profiles to Documents directory") - /// PublicStaging File Explorer - internal static let stagingExplorer = L10n.tr("Localizable", "DevModeView.Minimuxer.stagingExplorer", fallback: "PublicStaging File Explorer") - /// View provisioning profiles - internal static let viewProfiles = L10n.tr("Localizable", "DevModeView.Minimuxer.viewProfiles", fallback: "View provisioning profiles") + /// Notes on AFC File Explorer: + /// - If nothing shows up, check minimuxer logs for error + /// - It is currently extremely very unoptimized and may be very slow; a new AFC client is created for every action + /// - It is currently limited to a maximum depth of 3 to ensure it doesn't take too long to iterate over everything when you open it + /// - Very buggy + /// - There are multiple unimplemented actions + internal static let footer = L10n.tr("Localizable", "DevModeView.Minimuxer.footer", fallback: "Notes on AFC File Explorer:\n- If nothing shows up, check minimuxer logs for error\n- It is currently extremely very unoptimized and may be very slow; a new AFC client is created for every action\n- It is currently limited to a maximum depth of 3 to ensure it doesn't take too long to iterate over everything when you open it\n- Very buggy\n- There are multiple unimplemented actions") } } internal enum MyAppsView { diff --git a/AltStore/Operations/OperationError.swift b/AltStore/Operations/OperationError.swift index 4e7a86b2..fc124c0f 100644 --- a/AltStore/Operations/OperationError.swift +++ b/AltStore/Operations/OperationError.swift @@ -123,50 +123,53 @@ enum OperationError: LocalizedError } } -/// crashes if error is not a MinimuxerError +/// crashes if error is not a MinimuxerError or OperationError func minimuxerToOperationError(_ error: Error) -> OperationError { - switch error as! MinimuxerError { - case .NoDevice: - return OperationError.noDevice - case .NoConnection: - return OperationError.noConnection - case .PairingFile: - return OperationError.invalidPairingFile - case .CreateDebug: - return OperationError.createService(name: "debug") - case .CreateInstproxy: - return OperationError.createService(name: "instproxy") - case .LookupApps: - return OperationError.getFromDevice(name: "installed apps") - case .FindApp: - return OperationError.getFromDevice(name: "path to the app") - case .BundlePath: - return OperationError.getFromDevice(name: "bundle path") - case .MaxPacket: - return OperationError.setArgument(name: "max packet") - case .WorkingDirectory: - return OperationError.setArgument(name: "working directory") - case .Argv: - return OperationError.setArgument(name: "argv") - case .LaunchSuccess: - return OperationError.getFromDevice(name: "launch success") - case .Detach: - return OperationError.detach - case .Attach: - return OperationError.attach - case .CreateAfc: - return OperationError.createService(name: "AFC") - case .RwAfc: - return OperationError.afc - case .InstallApp: - return OperationError.install - case .UninstallApp: - return OperationError.uninstall - case .CreateMisagent: - return OperationError.createService(name: "misagent") - case .ProfileInstall: - return OperationError.profileManage - case .ProfileRemove: - return OperationError.profileManage + if let error = error as? MinimuxerError { + switch error { + case .NoDevice: + return OperationError.noDevice + case .NoConnection: + return OperationError.noConnection + case .PairingFile: + return OperationError.invalidPairingFile + case .CreateDebug: + return OperationError.createService(name: "debug") + case .CreateInstproxy: + return OperationError.createService(name: "instproxy") + case .LookupApps: + return OperationError.getFromDevice(name: "installed apps") + case .FindApp: + return OperationError.getFromDevice(name: "path to the app") + case .BundlePath: + return OperationError.getFromDevice(name: "bundle path") + case .MaxPacket: + return OperationError.setArgument(name: "max packet") + case .WorkingDirectory: + return OperationError.setArgument(name: "working directory") + case .Argv: + return OperationError.setArgument(name: "argv") + case .LaunchSuccess: + return OperationError.getFromDevice(name: "launch success") + case .Detach: + return OperationError.detach + case .Attach: + return OperationError.attach + case .CreateAfc: + return OperationError.createService(name: "AFC") + case .RwAfc: + return OperationError.afc + case .InstallApp: + return OperationError.install + case .UninstallApp: + return OperationError.uninstall + case .CreateMisagent: + return OperationError.createService(name: "misagent") + case .ProfileInstall: + return OperationError.profileManage + case .ProfileRemove: + return OperationError.profileManage + } } + return error as! OperationError } diff --git a/AltStore/Resources/en.lproj/Localizable.strings b/AltStore/Resources/en.lproj/Localizable.strings index 536f0a2d..ff089400 100644 --- a/AltStore/Resources/en.lproj/Localizable.strings +++ b/AltStore/Resources/en.lproj/Localizable.strings @@ -11,6 +11,9 @@ "Action.done" = "Done"; "Action.close" = "Close"; "Action.enable" = "Enable"; +"Action.submit" = "Submit"; +"Action.cancel" = "Cancel"; +"Action.tryAgain" = "Try Again"; /* NewsView */ "NewsView.title" = "News"; @@ -174,7 +177,7 @@ You should only enable Developer Mode if you meet one of the following requirements: - You are a SideStore developer or contributor -- When getting support, you were asked to do this by a helper +- You were asked to do this by a helper when getting support - You were asked to do this when you reported a bug or helped a developer test a change **_We will not provide support if you break SideStore with Developer Mode._**"; @@ -185,11 +188,16 @@ You should only enable Developer Mode if you meet one of the following requireme "DevModeView.dataExplorer" = "Data File Explorer"; "DevModeView.tmpExplorer" = "Temporary File Explorer"; "DevModeView.skipResign" = "Skip Resign"; -"DevModeView.skipResignInfo" = "Skip Resign should only be used when you have an IPA that you have self signed. Otherwise, it will break things, and might make SideStore crash (there is absolutely no error handling and everything is expected to work). Useful for debugging ApplicationVerificationError"; +"DevModeView.footer" = "Skip Resign should only be used when you have an IPA that you have self signed. Otherwise, it will break things, and might make SideStore crash (there is absolutely no error handling and everything is expected to work)."; "DevModeView.minimuxer" = "minimuxer debug actions"; -"DevModeView.Minimuxer.stagingExplorer" = "PublicStaging File Explorer"; -"DevModeView.Minimuxer.viewProfiles" = "View provisioning profiles"; "DevModeView.Minimuxer.dumpProfiles" = "Dump provisioning profiles to Documents directory"; +"DevModeView.Minimuxer.afcExplorer" = "AFC File Explorer (check footer for notes)"; +"DevModeView.Minimuxer.footer" = "Notes on AFC File Explorer: +- If nothing shows up, check minimuxer logs for error +- It is currently extremely very unoptimized and may be very slow; a new AFC client is created for every action +- It is currently limited to a maximum depth of 3 to ensure it doesn't take too long to iterate over everything when you open it +- Very buggy +- There are multiple unimplemented actions"; /* AsyncFallibleButton */ "AsyncFallibleButton.error" = "An error occurred"; diff --git a/AltStore/View Components/FileExplorer.swift b/AltStore/View Components/FileExplorer.swift index b7385162..85ce78d6 100644 --- a/AltStore/View Components/FileExplorer.swift +++ b/AltStore/View Components/FileExplorer.swift @@ -9,6 +9,309 @@ import SwiftUI import ZIPFoundation import UniformTypeIdentifiers +import minimuxer + +extension Binding: Equatable { + public static func == (lhs: Binding, rhs: Binding) -> Bool { + return lhs.wrappedValue == rhs.wrappedValue + } +} + +private protocol FileExplorerBackend { + func delete(_ path: URL) throws + func zip(_ path: URL) throws + func insert(file: URL, to: URL) throws + func iterate(_ directory: URL) -> DirectoryEntry + func getQuickLookURL(_ path: URL) throws -> URL +} + +private class NormalFileExplorerBackend: FileExplorerBackend { + func delete(_ path: URL) throws { + try FileManager.default.removeItem(at: path) + } + + func zip(_ path: URL) throws { + let dest = FileManager.default.documentsDirectory.appendingPathComponent(path.pathComponents.last! + ".zip") + do { + try FileManager.default.removeItem(at: dest) + } catch {} + + try FileManager.default.zipItem(at: path, to: dest) + } + + func insert(file: URL, to: URL) throws { + try FileManager.default.copyItem(at: file, to: to.appendingPathComponent(file.pathComponents.last!), shouldReplace: true) + } + + private func _iterate(directory: URL, parent: URL) -> DirectoryEntry { + var directoryEntry = DirectoryEntry(path: directory, parent: parent, isFile: false) + if let contents = try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil, options: []) { + for entry in contents { + if entry.hasDirectoryPath { + directoryEntry.children!.append(_iterate(directory: entry, parent: directory)) + } else { + directoryEntry.children!.append(DirectoryEntry(path: entry, parent: directory, isFile: true, size: { + guard let attributes = try? FileManager.default.attributesOfItem(atPath: entry.description.replacingOccurrences(of: "file://", with: "")) else { return nil } + return attributes[FileAttributeKey.size] as? Double + }())) + } + } + } + return directoryEntry + } + + func iterate(_ directory: URL) -> DirectoryEntry { + return _iterate(directory: directory, parent: directory) + } + + func getQuickLookURL(_ path: URL) throws -> URL { + path + } +} + +private class AfcFileExplorerBackend: FileExplorerBackend { + func delete(_ path: URL) throws { + try AfcFileManager.remove(path.description.replacingOccurrences(of: "file://", with: "").removingPercentEncoding!) + } + + func zip(_ path: URL) throws { + throw NSError(domain: "AFC currently doesn't support zipping a directory/file. however, it is possible (we should be able to copy the files outside of AFC and then zip the copied directory/file), it just hasn't been implemented", code: -1) + } + + func insert(file: URL, to: URL) throws { + let data = try Data(contentsOf: file) + let rustByteSlice = data.toRustByteSlice() + let to = to.appendingPathComponent(file.lastPathComponent).description.replacingOccurrences(of: "file://", with: "").removingPercentEncoding! + print("writing to \(to)") + try AfcFileManager.writeFile(to, rustByteSlice.forRust()) + } + + private func _addChildren(_ rustEntry: RustDirectoryEntryRef) -> DirectoryEntry { + var entry = DirectoryEntry( + path: URL(string: rustEntry.path().toString())!, + parent: URL(string: rustEntry.parent().toString())!, + isFile: rustEntry.isFile(), + size: rustEntry.size() != nil ? Double(rustEntry.size()!) : nil + ) + for child in rustEntry.children() { + entry.children!.append(_addChildren(child)) + } + return entry + } + + func iterate(_ directory: URL) -> DirectoryEntry { + var directoryEntry = DirectoryEntry(path: directory, parent: directory, isFile: false) + for child in AfcFileManager.contents() { + directoryEntry.children!.append(_addChildren(child)) + } + return directoryEntry + } + + func getQuickLookURL(_ path: URL) throws -> URL { + throw NSError(domain: "AFC currently doesn't support viewing a file. however, it is possible (we should be able to copy the file outside of AFC and then view the copied file), it just hasn't been implemented", code: -1) + } +} + +private struct DirectoryEntry: Identifiable { + var id = UUID() + + var path: URL + var parent: URL + + var isFile: Bool + var size: Double? + var children: [DirectoryEntry]? = [] + + var asString: String { + let str = path.description.replacingOccurrences(of: parent.description, with: "").removingPercentEncoding! + if str.count <= 0 { + return "/" + } + return str + } +} + +private enum FileExplorerAction { + case delete + case zip + case insert + case quickLook +} + +private struct File: View { + @ObservedObject private var iO = Inject.observer + + var item: DirectoryEntry + var backend: FileExplorerBackend + @Binding var explorerHidden: Bool + + @State var quickLookURL: URL? + @State var fileExplorerAction: FileExplorerAction? + @State var hidden = false + @State var isShowingFilePicker = false + @State var selectedFile: URL? + + var body: some View { + AsyncFallibleButton(action: { + switch (fileExplorerAction) { + case .delete: + print("deleting \(item.path.description)") + try backend.delete(item.path) + + case .zip: + print("zipping \(item.path.description)") + try backend.zip(item.path) + + case .insert: + print("inserting \(selectedFile!.description) to \(item.path.description)") + + try backend.insert(file: selectedFile!, to: item.path) + explorerHidden = true + explorerHidden = false + + case .quickLook: + print("viewing \(item.path.description)") + quickLookURL = try backend.getQuickLookURL(item.path) + + default: + print("unknown action for \(item.path.description): \(String(describing: fileExplorerAction))") + } + }, label: { execute in + HStack { + Text(item.asString) + if item.isFile { + Text(getFileSize(item.size)).foregroundColor(.secondary) + } + Spacer() + Menu { + if item.isFile { + SwiftUI.Button(action: { + fileExplorerAction = .quickLook + execute() + }) { + Label("View/Share", systemSymbol: .eye) + } + } else { + SwiftUI.Button(action: { + fileExplorerAction = .zip + execute() + }) { + Label("Save to ZIP file", systemSymbol: .squareAndArrowDown) + } + + SwiftUI.Button { + isShowingFilePicker = true + } label: { + Label("Insert file", systemSymbol: .plus) + } + } + + if item.asString != "/" { + SwiftUI.Button(action: { + fileExplorerAction = .delete + execute() + }) { + Label("Delete", systemSymbol: .trash) + } + } + } label: { + Image(systemSymbol: .ellipsis) + .frame(width: 20, height: 20) // Make it easier to tap + } + } + .onChange(of: $selectedFile) { file in + guard file.wrappedValue != nil else { return } + + fileExplorerAction = .insert + execute() + } + }, afterFinish: { success in + switch (fileExplorerAction) { + case .delete: + if success { hidden = true } + + case .zip: + UIApplication.shared.open(URL(string: "shareddocuments://" + FileManager.default.documentsDirectory.description.replacingOccurrences(of: "file://", with: ""))!, options: [:], completionHandler: nil) + + default: break + } + }, wrapInButton: false) + .quickLookPreview($quickLookURL) + .sheet(isPresented: $isShowingFilePicker) { + DocumentPicker(selectedUrl: $selectedFile, supportedTypes: allUTITypes().map({ $0.identifier })) + .ignoresSafeArea() + } + .isHidden($hidden) + .enableInjection() + } + + func getFileSize(_ bytes: Double?) -> String { + guard var bytes = bytes else { return "Unknown file size" } + + // https://stackoverflow.com/a/14919494 (ported to swift) + let thresh = 1024.0; + + if (bytes < thresh) { + return String(describing: bytes) + " B"; + } + + let units = ["kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; + var u = -1; + + while (bytes >= thresh && u < units.count - 1) { + bytes /= thresh; + u += 1; + } + + return String(format: "%.2f", bytes) + " " + units[u]; + } +} + +struct FileExplorer: View { + @ObservedObject private var iO = Inject.observer + + private var url: URL? + private var backend: FileExplorerBackend + + private init(_ url: URL?, _ backend: FileExplorerBackend) { + self.url = url + self.backend = backend + } + + static func normal(url: URL?) -> FileExplorer { + FileExplorer(url, NormalFileExplorerBackend()) + } + + static func afc() -> FileExplorer { + FileExplorer(URL(string: "/")!, AfcFileExplorerBackend()) + } + + @State var hidden = false + + var body: some View { + List([backend.iterate(url!)], children: \.children) { item in + File(item: item, backend: backend, explorerHidden: $hidden) + } + .toolbar { + ToolbarItem { + SwiftUI.Button { + hidden = true + hidden = false + } label: { + Image(systemSymbol: .arrowClockwise) + } + } + } + .isHidden($hidden) + .enableInjection() + } +} + +struct FileExplorer_Previews: PreviewProvider { + static var previews: some View { + FileExplorer.normal(url: FileManager.default.altstoreSharedDirectory) + } +} // https://stackoverflow.com/a/72165424 func allUTITypes() -> [UTType] { @@ -152,215 +455,3 @@ func allUTITypes() -> [UTType] { return types + types_1 + types_2 } - -extension Binding: Equatable { - public static func == (lhs: Binding, rhs: Binding) -> Bool { - return lhs.wrappedValue == rhs.wrappedValue - } -} - -private struct DirectoryEntry: Identifiable { - var id = UUID() - var path: URL - var parent: URL - var isFile = false - var childFiles = [URL]() - var childDirectories: [DirectoryEntry]? - var filesAndDirectories: [DirectoryEntry]? { - if childFiles.count <= 0 { return childDirectories } - - var filesAndDirectories = childDirectories ?? [] - for file in childFiles { - filesAndDirectories.insert(DirectoryEntry(path: file, parent: path, isFile: true), at: 0) - } - - return filesAndDirectories.sorted(by: { $0.asString < $1.asString }) - } - var asString: String { - let str = path.description.replacingOccurrences(of: parent.description, with: "").removingPercentEncoding! - if str.count <= 0 { - return "/" - } - return str - } -} - -private enum FileExplorerAction { - case delete - case zip - case insert -} - -private struct File: View { - @ObservedObject private var iO = Inject.observer - - var item: DirectoryEntry - @Binding var explorerHidden: Bool - - @State var quickLookURL: URL? - @State var fileExplorerAction: FileExplorerAction? - @State var hidden = false - @State var isShowingFilePicker = false - @State var selectedFile: URL? - - var body: some View { - AsyncFallibleButton(action: { - switch (fileExplorerAction) { - case .delete: - print("deleting \(item.path.description)") - try FileManager.default.removeItem(at: item.path) - - case .zip: - print("zipping \(item.path.description)") - let dest = FileManager.default.documentsDirectory.appendingPathComponent(item.path.pathComponents.last! + ".zip") - do { - try FileManager.default.removeItem(at: dest) - } catch {} - - try FileManager.default.zipItem(at: item.path, to: dest) - - case .insert: - print("inserting \(selectedFile!.description) to \(item.path.description)") - - try FileManager.default.copyItem(at: selectedFile!, to: item.path.appendingPathComponent(selectedFile!.pathComponents.last!), shouldReplace: true) - explorerHidden = true - explorerHidden = false - - default: - print("unknown action for \(item.path.description): \(String(describing: fileExplorerAction))") - } - }, label: { execute in - HStack { - Text(item.asString) - if item.isFile { - Text(getFileSize(file: item.path)).foregroundColor(.secondary) - } - Spacer() - Menu { - if item.isFile { - SwiftUI.Button(action: { quickLookURL = item.path }) { - Label("View/Share", systemSymbol: .eye) - } - } else { - SwiftUI.Button(action: { - fileExplorerAction = .zip - execute() - }) { - Label("Save to ZIP file", systemSymbol: .squareAndArrowDown) - } - - SwiftUI.Button { - isShowingFilePicker = true - } label: { - Label("Insert file", systemSymbol: .plus) - } - } - - if item.asString != "/" { - SwiftUI.Button(action: { - fileExplorerAction = .delete - execute() - }) { - Label("Delete", systemSymbol: .trash) - } - } - } label: { - Image(systemSymbol: .ellipsis) - .frame(width: 20, height: 20) // Make it easier to tap - } - } - .onChange(of: $selectedFile) { file in - guard file.wrappedValue != nil else { return } - - fileExplorerAction = .insert - execute() - } - }, afterFinish: { success in - switch (fileExplorerAction) { - case .delete: - if success { hidden = true } - - case .zip: - UIApplication.shared.open(URL(string: "shareddocuments://" + FileManager.default.documentsDirectory.description.replacingOccurrences(of: "file://", with: ""))!, options: [:], completionHandler: nil) - - default: break - } - }, wrapInButton: false) - .quickLookPreview($quickLookURL) - .sheet(isPresented: $isShowingFilePicker) { - DocumentPicker(selectedUrl: $selectedFile, supportedTypes: allUTITypes().map({ $0.identifier })) - .ignoresSafeArea() - } - .isHidden($hidden) - .enableInjection() - } - - func getFileSize(file: URL) -> String { - guard let attributes = try? FileManager.default.attributesOfItem(atPath: file.description.replacingOccurrences(of: "file://", with: "")) else { return "Unknown file size" } - var bytes = attributes[FileAttributeKey.size] as! Double - - // https://stackoverflow.com/a/14919494 (ported to swift) - let thresh = 1024.0; - - if (bytes < thresh) { - return String(describing: bytes) + " B"; - } - - let units = ["kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; - var u = -1; - - while (bytes >= thresh && u < units.count - 1) { - bytes /= thresh; - u += 1; - } - - return String(format: "%.2f", bytes) + " " + units[u]; - } -} - -struct FileExplorer: View { - @ObservedObject private var iO = Inject.observer - - var url: URL? - - @State var hidden = false - - var body: some View { - List([iterateOverDirectory(directory: url!, parent: url!)], children: \.filesAndDirectories) { item in - File(item: item, explorerHidden: $hidden) - } - .toolbar { - ToolbarItem { - SwiftUI.Button { - hidden = true - hidden = false - } label: { - Image(systemSymbol: .arrowClockwise) - } - } - } - .isHidden($hidden) - .enableInjection() - } - - private func iterateOverDirectory(directory: URL, parent: URL) -> DirectoryEntry { - var directoryEntry = DirectoryEntry(path: directory, parent: parent) - if let contents = try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil, options: []) { - for entry in contents { - if entry.hasDirectoryPath { - if directoryEntry.childDirectories == nil { directoryEntry.childDirectories = [] } - directoryEntry.childDirectories!.append(iterateOverDirectory(directory: entry, parent: directory)) - } else { - directoryEntry.childFiles.append(entry) - } - } - } - return directoryEntry - } -} - -struct FileExplorer_Previews: PreviewProvider { - static var previews: some View { - FileExplorer(url: FileManager.default.altstoreSharedDirectory) - } -} diff --git a/AltStore/Views/Settings/DevModeView.swift b/AltStore/Views/Settings/DevModeView.swift index b55bf0c6..0b074885 100644 --- a/AltStore/Views/Settings/DevModeView.swift +++ b/AltStore/Views/Settings/DevModeView.swift @@ -8,8 +8,9 @@ import SwiftUI import LocalConsole +import minimuxer -// Yes, we know the password is right here. It's also in CONTRIBUTING.md. It's not supposed to be a secret, just something to hopefully prevent people breaking SideStore with dev mode and then complaining to us. +// Yes, we know the password is right here. It's not supposed to be a secret, just something to hopefully prevent people breaking SideStore with dev mode and then complaining to us. let DEV_MODE_PASSWORD = "devmode" struct DevModePrompt: View { @@ -23,16 +24,18 @@ struct DevModePrompt: View { var button: some View { SwiftUI.Button(action: { - if #available(iOS 15.0, *) { + if #available(iOS 16.0, *) { isShowingPasswordAlert = true } else { // iOS 14 doesn't support .alert, so just go straight to dev mode without asking for a password + // iOS 15 also doesn't seem to support TextField in an alert (the text field was nonexistent) enableDevMode() } }) { Text(countdown <= 0 ? L10n.Action.enable + " " + L10n.DevModeView.title : L10n.DevModeView.read + " (\(countdown))") .foregroundColor(.red) } + .buttonStyle(FilledButtonStyle()) // TODO: set tintColor so text is more readable .disabled(countdown > 0) } @@ -53,11 +56,7 @@ struct DevModePrompt: View { .foregroundColor(.primary) .padding(.bottom) - if #available(iOS 15.0, *) { - button.buttonStyle(.bordered) - } else { - button - } + button } .padding(.horizontal) } @@ -81,10 +80,10 @@ struct DevModePrompt: View { if #available(iOS 15.0, *) { view .alert(L10n.DevModeView.password, isPresented: $isShowingPasswordAlert) { - TextField("Password", text: $password) + TextField(L10n.DevModeView.password, text: $password) .autocapitalization(.none) .autocorrectionDisabled(true) - SwiftUI.Button("Submit", action: { + SwiftUI.Button(L10n.Action.submit, action: { if password == DEV_MODE_PASSWORD { enableDevMode() } else { @@ -93,11 +92,11 @@ struct DevModePrompt: View { }) } .alert(L10n.DevModeView.incorrectPassword, isPresented: $isShowingIncorrectPasswordAlert) { - SwiftUI.Button("Try again", action: { + SwiftUI.Button(L10n.Action.tryAgain, action: { isShowingIncorrectPasswordAlert = false isShowingPasswordAlert = true }) - SwiftUI.Button("Cancel", action: { + SwiftUI.Button(L10n.Action.cancel, action: { isShowingIncorrectPasswordAlert = false isShowingDevModePrompt = false }) @@ -138,36 +137,39 @@ struct DevModeMenu: View { } NavigationLink(L10n.DevModeView.dataExplorer) { - FileExplorer(url: FileManager.default.altstoreSharedDirectory) + FileExplorer.normal(url: FileManager.default.altstoreSharedDirectory) .navigationTitle(L10n.DevModeView.dataExplorer) }.foregroundColor(.red) NavigationLink(L10n.DevModeView.tmpExplorer) { - FileExplorer(url: FileManager.default.temporaryDirectory) + FileExplorer.normal(url: FileManager.default.temporaryDirectory) .navigationTitle(L10n.DevModeView.tmpExplorer) }.foregroundColor(.red) Toggle(L10n.DevModeView.skipResign, isOn: ResignAppOperation.skipResignBinding) .foregroundColor(.red) } footer: { - Text(L10n.DevModeView.skipResignInfo) + Text(L10n.DevModeView.footer) } Section { - NavigationLink(L10n.DevModeView.Minimuxer.stagingExplorer + " (Coming soon, needs minimuxer additions)") { - FileExplorer(url: FileManager.default.altstoreSharedDirectory) - .navigationTitle(L10n.DevModeView.Minimuxer.stagingExplorer) - }.foregroundColor(.red).disabled(true) + AsyncFallibleButton(action: { + let dir = try dump_profiles(FileManager.default.documentsDirectory.absoluteString) + DispatchQueue.main.async { + UIApplication.shared.open(URL(string: "shareddocuments://" + dir.toString())!, options: [:], completionHandler: nil) + } + }) { execute in + Text(L10n.DevModeView.Minimuxer.dumpProfiles) + } - NavigationLink(L10n.DevModeView.Minimuxer.viewProfiles + " (Coming soon, needs minimuxer additions)") { - - }.disabled(true) - - SwiftUI.Button(L10n.DevModeView.Minimuxer.dumpProfiles + " (Coming soon, needs minimuxer additions)", action: { - // TODO: dump profiles to Documents/ProfileDump/[current time] - }).disabled(true) + NavigationLink(L10n.DevModeView.Minimuxer.afcExplorer) { + FileExplorer.afc() + .navigationTitle(L10n.DevModeView.Minimuxer.afcExplorer) + }.foregroundColor(.red) } header: { Text(L10n.DevModeView.minimuxer) + } footer: { + Text(L10n.DevModeView.Minimuxer.footer) } } .navigationTitle(L10n.DevModeView.title)