Skip to content

Commit

Permalink
Add PTY option to shell
Browse files Browse the repository at this point in the history
  • Loading branch information
milanvarady committed Dec 29, 2024
1 parent 080c367 commit 1586f56
Show file tree
Hide file tree
Showing 18 changed files with 156 additions and 73 deletions.
16 changes: 14 additions & 2 deletions Applite/Extensions/StringExtension.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
2 changes: 1 addition & 1 deletion Applite/Model/Cask Models/Cask/Cask+BrewFunctions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
4 changes: 2 additions & 2 deletions Applite/Model/Cask Models/Cask/Cask+LaunchApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import Foundation

extension Cask {
func launchApp() throws {
func launchApp() async throws {
let appPath: String

if self.pkgInstaller {
Expand All @@ -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)")
}
}
8 changes: 5 additions & 3 deletions Applite/Utilities/Brew Installation/DependencyManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -40,7 +40,7 @@ struct DependencyManager {
var didBreak = false

for _ in 0...360 {
if isCommandLineToolsInstalled() {
if await isCommandLineToolsInstalled() {
didBreak = true
break
}
Expand All @@ -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
Expand Down
6 changes: 3 additions & 3 deletions Applite/Utilities/Import Export/ExportCasks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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)

Expand All @@ -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)\"")
}
}

Expand Down
4 changes: 2 additions & 2 deletions Applite/Utilities/Other/BrewPaths.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
6 changes: 3 additions & 3 deletions Applite/Utilities/Other/UninstallSelf.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand Down Expand Up @@ -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")
Expand Down
93 changes: 74 additions & 19 deletions Applite/Utilities/Shell/Shell.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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(
Expand All @@ -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<String, Error> {
/// 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<String, Error> {
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)
}

Expand All @@ -90,23 +125,37 @@ 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()
let pipe = Pipe()

// 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() {
Expand All @@ -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)
}
Expand Down
13 changes: 8 additions & 5 deletions Applite/Utilities/Shell/ShellError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
12 changes: 6 additions & 6 deletions Applite/Views/App Views/App View/AppView+DownloadButton.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down
10 changes: 6 additions & 4 deletions Applite/Views/App Views/App View/AppView+OpenAndManageView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Loading

0 comments on commit 1586f56

Please sign in to comment.