Files
SideStore/AltStore/Settings/Error Log/ErrorLogViewController.swift
2025-07-02 18:19:36 -06:00

607 lines
25 KiB
Swift

//
// ErrorLogViewController.swift
// AltStore
//
// Created by Riley Testut on 9/6/22.
// Copyright © 2022 Riley Testut. All rights reserved.
//
import UIKit
import SafariServices
import QuickLook
import AltStoreCore
import Roxas
import Nuke
import QuickLook
import SwiftUI
final class ErrorLogViewController: UITableViewController, QLPreviewControllerDelegate
{
private lazy var dataSource = self.makeDataSource()
private var expandedErrorIDs = Set<NSManagedObjectID>()
private var isScrolling = false {
didSet {
guard self.isScrolling != oldValue else { return }
self.updateButtonInteractivity()
}
}
private lazy var timeFormatter: DateFormatter = {
let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .none
dateFormatter.timeStyle = .short
return dateFormatter
}()
@IBOutlet private var exportLogButton: UIBarButtonItem!
@IBOutlet private var clearLogButton: UIBarButtonItem!
private var _exportedLogURL: URL?
override var preferredStatusBarStyle: UIStatusBarStyle {
return .lightContent
}
override func viewDidLoad()
{
super.viewDidLoad()
self.tableView.dataSource = self.dataSource
self.tableView.prefetchDataSource = self.dataSource
self.exportLogButton.activityIndicatorView.color = .white
if #unavailable(iOS 15)
{
// Assign just clearLogButton to hide export button.
self.navigationItem.rightBarButtonItems = [self.clearLogButton]
}
// // Adjust the width of the right bar button items
// adjustRightBarButtonWidth()
}
// func adjustRightBarButtonWidth() {
// // Access the current rightBarButtonItems
// if let rightBarButtonItems = self.navigationItem.rightBarButtonItems {
// for barButtonItem in rightBarButtonItems {
// // Check if the button is a system button, and if so, replace it with a custom button
// if barButtonItem.customView == nil {
// // Replace with a custom UIButton for each bar button item
// let customButton = UIButton(type: .custom)
// if let image = barButtonItem.image {
// customButton.setImage(image, for: .normal)
// }
// if let action = barButtonItem.action{
// customButton.addTarget(barButtonItem.target, action: action, for: .touchUpInside)
// }
//
// // Calculate the original size based on the system button
// let originalSize = customButton.sizeThatFits(CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude))
//
// let scaleFactor = 0.7
//
// // Scale the size by 0.7 (70%)
// let scaledSize = CGSize(width: originalSize.width * scaleFactor, height: originalSize.height * scaleFactor)
//
// // Adjust the custom button's width
//// customButton.frame.size = CGSize(width: 22, height: 22) // Adjust width as needed
// customButton.frame.size = scaledSize // Adjust width as needed
//
// // Set the custom button as the custom view for the UIBarButtonItem
// barButtonItem.customView = customButton
// }
// }
// }
// }
override func prepare(for segue: UIStoryboardSegue, sender: Any?)
{
guard let loggedError = sender as? LoggedError, segue.identifier == "showErrorDetails" else { return }
let navigationController = segue.destination as! UINavigationController
let errorDetailsViewController = navigationController.viewControllers.first as! ErrorDetailsViewController
errorDetailsViewController.loggedError = loggedError
}
@IBAction private func unwindFromErrorDetails(_ segue: UIStoryboardSegue)
{
}
}
private extension ErrorLogViewController
{
func makeDataSource() -> RSTFetchedResultsTableViewPrefetchingDataSource<LoggedError, UIImage>
{
let fetchRequest = LoggedError.fetchRequest() as NSFetchRequest<LoggedError>
fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \LoggedError.date, ascending: false)]
fetchRequest.returnsObjectsAsFaults = false
let fetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext, sectionNameKeyPath: #keyPath(LoggedError.localizedDateString), cacheName: nil)
let dataSource = RSTFetchedResultsTableViewPrefetchingDataSource<LoggedError, UIImage>(fetchedResultsController: fetchedResultsController)
dataSource.proxy = self
dataSource.rowAnimation = .fade
dataSource.cellConfigurationHandler = { [weak self] (cell, loggedError, indexPath) in
guard let self else { return }
let cell = cell as! ErrorLogTableViewCell
cell.dateLabel.text = self.timeFormatter.string(from: loggedError.date)
cell.errorFailureLabel.text = loggedError.localizedFailure ?? NSLocalizedString("Operation Failed", comment: "")
cell.errorCodeLabel.text = loggedError.error.localizedErrorCode
let nsError = loggedError.error as NSError
let errorDescription = [nsError.localizedDescription, nsError.localizedRecoverySuggestion].compactMap { $0 }.joined(separator: "\n\n")
cell.errorDescriptionTextView.text = errorDescription
cell.errorDescriptionTextView.maximumNumberOfLines = 5
cell.errorDescriptionTextView.isCollapsed = !self.expandedErrorIDs.contains(loggedError.objectID)
cell.errorDescriptionTextView.moreButton.addTarget(self, action: #selector(ErrorLogViewController.toggleCollapsingCell(_:)), for: .primaryActionTriggered)
cell.appIconImageView.image = nil
cell.appIconImageView.isIndicatingActivity = true
cell.appIconImageView.layer.borderColor = UIColor.gray.cgColor
let displayScale = (self.traitCollection.displayScale == 0.0) ? 1.0 : self.traitCollection.displayScale // 0.0 == "unspecified"
cell.appIconImageView.layer.borderWidth = 1.0 / displayScale
let menu = UIMenu(title: "", children: [
UIAction(title: NSLocalizedString("Copy Error Message", comment: ""), image: UIImage(systemName: "doc.on.doc")) { [weak self] _ in
self?.copyErrorMessage(for: loggedError)
},
UIAction(title: NSLocalizedString("Copy Error Code", comment: ""), image: UIImage(systemName: "doc.on.doc")) { [weak self] _ in
self?.copyErrorCode(for: loggedError)
},
UIAction(title: NSLocalizedString("Search FAQ", comment: ""), image: UIImage(systemName: "magnifyingglass")) { [weak self] _ in
self?.searchFAQ(for: loggedError)
},
UIAction(title: NSLocalizedString("View More Details", comment: ""), image: UIImage(systemName: "ellipsis.circle")) { [weak self] _ in
self?.viewMoreDetails(for: loggedError)
},
])
cell.menuButton.menu = menu
if self.isScrolling
{
cell.menuButton.showsMenuAsPrimaryAction = false
}
else
{
cell.menuButton.showsMenuAsPrimaryAction = true
}
cell.selectionStyle = .none
// Include errorDescriptionTextView's text in cell summary.
cell.accessibilityLabel = [cell.errorFailureLabel.text, cell.dateLabel.text, cell.errorCodeLabel.text, cell.errorDescriptionTextView.text].compactMap { $0 }.joined(separator: ". ")
// Group all paragraphs together into single accessibility element (otherwise, each paragraph is independently selectable).
cell.errorDescriptionTextView.accessibilityLabel = cell.errorDescriptionTextView.text
}
dataSource.prefetchHandler = { (loggedError, indexPath, completion) in
RSTAsyncBlockOperation { (operation) in
loggedError.managedObjectContext?.perform {
if let installedApp = loggedError.installedApp
{
installedApp.loadIcon { (result) in
switch result
{
case .failure(let error): completion(nil, error)
case .success(let image): completion(image, nil)
}
}
}
else if let storeApp = loggedError.storeApp
{
ImagePipeline.shared.loadImage(with: storeApp.iconURL, progress: nil) { result in
guard !operation.isCancelled else { return operation.finish() }
switch result
{
case .success(let response): completion(response.image, nil)
case .failure(let error): completion(nil, error)
}
}
}
else
{
// InstalledApp was probably deleted.
completion(nil, nil)
}
}
}
}
dataSource.prefetchCompletionHandler = { (cell, image, indexPath, error) in
let cell = cell as! ErrorLogTableViewCell
cell.appIconImageView.image = image
cell.appIconImageView.isIndicatingActivity = false
}
let placeholderView = RSTPlaceholderView()
placeholderView.textLabel.text = NSLocalizedString("No Errors", comment: "")
placeholderView.detailTextLabel.text = NSLocalizedString("Errors that occur when sideloading or refreshing apps will appear here.", comment: "")
dataSource.placeholderView = placeholderView
return dataSource
}
}
private extension ErrorLogViewController
{
@IBAction func toggleCollapsingCell(_ sender: UIButton)
{
let point = self.tableView.convert(sender.center, from: sender.superview)
guard let indexPath = self.tableView.indexPathForRow(at: point), let cell = self.tableView.cellForRow(at: indexPath) as? ErrorLogTableViewCell else { return }
let loggedError = self.dataSource.item(at: indexPath)
if cell.errorDescriptionTextView.isCollapsed
{
self.expandedErrorIDs.remove(loggedError.objectID)
}
else
{
self.expandedErrorIDs.insert(loggedError.objectID)
}
self.tableView.performBatchUpdates {
cell.layoutIfNeeded()
}
}
enum LogView: String {
case consoleLog = "console-log"
case minimuxerLog = "minimuxer-log"
// This class will manage the QLPreviewController and the timer.
private class LogViewManager {
var previewController: QLPreviewController
var refreshTimer: Timer?
var logView: LogView
init(previewController: QLPreviewController, logView: LogView) {
self.previewController = previewController
self.logView = logView
}
// Start refreshing the preview controller every second
func startRefreshing() {
refreshTimer = Timer.scheduledTimer(timeInterval: 1.0, target: self, selector: #selector(refreshPreview), userInfo: nil, repeats: true)
}
@objc private func refreshPreview() {
previewController.reloadData()
}
// Stop the timer to prevent leaks
func stopRefreshing() {
refreshTimer?.invalidate()
refreshTimer = nil
}
func updateLogPath() {
// Force the QLPreviewController to reload by changing the file path
previewController.reloadData()
}
}
// Method to get the QLPreviewController for this log type
func getViewController(_ dataSource: QLPreviewControllerDataSource) -> QLPreviewController {
let previewController = QLPreviewController()
previewController.restorationIdentifier = self.rawValue
previewController.dataSource = dataSource
// Create LogViewManager and start refreshing
let manager = LogViewManager(previewController: previewController, logView: self)
// manager.startRefreshing() // DO NOT REFRESH the full view contents causing flickering
return previewController
}
func getLogPath() -> URL {
switch self {
case .consoleLog:
let appDelegate = UIApplication.shared.delegate as! AppDelegate
return appDelegate.consoleLog.logFileURL
case .minimuxerLog:
return FileManager.default.documentsDirectory.appendingPathComponent("minimuxer.log")
}
}
}
@IBAction func showConsoleLogs(_ sender: UIBarButtonItem) {
// Create the SwiftUI ConsoleLogView with the URL
let consoleLogView = ConsoleLogView(logURL: (UIApplication.shared.delegate as! AppDelegate).consoleLog.logFileURL)
// Create the UIHostingController
let consoleLogController = UIHostingController(rootView: consoleLogView)
// Configure the bottom sheet presentation
consoleLogController.modalPresentationStyle = .pageSheet
if let sheet = consoleLogController.sheetPresentationController {
sheet.detents = [.medium(), .large()] // You can adjust the size of the sheet (medium/large)
sheet.prefersGrabberVisible = true // Optional: Shows a grabber at the top of the sheet
sheet.selectedDetentIdentifier = .large // Default size when presented
}
// Present the bottom sheet
present(consoleLogController, animated: true, completion: nil)
}
@IBAction func clearLoggedErrors(_ sender: UIBarButtonItem)
{
let alertController = UIAlertController(title: NSLocalizedString("Are you sure you want to clear the error log?", comment: ""), message: nil, preferredStyle: .actionSheet)
alertController.popoverPresentationController?.barButtonItem = sender
alertController.addAction(.cancel)
alertController.addAction(UIAlertAction(title: NSLocalizedString("Clear Error Log", comment: ""), style: .destructive) { _ in
self.clearLoggedErrors()
})
self.present(alertController, animated: true)
}
func clearLoggedErrors()
{
DatabaseManager.shared.purgeLoggedErrors { result in
do
{
try result.get()
}
catch
{
DispatchQueue.main.async {
let alertController = UIAlertController(title: NSLocalizedString("Failed to Clear Error Log", comment: ""), message: error.localizedDescription, preferredStyle: .alert)
alertController.addAction(.ok)
self.present(alertController, animated: true)
}
}
}
}
func copyErrorMessage(for loggedError: LoggedError)
{
let nsError = loggedError.error as NSError
let errorMessage = [nsError.localizedDescription, nsError.localizedRecoverySuggestion].compactMap { $0 }.joined(separator: "\n\n")
UIPasteboard.general.string = errorMessage
}
func copyErrorCode(for loggedError: LoggedError)
{
let errorCode = loggedError.error.localizedErrorCode
UIPasteboard.general.string = errorCode
}
func searchFAQ(for loggedError: LoggedError)
{
let staticURL = URL(string: "https://docs.sidestore.io/docs/troubleshooting/error-codes")!
let safariViewController = SFSafariViewController(url: staticURL)
safariViewController.preferredControlTintColor = .altPrimary
self.present(safariViewController, animated: true)
}
func viewMoreDetails(for loggedError: LoggedError) {
self.performSegue(withIdentifier: "showErrorDetails", sender: loggedError)
}
@IBAction func showMinimuxerLogs(_ sender: UIBarButtonItem)
{
// Show minimuxer.log
let previewController = LogView.minimuxerLog.getViewController(self)
let navigationController = UINavigationController(rootViewController: previewController)
present(navigationController, animated: true, completion: nil)
}
// @available(iOS 15, *)
// @IBAction func exportDetailedLog(_ sender: UIBarButtonItem)
// {
// self.exportLogButton.isIndicatingActivity = true
//
// Task<Void, Never>.detached(priority: .userInitiated) {
// do
// {
// let store = try OSLogStore(scope: .currentProcessIdentifier)
//
// // All logs since the app launched.
// let position = store.position(timeIntervalSinceLatestBoot: 0)
//// let predicate = NSPredicate(format: "subsystem == %@", Logger.altstoreSubsystem)
////
//// let entries = try store.getEntries(at: position, matching: predicate)
//// .compactMap { $0 as? OSLogEntryLog }
//// .map { "[\($0.date.formatted())] [\($0.category)] [\($0.level.localizedName)] \($0.composedMessage)" }
////
// // Remove the predicate to get all log entries
//// let entries = try store.getEntries(at: position)
//// .compactMap { $0 as? OSLogEntryLog }
//// .map { "[\($0.date.formatted())] [\($0.category)] [\($0.level.localizedName)] \($0.composedMessage)" }
//
// let entries = try store.getEntries(at: position)
//
//// let outputText = entries.joined(separator: "\n")
// let outputText = entries.map { $0.description }.joined(separator: "\n")
//
// let outputDirectory = FileManager.default.uniqueTemporaryURL()
// try FileManager.default.createDirectory(at: outputDirectory, withIntermediateDirectories: true)
//
// let outputURL = outputDirectory.appendingPathComponent("altstore.log")
// try outputText.write(to: outputURL, atomically: true, encoding: .utf8)
//
// await MainActor.run {
// self._exportedLogURL = outputURL
//
// let previewController = QLPreviewController()
// previewController.delegate = self
// previewController.dataSource = self
// previewController.view.tintColor = .altPrimary
// self.present(previewController, animated: true)
// }
// }
// catch
// {
// Logger.main.error("Failed to export OSLog entries. \(error.localizedDescription, privacy: .public)")
//
// await MainActor.run {
// let alertController = UIAlertController(title: NSLocalizedString("Unable to Export Detailed Log", comment: ""), message: error.localizedDescription, preferredStyle: .alert)
// alertController.addAction(.ok)
// self.present(alertController, animated: true)
// }
// }
//
// await MainActor.run {
// self.exportLogButton.isIndicatingActivity = false
// }
// }
// }
}
extension ErrorLogViewController
{
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath)
{
guard #unavailable(iOS 14) else { return }
let loggedError = self.dataSource.item(at: indexPath)
let alertController = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)
alertController.addAction(UIAlertAction(title: UIAlertAction.cancel.title, style: UIAlertAction.cancel.style) { _ in
tableView.deselectRow(at: indexPath, animated: true)
})
alertController.addAction(UIAlertAction(title: NSLocalizedString("Copy Error Message", comment: ""), style: .default) { [weak self] _ in
self?.copyErrorMessage(for: loggedError)
tableView.deselectRow(at: indexPath, animated: true)
})
alertController.addAction(UIAlertAction(title: NSLocalizedString("Copy Error Code", comment: ""), style: .default) { [weak self] _ in
self?.copyErrorCode(for: loggedError)
tableView.deselectRow(at: indexPath, animated: true)
})
alertController.addAction(UIAlertAction(title: NSLocalizedString("Search FAQ", comment: ""), style: .default) { [weak self] _ in
self?.searchFAQ(for: loggedError)
tableView.deselectRow(at: indexPath, animated: true)
})
self.present(alertController, animated: true)
}
override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration?
{
let deleteAction = UIContextualAction(style: .destructive, title: NSLocalizedString("Delete", comment: "")) { _, _, completion in
let loggedError = self.dataSource.item(at: indexPath)
DatabaseManager.shared.persistentContainer.performBackgroundTask { context in
do
{
let loggedError = context.object(with: loggedError.objectID) as! LoggedError
context.delete(loggedError)
try context.save()
completion(true)
}
catch
{
print("[ALTLog] Failed to delete LoggedError \(loggedError.objectID):", error)
completion(false)
}
}
}
let configuration = UISwipeActionsConfiguration(actions: [deleteAction])
configuration.performsFirstActionWithFullSwipe = false
return configuration
}
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String?
{
let indexPath = IndexPath(row: 0, section: section)
let loggedError = self.dataSource.item(at: indexPath)
if Calendar.current.isDateInToday(loggedError.date)
{
return NSLocalizedString("Today", comment: "")
}
else
{
return loggedError.localizedDateString
}
}
}
extension ErrorLogViewController: QLPreviewControllerDataSource {
func numberOfPreviewItems(in controller: QLPreviewController) -> Int {
return 1
}
func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem
{
guard let identifier = controller.restorationIdentifier,
let logView = LogView(rawValue: identifier) else {
fatalError("Invalid restorationIdentifier")
}
return logView.getLogPath() as QLPreviewItem
}
}
extension ErrorLogViewController
{
override func scrollViewWillBeginDragging(_ scrollView: UIScrollView)
{
self.isScrolling = true
}
override func scrollViewDidEndDecelerating(_ scrollView: UIScrollView)
{
self.isScrolling = false
}
override func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool)
{
guard !decelerate else { return }
self.isScrolling = false
}
private func updateButtonInteractivity()
{
for case let cell as ErrorLogTableViewCell in self.tableView.visibleCells
{
if self.isScrolling
{
cell.menuButton.showsMenuAsPrimaryAction = false
}
else
{
cell.menuButton.showsMenuAsPrimaryAction = true
}
}
}
}
//extension ErrorLogViewController: QLPreviewControllerDataSource, QLPreviewControllerDelegate
//{
// func numberOfPreviewItems(in controller: QLPreviewController) -> Int
// {
// return 1
// }
//
// func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem
// {
// return (_exportedLogURL as? NSURL) ?? NSURL()
// }
//
// func previewControllerDidDismiss(_ controller: QLPreviewController)
// {
// guard let exportedLogURL = _exportedLogURL else { return }
//
// let parentDirectory = exportedLogURL.deletingLastPathComponent()
//
// do
// {
// try FileManager.default.removeItem(at: parentDirectory)
// }
// catch
// {
// Logger.main.error("Failed to remove temporary log directory \(parentDirectory.lastPathComponent, privacy: .public). \(error.localizedDescription, privacy: .public)")
// }
//
// _exportedLogURL = nil
// }
//}