diff --git a/Mist.xcodeproj/project.pbxproj b/Mist.xcodeproj/project.pbxproj index 8b8f08b..8b025ee 100644 --- a/Mist.xcodeproj/project.pbxproj +++ b/Mist.xcodeproj/project.pbxproj @@ -138,6 +138,8 @@ 575812C22A380B5E00425BAF /* InstallerVolume.swift in Sources */ = {isa = PBXBuildFile; fileRef = 575812C12A380B5E00425BAF /* InstallerVolume.swift */; }; 575812C42A3821A900425BAF /* InstallerVolumeSelectionInformationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 575812C32A3821A900425BAF /* InstallerVolumeSelectionInformationView.swift */; }; 575812C62A38296A00425BAF /* InstallerVolumeSelectionPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 575812C52A38296A00425BAF /* InstallerVolumeSelectionPickerView.swift */; }; + 577267AC2A4568CD00434B2C /* SettingsInstallerCacheAlertType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 577267AB2A4568CD00434B2C /* SettingsInstallerCacheAlertType.swift */; }; + 577267AE2A45734700434B2C /* SettingsInstallersCacheTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 577267AD2A45734700434B2C /* SettingsInstallersCacheTableView.swift */; }; 5795700B2A31B06F004C7051 /* ButtonStyle+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5795700A2A31B06F004C7051 /* ButtonStyle+Extension.swift */; }; 5795700D2A31B081004C7051 /* MistActionButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5795700C2A31B081004C7051 /* MistActionButtonStyle.swift */; }; /* End PBXBuildFile section */ @@ -288,6 +290,8 @@ 575812C12A380B5E00425BAF /* InstallerVolume.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstallerVolume.swift; sourceTree = ""; }; 575812C32A3821A900425BAF /* InstallerVolumeSelectionInformationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstallerVolumeSelectionInformationView.swift; sourceTree = ""; }; 575812C52A38296A00425BAF /* InstallerVolumeSelectionPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstallerVolumeSelectionPickerView.swift; sourceTree = ""; }; + 577267AB2A4568CD00434B2C /* SettingsInstallerCacheAlertType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsInstallerCacheAlertType.swift; sourceTree = ""; }; + 577267AD2A45734700434B2C /* SettingsInstallersCacheTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsInstallersCacheTableView.swift; sourceTree = ""; }; 5795700A2A31B06F004C7051 /* ButtonStyle+Extension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ButtonStyle+Extension.swift"; sourceTree = ""; }; 5795700C2A31B081004C7051 /* MistActionButtonStyle.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MistActionButtonStyle.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -436,6 +440,7 @@ 390451D32856F74B00E0B563 /* Package.swift */, 3935F48F286976D000760AB0 /* ProgressAlertType.swift */, 393F35BB28641181005B7165 /* RefreshState.swift */, + 577267AB2A4568CD00434B2C /* SettingsInstallerCacheAlertType.swift */, ); path = Model; sourceTree = ""; @@ -556,6 +561,7 @@ 39FF05F52859850F00A86670 /* SettingsFirmwaresView.swift */, 39252A78285A85AF00956C74 /* SettingsInstallersView.swift */, 39252AB8285C7BC700956C74 /* SettingsInstallersCacheView.swift */, + 577267AD2A45734700434B2C /* SettingsInstallersCacheTableView.swift */, 39252ABA285C7D3800956C74 /* SettingsInstallersCatalogsView.swift */, 39FF05F72859851800A86670 /* SettingsApplicationsView.swift */, 39252A7A285AC50400956C74 /* SettingsDiskImagesView.swift */, @@ -766,6 +772,7 @@ files = ( 39FF05F02859848500A86670 /* SettingsView.swift in Sources */, 39CF562A2861E1CB006FB5D2 /* DirectoryRemover.swift in Sources */, + 577267AE2A45734700434B2C /* SettingsInstallersCacheTableView.swift in Sources */, 575812C62A38296A00425BAF /* InstallerVolumeSelectionPickerView.swift in Sources */, 398734C828601FFC00B4C357 /* FileMover.swift in Sources */, 39CF55AF2861582F006FB5D2 /* AuthorizationError+Extension.swift in Sources */, @@ -790,6 +797,7 @@ 39252AC3285CA5FE00956C74 /* InstallerExportView.swift in Sources */, 398734D228603DE700B4C357 /* Array+Extension.swift in Sources */, 39FF05F82859851800A86670 /* SettingsApplicationsView.swift in Sources */, + 577267AC2A4568CD00434B2C /* SettingsInstallerCacheAlertType.swift in Sources */, 39CF561D2861C3F5006FB5D2 /* DiskImageCreator.swift in Sources */, 575812BA2A373A4F00425BAF /* FirmwareAlertType.swift in Sources */, 39CF55AD28615530006FB5D2 /* SettingsGeneralHelperView.swift in Sources */, diff --git a/Mist/Model/SettingsInstallerCacheAlertType.swift b/Mist/Model/SettingsInstallerCacheAlertType.swift new file mode 100644 index 0000000..acc40f3 --- /dev/null +++ b/Mist/Model/SettingsInstallerCacheAlertType.swift @@ -0,0 +1,11 @@ +// +// SettingsInstallerCacheAlertType.swift +// Mist +// +// Created by Nindi Gill on 23/6/2023. +// + +enum SettingsInstallerCacheAlertType: String { + case confirmation = "Confirmation" + case error = "Error" +} diff --git a/Mist/Views/Refresh/RefreshView.swift b/Mist/Views/Refresh/RefreshView.swift index a00b75a..1688972 100644 --- a/Mist/Views/Refresh/RefreshView.swift +++ b/Mist/Views/Refresh/RefreshView.swift @@ -119,7 +119,11 @@ struct RefreshView: View { } } - firmwares.sort { $0.version == $1.version ? ($0.build.count == $1.build.count ? $0.build > $1.build : $0.build.count > $1.build.count) : $0.version > $1.version } + firmwares.sort { + $0.version == $1.version ? + ($0.build.count == $1.build.count ? $0.build > $1.build : $0.build.count > $1.build.count) : + $0.version > $1.version + } return firmwares } diff --git a/Mist/Views/Settings/SettingsInstallersCacheTableView.swift b/Mist/Views/Settings/SettingsInstallersCacheTableView.swift new file mode 100644 index 0000000..4dd4ffb --- /dev/null +++ b/Mist/Views/Settings/SettingsInstallersCacheTableView.swift @@ -0,0 +1,49 @@ +// +// SettingsInstallersCacheTableView.swift +// Mist +// +// Created by Nindi Gill on 23/6/2023. +// + +import SwiftUI + +struct SettingsInstallersCacheTableView: View { + var installers: [Installer] + @Binding var selectedInstallerId: String? + var cacheDownloads: Bool + private let height: CGFloat = 126 + private let width: CGFloat = 150 + private let length: CGFloat = 16 + + var body: some View { + Table(installers, selection: $selectedInstallerId) { + TableColumn("") { installer in + ScaledImage(name: "Application - \(installer.version.isEmpty ? "macOS" : installer.imageName)", length: length) + } + .width(length) + TableColumn("Release") { installer in + Text(installer.version.isEmpty ? installer.id : installer.name) + } + .width(width) + TableColumn("Version") { installer in + Text(installer.version.isEmpty ? "Unknown" : installer.version) + } + TableColumn("Build") { installer in + Text(installer.build.isEmpty ? "Unknown" : installer.build) + } + TableColumn("Size") { installer in + Text(installer.size.bytesString()) + } + } + .tableStyle(.bordered) + .frame(minHeight: height, maxHeight: height) + .disabled(!cacheDownloads) + .opacity(cacheDownloads ? 1 : 0.5) + } +} + +struct SettingsInstallersCacheTableView_Previews: PreviewProvider { + static var previews: some View { + SettingsInstallersCacheTableView(installers: [.example], selectedInstallerId: .constant(nil), cacheDownloads: true) + } +} diff --git a/Mist/Views/Settings/SettingsInstallersCacheView.swift b/Mist/Views/Settings/SettingsInstallersCacheView.swift index d61fcc3..3a44925 100644 --- a/Mist/Views/Settings/SettingsInstallersCacheView.swift +++ b/Mist/Views/Settings/SettingsInstallersCacheView.swift @@ -11,48 +11,78 @@ struct SettingsInstallersCacheView: View { @Binding var cacheDownloads: Bool @Binding var cacheDirectory: String @State private var cacheSize: UInt64 = 0 - @State private var buttonClicked: Bool = false @State private var openPanel: NSOpenPanel = NSOpenPanel() + @State private var installers: [Installer] = [] + @State private var selectedInstallerId: String? + @State private var showAlert: Bool = false + @State private var alertType: SettingsInstallerCacheAlertType = .confirmation + private let padding: CGFloat = 5 + private var removalMessage: String { + guard let installer: Installer = installers.first(where: { $0.id == selectedInstallerId }) else { + return "" + } + + return "Removing '\(installer.version.isEmpty ? installer.id : "\(installer.name) \(installer.version) (\(installer.build))")' will free up \(installer.size.bytesString())." + } var body: some View { VStack(alignment: .leading) { HStack(alignment: .firstTextBaseline) { VStack(alignment: .leading) { - Toggle(isOn: $cacheDownloads) { - Text("Cache downloads") - } + Toggle(isOn: $cacheDownloads) { Text("Cache downloads") } FooterText("Speed up future operations by caching a local copy of macOS Installer files.") } Spacer() - Button("Select...") { - selectCacheDirectory() - } - .disabled(!cacheDownloads) + Button("Select...") { selectCacheDirectory() } + .disabled(!cacheDownloads) } PathControl(path: $cacheDirectory) .disabled(true) + .opacity(cacheDownloads ? 1 : 0.5) + SettingsInstallersCacheTableView(installers: installers, selectedInstallerId: $selectedInstallerId, cacheDownloads: cacheDownloads) + .padding(.bottom, padding) HStack(alignment: .firstTextBaseline) { FooterText("Cache directory currently contains \(cacheSize.bytesString()) of data.") Spacer() - Button("Empty Cache...") { - buttonClicked.toggle() + Button("Show in Finder") { + showInFinder() } - .disabled(cacheSize == 0) + .disabled(!cacheDownloads || selectedInstallerId == nil) + Button("Remove...") { + alertType = .confirmation + showAlert = true + } + .disabled(!cacheDownloads || selectedInstallerId == nil) } } .onAppear { - retrieveCacheSize() + retrieveCache() } .onChange(of: cacheDirectory) { _ in - retrieveCacheSize() + retrieveCache() } - .alert(isPresented: $buttonClicked) { - Alert( - title: Text("Empty Cache Directory?"), - message: Text("Emptying the cache directory will free up \(cacheSize.bytesString())."), - primaryButton: .cancel(), - secondaryButton: .destructive(Text("Empty")) { emptyCache() ; retrieveCacheSize() } - ) + .alert(isPresented: $showAlert) { + switch alertType { + case .confirmation: + return Alert( + title: Text("Remove Cached Installer?"), + message: Text(removalMessage), + primaryButton: .cancel(), + secondaryButton: .destructive(Text("Remove")) { + Task { + await emptyCache(for: selectedInstallerId) + retrieveCache() + } + } + ) + case .error: + return Alert( + title: Text("An error has occured!"), + message: Text("There was an error removing the cached Installer directory. Show in Finder to remove manually."), + primaryButton: .default(Text("OK")) { }, + secondaryButton: .default(Text("Show in Finder")) { showInFinder() } + ) + } } } @@ -75,7 +105,7 @@ struct SettingsInstallersCacheView: View { cacheDirectory = url.path } - private func retrieveCacheSize() { + private func retrieveCache() { let url: URL = URL(fileURLWithPath: cacheDirectory) var isDirectory: ObjCBool = false @@ -86,22 +116,126 @@ struct SettingsInstallersCacheView: View { } cacheSize = try FileManager.default.sizeOfDirectory(at: url) + let ids: [String] = try FileManager.default.contentsOfDirectory(atPath: url.path) + var installers: [Installer] = [] + + for id in ids { + let url: URL = url.appendingPathComponent(id) + + guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory), + isDirectory.boolValue, + let installer: Installer = installer(for: url) else { + continue + } + + installers.append(installer) + } + + self.installers = installers.sorted { + $0.version == $1.version ? + ($0.build.count == $1.build.count ? $0.build > $1.build : $0.build.count > $1.build.count) : + $0.version.compare($1.version, options: .numeric) == .orderedDescending + } + selectedInstallerId = nil } catch { print(error.localizedDescription) } } - private func emptyCache() { + private func installer(for url: URL) -> Installer? { + + let id: String = url.lastPathComponent do { - let paths: [String] = try FileManager.default.contentsOfDirectory(atPath: cacheDirectory) + if let installer: Installer = Installer.legacyInstallers.first(where: { $0.id == id }) { + return installer + } else { + let distributionURL: URL = url.appendingPathComponent("\(id).English.dist") + let string: String = try String(contentsOf: distributionURL) - for path in paths { - let url: URL = URL(fileURLWithPath: cacheDirectory + "/" + path) - try FileManager.default.removeItem(at: url) + if let version: String = versionFromDistribution(string), + let build: String = buildFromDistribution(string) { + let size: UInt64 = try FileManager.default.sizeOfDirectory(at: url) + return Installer( + id: id, + version: version, + build: build, + date: "", + distributionURL: "", + distributionSize: 0, + packages: [Package(url: "", size: Int(size), integrityDataURL: nil, integrityDataSize: nil)], + boardIDs: [], + deviceIDs: [], + unsupportedModelIdentifiers: [] + ) + } } } catch { - print(error.localizedDescription) + // do nothing + } + + do { + let size: UInt64 = try FileManager.default.sizeOfDirectory(at: url) + return Installer( + id: id, + version: "", + build: "", + date: "", + distributionURL: "", + distributionSize: 0, + packages: [Package(url: "", size: Int(size), integrityDataURL: nil, integrityDataSize: nil)], + boardIDs: [], + deviceIDs: [], + unsupportedModelIdentifiers: [] + ) + } catch { + return nil + } + } + + private func versionFromDistribution(_ string: String) -> String? { + + guard string.contains("VERSION") else { + return nil + } + + return string.replacingOccurrences(of: "^[\\s\\S]*VERSION<\\/key>\\s*", with: "", options: .regularExpression) + .replacingOccurrences(of: "<\\/string>[\\s\\S]*$", with: "", options: .regularExpression) + } + + private func buildFromDistribution(_ string: String) -> String? { + + guard string.contains("BUILD") else { + return nil + } + + return string.replacingOccurrences(of: "^[\\s\\S]*BUILD<\\/key>\\s*", with: "", options: .regularExpression) + .replacingOccurrences(of: "<\\/string>[\\s\\S]*$", with: "", options: .regularExpression) + } + + private func showInFinder() { + + guard let id: String = selectedInstallerId else { + return + } + + let url: URL = URL(fileURLWithPath: "\(cacheDirectory)/\(id)") + NSWorkspace.shared.open(url) + } + + private func emptyCache(for id: String?) async { + + guard let id: String = id else { + return + } + + let url: URL = URL(fileURLWithPath: "\(cacheDirectory)/\(id)") + + do { + try await DirectoryRemover.remove(url) + } catch { + alertType = .error + showAlert = true } } }