From 2e01116f1fecd750e3ef42cf34805c5f0b8a1bd4 Mon Sep 17 00:00:00 2001 From: Magesh K <47920326+mahee96@users.noreply.github.com> Date: Sun, 29 Dec 2024 03:12:59 +0530 Subject: [PATCH] [Console-Log]: Added raw console logging in ErrorLog section (ladybug icon) --- AltStore.xcodeproj/project.pbxproj | 48 +++++ AltStore/AppDelegate.swift | 11 ++ .../Settings/Error Log/ConsoleLogView.swift | 123 +++++++++++++ .../Error Log/ErrorLogViewController.swift | 156 ++++++++++++++-- .../Utils/common/AbstractClassError.swift | 12 ++ SideStore/Utils/common/FileOutputStream.swift | 29 +++ SideStore/Utils/common/OutputStream.swift | 15 ++ SideStore/Utils/iostreams/ConsoleLog.swift | 73 ++++++++ SideStore/Utils/iostreams/ConsoleLogger.swift | 166 ++++++++++++++++++ 9 files changed, 616 insertions(+), 17 deletions(-) create mode 100644 AltStore/Settings/Error Log/ConsoleLogView.swift create mode 100644 SideStore/Utils/common/AbstractClassError.swift create mode 100644 SideStore/Utils/common/FileOutputStream.swift create mode 100644 SideStore/Utils/common/OutputStream.swift create mode 100644 SideStore/Utils/iostreams/ConsoleLog.swift create mode 100644 SideStore/Utils/iostreams/ConsoleLogger.swift diff --git a/AltStore.xcodeproj/project.pbxproj b/AltStore.xcodeproj/project.pbxproj index 87e53fa4..fe7ce1b3 100644 --- a/AltStore.xcodeproj/project.pbxproj +++ b/AltStore.xcodeproj/project.pbxproj @@ -61,6 +61,12 @@ A8945AA62D059B6100D86CBE /* Roxas.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A8945AA52D059B6100D86CBE /* Roxas.framework */; }; A8A543302D04F14400D72399 /* libfragmentzip.a in Frameworks */ = {isa = PBXBuildFile; fileRef = A8A5432F2D04F0C100D72399 /* libfragmentzip.a */; }; A8BB34E52D04EC8E000A8B4D /* minimuxer-helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = A809F6A52D04DA1900F0F0F3 /* minimuxer-helpers.swift */; }; + A8C38C242D206A3A00E83DBD /* ConsoleLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8C38C1D2D206A3A00E83DBD /* ConsoleLogger.swift */; }; + A8C38C262D206A3A00E83DBD /* ConsoleLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8C38C1E2D206A3A00E83DBD /* ConsoleLog.swift */; }; + A8C38C2A2D206AC100E83DBD /* OutputStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8C38C282D206AC100E83DBD /* OutputStream.swift */; }; + A8C38C2C2D206AD900E83DBD /* AbstractClassError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8C38C2B2D206AD900E83DBD /* AbstractClassError.swift */; }; + A8C38C322D206B2500E83DBD /* FileOutputStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8C38C312D206B2500E83DBD /* FileOutputStream.swift */; }; + A8C38C382D2084D000E83DBD /* ConsoleLogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8C38C372D2084D000E83DBD /* ConsoleLogView.swift */; }; A8C6D50C2D1EE87600DF01F1 /* AltSign-Static in Frameworks */ = {isa = PBXBuildFile; productRef = A8C6D50B2D1EE87600DF01F1 /* AltSign-Static */; }; A8C6D5122D1EE8AF00DF01F1 /* AltSign-Static in Frameworks */ = {isa = PBXBuildFile; productRef = A8C6D5112D1EE8AF00DF01F1 /* AltSign-Static */; }; A8C6D5132D1EE8D700DF01F1 /* OpenSSL.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A859ED5B2D1EE80D003DCC58 /* OpenSSL.xcframework */; }; @@ -624,6 +630,12 @@ A86202322D1F35640091187B /* AltStore.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AltStore.xcconfig; sourceTree = ""; }; A86202332D1F35640091187B /* AltStoreCore.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AltStoreCore.xcconfig; sourceTree = ""; }; A8945AA52D059B6100D86CBE /* Roxas.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Roxas.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + A8C38C1D2D206A3A00E83DBD /* ConsoleLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsoleLogger.swift; sourceTree = ""; }; + A8C38C1E2D206A3A00E83DBD /* ConsoleLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsoleLog.swift; sourceTree = ""; }; + A8C38C282D206AC100E83DBD /* OutputStream.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutputStream.swift; sourceTree = ""; }; + A8C38C2B2D206AD900E83DBD /* AbstractClassError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AbstractClassError.swift; sourceTree = ""; }; + A8C38C312D206B2500E83DBD /* FileOutputStream.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileOutputStream.swift; sourceTree = ""; }; + A8C38C372D2084D000E83DBD /* ConsoleLogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsoleLogView.swift; sourceTree = ""; }; A8D484D72D0CD306002C691D /* AltBackup.ipa */ = {isa = PBXFileReference; lastKnownFileType = file; path = AltBackup.ipa; sourceTree = ""; }; A8F66C3C2D04D433009689E6 /* em_proxy.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = em_proxy.h; sourceTree = ""; }; A8F66C602D04D464009689E6 /* minimuxer.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; path = minimuxer.xcodeproj; sourceTree = ""; }; @@ -1161,6 +1173,34 @@ name = Products; sourceTree = ""; }; + A8C38C1C2D2068D100E83DBD /* Utils */ = { + isa = PBXGroup; + children = ( + A8C38C272D206AA500E83DBD /* common */, + A8C38C202D206A3A00E83DBD /* iostreams */, + ); + path = Utils; + sourceTree = ""; + }; + A8C38C202D206A3A00E83DBD /* iostreams */ = { + isa = PBXGroup; + children = ( + A8C38C1D2D206A3A00E83DBD /* ConsoleLogger.swift */, + A8C38C1E2D206A3A00E83DBD /* ConsoleLog.swift */, + ); + path = iostreams; + sourceTree = ""; + }; + A8C38C272D206AA500E83DBD /* common */ = { + isa = PBXGroup; + children = ( + A8C38C282D206AC100E83DBD /* OutputStream.swift */, + A8C38C312D206B2500E83DBD /* FileOutputStream.swift */, + A8C38C2B2D206AD900E83DBD /* AbstractClassError.swift */, + ); + path = common; + sourceTree = ""; + }; A8F66C072D04C025009689E6 /* SideStore */ = { isa = PBXGroup; children = ( @@ -1170,6 +1210,7 @@ B343F84D295F6323002B1159 /* em_proxy.xcodeproj */, 19104DB32909C06D00C49C7B /* EmotionalDamage */, B343F886295F7F9B002B1159 /* libfragmentzip.xcodeproj */, + A8C38C1C2D2068D100E83DBD /* Utils */, ); path = SideStore; sourceTree = ""; @@ -2095,6 +2136,7 @@ D589170128C7D93500E39C8B /* Error Log */ = { isa = PBXGroup; children = ( + A8C38C372D2084D000E83DBD /* ConsoleLogView.swift */, D57FE84328C7DB7100216002 /* ErrorLogViewController.swift */, D54DED1328CBC44B008B27A0 /* ErrorLogTableViewCell.swift */, 0EE7FDCC2BE9124400D1E390 /* ErrorDetailsViewController.swift */, @@ -2838,6 +2880,7 @@ files = ( BFDB6A0F22AB2776007EA6D6 /* SendAppOperation.swift in Sources */, BFDB6A0D22AAFC1A007EA6D6 /* OperationError.swift in Sources */, + A8C38C2C2D206AD900E83DBD /* AbstractClassError.swift in Sources */, BF74989B23621C0700CED65F /* ForwardingNavigationController.swift in Sources */, D57F2C9426E01BC700B9FA39 /* UIDevice+Vibration.swift in Sources */, BFD2478F2284C8F900981D42 /* Button.swift in Sources */, @@ -2865,11 +2908,14 @@ BFCCB51A245E3401001853EA /* VerifyAppOperation.swift in Sources */, BFF0B6982322CAB8007A79E1 /* InstructionsViewController.swift in Sources */, BF9ABA4522DCFF43008935CF /* BrowseViewController.swift in Sources */, + A8C38C242D206A3A00E83DBD /* ConsoleLogger.swift in Sources */, + A8C38C262D206A3A00E83DBD /* ConsoleLog.swift in Sources */, D5935AED29C39DE300C157EF /* SourceComponents.swift in Sources */, D5935AED29C39DE300C157EF /* SourceComponents.swift in Sources */, A8FD917C2D0478D200322782 /* VerificationError.swift in Sources */, D5A0537329B91DB400997551 /* SourceDetailContentViewController.swift in Sources */, BF770E5422BC044E002A40FE /* OperationContexts.swift in Sources */, + A8C38C322D206B2500E83DBD /* FileOutputStream.swift in Sources */, BFD2478C2284C4C300981D42 /* AppIconImageView.swift in Sources */, BF8F69C422E662D300049BA1 /* AppViewController.swift in Sources */, BFF0B68E23219520007A79E1 /* PatreonViewController.swift in Sources */, @@ -2929,12 +2975,14 @@ BF9ABA4B22DD1380008935CF /* NavigationBar.swift in Sources */, A8FD917B2D0472DD00322782 /* DeprecatedAPIs.swift in Sources */, BF6C8FAC242935ED00125131 /* NSAttributedString+Markdown.m in Sources */, + A8C38C382D2084D000E83DBD /* ConsoleLogView.swift in Sources */, BFF00D322501BDA100746320 /* BackgroundRefreshAppsOperation.swift in Sources */, BF0C4EBD22A1BD8B009A2DD7 /* AppManager.swift in Sources */, BF2901312318F7A800D88A45 /* AppBannerView.swift in Sources */, 0EE7FDC42BE8BC7900D1E390 /* ALTLocalizedError.swift in Sources */, BFF00D342501BDCF00746320 /* IntentHandler.swift in Sources */, BFDBBD80246CB84F004ED2F3 /* RemoveAppBackupOperation.swift in Sources */, + A8C38C2A2D206AC100E83DBD /* OutputStream.swift in Sources */, BFF0B6942321CB85007A79E1 /* AuthenticationViewController.swift in Sources */, 0EE7FDCD2BE9124400D1E390 /* ErrorDetailsViewController.swift in Sources */, D561AF822B21669400BF59C6 /* VerifyAppPledgeOperation.swift in Sources */, diff --git a/AltStore/AppDelegate.swift b/AltStore/AppDelegate.swift index 49c1bda1..ec270382 100644 --- a/AltStore/AppDelegate.swift +++ b/AltStore/AppDelegate.swift @@ -41,8 +41,14 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { private let intentHandler = IntentHandler() private let viewAppIntentHandler = ViewAppIntentHandler() + public let consoleLog = ConsoleLog() + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + + // start logging to console immediately on startup + consoleLog.startCapturing() + // Override point for customization after application launch. // UserDefaults.standard.setValue(true, forKey: "com.apple.CoreData.MigrationDebug") // UserDefaults.standard.setValue(true, forKey: "com.apple.CoreData.SQLDebug") @@ -130,6 +136,11 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { default: return nil } } + + func applicationWillTerminate(_ application: UIApplication) { + // Stop console logging and clean up resources + consoleLog.stopCapturing() + } } extension AppDelegate diff --git a/AltStore/Settings/Error Log/ConsoleLogView.swift b/AltStore/Settings/Error Log/ConsoleLogView.swift new file mode 100644 index 00000000..36568db3 --- /dev/null +++ b/AltStore/Settings/Error Log/ConsoleLogView.swift @@ -0,0 +1,123 @@ +// +// ConsoleLogView.swift +// AltStore +// +// Created by Magesh K on 29/12/24. +// Copyright © 2024 SideStore. All rights reserved. +// +import SwiftUI + +class ConsoleLogViewModel: ObservableObject { + @Published var logLines: [String] = [] + + private var fileWatcher: DispatchSourceFileSystemObject? + + private let backgroundQueue = DispatchQueue(label: "com.myapp.backgroundQueue", qos: .background) + private var logURL: URL + + init(logURL: URL) { + self.logURL = logURL + startFileWatcher() // Start monitoring the log file for changes + reloadLogData() // Load initial log data + } + + private func startFileWatcher() { + let fileDescriptor = open(logURL.path, O_RDONLY) + guard fileDescriptor != -1 else { + print("Unable to open file for reading.") + return + } + + fileWatcher = DispatchSource.makeFileSystemObjectSource(fileDescriptor: fileDescriptor, eventMask: .write, queue: backgroundQueue) + fileWatcher?.setEventHandler { + self.reloadLogData() + } + fileWatcher?.resume() + } + + private func reloadLogData() { + if let fileContents = try? String(contentsOf: logURL) { + let lines = fileContents.split(whereSeparator: \.isNewline).map { String($0) } + DispatchQueue.main.async { + self.logLines = lines + } + } + } + + deinit { + fileWatcher?.cancel() + } +} + + +public struct ConsoleLogView: View { + + @ObservedObject var viewModel: ConsoleLogViewModel + @State private var isAtBottom: Bool = true +// private let linesToShow: Int = 100 // Number of lines to show at once + @State private var scrollToBottom: Bool = false // State variable to trigger scroll + + init(logURL: URL) { + self.viewModel = ConsoleLogViewModel(logURL: logURL) + } + + public var body: some View { + VStack { + // Custom Header Bar (similar to QuickLook's preview screen) + HStack { + Text("Console Log") + .font(.system(size: 22, weight: .semibold)) + .foregroundColor(.white) + Spacer() + SwiftUI.Button(action: { + scrollToBottom.toggle() + }) { + Image(systemName: "ellipsis") + .foregroundColor(.white) + .imageScale(.large) + } + } + .padding(15) + .padding(.top, 5) + .padding(.bottom, 2.5) + .background(Color.black.opacity(0.9)) + .overlay( + Rectangle() + .frame(height: 1) + .foregroundColor(Color.gray.opacity(0.2)), alignment: .bottom + ) + + // Main Log Display (scrollable area) + ScrollView(.vertical) { + ScrollViewReader { scrollViewProxy in + LazyVStack(alignment: .leading, spacing: 4) { + ForEach(viewModel.logLines.indices, id: \.self) { index in + Text(viewModel.logLines[index]) + .font(.system(size: 12, design: .monospaced)) + .foregroundColor(.white) + } + } + .onChange(of: scrollToBottom) { _ in + scrollToBottomIfNeeded(scrollViewProxy: scrollViewProxy) + } + } + } + } + .background(Color.black) // Set background color to mimic QL's dark theme + .edgesIgnoringSafeArea(.all) + } + + // Scroll to the last index (bottom) only if logLines is not empty + private func scrollToBottomIfNeeded(scrollViewProxy: ScrollViewProxy) { + // Ensure we have log data before attempting to scroll + guard !viewModel.logLines.isEmpty else { + return + } + + let last = viewModel.logLines.count - 1 + let lastIdx = viewModel.logLines.indices.last + assert(last == lastIdx) +// scrollViewProxy.scrollTo(lastIdx, anchor: .bottom) + scrollViewProxy.scrollTo(last, anchor: .bottom) + } +} diff --git a/AltStore/Settings/Error Log/ErrorLogViewController.swift b/AltStore/Settings/Error Log/ErrorLogViewController.swift index 498609c7..28578194 100644 --- a/AltStore/Settings/Error Log/ErrorLogViewController.swift +++ b/AltStore/Settings/Error Log/ErrorLogViewController.swift @@ -16,6 +16,7 @@ import Roxas import Nuke import QuickLook +import SwiftUI final class ErrorLogViewController: UITableViewController, QLPreviewControllerDelegate { @@ -216,15 +217,124 @@ private extension ErrorLogViewController } } - @IBAction func showMinimuxerLogs(_ sender: UIBarButtonItem) - { - // Show minimuxer.log - let previewController = QLPreviewController() - previewController.dataSource = self - let navigationController = UINavigationController(rootViewController: previewController) - present(navigationController, animated: true, completion: nil) + + 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? +// +// init(previewController: QLPreviewController) { +// self.previewController = previewController +// } +// +// // 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 +// } +// } +// +// // 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) +// manager.startRefreshing() +// +// return previewController +// } + + // 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() + + 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 showMinimuxerLogs(_ 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) @@ -297,13 +407,21 @@ private extension ErrorLogViewController // 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)" } - - let outputText = entries.joined(separator: "\n") +// 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) @@ -412,9 +530,13 @@ extension ErrorLogViewController: QLPreviewControllerDataSource { return 1 } - func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem { - let fileURL = FileManager.default.documentsDirectory.appendingPathComponent("minimuxer.log") - return fileURL as QLPreviewItem + 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 } } diff --git a/SideStore/Utils/common/AbstractClassError.swift b/SideStore/Utils/common/AbstractClassError.swift new file mode 100644 index 00000000..15d3004e --- /dev/null +++ b/SideStore/Utils/common/AbstractClassError.swift @@ -0,0 +1,12 @@ +// +// OutputStream.swift +// AltStore +// +// Created by Magesh K on 28/12/24. +// Copyright © 2024 SideStore. All rights reserved. +// + +public enum AbstractClassError: Error { + case abstractInitializerInvoked + case abstractMethodInvoked +} diff --git a/SideStore/Utils/common/FileOutputStream.swift b/SideStore/Utils/common/FileOutputStream.swift new file mode 100644 index 00000000..fdee0a2f --- /dev/null +++ b/SideStore/Utils/common/FileOutputStream.swift @@ -0,0 +1,29 @@ +// +// FileOutputStream.swift +// AltStore +// +// Created by Magesh K on 28/12/24. +// Copyright © 2024 SideStore. All rights reserved. +// + +import Foundation + +public class FileOutputStream: OutputStream { + private let fileHandle: FileHandle + + init(_ fileHandle: FileHandle) { + self.fileHandle = fileHandle + } + + public func write(_ data: Data) { + fileHandle.write(data) + } + + public func flush() { + fileHandle.synchronizeFile() + } + + public func close() { + fileHandle.closeFile() + } +} diff --git a/SideStore/Utils/common/OutputStream.swift b/SideStore/Utils/common/OutputStream.swift new file mode 100644 index 00000000..ddc421ca --- /dev/null +++ b/SideStore/Utils/common/OutputStream.swift @@ -0,0 +1,15 @@ +// +// OutputStream.swift +// AltStore +// +// Created by Magesh K on 28/12/24. +// Copyright © 2024 SideStore. All rights reserved. +// + +import Foundation + +public protocol OutputStream { + func write(_ data: Data) + func flush() + func close() +} diff --git a/SideStore/Utils/iostreams/ConsoleLog.swift b/SideStore/Utils/iostreams/ConsoleLog.swift new file mode 100644 index 00000000..3b1177a5 --- /dev/null +++ b/SideStore/Utils/iostreams/ConsoleLog.swift @@ -0,0 +1,73 @@ +// +// ConsoleLog.swift +// AltStore +// +// Created by Magesh K on 25/11/24. +// Copyright © 2024 SideStore. All rights reserved. +// +// + +import Foundation + +class ConsoleLog { + private static let CONSOLE_LOGS_DIRECTORY = "ConsoleLogs" + private static let CONSOLE_LOG_NAME_PREFIX = "console" + private static let CONSOLE_LOG_EXTN = ".log" + + private lazy var consoleLogger: ConsoleLogger = { + let logFileHandle = createLogFileHandle() + let fileOutputStream = FileOutputStream(logFileHandle) + + return UnBufferedConsoleLogger(stream: fileOutputStream) + }() + + private lazy var consoleLogsDir: URL = { + // create a directory for console logs + let docsDir = FileManager.default.documentsDirectory + let consoleLogsDir = docsDir.appendingPathComponent(ConsoleLog.CONSOLE_LOGS_DIRECTORY) + if !FileManager.default.fileExists(atPath: consoleLogsDir.path) { + try! FileManager.default.createDirectory(at: consoleLogsDir, withIntermediateDirectories: true, attributes: nil) + } + return consoleLogsDir + }() + + public lazy var logName: String = { + logFileURL.lastPathComponent + }() + + public lazy var logFileURL: URL = { + // get current timestamp + let currentTime = Date() + let dateTimeStamp = ConsoleLog.getDateInTimeStamp(date: currentTime) + + // create a log file with the current timestamp + let logName = "\(ConsoleLog.CONSOLE_LOG_NAME_PREFIX)-\(dateTimeStamp)\(ConsoleLog.CONSOLE_LOG_EXTN)" + let logFileURL = consoleLogsDir.appendingPathComponent(logName) + return logFileURL + }() + + + private func createLogFileHandle() -> FileHandle { + if !FileManager.default.fileExists(atPath: logFileURL.path) { + FileManager.default.createFile(atPath: logFileURL.path, contents: nil, attributes: nil) + } + + // return the file handle + return try! FileHandle(forWritingTo: logFileURL) + } + + private static func getDateInTimeStamp(date: Date) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyyMMdd_HHmmss" // Format: 20241228_142345 + return formatter.string(from: date) + } + + func startCapturing() { + consoleLogger.startCapturing() + } + + func stopCapturing() { + consoleLogger.stopCapturing() + } +} + diff --git a/SideStore/Utils/iostreams/ConsoleLogger.swift b/SideStore/Utils/iostreams/ConsoleLogger.swift new file mode 100644 index 00000000..479cc5c8 --- /dev/null +++ b/SideStore/Utils/iostreams/ConsoleLogger.swift @@ -0,0 +1,166 @@ +// +// ConsoleCapture.swift +// AltStore +// +// Created by Magesh K on 25/11/24. +// Copyright © 2024 SideStore. All rights reserved. +// + +import Foundation + +protocol ConsoleLogger{ + func startCapturing() + func stopCapturing() +} + +public class AbstractConsoleLogger: ConsoleLogger{ + var outPipe: Pipe? + var errPipe: Pipe? + + var outputHandle: FileHandle? + var errorHandle: FileHandle? + + var originalStdout: Int32? + var originalStderr: Int32? + + let ostream: T + + let writeQueue = DispatchQueue(label: "async-write-queue") + + public init(stream: T) throws { + // Since swift doesn't support compile time abstract classes Instantiation checking, + // we are using runtime check to prevent direct instantiation :( + if Self.self === AbstractConsoleLogger.self { + throw AbstractClassError.abstractInitializerInvoked + } + + self.ostream = stream + } + + deinit { + stopCapturing() + } + + public func startCapturing() { // made it public coz, let client ask for capturing + + // if already initialized within current instance, bail out + guard outPipe == nil, errPipe == nil else { + return + } + + // Create new pipes for stdout and stderr + self.outPipe = Pipe() + self.errPipe = Pipe() + + outputHandle = self.outPipe?.fileHandleForReading + errorHandle = self.errPipe?.fileHandleForReading + + // Store original file descriptors + originalStdout = dup(STDOUT_FILENO) + originalStderr = dup(STDERR_FILENO) + + // Redirect stdout and stderr to our pipes + dup2(self.outPipe?.fileHandleForWriting.fileDescriptor ?? -1, STDOUT_FILENO) + dup2(self.errPipe?.fileHandleForWriting.fileDescriptor ?? -1, STDERR_FILENO) + + // Setup readability handlers for raw data + setupReadabilityHandler(for: outputHandle, isError: false) + setupReadabilityHandler(for: errorHandle, isError: true) + } + + private func setupReadabilityHandler(for handle: FileHandle?, isError: Bool) { + handle?.readabilityHandler = { [weak self] handle in + let data = handle.availableData + if !data.isEmpty { + self?.writeQueue.async { + try? self?.writeData(data) + } + + // Forward to original std stream + if let originalFD = isError ? self?.originalStderr : self?.originalStdout { + data.withUnsafeBytes { (bufferPointer) -> Void in + if let baseAddress = bufferPointer.baseAddress, bufferPointer.count > 0 { + write(originalFD, baseAddress, bufferPointer.count) + } + } + } + } + } + } + + func writeData(_ data: Data) throws { + throw AbstractClassError.abstractMethodInvoked + } + + func stopCapturing() { + ostream.close() + + // Restore original stdout and stderr + if let stdout = originalStdout { + dup2(stdout, STDOUT_FILENO) + close(stdout) + } + if let stderr = originalStderr { + dup2(stderr, STDERR_FILENO) + close(stderr) + } + + // Clean up + outPipe?.fileHandleForReading.readabilityHandler = nil + errPipe?.fileHandleForReading.readabilityHandler = nil + outPipe = nil + errPipe = nil + outputHandle = nil + errorHandle = nil + originalStdout = nil + originalStderr = nil + } +} + + +public class UnBufferedConsoleLogger: AbstractConsoleLogger { + + required override init(stream: T) { + // cannot throw abstractInitializerInvoked, so need to override else client needs to handle it unnecessarily + try! super.init(stream: stream) + } + + override func writeData(_ data: Data) throws { + // directly write data to the stream without buffering + ostream.write(data) + } +} + +public class BufferedConsoleLogger: AbstractConsoleLogger { + + // Buffer size (bytes) and storage + private let maxBufferSize: Int + private var bufferedData = Data() + + required init(stream: T, bufferSize: Int = 1024) { + self.maxBufferSize = bufferSize + try! super.init(stream: stream) + } + + override func writeData(_ data: Data) throws { + // Append data to buffer + self.bufferedData.append(data) + + // Check if the buffer is full and flush + if self.bufferedData.count >= self.maxBufferSize { + self.flushBuffer() + } + } + + private func flushBuffer() { + // Write all buffered data to the stream + ostream.write(bufferedData) + bufferedData.removeAll() + } + + override func stopCapturing() { + // Flush buffer and close the file handles first + flushBuffer() + super.stopCapturing() + } +}