diff --git a/Mist.xcodeproj/project.pbxproj b/Mist.xcodeproj/project.pbxproj index 144e58c..8d3e273 100644 --- a/Mist.xcodeproj/project.pbxproj +++ b/Mist.xcodeproj/project.pbxproj @@ -93,6 +93,10 @@ 398734D028603D9E00B4C357 /* UInt8+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 398734CF28603D9E00B4C357 /* UInt8+Extension.swift */; }; 398734D228603DE700B4C357 /* [UInt8]+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 398734D128603DE700B4C357 /* [UInt8]+Extension.swift */; }; 398734D4286046B000B4C357 /* UInt32+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 398734D3286046B000B4C357 /* UInt32+Extension.swift */; }; + 398A131D2B78AA3700F96F7E /* LogManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 398A131C2B78AA3700F96F7E /* LogManager.swift */; }; + 398A131F2B78AF0B00F96F7E /* LogLevel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 398A131E2B78AF0B00F96F7E /* LogLevel.swift */; }; + 398A13222B78BA6D00F96F7E /* LogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 398A13212B78BA6D00F96F7E /* LogView.swift */; }; + 398A13472B78F22A00F96F7E /* LogEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 398A13462B78F22A00F96F7E /* LogEntry.swift */; }; 398BE6B52B62450500FE0C29 /* FloatingAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 398BE6B42B62450500FE0C29 /* FloatingAlert.swift */; }; 39CA25E32941D8BB0030711E /* FileAttributesUpdater.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39CA25E22941D8BB0030711E /* FileAttributesUpdater.swift */; }; 39CB5E3D293F5C2E00CFDBB8 /* Catalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39CB5E3C293F5C2E00CFDBB8 /* Catalog.swift */; }; @@ -251,6 +255,10 @@ 398734CF28603D9E00B4C357 /* UInt8+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UInt8+Extension.swift"; sourceTree = ""; }; 398734D128603DE700B4C357 /* [UInt8]+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "[UInt8]+Extension.swift"; sourceTree = ""; }; 398734D3286046B000B4C357 /* UInt32+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UInt32+Extension.swift"; sourceTree = ""; }; + 398A131C2B78AA3700F96F7E /* LogManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LogManager.swift; path = Mist/Helpers/LogManager.swift; sourceTree = SOURCE_ROOT; }; + 398A131E2B78AF0B00F96F7E /* LogLevel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogLevel.swift; sourceTree = ""; }; + 398A13212B78BA6D00F96F7E /* LogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogView.swift; sourceTree = ""; }; + 398A13462B78F22A00F96F7E /* LogEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogEntry.swift; sourceTree = ""; }; 398BE6B42B62450500FE0C29 /* FloatingAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatingAlert.swift; sourceTree = ""; }; 39CA25E22941D8BB0030711E /* FileAttributesUpdater.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileAttributesUpdater.swift; sourceTree = ""; }; 39CB5E3C293F5C2E00CFDBB8 /* Catalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Catalog.swift; sourceTree = ""; }; @@ -411,6 +419,7 @@ 393F35BD2864197F005B7165 /* PrivilegedHelperTool.swift */, 3935F4CC286C6A5D00760AB0 /* ProcessKiller.swift */, 574199D12AED15420086493F /* PropertyListUpdater.swift */, + 398A131C2B78AA3700F96F7E /* LogManager.swift */, 3935F4C6286B54E200760AB0 /* SparkleUpdater.swift */, 398734C328600E6E00B4C357 /* TaskManager.swift */, 398734C5286011C300B4C357 /* Validator.swift */, @@ -425,6 +434,8 @@ 39CB5E3C293F5C2E00CFDBB8 /* Catalog.swift */, 390451E428574F0000E0B563 /* CatalogType.swift */, 39CB5E3E2941486D00CFDBB8 /* CatalogSeedType.swift */, + 398A131E2B78AF0B00F96F7E /* LogLevel.swift */, + 398A13462B78F22A00F96F7E /* LogEntry.swift */, 398734CB28603D5F00B4C357 /* Chunklist.swift */, 398734CD28603D7F00B4C357 /* Chunk.swift */, 390451CD2856F42800E0B563 /* DownloadType.swift */, @@ -458,6 +469,7 @@ 393F35C128641E1F005B7165 /* HeaderView.swift */, 3935F49C286ABE4D00760AB0 /* FooterView.swift */, 390451D728573A2500E0B563 /* ExportListView.swift */, + 398A13212B78BA6D00F96F7E /* LogView.swift */, 3935F4A0286ACCE100760AB0 /* List */, 393F35BF28641D86005B7165 /* Activity */, 393F35C028641D8F005B7165 /* Refresh */, @@ -805,6 +817,7 @@ 39CF56172861BE66006FB5D2 /* FileCopier.swift in Sources */, 39252AB9285C7BC700956C74 /* SettingsInstallersCacheView.swift in Sources */, 390451C62856E80C00E0B563 /* RefreshView.swift in Sources */, + 398A131D2B78AA3700F96F7E /* LogManager.swift in Sources */, 39252A97285BF8BC00956C74 /* MistTaskType.swift in Sources */, 39CF56212861C992006FB5D2 /* DiskImageMounter.swift in Sources */, 398734CC28603D5F00B4C357 /* Chunklist.swift in Sources */, @@ -832,8 +845,10 @@ 39252A9F285C140D00956C74 /* ShellExecutor.swift in Sources */, 39CF561A2861C2D1006FB5D2 /* DirectoryCreator.swift in Sources */, 39252A77285A849F00956C74 /* AppDelegate.swift in Sources */, + 398A13472B78F22A00F96F7E /* LogEntry.swift in Sources */, 3935F47C2864434B00760AB0 /* SettingsGeneralNotificationsView.swift in Sources */, 3935F4C7286B54E200760AB0 /* SparkleUpdater.swift in Sources */, + 398A13222B78BA6D00F96F7E /* LogView.swift in Sources */, 393F35BE2864197F005B7165 /* PrivilegedHelperTool.swift in Sources */, 573A23622A28711C00EC9470 /* Architecture.swift in Sources */, 390451B92856E24200E0B563 /* Firmware.swift in Sources */, @@ -854,6 +869,7 @@ 3935F47428643AB800760AB0 /* UNNotificationCategory+Extension.swift in Sources */, 398734D028603D9E00B4C357 /* UInt8+Extension.swift in Sources */, 39252AB7285C718C00956C74 /* FileManager+Extension.swift in Sources */, + 398A131F2B78AF0B00F96F7E /* LogLevel.swift in Sources */, 398734C428600E6E00B4C357 /* TaskManager.swift in Sources */, 390451D62856F7FE00E0B563 /* UInt64+Extension.swift in Sources */, 3935F47E2864813B00760AB0 /* DownloadManager.swift in Sources */, diff --git a/Mist/AppCommands.swift b/Mist/AppCommands.swift index b83add7..a9e0e7c 100644 --- a/Mist/AppCommands.swift +++ b/Mist/AppCommands.swift @@ -35,6 +35,12 @@ struct AppCommands: Commands { } .disabled(tasksInProgress) } + CommandGroup(after: .windowList) { + Button("Show Mist Log") { + showLog() + } + .keyboardShortcut("l") + } CommandGroup(replacing: .help) { Button("Mist Help") { help() @@ -50,6 +56,14 @@ struct AppCommands: Commands { try? PrivilegedHelperManager.shared.authorizeAndBless() } + private func showLog() { + guard let url = URL(string: .logURL) else { + return + } + + openURL(url) + } + private func help() { guard let url: URL = URL(string: .repositoryURL) else { return diff --git a/Mist/Extensions/String+Extension.swift b/Mist/Extensions/String+Extension.swift index 8b072e0..6e6b233 100644 --- a/Mist/Extensions/String+Extension.swift +++ b/Mist/Extensions/String+Extension.swift @@ -24,6 +24,7 @@ extension String { static let temporaryDirectory: String = "/private/tmp/\(appIdentifier)" static let cacheDirectory: String = "/Users/Shared/Mist/Cache" static let tccDatabasePath: String = "/Library/Application Support/com.apple.TCC/TCC.db" + static let logURL: String = "\(appName)://log" func stringWithSubstitutions(name: String, version: String, build: String) -> String { replacingOccurrences(of: "%NAME%", with: name) diff --git a/Mist/Helpers/LogManager.swift b/Mist/Helpers/LogManager.swift new file mode 100644 index 0000000..cc7bea7 --- /dev/null +++ b/Mist/Helpers/LogManager.swift @@ -0,0 +1,40 @@ +// +// LogManager.swift +// Mist +// +// Created by Nindi Gill on 11/2/2024. +// + +import Foundation +import OSLog + +/// Helper class used to manage Log Entries and writing out to the unified logging system. +class LogManager: ObservableObject { + /// Shared instance of a LogManager helper object + static let shared: LogManager = .init() + /// Array of Log Entries that have been recorded. + @Published var logEntries: [LogEntry] = [] + /// Logger object used to send logs. + private let logger: Logger = .init(subsystem: .appIdentifier, category: "") + + /// Records a log entry and also sends it to the unified logging system. + /// + /// - Parameters: + /// - level: The log level of the message. + /// - message: The message to log. + func log(_ level: LogLevel, message: String) { + DispatchQueue.main.async { + let entry: LogEntry = .init(timestamp: .now, level: level, message: message) + self.logEntries.append(entry) + + switch level { + case .info: + self.logger.notice("\(message)") + case .warning: + self.logger.error("\(message)") + case .error: + self.logger.fault("\(message)") + } + } + } +} diff --git a/Mist/Helpers/TaskManager.swift b/Mist/Helpers/TaskManager.swift index c7cbe8f..46fb6bb 100644 --- a/Mist/Helpers/TaskManager.swift +++ b/Mist/Helpers/TaskManager.swift @@ -80,6 +80,7 @@ class TaskManager: ObservableObject { private static func firmwareSetupTasks(temporaryDirectory temporaryDirectoryURL: URL) -> [MistTask] { [ MistTask(type: .configure, description: "temporary directory") { + LogManager.shared.log(.info, message: "Configuring temporary directory '\(temporaryDirectoryURL.path)'...") try await DirectoryCreator.create(temporaryDirectoryURL) } ] @@ -96,6 +97,7 @@ class TaskManager: ObservableObject { ) -> [MistTask] { var tasks: [MistTask] = [ MistTask(type: .download, description: firmwareURL.lastPathComponent, downloadSize: firmware.size) { + LogManager.shared.log(.info, message: "Downloading '\(firmwareURL.absoluteString)' to '\(temporaryFirmwareURL.path)'...") try await DownloadManager.shared.download(firmwareURL, to: temporaryFirmwareURL, retries: retries, delay: retryDelay) } ] @@ -103,13 +105,15 @@ class TaskManager: ObservableObject { if !firmware.shasum.isEmpty { tasks += [ MistTask(type: .verify, description: firmwareURL.lastPathComponent) { + LogManager.shared.log(.info, message: "Verifying '\(temporaryFirmwareURL.path)'...") try await Validator.validate(firmware, at: temporaryFirmwareURL) } ] } tasks += [ - MistTask(type: .save, description: "Firmware to destination") { + MistTask(type: .move, description: "Firmware to destination") { + LogManager.shared.log(.info, message: "Moving '\(temporaryFirmwareURL.path)' to destination '\(destinationURL.path)'...") try await FileMover.move(temporaryFirmwareURL, to: destinationURL) } ] @@ -120,6 +124,7 @@ class TaskManager: ObservableObject { private static func firmwareCleanupTasks(temporaryDirectory temporaryDirectoryURL: URL) -> [MistTask] { [ MistTask(type: .remove, description: "temporary directory") { + LogManager.shared.log(.info, message: "Removing temporary directory '\(temporaryDirectoryURL.path)'...") try await DirectoryRemover.remove(temporaryDirectoryURL) } ] @@ -267,6 +272,7 @@ class TaskManager: ObservableObject { if !FileManager.default.fileExists(atPath: cacheDirectoryURL.path) { tasks += [ MistTask(type: .configure, description: "cache directory") { + LogManager.shared.log(.info, message: "Configuring cache directory '\(cacheDirectoryURL.path)'...") try await DirectoryCreator.create(cacheDirectoryURL, withIntermediateDirectories: true) } ] @@ -285,6 +291,7 @@ class TaskManager: ObservableObject { if filePermissions != [.ownerReadWriteExecute, .groupReadExecute, .otherReadExecute] || ownerAccountName != NSUserName() || groupOwnerAccountName != "wheel" { tasks += [ MistTask(type: .configure, description: "cache directory") { + LogManager.shared.log(.info, message: "Configuring cache directory '\(cacheDirectoryURL.path)'...") try await FileAttributesUpdater.update(url: cacheDirectoryURL, ownerAccountName: ownerAccountName) } ] @@ -300,9 +307,11 @@ class TaskManager: ObservableObject { tasks += [ MistTask(type: .download, description: package.filename, downloadSize: UInt64(package.size)) { + LogManager.shared.log(.info, message: "Downloading '\(packageURL.absoluteString)' to '\(cachePackageURL.path)'...") try await DownloadManager.shared.download(packageURL, to: cachePackageURL, retries: retries, delay: retryDelay) }, MistTask(type: .verify, description: package.filename) { + LogManager.shared.log(.info, message: "Verifying '\(cachePackageURL.path)'...") try await Validator.validate(package, at: cachePackageURL) } ] @@ -311,16 +320,20 @@ class TaskManager: ObservableObject { return tasks } + // swiftlint:disable:next function_body_length private static func installTasks(for installer: Installer, temporaryDirectory temporaryDirectoryURL: URL, mountPoint mountPointURL: URL, cacheDirectory: String) -> [MistTask] { let imageURL: URL = temporaryDirectoryURL.appendingPathComponent("\(installer.id) Temp.dmg") var tasks: [MistTask] = [ MistTask(type: .configure, description: "temporary directory") { + LogManager.shared.log(.info, message: "Configuring temporary directory '\(temporaryDirectoryURL.path)'...") try await DirectoryCreator.create(temporaryDirectoryURL) }, MistTask(type: .create, description: "Disk Image") { + LogManager.shared.log(.info, message: "Creating Disk Image '\(imageURL.path)'...") try await DiskImageCreator.create(imageURL, size: installer.diskImageSize) }, MistTask(type: .mount, description: "Disk Image") { + LogManager.shared.log(.info, message: "Mounting Disk Image '\(imageURL.path)' at mount point '\(mountPointURL.path)'...") try await DiskImageMounter.mount(imageURL, mountPoint: mountPointURL) } ] @@ -333,18 +346,22 @@ class TaskManager: ObservableObject { tasks += [ MistTask(type: .mount, description: "Installer Disk Image") { + LogManager.shared.log(.info, message: "Mounting Disk Image '\(legacyDiskImageURL.path)' at mount point '\(legacyDiskImageMountPointURL.path)'...") try await DiskImageMounter.mount(legacyDiskImageURL, mountPoint: legacyDiskImageMountPointURL) }, MistTask(type: .create, description: "Installer in Disk Image") { + LogManager.shared.log(.info, message: "Creating Installer in Disk Image at mount point '\(mountPointURL.path)' using cache directory '\(cacheDirectory)/\(installer.id)'...") try await InstallerCreator.create(installer, mountPoint: mountPointURL, cacheDirectory: cacheDirectory) }, MistTask(type: .unmount, description: "Installer Disk Image") { + LogManager.shared.log(.info, message: "Unmounting Installer Disk Image at mount point '\(legacyDiskImageMountPointURL.path)'...") try await DiskImageUnmounter.unmount(legacyDiskImageMountPointURL) } ] } else { tasks += [ MistTask(type: .create, description: "macOS Installer in Disk Image") { + LogManager.shared.log(.info, message: "Creating macOS Installer in Disk Image at mount point '\(mountPointURL.path)' using cache directory '\(cacheDirectory)/\(installer.id)'...") try await InstallerCreator.create(installer, mountPoint: mountPointURL, cacheDirectory: cacheDirectory) guard let major: Substring = installer.version.split(separator: ".").first else { @@ -358,6 +375,7 @@ class TaskManager: ObservableObject { return } + LogManager.shared.log(.info, message: "Moving '\(source.path)' to '\(destination.path)'...") try await FileMover.move(source, to: destination) } ] @@ -370,7 +388,8 @@ class TaskManager: ObservableObject { let applicationURL: URL = destinationURL.appendingPathComponent(filename.stringWithSubstitutions(name: installer.name, version: installer.version, build: installer.build)) return [ - MistTask(type: .save, description: "Application to destination") { + MistTask(type: .copy, description: "Application to destination") { + LogManager.shared.log(.info, message: "Copying Application '\(installer.temporaryInstallerURL.path)' to destination '\(applicationURL.path)'...") try await FileCopier.copy(installer.temporaryInstallerURL, to: applicationURL) } ] @@ -391,15 +410,19 @@ class TaskManager: ObservableObject { let imageURL: URL = destinationURL.appendingPathComponent(filename.stringWithSubstitutions(name: installer.name, version: installer.version, build: installer.build)) var tasks: [MistTask] = [ MistTask(type: .configure, description: "temporary Disk Image directory") { + LogManager.shared.log(.info, message: "Configuring temporary Disk Image directory '\(imageDirectoryURL.path)'...") try await DirectoryCreator.create(imageDirectoryURL) }, - MistTask(type: .save, description: "macOS Installer to temporary Disk Image directory") { + MistTask(type: .copy, description: "macOS Installer to temporary Disk Image directory") { + LogManager.shared.log(.info, message: "Copying macOS Installer '\(installer.temporaryInstallerURL.path)' to temporary Disk Image directory '\(applicationURL.path)'...") try await FileCopier.copy(installer.temporaryInstallerURL, to: applicationURL) }, MistTask(type: .create, description: "Disk Image") { + LogManager.shared.log(.info, message: "Creating Disk Image '\(temporaryImageURL.path)' from '\(imageDirectoryURL.path)'...") try await DiskImageCreator.create(temporaryImageURL, from: imageDirectoryURL) }, MistTask(type: .remove, description: "temporary Disk Image directory") { + LogManager.shared.log(.info, message: "Removing temporary Disk Image directory '\(imageDirectoryURL.path)'...") try await DirectoryRemover.remove(imageDirectoryURL) } ] @@ -407,19 +430,22 @@ class TaskManager: ObservableObject { if diskImageSign, !diskImageSigningIdentity.isEmpty, diskImageSigningIdentity != "-" { tasks += [ MistTask(type: .codesign, description: "Disk Image") { + LogManager.shared.log(.info, message: "Codesigning Disk Image '\(temporaryImageURL.path)' using signing identity '\(diskImageSigningIdentity)'...") try await Codesigner.sign(temporaryImageURL, identity: diskImageSigningIdentity) } ] } tasks += [ - MistTask(type: .save, description: "Disk Image to destination") { + MistTask(type: .move, description: "Disk Image to destination") { + LogManager.shared.log(.info, message: "Moving Disk Image '\(temporaryImageURL.path)' to destination '\(imageURL.path)'...") try await FileMover.move(temporaryImageURL, to: imageURL) } ] return tasks } + // swiftlint:disable:next function_body_length private static func isoTasks(for installer: Installer, filename: String, destination destinationURL: URL, temporaryDirectory temporaryDirectoryURL: URL) -> [MistTask] { let temporaryImageURL: URL = temporaryDirectoryURL.appendingPathComponent("\(installer.id).dmg") let createInstallMediaURL: URL = installer.temporaryInstallerURL.appendingPathComponent("Contents/Resources/createinstallmedia") @@ -429,22 +455,28 @@ class TaskManager: ObservableObject { if installer.mavericksOrNewer { return [ MistTask(type: .create, description: "temporary Disk Image") { + LogManager.shared.log(.info, message: "Creating temporary Disk Image '\(temporaryImageURL.path)'...") try await DiskImageCreator.create(temporaryImageURL, size: installer.isoSize) }, MistTask(type: .mount, description: "temporary Disk Image") { + LogManager.shared.log(.info, message: "Mounting temporary Disk Image '\(temporaryImageURL.path)' at mount point '\(installer.temporaryISOMountPointURL.path)'...") try await DiskImageMounter.mount(temporaryImageURL, mountPoint: installer.temporaryISOMountPointURL) }, MistTask(type: .create, description: "macOS Installer in temporary Disk Image") { // Workaround to make macOS Sierra 10.12 createinstallmedia work if installer.version.hasPrefix("10.12") { let infoPlistURL: URL = installer.temporaryInstallerURL.appendingPathComponent("Contents/Info.plist") + LogManager.shared.log(.info, message: "Updating Property List '\(infoPlistURL.path)'...") try PropertyListUpdater.update(infoPlistURL, key: "CFBundleShortVersionString", value: "12.6.03") } + // swiftlint:disable:next line_length + LogManager.shared.log(.info, message: "Creating macOS Installer in temporary Disk Image at mount point '\(installer.temporaryISOMountPointURL.path)' using createinstallmedia '\(createInstallMediaURL.path)'...") try await InstallMediaCreator.create(createInstallMediaURL, mountPoint: installer.temporaryISOMountPointURL, sierraOrOlder: installer.sierraOrOlder) }, MistTask(type: .unmount, description: "temporary Disk Image") { if FileManager.default.fileExists(atPath: installer.temporaryISOMountPointURL.path) { + LogManager.shared.log(.info, message: "Unmounting temporary Disk Image at mount point '\(installer.temporaryISOMountPointURL.path)'...") try await DiskImageUnmounter.unmount(installer.temporaryISOMountPointURL) } @@ -455,13 +487,16 @@ class TaskManager: ObservableObject { let url: URL = .init(fileURLWithPath: "/Volumes/Install macOS \(major) beta") if FileManager.default.fileExists(atPath: url.path) { + LogManager.shared.log(.info, message: "Unmounting temporary Disk Image at mount point '\(url.path)'...") try await DiskImageUnmounter.unmount(url) } }, MistTask(type: .convert, description: "temporary Disk Image to ISO") { + LogManager.shared.log(.info, message: "Converting temporary Disk Image '\(temporaryImageURL.path)' to ISO '\(temporaryCDRURL.path)'...") try await ISOConverter.convert(temporaryImageURL, destination: temporaryCDRURL) }, - MistTask(type: .save, description: "ISO to destination") { + MistTask(type: .move, description: "ISO to destination") { + LogManager.shared.log(.info, message: "Moving ISO '\(temporaryCDRURL.path)' to destination '\(isoURL.path)'...") try await FileMover.move(temporaryCDRURL, to: isoURL) } ] @@ -469,9 +504,11 @@ class TaskManager: ObservableObject { let installESDURL: URL = installer.temporaryInstallerURL.appendingPathComponent("Contents/SharedSupport/InstallESD.dmg") return [ MistTask(type: .convert, description: "Installer Disk Image to ISO") { + LogManager.shared.log(.info, message: "Converting Installer Disk Image '\(installESDURL.path)' to ISO '\(temporaryCDRURL.path)'...") try await ISOConverter.convert(installESDURL, destination: temporaryCDRURL) }, - MistTask(type: .save, description: "ISO to destination") { + MistTask(type: .move, description: "ISO to destination") { + LogManager.shared.log(.info, message: "Moving ISO '\(temporaryCDRURL.path)' to destination '\(isoURL.path)'...") try await FileMover.move(temporaryCDRURL, to: isoURL) } ] @@ -496,7 +533,8 @@ class TaskManager: ObservableObject { let sourceURL: URL = cacheDirectoryURL.appendingPathComponent("InstallAssistant.pkg") tasks = [ - MistTask(type: .save, description: "Package to destination") { + MistTask(type: .copy, description: "Package to destination") { + LogManager.shared.log(.info, message: "Copying Package '\(sourceURL.path)' to destination '\(packageURL.path)'...") try await FileCopier.copy(sourceURL, to: packageURL) } ] @@ -507,9 +545,11 @@ class TaskManager: ObservableObject { tasks = [ MistTask(type: .create, description: "Package") { + LogManager.shared.log(.info, message: "Creating Package '\(temporaryPackageURL.path)'...") try await PackageCreator.create(temporaryPackageURL, from: installer, identifier: identifier, identity: identity) }, - MistTask(type: .save, description: "Package to destination") { + MistTask(type: .move, description: "Package to destination") { + LogManager.shared.log(.info, message: "Moving Package '\(temporaryPackageURL.path)' to destination '\(packageURL.path)'...") try await FileMover.move(temporaryPackageURL, to: packageURL) } ] @@ -526,9 +566,11 @@ class TaskManager: ObservableObject { // Workaround to make macOS Sierra 10.12 createinstallmedia work if installer.version.hasPrefix("10.12") { let infoPlistURL: URL = installer.temporaryInstallerURL.appendingPathComponent("Contents/Info.plist") + LogManager.shared.log(.info, message: "Updating Property List '\(infoPlistURL.path)'...") try PropertyListUpdater.update(infoPlistURL, key: "CFBundleShortVersionString", value: "12.6.03") } + LogManager.shared.log(.info, message: "Creating Bootable Installer at mount point '\(mountPointURL.path)' using createinstallmedia '\(createInstallMediaURL.path)'...") try await InstallMediaCreator.create(createInstallMediaURL, mountPoint: mountPointURL, sierraOrOlder: installer.sierraOrOlder) } ] @@ -539,9 +581,11 @@ class TaskManager: ObservableObject { private static func cleanupTasks(mountPoint mountPointURL: URL, temporaryDirectory temporaryDirectoryURL: URL, cacheDownloads: Bool, cacheDirectory cacheDirectoryURL: URL) -> [MistTask] { var tasks: [MistTask] = [ MistTask(type: .unmount, description: "Disk Image") { + LogManager.shared.log(.info, message: "Unmounting Disk Image at mount point '\(mountPointURL.path)'...") try await DiskImageUnmounter.unmount(mountPointURL) }, MistTask(type: .remove, description: "temporary directory") { + LogManager.shared.log(.info, message: "Removing temporary directory '\(temporaryDirectoryURL.path)'...") try await DirectoryRemover.remove(temporaryDirectoryURL) } ] @@ -549,6 +593,7 @@ class TaskManager: ObservableObject { if !cacheDownloads { tasks += [ MistTask(type: .remove, description: "cache directory") { + LogManager.shared.log(.info, message: "Removing cache directory '\(cacheDirectoryURL.path)'...") try await DirectoryRemover.remove(cacheDirectoryURL) } ] diff --git a/Mist/Info.plist b/Mist/Info.plist index e4a66fd..ba3336a 100644 --- a/Mist/Info.plist +++ b/Mist/Info.plist @@ -2,6 +2,19 @@ + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + mist + CFBundleURLSchemes + + mist + + + SMPrivilegedExecutables com.ninxsoft.mist.helper diff --git a/Mist/MistApp.swift b/Mist/MistApp.swift index 46187c7..12ee0d9 100644 --- a/Mist/MistApp.swift +++ b/Mist/MistApp.swift @@ -13,6 +13,7 @@ struct MistApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate: AppDelegate @StateObject var sparkleUpdater: SparkleUpdater = .init() + @StateObject var logManager: LogManager = .shared @State private var refreshing: Bool = false @State private var tasksInProgress: Bool = false @@ -30,6 +31,11 @@ struct MistApp: App { Settings { SettingsView(sparkleUpdater: sparkleUpdater) } + WindowGroup("Mist Log") { + LogView(logEntries: logManager.logEntries) + .handlesExternalEvents(preferring: ["log"], allowing: ["*"]) + } + .handlesExternalEvents(matching: ["log"]) } func hideZoomButton() { diff --git a/Mist/Model/LogEntry.swift b/Mist/Model/LogEntry.swift new file mode 100644 index 0000000..a9f3966 --- /dev/null +++ b/Mist/Model/LogEntry.swift @@ -0,0 +1,23 @@ +// +// LogEntry.swift +// Mist +// +// Created by Nindi Gill on 11/2/2024. +// + +import Foundation + +struct LogEntry: Identifiable { + static var example: LogEntry { + LogEntry(timestamp: .now, level: .info, message: "Message") + } + + let id: String = UUID().uuidString + let timestamp: Date + let level: LogLevel + let message: String + + var description: String { + "\(timestamp.ISO8601Format()) \(level.description) \(message)" + } +} diff --git a/Mist/Model/LogLevel.swift b/Mist/Model/LogLevel.swift new file mode 100644 index 0000000..b1868fa --- /dev/null +++ b/Mist/Model/LogLevel.swift @@ -0,0 +1,45 @@ +// +// LogLevel.swift +// Mist +// +// Created by Nindi Gill on 11/2/2024. +// + +import Foundation +import SwiftUI + +enum LogLevel: String, Identifiable, CaseIterable { + case info + case warning + case error + + var id: String { + rawValue + } + + var description: String { + rawValue.capitalized + } + + var detailLevelDescription: String { + switch self { + case .info: + "Show all logs" + case .warning: + "Show errors and warnings" + case .error: + "Show errors only" + } + } + + var color: Color { + switch self { + case .info: + .blue + case .warning: + .yellow + case .error: + .red + } + } +} diff --git a/Mist/Model/MistError.swift b/Mist/Model/MistError.swift index b44218e..2b3dffa 100644 --- a/Mist/Model/MistError.swift +++ b/Mist/Model/MistError.swift @@ -28,7 +28,7 @@ enum MistError: Error, Equatable { var description: String { switch self { case let .generalError(string): - return "Error: \(string)" + return string case let .chunklistValidationError(string): return "Chunklist validation failed: \(string)" case let .fileSizeAttributesError(url): diff --git a/Mist/Model/MistTask.swift b/Mist/Model/MistTask.swift index 90797c8..cc5aafd 100644 --- a/Mist/Model/MistTask.swift +++ b/Mist/Model/MistTask.swift @@ -23,21 +23,30 @@ struct MistTask: Identifiable { case .pending: break case .inProgress: - prefix = "\(prefix.last == "e" ? String(prefix.dropLast(1)) : prefix)ing" + switch type { + case .configure, .move, .create, .remove: + prefix = "\(prefix.dropLast(1))ing" + default: + prefix = "\(prefix)ing" + } suffix = "\(suffix)..." case .complete: switch type { - case .download, .codesign, .mount, .unmount, .convert, .compress: - prefix = "\(prefix)ed" - case .verify: - prefix = "Verified" - case .configure, .save, .create, .remove: + case .configure, .move, .create, .remove: prefix = "\(prefix)d" - case .split: - break + case .download, .codesign, .mount, .unmount, .convert: + prefix = "\(prefix)ed" + case .verify, .copy: + prefix = "\(prefix.dropLast(1))ied" } case .error: - prefix = "Error \(prefix.last == "e" ? String(prefix.dropLast(1)).lowercased() : prefix)ing" + switch type { + case .configure, .move, .create, .remove: + prefix = "Error \(prefix.dropLast(1).lowercased())ing" + default: + prefix = "Error \(prefix.lowercased())ing" + } + suffix = "\(suffix)..." } return "\(prefix) \(suffix)" diff --git a/Mist/Model/MistTaskType.swift b/Mist/Model/MistTaskType.swift index 08fd1b1..6d038c2 100644 --- a/Mist/Model/MistTaskType.swift +++ b/Mist/Model/MistTaskType.swift @@ -9,13 +9,12 @@ enum MistTaskType: String { case download = "Download" case verify = "Verify" case configure = "Configure" - case save = "Save" + case move = "Move" + case copy = "Copy" case create = "Create" case remove = "Remove" case codesign = "Codesign" case mount = "Mount" case unmount = "Unmount" case convert = "Convert" - case compress = "Compress" - case split = "Split" } diff --git a/Mist/Views/Activity/ActivityView.swift b/Mist/Views/Activity/ActivityView.swift index fb0b491..006003d 100644 --- a/Mist/Views/Activity/ActivityView.swift +++ b/Mist/Views/Activity/ActivityView.swift @@ -150,6 +150,7 @@ struct ActivityView: View { taskManager.taskGroups[taskGroupIndex].tasks[taskIndex].state = .error error = failure as? MistError ?? MistError.generalError(failure.localizedDescription) + LogManager.shared.log(.error, message: error?.description ?? "Fatal Error") alertType = .error showAlert = true diff --git a/Mist/Views/ContentView.swift b/Mist/Views/ContentView.swift index af6f44a..a35d04d 100644 --- a/Mist/Views/ContentView.swift +++ b/Mist/Views/ContentView.swift @@ -8,6 +8,8 @@ import SwiftUI struct ContentView: View { + @Environment(\.openURL) + var openURL: OpenURLAction @AppStorage("downloadType") private var downloadType: DownloadType = .firmware @AppStorage("includeBetas") @@ -118,6 +120,13 @@ struct ContentView: View { .foregroundColor(.accentColor) } .help("Refresh") + Button { + showLog() + } label: { + Label("Show Log", systemImage: "text.and.command.macwindow") + .foregroundColor(.accentColor) + } + .help("Show Mist Log") } .searchable(text: $searchString) .sheet(isPresented: $refreshing) { @@ -144,6 +153,14 @@ struct ContentView: View { refreshing = true } + private func showLog() { + guard let url = URL(string: .logURL) else { + return + } + + openURL(url) + } + private func releaseNames(for type: DownloadType) -> [String] { var releaseNames: [String] = [] diff --git a/Mist/Views/LogView.swift b/Mist/Views/LogView.swift new file mode 100644 index 0000000..3b8dcc8 --- /dev/null +++ b/Mist/Views/LogView.swift @@ -0,0 +1,119 @@ +// +// LogView.swift +// Mist +// +// Created by Nindi Gill on 11/2/2024. +// + +import SwiftUI + +struct LogView: View { + @AppStorage("logDetailLevel") + private var detailLevel: LogLevel = .info + var logEntries: [LogEntry] + @State private var selectedLogEntries: Set = [] + @State private var searchString: String = "" + @State private var savePanel: NSSavePanel = .init() + private let dateFormatter: DateFormatter = .init() + private var filteredLogEntries: [LogEntry] { + let filteredLogEntries: [LogEntry] = switch detailLevel { + case .info: + logEntries + case .warning: + logEntries.filter { $0.level == .error || $0.level == .warning } + case .error: + logEntries.filter { $0.level == .error } + } + + guard !searchString.isEmpty else { + return filteredLogEntries + } + + return filteredLogEntries.filter { $0.message.lowercased().contains(searchString.lowercased()) } + } + + private let timeColumnWidth: CGFloat = 160 + private let levelColumnWidth: CGFloat = 80 + private let levelCircleRadius: CGFloat = 8 + private let width: CGFloat = 800 + private let height: CGFloat = 600 + + var body: some View { + VStack(spacing: 0) { + Table(filteredLogEntries, selection: $selectedLogEntries) { + TableColumn("Timestamp") { logEntry in + Text(logEntry.timestamp.ISO8601Format()) + } + .width(timeColumnWidth) + TableColumn("Level") { logEntry in + HStack { + Circle() + .fill(logEntry.level.color) + .frame(width: levelCircleRadius, height: levelCircleRadius) + Text(logEntry.level.description) + Spacer() + } + } + .width(levelColumnWidth) + TableColumn("Message") { logEntry in + Text(logEntry.message) + .help(logEntry.message) + } + } + .textSelection(.enabled) + Divider() + HStack { + Spacer() + Button("Export Log...") { + export() + } + } + .padding() + } + .frame(minWidth: width, minHeight: height) + .toolbar { + Picker("Detail Level", selection: $detailLevel) { + ForEach(LogLevel.allCases.reversed()) { logLevel in + Text(logLevel.detailLevelDescription) + .tag(logLevel) + } + } + .help("Detail Level") + } + .searchable(text: $searchString) + } + + private func export() { + dateFormatter.dateFormat = "yyyy-MM-dd_HH-mm-ss" + let date: String = dateFormatter.string(from: Date()) + + savePanel.title = "Export Mist Log" + savePanel.prompt = "Export" + savePanel.nameFieldStringValue = "Mist Log \(date).log" + savePanel.canCreateDirectories = true + savePanel.canSelectHiddenExtension = true + savePanel.isExtensionHidden = false + savePanel.allowedContentTypes = [.log] + + let response: NSApplication.ModalResponse = savePanel.runModal() + + guard + response == .OK, + let url: URL = savePanel.url else { + return + } + + do { + let string: String = logEntries.map(\.description).joined(separator: "\n") + try string.write(to: url, atomically: true, encoding: .utf8) + } catch { + print(error.localizedDescription) + } + } +} + +struct LogView_Previews: PreviewProvider { + static var previews: some View { + LogView(logEntries: [.example]) + } +}