Files
Pearcleaner/Pearcleaner/Logic/Utilities.swift
T
2024-04-11 14:21:54 -06:00

718 lines
24 KiB
Swift

//
// Utilities.swift
// Pearcleaner
//
// Created by Alin Lupascu on 11/3/23.
//
import Foundation
import SwiftUI
import OSLog
import CoreImage
import AppKit
// Make updates on main thread
func updateOnMain(after delay: Double? = nil, _ updates: @escaping () -> Void) {
if let delay = delay {
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
updates()
}
} else {
DispatchQueue.main.async {
updates()
}
}
}
// Execute functions on background thread
func updateOnBackground(_ updates: @escaping () -> Void) {
DispatchQueue.global(qos: .background).async {
updates()
}
}
// Resize window
//func resizeWindow(width: CGFloat, height: CGFloat) {
// if let window = NSApplication.shared.windows.first {
// let newSize = NSSize(width: width, height: height)
// window.setContentSize(newSize)
// }
//}
func resizeWindowAuto(windowSettings: WindowSettings, title: String) {
if let window = NSApplication.shared.windows.first(where: { $0.title == title }) {
let newSize = NSSize(width: windowSettings.loadWindowSettings().width, height: windowSettings.loadWindowSettings().height)
window.setContentSize(newSize)
}
}
// Check FDA
func checkFullDiskAccessForApp() -> Bool {
let process = Process()
process.launchPath = "/usr/bin/sqlite3"
process.arguments = ["/Library/Application Support/com.apple.TCC/TCC.db", "select client from access where auth_value and service = \"kTCCServiceSystemPolicyAllFiles\" and client = \"com.alienator88.Pearcleaner\""]
let pipe = Pipe()
let pipeErr = Pipe()
process.standardOutput = pipe
process.standardError = pipeErr
process.launch()
let dataErr = pipeErr.fileHandleForReading.readDataToEndOfFile()
let output = String(data: dataErr, encoding: .utf8)
// Check if the app is in the results
if let result = output, result.isEmpty {
return true
} else {
return false
}
}
// Check for access to Full Disk access
func checkAndRequestFullDiskAccess(appState: AppState, skipAlert: Bool = false) -> Bool {
@AppStorage("settings.permissions.disk") var diskP: Bool = false
@AppStorage("settings.permissions.hasLaunched") var hasLaunched: Bool = false
let process = Process()
process.launchPath = "/usr/bin/sqlite3"
process.arguments = ["/Library/Application Support/com.apple.TCC/TCC.db", "select client from access where auth_value and service = \"kTCCServiceSystemPolicyAllFiles\" and client = \"com.alienator88.Pearcleaner\""]
let pipe = Pipe()
let pipeErr = Pipe()
process.standardOutput = pipe
process.standardError = pipeErr
process.launch()
let dataErr = pipeErr.fileHandleForReading.readDataToEndOfFile()
let output = String(data: dataErr, encoding: .utf8)
// Check if the app is in the results
if let result = output, result.isEmpty {
diskP = true
_ = checkAndRequestAccessibilityAccess(appState: appState)
return true
} else {
diskP = false
if !skipAlert {
NewWin.show(appState: appState, width: 500, height: 350, newWin: .perm)
}
return false
}
}
// Check for access to System Events
func checkAndRequestAccessibilityAccess(appState: AppState) -> Bool {
@AppStorage("settings.permissions.events") var diskE: Bool = false
let options: NSDictionary = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true]
let accessEnabled = AXIsProcessTrustedWithOptions(options)
if accessEnabled {
diskE = true
return accessEnabled
} else {
diskE = false
return false
}
}
// Check app directory based on user permission
func checkAppDirectoryAndUserRole(completion: @escaping ((isInCorrectDirectory: Bool, isAdmin: Bool)) -> Void) {
isCurrentUserAdmin { isAdmin in
let bundlePath = Bundle.main.bundlePath as NSString
let applicationsDir = "/Applications"
let userApplicationsDir = "\(home)/Applications"
var isInCorrectDirectory = false
if isAdmin {
// Admins can have the app in either /Applications or ~/Applications
isInCorrectDirectory = bundlePath.deletingLastPathComponent == applicationsDir ||
bundlePath.deletingLastPathComponent == userApplicationsDir
} else {
// Standard users should only have the app in ~/Applications
isInCorrectDirectory = bundlePath.deletingLastPathComponent == userApplicationsDir
}
// Return both conditions: if the app is in the correct directory and if the user is an admin
completion((isInCorrectDirectory, isAdmin))
}
}
// Check if user is admin or standard user
func isCurrentUserAdmin(completion: @escaping (Bool) -> Void) {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/zsh") // Using zsh, macOS default shell
process.arguments = ["-c", "groups $(whoami) | grep -q ' admin '"]
process.terminationHandler = { process in
// On macOS, a process's exit status of 0 indicates success (admin group found in this context)
completion(process.terminationStatus == 0)
}
do {
try process.run()
} catch {
print("Failed to execute command: \(error)")
completion(false)
}
}
// Check if appearance is dark mode
func isDarkModeEnabled() -> Bool {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/zsh")
process.arguments = ["-c", "defaults read -g AppleInterfaceStyle"]
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
try? process.run()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
if !data.isEmpty{
return true
} else {
return false
}
}
// Check if Pearcleaner has any windows open
func hasWindowOpen() -> Bool {
for window in NSApp.windows where window.title == "Pearcleaner" {
return true
}
return false
}
// Find and hide/show main app window when using menubar item
func findAndHideWindows(named titles: [String]) {
for title in titles {
if let window = NSApp.windows.first(where: { $0.title == title }) {
// window.orderOut(nil)
window.close()
}
}
}
func findAndShowWindows(named titles: [String]) {
for title in titles {
if let window = NSApp.windows.first(where: { $0.title == title }) {
window.makeKeyAndOrderFront(nil)
}
}
}
// Check if appearance is dark mode
//func getCasks() -> [String] {
// let process = Process()
//#if arch(x86_64)
// let cmd = "/usr/local/bin/brew"
//#elseif arch(arm64)
// let cmd = "/opt/homebrew/bin/brew"
//#endif
// process.executableURL = URL(fileURLWithPath: cmd)
// process.arguments = ["list", "--cask"]
// let pipe = Pipe()
// process.standardOutput = pipe
// process.standardError = pipe
// try? process.run()
// process.waitUntilExit() // Ensure the process completes
// let data = pipe.fileHandleForReading.readDataToEndOfFile()
// if let output = String(data: data, encoding: .utf8), !output.isEmpty {
// return output.components(separatedBy: "\n").filter { !$0.isEmpty }.map { $0.replacingOccurrences(of: "-", with: " ") }
// } else {
// return []
// }
//}
// Brew cleanup
func caskCleanup(app: String) {
Task(priority: .high) {
let process = Process()
#if arch(x86_64)
let cmd = "/usr/local/bin/brew"
#elseif arch(arm64)
let cmd = "/opt/homebrew/bin/brew"
#endif
process.executableURL = URL(fileURLWithPath: "/bin/zsh")
process.arguments = ["-c", "\(cmd) uninstall --cask \(app) --force; \(cmd) cleanup"]
// let pipe = Pipe()
process.standardOutput = FileHandle.nullDevice//pipe
process.standardError = FileHandle.nullDevice//pipe
try? process.run()
process.waitUntilExit() // Ensure the process completes
// let data = pipe.fileHandleForReading.readDataToEndOfFile()
// let output = (String(data: data, encoding: .utf8) ?? "") as String
// print(output)
}
}
// Check if symlink
func isSymlink(atPath path: URL) -> Bool {
do {
let _ = try path.checkResourceIsReachable()
let resourceValues = try path.resourceValues(forKeys: [.isSymbolicLinkKey])
return resourceValues.isSymbolicLink == true
} catch {
return false
}
}
// Open trash folder
func openTrash() {
if let trashURL = try? FileManager.default.url(for: .trashDirectory, in: .userDomainMask, appropriateFor: nil, create: false) {
NSWorkspace.shared.open(trashURL)
}
}
// Check if restricted app
func isRestricted(atPath path: URL) -> Bool {
if path.path.contains("Safari") || path.path.contains(Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String ?? "") || path.path.contains("/Applications/Utilities") {
return true
} else {
return false
}
}
// Convert icon to png so colors render correctly
func convertICNSToPNG(icon: NSImage, size: NSSize) -> NSImage? {
// Resize the icon to the specified size
let resizedIcon = NSImage(size: size)
resizedIcon.lockFocus()
icon.draw(in: NSRect(x: 0, y: 0, width: size.width, height: size.height))
resizedIcon.unlockFocus()
// Convert the resized icon to PNG format
if let resizedImageData = resizedIcon.tiffRepresentation,
let resizedBitmap = NSBitmapImageRep(data: resizedImageData),
let pngData = resizedBitmap.representation(using: .png, properties: [:]) {
return NSImage(data: pngData)
}
return nil
}
// Get icon for files and folders
func getIconForFileOrFolder(atPath path: URL) -> Image? {
let icon = NSWorkspace.shared.icon(forFile: path.path)
let nsImage = icon
return Image(nsImage: nsImage)
}
func getIconForFileOrFolderNS(atPath path: URL) -> NSImage? {
let icon = NSWorkspace.shared.icon(forFile: path.path)
let nsImage = icon
return nsImage
}
// Get average color from image
extension NSImage {
var averageColor: NSColor? {
guard let tiffData = self.tiffRepresentation, let bitmapImage = NSBitmapImageRep(data: tiffData), let inputImage = CIImage(bitmapImageRep: bitmapImage) else { return nil }
let extentVector = CIVector(x: inputImage.extent.origin.x, y: inputImage.extent.origin.y, z: inputImage.extent.size.width, w: inputImage.extent.size.height)
guard let filter = CIFilter(name: "CIAreaAverage", parameters: [kCIInputImageKey: inputImage, kCIInputExtentKey: extentVector]) else { return nil }
guard let outputImage = filter.outputImage else { return nil }
var bitmap = [UInt8](repeating: 0, count: 4)
let context = CIContext(options: [.workingColorSpace: NSNull()])
context.render(outputImage, toBitmap: &bitmap, rowBytes: 4, bounds: CGRect(x: 0, y: 0, width: 1, height: 1), format: CIFormat.RGBA8, colorSpace: nil)
return NSColor(red: CGFloat(bitmap[0]) / 255, green: CGFloat(bitmap[1]) / 255, blue: CGFloat(bitmap[2]) / 255, alpha: CGFloat(bitmap[3]) / 255)
}
}
func startEnd(_ function: @escaping () -> Void) {
let startTime = Date() // Capture start time
function()
let endTime = Date()
let executionTime = endTime.timeIntervalSince(startTime)
printOS("Function executed in: \n\(executionTime) seconds")
}
// Relaunch app
func relaunchApp(afterDelay seconds: TimeInterval = 0.5) -> Never {
let task = Process()
task.launchPath = "/bin/sh"
task.arguments = ["-c", "sleep \(seconds); open \"\(Bundle.main.bundlePath)\""]
task.launch()
NSApp.terminate(nil)
exit(0)
}
// Check if app is running before deleting app files
func killApp(appId: String, completion: @escaping () -> Void = {}) {
let runningApps = NSWorkspace.shared.runningApplications
for app in runningApps {
if app.bundleIdentifier == appId {
app.terminate()
}
}
completion()
}
// Remove app from cache
func removeApp(appState: AppState, withId id: UUID) {
DispatchQueue.main.async {
// Remove from sortedApps if found
if let index = appState.sortedApps.firstIndex(where: { $0.id == id }) {
appState.sortedApps.remove(at: index)
return // Exit the function if the app was found and removed
}
// Remove from appInfoStore if found
if let index = appState.appInfoStore.firstIndex(where: { $0.id == id }) {
appState.appInfoStore.remove(at: index)
}
}
}
// Check if app bundle is nested
func isNested(path: URL) -> Bool {
let applicationsPath = "/Applications"
let homeApplicationsPath = "\(home)/Applications"
guard path.path.contains("Applications") else {
return false
}
// Get the parent directory of the app
let parentDirectory = path.deletingLastPathComponent().path
// Check if the parent directory is not directly /Applications or ~/Applications
return parentDirectory != applicationsPath && parentDirectory != homeApplicationsPath
}
// --- Extend Int to convert hours to seconds ---
extension Int {
var daysToSeconds: Double {
return Double(self) * 24 * 60 * 60
}
}
// --- Extend String to remove periods, spaces and lowercase the string
extension String {
func pearFormat() -> String {
return self.replacingOccurrences(of: ".", with: "").replacingOccurrences(of: " ", with: "").replacingOccurrences(of: "-", with: "").lowercased()
}
}
// --- Extend string to replace - and | with custom characters
extension String {
func featureFormat() -> String {
return self.replacingOccurrences(of: "- ", with: "• ").replacingOccurrences(of: "|", with: "\n\n")
}
}
// --- Capitalize first letter of string only
extension String {
func capitalizingFirstLetter() -> String {
return prefix(1).capitalized + dropFirst()
}
mutating func capitalizeFirstLetter() {
self = self.capitalizingFirstLetter()
}
}
// --- Trash Relationship ---
extension FileManager {
public func isInTrash(_ file: URL) -> Bool {
var relationship: URLRelationship = .other
try? getRelationship(&relationship, of: .trashDirectory, in: .userDomainMask, toItemAt: file)
return relationship == .contains
}
}
// --- Extend print command to also output to the Console ---
func printOS(_ items: Any..., separator: String = " ", terminator: String = "\n") {
let message = items.map { "\($0)" }.joined(separator: separator)
let log = OSLog(subsystem: "pearcleaner", category: "Application")
os_log("%@", log: log, type: .default, message)
}
//func totalSizeOnDisk2(for paths: [URL]) -> Int64 {
// var totalSize = 0
//
// let process = Process()
// process.launchPath = "/usr/bin/du"
// process.arguments = ["-sk"] + paths.map { (url: URL) -> String in
// return url.path
// }
// let pipe = Pipe()
// process.standardOutput = pipe
// process.standardError = pipe//FileHandle.nullDevice
//
// try? process.run()
// process.waitUntilExit()
//
// let data = pipe.fileHandleForReading.readDataToEndOfFile()
// if let output = String(data: data, encoding: .utf8) {
// let lines = output.components(separatedBy: .newlines)
// for line in lines {
// let components = line.components(separatedBy: "\t")
// if let sizeString = components.first, let size = Int(sizeString) {
// totalSize += size
// }
// }
// }
//
// return Int64(totalSize) * 1024 // Convert the size from kilobytes to bytes
//}
// Get total size of folders and files using DU cli command
func totalSizeOnDisk(for paths: [URL]) -> Int64 {
let fileManager = FileManager.default
var totalSize: Int64 = 0
for url in paths {
var isDirectory: ObjCBool = false
if fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory) {
if isDirectory.boolValue {
// It's a directory, recurse into it
if let enumerator = fileManager.enumerator(at: url, includingPropertiesForKeys: [.totalFileAllocatedSizeKey], errorHandler: nil) {
for case let fileURL as URL in enumerator {
do {
let fileAttributes = try fileURL.resourceValues(forKeys: [.totalFileAllocatedSizeKey])
if let fileSize = fileAttributes.totalFileAllocatedSize {
totalSize += Int64(fileSize)
}
} catch {
printOS("Error getting file size for \(fileURL): \(error)")
}
}
}
} else {
// It's a file
do {
let fileAttributes = try url.resourceValues(forKeys: [.totalFileAllocatedSizeKey])
if let fileSize = fileAttributes.totalFileAllocatedSize {
totalSize += Int64(fileSize)
}
} catch {
printOS("Error getting file size for \(url): \(error)")
}
}
}
}
return totalSize
}
func totalSizeOnDisk(for path: URL) -> Int64 {
return totalSizeOnDisk(for: [path])
}
// ByteFormatter
func formatByte(size: Int64) -> String {
let byteCountFormatter = ByteCountFormatter()
byteCountFormatter.countStyle = .file
byteCountFormatter.allowedUnits = [.useAll]
return byteCountFormatter.string(fromByteCount: size)
}
// Only process supported files
func isSupportedFileType(at path: String) -> Bool {
let fileManager = FileManager.default
do {
let attributes = try fileManager.attributesOfItem(atPath: path)
if let fileType = attributes[FileAttributeKey.type] as? FileAttributeType {
switch fileType {
case .typeRegular, .typeDirectory, .typeSymbolicLink:
// The file is a regular file, directory, or symbolic link
return true
default:
// The file is a socket, pipe, or another type not supported
return false
}
}
} catch {
printOS("Error getting file attributes: \(error)")
}
return false
}
// Alerts
func presentAlert(appState: AppState) -> Alert {
switch appState.alertType {
case .update:
return Alert(title: Text("Update Available 🥳"), message: Text("You may choose to install the update now, otherwise you may check again later from Settings"), primaryButton: .default(Text("Install")) {
downloadUpdate(appState: appState)
appState.alertType = .off
}, secondaryButton: .cancel())
case .no_update:
return Alert(title: Text("No Updates 😌"), message: Text("Pearcleaner is on the latest release available"), primaryButton: .cancel(Text("Okay")), secondaryButton: .default(Text("Force Update")) {
downloadUpdate(appState: appState)
appState.alertType = .off
})
case .diskAccess:
return Alert(title: Text("Permissions"), message: Text("Pearcleaner requires Full Disk and Accessibility permissions. Drag the app into the Full Disk and Accessibility pane to enable or toggle On if already present."), primaryButton: .default(Text("Allow in Settings")) {
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles") {
NSWorkspace.shared.open(url)
}
appState.alertType = .off
}, secondaryButton: .cancel(Text("Later")))
case .restartApp:
return Alert(title: Text("Update Completed!"), message: Text("The application has been updated to the latest version, would you like to restart now?"), primaryButton: .default(Text("Restart")) {
appState.alertType = .off
relaunchApp()
}, secondaryButton: .cancel(Text("Later")))
case .off:
return Alert(title: Text(""))
}
}
// --- Pearcleaner Uninstall --
func uninstallPearcleaner(appState: AppState, locations: Locations) {
// Unload Sentinel Monitor if running
launchctl(load: false)
// Get app info for Pearcleaner
let appInfo = AppInfoFetcher.getAppInfo(atPath: Bundle.main.bundleURL)
// appState.appInfo = appInfo!
// Find application files for Pearcleaner
let pathFinder = AppPathFinder(appInfo: appInfo!, appState: appState, locations: locations)
pathFinder.findPaths()
// findPathsForApp(appInfo: appInfo!,appState: appState, locations: locations)
// Kill Pearcleaner and tell Finder to trash the files
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
let selectedItemsArray = Array(appState.selectedItems).filter { !$0.path.contains(".Trash") }
let posixFiles = selectedItemsArray.map { "POSIX file \"\($0.path)\", " }.joined().dropLast(3)
let scriptSource = """
tell application \"Finder\" to delete { \(posixFiles)" }
"""
let task = Process()
task.launchPath = "/bin/sh"
task.arguments = ["-c", "sleep 1; osascript -e '\(scriptSource)'"]
task.launch()
NSApp.terminate(nil)
exit(0)
}
}
// --- Create Application Support folder if it doesn't exist ---
func ensureApplicationSupportFolderExists(appState: AppState) {
let fileManager = FileManager.default
let supportURL = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!.appendingPathComponent("Pearcleaner")
// Check to make sure Application Support/Pearcleaner folder exists
if !fileManager.fileExists(atPath: supportURL.path) {
try! fileManager.createDirectory(at: supportURL, withIntermediateDirectories: true)
printOS("Created Application Support/Pearcleaner folder")
}
}
// --- Write Log to File for troubleshooting ---
func writeLog(string: String) {
let fileManager = FileManager.default
let home = fileManager.homeDirectoryForCurrentUser.path
let logFilePath = "\(home)/Downloads/log.txt"
// Check if the log file exists, and create it if it doesn't
if !fileManager.fileExists(atPath: logFilePath) {
if !fileManager.createFile(atPath: logFilePath, contents: nil, attributes: nil) {
printOS("Failed to create the log file.")
return
}
}
do {
if let fileHandle = FileHandle(forWritingAtPath: logFilePath) {
let ns = "\(string)\n"
fileHandle.seekToEndOfFile()
fileHandle.write(ns.data(using: .utf8)!)
fileHandle.closeFile()
} else {
printOS("Error opening file for appending")
}
}
}
// --- Load Plist file with launchctl ---
func launchctl(load: Bool, completion: @escaping () -> Void = {}) {
let cmd = load ? "load" : "unload"
if let plistPath = Bundle.main.path(forResource: "com.alienator88.PearcleanerSentinel", ofType: "plist") {
var plistContent = try! String(contentsOfFile: plistPath)
let executableURL = Bundle.main.bundleURL.appendingPathComponent("Contents/MacOS/PearcleanerSentinel")
// Replace the placeholder with the actual executable path
plistContent = plistContent.replacingOccurrences(of: "__EXECUTABLE_PATH__", with: executableURL.path)
// Create a temporary plist file with the updated content
let temporaryPlistURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("com.alienator88.PearcleanerSentinel.plist")
do {
try plistContent.write(to: temporaryPlistURL, atomically: true, encoding: .utf8)
} catch {
printOS("Error writing the temporary plist file: \(error)")
return
}
let task = Process()
task.launchPath = "/bin/launchctl"
task.arguments = [cmd, "-w", temporaryPlistURL.path]
task.standardOutput = FileHandle.nullDevice
task.standardError = FileHandle.nullDevice
task.launch()
completion()
}
}
func getCurrentTimestamp() -> String {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS"
return dateFormatter.string(from: Date())
}