From 1586f5686c5dee1b3f9fb23e0468a1b25053c1fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1rady=20Mil=C3=A1n?= <61704770+MilanVarady@users.noreply.github.com> Date: Sun, 29 Dec 2024 22:59:57 +0100 Subject: [PATCH] Add PTY option to shell --- Applite/Extensions/StringExtension.swift | 16 +++- .../Cask Models/Cask/Cask+BrewFunctions.swift | 2 +- .../Cask Models/Cask/Cask+LaunchApp.swift | 4 +- .../Brew Installation/DependencyManager.swift | 8 +- .../Utilities/Import Export/ExportCasks.swift | 6 +- Applite/Utilities/Other/BrewPaths.swift | 4 +- Applite/Utilities/Other/UninstallSelf.swift | 6 +- Applite/Utilities/Shell/Shell.swift | 93 +++++++++++++++---- Applite/Utilities/Shell/ShellError.swift | 13 ++- .../isBrewPathValid.swift | 4 +- .../isCommandLineToolsInstalled.swift | 4 +- .../App View/AppView+DownloadButton.swift | 12 +-- .../App View/AppView+OpenAndManageView.swift | 10 +- .../BrewPathSelectorView.swift | 12 ++- .../Content View/ContentView+LoadCasks.swift | 2 +- .../Detail Views/BrewManagementView.swift | 14 +-- .../Views/Setup/SetupView+BrewInstall.swift | 9 +- Applite/Views/UninstallSelfView.swift | 10 +- 18 files changed, 156 insertions(+), 73 deletions(-) diff --git a/Applite/Extensions/StringExtension.swift b/Applite/Extensions/StringExtension.swift index dfef4e5..a91780d 100644 --- a/Applite/Extensions/StringExtension.swift +++ b/Applite/Extensions/StringExtension.swift @@ -8,7 +8,19 @@ import Foundation extension String { - func cleanANSIEscapeCodes() -> String { - replacingOccurrences(of: "\\\u{001B}\\[[0-9;]*[a-zA-Z]", with: "", options: .regularExpression) + func cleanTerminalOutput() -> String { + // Regex to match ANSI escape codes + let ansiEscapePattern = "\\u001B\\[[0-9;]*[a-zA-Z]" + // Match backspaces with preceding characters + let backspacePattern = ".\\u{08}" + // Regex for other unwanted characters + let unwantedPattern = "[\\r\\f]" + + let cleaned = self + .replacingOccurrences(of: ansiEscapePattern, with: "", options: .regularExpression) + .replacingOccurrences(of: backspacePattern, with: "", options: .regularExpression) + .replacingOccurrences(of: unwantedPattern, with: "", options: .regularExpression) + + return cleaned } } diff --git a/Applite/Model/Cask Models/Cask/Cask+BrewFunctions.swift b/Applite/Model/Cask Models/Cask/Cask+BrewFunctions.swift index de13fba..a4286de 100644 --- a/Applite/Model/Cask Models/Cask/Cask+BrewFunctions.swift +++ b/Applite/Model/Cask Models/Cask/Cask+BrewFunctions.swift @@ -37,7 +37,7 @@ extension Cask { // Run install command and stream output do { - for try await line in Shell.stream(command) { + for try await line in Shell.stream(command, pty: true) { completeOutput += line self.progressState = self.parseBrewInstall(output: line) } diff --git a/Applite/Model/Cask Models/Cask/Cask+LaunchApp.swift b/Applite/Model/Cask Models/Cask/Cask+LaunchApp.swift index 8c49f1b..8396454 100644 --- a/Applite/Model/Cask Models/Cask/Cask+LaunchApp.swift +++ b/Applite/Model/Cask Models/Cask/Cask+LaunchApp.swift @@ -8,7 +8,7 @@ import Foundation extension Cask { - func launchApp() throws { + func launchApp() async throws { let appPath: String if self.pkgInstaller { @@ -33,6 +33,6 @@ extension Cask { appPath = "\(brewDirectory.replacingOccurrences(of: " ", with: "\\ ") )/Caskroom/\(self.id)/*/*.app" } - try Shell.run("open \(appPath)") + try await Shell.runAsync("open \(appPath)") } } diff --git a/Applite/Utilities/Brew Installation/DependencyManager.swift b/Applite/Utilities/Brew Installation/DependencyManager.swift index 8e92149..2215f4d 100755 --- a/Applite/Utilities/Brew Installation/DependencyManager.swift +++ b/Applite/Utilities/Brew Installation/DependencyManager.swift @@ -31,7 +31,7 @@ struct DependencyManager { Self.logger.info("Brew installation started") // Install command line tools - if !isCommandLineToolsInstalled() { + if await !isCommandLineToolsInstalled() { Self.logger.info("Prompting user to install Xcode Command Line Tools") try await Shell.runAsync("xcode-select --install") @@ -40,7 +40,7 @@ struct DependencyManager { var didBreak = false for _ in 0...360 { - if isCommandLineToolsInstalled() { + if await isCommandLineToolsInstalled() { didBreak = true break } @@ -57,7 +57,9 @@ struct DependencyManager { } // Skip Homebrew installation if keepCurrentInstall is set to true - guard keepCurrentInstall && !isBrewPathValid(path: BrewPaths.appBrewExetutable.path) else { + let brewPathValid = await isBrewPathValid(path: BrewPaths.appBrewExetutable.path) + + guard keepCurrentInstall && !brewPathValid else { Self.logger.notice("Brew is already installed, skipping installation") await MainActor.run { progressObject.phase = .done } return diff --git a/Applite/Utilities/Import Export/ExportCasks.swift b/Applite/Utilities/Import Export/ExportCasks.swift index 7dc5395..0417394 100644 --- a/Applite/Utilities/Import Export/ExportCasks.swift +++ b/Applite/Utilities/Import Export/ExportCasks.swift @@ -13,7 +13,7 @@ enum CaskImportError: Error { } enum CaskToFileManager { - static func export(url: URL, exportType: CaskExportType) throws { + static func export(url: URL, exportType: CaskExportType) async throws { let today = Date.now let formatter = DateFormatter() @@ -22,7 +22,7 @@ enum CaskToFileManager { switch exportType { case .txtFile: - let output = try Shell.run("\(BrewPaths.currentBrewExecutable) list --cask") + let output = try await Shell.runAsync("\(BrewPaths.currentBrewExecutable) list --cask") let exportedCasks = output.trimmingCharacters(in: .whitespacesAndNewlines) @@ -33,7 +33,7 @@ enum CaskToFileManager { case .brewfile: let brewfileURL = url.appendingPathComponent("Brewfile_\(currentDateString)") - try Shell.run("\(BrewPaths.currentBrewExecutable) bundle dump --file=\"\(brewfileURL.path)\"") + try await Shell.runAsync("\(BrewPaths.currentBrewExecutable) bundle dump --file=\"\(brewfileURL.path)\"") } } diff --git a/Applite/Utilities/Other/BrewPaths.swift b/Applite/Utilities/Other/BrewPaths.swift index 48eadb4..8e458b3 100755 --- a/Applite/Utilities/Other/BrewPaths.swift +++ b/Applite/Utilities/Other/BrewPaths.swift @@ -103,7 +103,7 @@ struct BrewPaths { } /// Checks if currently selected brew executable path is valid - static public func isSelectedBrewPathValid() -> Bool { - return isBrewPathValid(path: Self.currentBrewExecutable) + static public func isSelectedBrewPathValid() async -> Bool { + return await isBrewPathValid(path: Self.currentBrewExecutable) } } diff --git a/Applite/Utilities/Other/UninstallSelf.swift b/Applite/Utilities/Other/UninstallSelf.swift index 87bb114..5b63737 100755 --- a/Applite/Utilities/Other/UninstallSelf.swift +++ b/Applite/Utilities/Other/UninstallSelf.swift @@ -10,7 +10,7 @@ import OSLog import Kingfisher /// This function will uninstall Applite and all it's related files -func uninstallSelf(deleteBrewCache: Bool) throws { +func uninstallSelf(deleteBrewCache: Bool) async throws { let logger = Logger() logger.notice("Applite uninstallation stated. deleteBrewCache: \(deleteBrewCache)") @@ -42,13 +42,13 @@ func uninstallSelf(deleteBrewCache: Bool) throws { logger.notice("Running command: \(command)") - let output = try Shell.run(command) + let output = try await Shell.runAsync(command) logger.notice("Uninstall result: \(output)") // Homebrew cache if deleteBrewCache { - try Shell.run("rm -rf $HOME/Library/Caches/Homebrew") + try await Shell.runAsync("rm -rf $HOME/Library/Caches/Homebrew") } logger.notice("Self destructing. Goodbye world! o7") diff --git a/Applite/Utilities/Shell/Shell.swift b/Applite/Utilities/Shell/Shell.swift index d188913..8d154b8 100644 --- a/Applite/Utilities/Shell/Shell.swift +++ b/Applite/Utilities/Shell/Shell.swift @@ -9,14 +9,25 @@ import Foundation import OSLog /// Namespace for shell command execution utilities -public enum Shell { +enum Shell { private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "Shell") + + /// MD5 checksum of the askpass script. + /// We want to make sure the script isn't modified by any outside actor private static let askpassChecksum = "fAl63ShrMp8Sp9HIj/FYYA==" /// Executes a shell command synchronously + /// + /// - Parameters: + /// - command: The shell command to run + /// - pty: Wether to use pseudo-TTY behavior or not + /// + /// - Returns: The output of the shell command + /// + /// Using the `pty` option can leave unwanted characters in the output, use only when necessary @discardableResult - static func run(_ command: String) throws -> String { - let (task, pipe) = try createProcess(command: command) + static func run(_ command: String, pty: Bool = false) throws -> String { + let (task, pipe) = try createProcess(command: command, pty: pty) try task.run() task.waitUntilExit() @@ -27,7 +38,7 @@ public enum Shell { throw ShellError.outputDecodingFailed } - let cleanOutput = output.cleanANSIEscapeCodes() + let cleanOutput = output.cleanTerminalOutput() guard task.terminationStatus == 0 else { throw ShellError.nonZeroExit( @@ -41,31 +52,55 @@ public enum Shell { } /// Executes a shell command asynchronously + /// + /// - Parameters: + /// - command: The shell command to run + /// - pty: Wether to use pseudo-TTY behavior or not + /// + /// - Returns: The output of the shell command + /// + /// Using the `pty` option can leave unwanted characters in the output, use only when necessary @discardableResult - static func runAsync(_ command: String) async throws -> String { + static func runAsync(_ command: String, pty: Bool = false) async throws -> String { // Simply mark it as async and use the same implementation try run(command) } /// Executes a brew command asynchronously + /// + /// - Parameters: + /// - command: The shell command to run + /// - pty: Wether to use pseudo-TTY behavior or not + /// + /// - Returns: The output of the shell command + /// + /// Using the `pty` option can leave unwanted characters in the output, use only when necessary @discardableResult - static func runBrewCommand(_ brewCommand: String, arguments: [String]) async throws -> String { - let command = "brew \(brewCommand) --cask \(arguments.joined(separator: " "))" + static func runBrewCommand(_ brewCommand: String, arguments: [String], pty: Bool = false) async throws -> String { + let command = "\(BrewPaths.currentBrewExecutable) \(brewCommand) --cask \(arguments.joined(separator: " "))" return try await runAsync(command) } - /// Executes a shell command and streams the output - static func stream(_ command: String) -> AsyncThrowingStream { + /// Executes a shell command and streams the output line-by-line + /// + /// - Parameters: + /// - command: The shell command to run + /// - pty: Wether to use pseudo-TTY behavior or not + /// + /// - Returns: An ``AsyncThrowingStream`` that yields the output in real time + /// + /// Using the `pty` option can leave unwanted characters in the output, use only when necessary + static func stream(_ command: String, pty: Bool = false) -> AsyncThrowingStream { AsyncThrowingStream { continuation in Task { do { - let (task, pipe) = try createProcess(command: command) + let (task, pipe) = try createProcess(command: command, pty: pty) let fileHandle = pipe.fileHandleForReading try task.run() for try await line in fileHandle.bytes.lines { - let cleanOutput = line.cleanANSIEscapeCodes() + let cleanOutput = line.cleanTerminalOutput() continuation.yield(cleanOutput) } @@ -90,15 +125,27 @@ public enum Shell { } } - /// Creates a shell process with a given command - private static func createProcess(command: String) throws -> (Process, Pipe) { + /// Initializes a shell process with a given command + /// + /// - Parameters: + /// - command: The shell command to run + /// - pty: Wether to use pseudo-TTY behavior or not + /// + /// - Returns: The initialized ``Process`` and ``Pipe`` object + /// + /// We need the `pty` option because some brew commands run in quiet mode if it detects its not in a interactive environment + private static func createProcess(command: String, pty: Bool) throws -> (Process, Pipe) { // Verify askpass script guard let scriptPath = Bundle.main.path(forResource: "askpass", ofType: "js") else { - throw ShellError.scriptNotFound + throw ShellError.askpassNotFound } if URL(string: scriptPath)?.checksumInBase64() != askpassChecksum { - throw ShellError.checksumMismatch + throw ShellError.askpassChecksumMismatch + } + + guard let homeDirectory = ProcessInfo.processInfo.environment["HOME"] else { + throw ShellError.coundtGetHomeDirectory } let task = Process() @@ -106,7 +153,9 @@ public enum Shell { // Set up environment var environment: [String: String] = [ - "SUDO_ASKPASS": scriptPath + "SUDO_ASKPASS": scriptPath, + "TERM": "xterm-256color", // Ensure terminal emulation + "HOME": homeDirectory ] if let proxySettings = try? NetworkProxyManager.getSystemProxySettings() { @@ -117,9 +166,15 @@ public enum Shell { task.standardOutput = pipe task.standardError = pipe task.environment = environment - task.executableURL = URL(fileURLWithPath: "/bin/zsh") - task.arguments = ["-l", "-c", command] - task.standardInput = nil + + if pty { + // Use `script` for pseudo-TTY behavior + task.executableURL = URL(fileURLWithPath: "/usr/bin/script") + task.arguments = ["-q", "/dev/null", "/bin/sh", "-c", command] + } else { + task.executableURL = URL(fileURLWithPath: "/bin/sh") + task.arguments = ["-c", command] + } return (task, pipe) } diff --git a/Applite/Utilities/Shell/ShellError.swift b/Applite/Utilities/Shell/ShellError.swift index d693896..18334b6 100644 --- a/Applite/Utilities/Shell/ShellError.swift +++ b/Applite/Utilities/Shell/ShellError.swift @@ -8,19 +8,22 @@ import Foundation enum ShellError: LocalizedError { - case scriptNotFound - case checksumMismatch + case askpassNotFound + case askpassChecksumMismatch case outputDecodingFailed + case coundtGetHomeDirectory case nonZeroExit(command: String, exitCode: Int32, output: String) var errorDescription: String? { switch self { - case .scriptNotFound: - return "Required script file not found" - case .checksumMismatch: + case .askpassNotFound: + return "askpass script not found" + case .askpassChecksumMismatch: return "Script checksum mismatch. The file has been modified." case .outputDecodingFailed: return "Failed to decode command output as UTF-8" + case .coundtGetHomeDirectory: + return "Failed to get home directory" case .nonZeroExit(let command, let exitCode, let output): return "Failed to run shell command.\nCommand: \(command) (exit code: \(exitCode))\nOutput: \(output)" } diff --git a/Applite/Utilities/Verify Brew Installation/isBrewPathValid.swift b/Applite/Utilities/Verify Brew Installation/isBrewPathValid.swift index e5c009c..0c1eeb6 100755 --- a/Applite/Utilities/Verify Brew Installation/isBrewPathValid.swift +++ b/Applite/Utilities/Verify Brew Installation/isBrewPathValid.swift @@ -13,7 +13,7 @@ import Foundation /// - path: Path to be checked /// /// - Returns: Whether the path is valid or not -public func isBrewPathValid(path: String) -> Bool { +public func isBrewPathValid(path: String) async -> Bool { var path = path // Add " marks so shell doesn't fail on spaces @@ -27,7 +27,7 @@ public func isBrewPathValid(path: String) -> Bool { } // Check if Homebrew is returned when checking version - guard let output = try? Shell.run("\(path) --version") else { + guard let output = try? await Shell.runAsync("\(path) --version") else { return false } diff --git a/Applite/Utilities/Verify Brew Installation/isCommandLineToolsInstalled.swift b/Applite/Utilities/Verify Brew Installation/isCommandLineToolsInstalled.swift index 5a3e321..7c69f06 100755 --- a/Applite/Utilities/Verify Brew Installation/isCommandLineToolsInstalled.swift +++ b/Applite/Utilities/Verify Brew Installation/isCommandLineToolsInstalled.swift @@ -10,9 +10,9 @@ import Foundation /// Checks if Xcode Command Line Tools is installed /// /// - Returns: Whether it is installed or not -public func isCommandLineToolsInstalled() -> Bool { +public func isCommandLineToolsInstalled() async -> Bool { do { - try Shell.run("xcode-select -p") + try await Shell.runAsync("xcode-select -p") } catch { return false } diff --git a/Applite/Views/App Views/App View/AppView+DownloadButton.swift b/Applite/Views/App Views/App View/AppView+DownloadButton.swift index 3d27b74..9dd534f 100644 --- a/Applite/Views/App Views/App View/AppView+DownloadButton.swift +++ b/Applite/Views/App Views/App View/AppView+DownloadButton.swift @@ -98,13 +98,13 @@ extension AppView { } private func download(force: Bool = false) { - // Check if brew path is valid - guard BrewPaths.isSelectedBrewPathValid() else { - showingBrewError = true - return - } + Task { @MainActor in + // Check if brew path is valid + guard await BrewPaths.isSelectedBrewPathValid() else { + showingBrewError = true + return + } - Task { await cask.install(caskData: caskData, force: force) } } diff --git a/Applite/Views/App Views/App View/AppView+OpenAndManageView.swift b/Applite/Views/App Views/App View/AppView+OpenAndManageView.swift index 196ac50..9935d76 100644 --- a/Applite/Views/App Views/App View/AppView+OpenAndManageView.swift +++ b/Applite/Views/App Views/App View/AppView+OpenAndManageView.swift @@ -23,10 +23,12 @@ extension AppView { var body: some View { // Lauch app Button("Open") { - do { - try cask.launchApp() - } catch { - showAppNotFoundAlert = true + Task { + do { + try await cask.launchApp() + } catch { + showAppNotFoundAlert = true + } } } .font(.system(size: 14)) diff --git a/Applite/Views/Components/Brew Path Selector/BrewPathSelectorView.swift b/Applite/Views/Components/Brew Path Selector/BrewPathSelectorView.swift index 4fa9bf0..f44768d 100755 --- a/Applite/Views/Components/Brew Path Selector/BrewPathSelectorView.swift +++ b/Applite/Views/Components/Brew Path Selector/BrewPathSelectorView.swift @@ -33,18 +33,24 @@ struct BrewPathSelectorView: View { } .pickerStyle(.radioGroup) } + .task { + isSelectedPathValid = await BrewPaths.isSelectedBrewPathValid() + } .onAppear { customBrewPathDebounced.text = customUserBrewPath - isSelectedPathValid = BrewPaths.isSelectedBrewPathValid() } .onChange(of: brewPathOption) { _ in - isSelectedPathValid = BrewPaths.isSelectedBrewPathValid() + Task { @MainActor in + isSelectedPathValid = await BrewPaths.isSelectedBrewPathValid() + } } .onChange(of: customBrewPathDebounced.debouncedText) { newPath in customUserBrewPath = newPath if brewPathOption == BrewPaths.PathOption.custom.rawValue { - isSelectedPathValid = isBrewPathValid(path: newPath) + Task { @MainActor in + isSelectedPathValid = await isBrewPathValid(path: newPath) + } } } } diff --git a/Applite/Views/Content View/ContentView+LoadCasks.swift b/Applite/Views/Content View/ContentView+LoadCasks.swift index 9025dde..d8bef85 100644 --- a/Applite/Views/Content View/ContentView+LoadCasks.swift +++ b/Applite/Views/Content View/ContentView+LoadCasks.swift @@ -9,7 +9,7 @@ import SwiftUI extension ContentView { func loadCasks() async { - guard BrewPaths.isSelectedBrewPathValid() else { + guard await BrewPaths.isSelectedBrewPathValid() else { loadAlert.show(title: "Couldn't load app catalog", message: DependencyManager.brokenPathOrIstallMessage) brokenInstall = true diff --git a/Applite/Views/Detail Views/BrewManagementView.swift b/Applite/Views/Detail Views/BrewManagementView.swift index 9c6c9d5..4e163ed 100755 --- a/Applite/Views/Detail Views/BrewManagementView.swift +++ b/Applite/Views/Detail Views/BrewManagementView.swift @@ -152,7 +152,7 @@ struct BrewManagementView: View { reinstallButton .task { // Check if brew is installed in application support - isAppBrewInstalled = isBrewPathValid(path: BrewPaths.getBrewExectuablePath(for: .appPath)) + isAppBrewInstalled = await isBrewPathValid(path: BrewPaths.getBrewExectuablePath(for: .appPath)) } if reinstallDone { @@ -287,11 +287,13 @@ struct BrewManagementView: View { ) { result in switch result { case .success(let url): - do { - try CaskToFileManager.export(url: url[0], exportType: selectedExportFileType) - } catch { - logger.error("Failed to export casks. Error: \(error.localizedDescription)") - showingExportError = true + Task { @MainActor in + do { + try await CaskToFileManager.export(url: url[0], exportType: selectedExportFileType) + } catch { + logger.error("Failed to export casks. Error: \(error.localizedDescription)") + showingExportError = true + } } case .failure(let error): logger.error("\(error.localizedDescription)") diff --git a/Applite/Views/Setup/SetupView+BrewInstall.swift b/Applite/Views/Setup/SetupView+BrewInstall.swift index 298c9ce..d32c4f1 100644 --- a/Applite/Views/Setup/SetupView+BrewInstall.swift +++ b/Applite/Views/Setup/SetupView+BrewInstall.swift @@ -54,13 +54,12 @@ extension SetupView { } .frame(width: 440) .task { - // Start installation when view loads - await installDependencies() - } - .onAppear() { - if !isCommandLineToolsInstalled() { + if await !isCommandLineToolsInstalled() { showCommandLineToolsInstallAlert = true } + + // Start installation when view loads + await installDependencies() } .alert("Xcode Command Line Tools", isPresented: $showCommandLineToolsInstallAlert) {} message: { Text("You will be prompted to install Xcode Command Line Tools. Please select \"Install\" as it is required for this application to work.") diff --git a/Applite/Views/UninstallSelfView.swift b/Applite/Views/UninstallSelfView.swift index cc60b49..96b0aec 100755 --- a/Applite/Views/UninstallSelfView.swift +++ b/Applite/Views/UninstallSelfView.swift @@ -41,10 +41,12 @@ struct UninstallSelfView: View { .frame(width: 400, height: 250) .confirmationDialog("Are you sure you want to permanently uninstall \(Bundle.main.appName)?", isPresented: $showConfirmation) { Button("Uninstall", role: .destructive) { - do { - try uninstallSelf(deleteBrewCache: deleteBrewCache) - } catch { - uninstallAlert.show(title: "Failed to uninstall", message: error.localizedDescription) + Task.detached { + do { + try await uninstallSelf(deleteBrewCache: deleteBrewCache) + } catch { + await uninstallAlert.show(title: "Failed to uninstall", message: error.localizedDescription) + } } }