diff --git a/.swiftlint.yml b/.swiftlint.yml index da5dadd1..e9f7e9f9 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -51,7 +51,7 @@ file_length: ignore_comment_only_lines: true warning: 500 file_name: - excluded: [Group.swift, User.swift] + excluded: [Group.swift, Process.swift, User.swift] file_types_order: order: - main_type diff --git a/Sources/mas/AppStore/AppStoreAction+download.swift b/Sources/mas/AppStore/AppStoreAction+download.swift index 866db4b4..bcc6b570 100644 --- a/Sources/mas/AppStore/AppStoreAction+download.swift +++ b/Sources/mas/AppStore/AppStoreAction+download.swift @@ -234,7 +234,7 @@ private actor DownloadQueueObserver: CKDownloadQueueObserver { prevPhaseType = snapshot.activePhaseType } - private func removed(_ snapshot: DownloadSnapshot) { + private func removed(_ snapshot: DownloadSnapshot) async { MAS.printer.clearCurrentLine(of: .standardOutput) do { @@ -250,7 +250,7 @@ private actor DownloadQueueObserver: CKDownloadQueueObserver { "progress cannot be displayed", terminator: "", ) - try install(appNameAndVersion: snapshot.appNameAndVersion) + try await install(appNameAndVersion: snapshot.appNameAndVersion) MAS.printer.clearCurrentLine(of: .standardOutput) } else { guard !snapshot.isFailed else { @@ -289,7 +289,7 @@ private actor DownloadQueueObserver: CKDownloadQueueObserver { return hardLinkURL } - private func install(appNameAndVersion: String) throws { + private func install(appNameAndVersion: String) async throws { guard let pkgHardLinkPath = pkgHardLinkURL?.path(percentEncoded: false) else { throw MASError.error("Failed to find pkg to \(action) \(appNameAndVersion)") } @@ -297,7 +297,31 @@ private actor DownloadQueueObserver: CKDownloadQueueObserver { throw MASError.error("Failed to find receipt to import for \(appNameAndVersion)") } - let appFolderURL = try installPkg(appNameAndVersion: appNameAndVersion) + let (_, standardErrorString) = try await run( + "/usr/sbin/installer", + "-dumplog", + "-pkg", + pkgHardLinkPath, + "-target", + "/", + errorMessage: "Failed to \(action) \(appNameAndVersion) from \(pkgHardLinkPath)", + ) { process in try run(asEffectiveUID: 0, andEffectiveGID: 0) { try process.run() } } + + let appFolderURLMatches = standardErrorString.matches(of: unsafe appFolderURLRegex) + // swiftlint:disable:next prefer_key_path + guard let appFolderURLSubstring = appFolderURLMatches.compactMap({ $0.1 }).min(by: { $0.count < $1.count }) else { + throw MASError.error( // swiftformat:disable:previous preferKeyPath + "Failed to find app folder URL in installer output for \(appNameAndVersion)", + error: standardErrorString, + ) + } + guard let appFolderURL = URL(string: String(appFolderURLSubstring)) else { + throw MASError.error( + "Failed to parse app folder URL for \(appNameAndVersion) from \(appFolderURLSubstring)", + error: standardErrorString, + ) + } + let receiptURL = appFolderURL.appending(path: "Contents/_MASReceipt/receipt", directoryHint: .notDirectory) do { let fileManager = FileManager.default @@ -323,96 +347,14 @@ private actor DownloadQueueObserver: CKDownloadQueueObserver { ) } - let process = Process() - process.executableURL = URL(filePath: "/usr/bin/mdimport", directoryHint: .notDirectory) - process.arguments = [appFolderURL.path(percentEncoded: false)] - let standardOutputPipe = Pipe() - process.standardOutput = standardOutputPipe - let standardErrorPipe = Pipe() - process.standardError = standardErrorPipe - do { - try process.run() - } catch { - throw MASError.error("Failed to \(action) \(appNameAndVersion) from \(pkgHardLinkPath)", error: error) - } - process.waitUntilExit() - guard process.terminationStatus == 0 else { - throw MASError.error( - """ - Failed to \(action) \(appNameAndVersion) from \(pkgHardLinkPath) - Exit status: \(process.terminationStatus)\( - try standardOutputPipe.fileHandleForReading - .readToEnd() // swiftformat:disable indent - .flatMap { String(data: $0, encoding: .utf8) }? - .trimmingCharacters(in: .whitespacesAndNewlines) - .prependIfNotEmpty("\n\nStandard output:\n") - ?? "" - )\(try standardErrorPipe.fileHandleForReading // swiftformat:enable indent - .readToEnd() - .flatMap { String(data: $0, encoding: .utf8) }? - .trimmingCharacters(in: .whitespacesAndNewlines) - .prependIfNotEmpty("\n\nStandard error:\n") - ?? "" // swiftformat:disable:this wrap - ) - """, - ) - } + _ = try await run( + "/usr/bin/mdimport", + appFolderURL.path(percentEncoded: false), + errorMessage: "Failed to \(action) \(appNameAndVersion) from \(pkgHardLinkPath)", + ) LSRegisterURL(appFolderURL as NSURL, true) // swiftlint:disable:this legacy_objc_type } - - private func installPkg(appNameAndVersion: String) throws -> URL { - guard let pkgHardLinkPath = pkgHardLinkURL?.path(percentEncoded: false) else { - throw MASError.error("Failed to find pkg to \(action) \(appNameAndVersion)") - } - - let process = Process() - process.executableURL = URL(filePath: "/usr/sbin/installer", directoryHint: .notDirectory) - process.arguments = ["-dumplog", "-pkg", pkgHardLinkPath, "-target", "/"] - let standardOutputPipe = Pipe() - process.standardOutput = standardOutputPipe - let standardErrorPipe = Pipe() - process.standardError = standardErrorPipe - do { - try run(asEffectiveUID: 0, andEffectiveGID: 0) { try process.run() } - } catch { - throw MASError.error("Failed to \(action) \(appNameAndVersion) from \(pkgHardLinkPath)", error: error) - } - process.waitUntilExit() - let standardOutputText = - try standardOutputPipe.fileHandleForReading.readToEnd().flatMap { String(data: $0, encoding: .utf8) } ?? "" - let standardErrorText = - try standardErrorPipe.fileHandleForReading.readToEnd().flatMap { String(data: $0, encoding: .utf8) } ?? "" - guard process.terminationStatus == 0 else { - throw MASError.error( - """ - Failed to \(action) \(appNameAndVersion) from \(pkgHardLinkPath) - Exit status: \(process.terminationStatus)\( - standardOutputText.trimmingCharacters(in: .whitespacesAndNewlines).prependIfNotEmpty("\n\nStandard output:\n") - )\(standardErrorText.trimmingCharacters(in: .whitespacesAndNewlines).prependIfNotEmpty("\n\nStandard error:\n")) - """, - ) - } - guard - let appFolderURLSubstring = standardErrorText - .matches(of: unsafe appFolderURLRegex) - .compactMap(\.1) - .min(by: { $0.count < $1.count }) - else { - throw MASError.error( - "Failed to find app folder URL in installer output for \(appNameAndVersion)", - error: standardErrorText, - ) - } - guard let appFolderURL = URL(string: String(appFolderURLSubstring)) else { - throw MASError.error( - "Failed to parse app folder URL for \(appNameAndVersion) from \(appFolderURLSubstring)", - error: standardErrorText, - ) - } - - return appFolderURL - } } private struct DownloadSnapshot: Sendable { // swiftlint:disable:this one_declaration_per_file @@ -500,10 +442,6 @@ private extension String { var capitalizingFirstCharacter: Self { prefix(1).capitalized + dropFirst() } - - func prependIfNotEmpty(_ prefix: String) -> Self { - isEmpty ? self : prefix + self - } } private extension URL { diff --git a/Sources/mas/Utilities/Pipe.swift b/Sources/mas/Utilities/Pipe.swift new file mode 100644 index 00000000..b1825c3c --- /dev/null +++ b/Sources/mas/Utilities/Pipe.swift @@ -0,0 +1,14 @@ +// +// Pipe.swift +// mas +// +// Copyright © 2025 mas-cli. All rights reserved. +// + +internal import Foundation + +extension Pipe { + func readToEnd(encoding: String.Encoding = .utf8) throws -> String? { + try fileHandleForReading.readToEnd().flatMap { String(data: $0, encoding: encoding) } + } +} diff --git a/Sources/mas/Utilities/Process.swift b/Sources/mas/Utilities/Process.swift new file mode 100644 index 00000000..32421af3 --- /dev/null +++ b/Sources/mas/Utilities/Process.swift @@ -0,0 +1,62 @@ +// +// Process.swift +// mas +// +// Copyright © 2025 mas-cli. All rights reserved. +// + +internal import Foundation +private import ObjectiveC + +func run( + _ executablePath: String, + _ args: String..., + errorMessage: @autoclosure () -> String, + runProcess run: (Process) throws -> Void = { try $0.run() }, +) async throws -> (standardOutputString: String, standardErrorString: String) { + let process = Process() + process.executableURL = URL(filePath: executablePath, directoryHint: .notDirectory) + process.arguments = args + + let standardOutputPipe = Pipe() + let standardErrorPipe = Pipe() + + process.standardOutput = standardOutputPipe + process.standardError = standardErrorPipe + + let standardOutputTask = Task(priority: .background) { + try standardOutputPipe.readToEnd()?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + } + let standardErrorTask = Task(priority: .background) { + try standardErrorPipe.readToEnd()?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + } + + do { + try run(process) + } catch { + throw MASError.error(errorMessage(), error: error) + } + process.waitUntilExit() + + let standardOutputString = try await standardOutputTask.value + let standardErrorString = try await standardErrorTask.value + + guard process.terminationStatus == 0 else { + throw MASError.error( + """ + \(errorMessage()) + Exit status: \(process.terminationStatus)\ + \(standardOutputString.ifNotEmptyPrepend("\n\nStandard output:\n"))\ + \(standardErrorString.ifNotEmptyPrepend("\n\nStandard error:\n")) + """, + ) + } + + return (standardOutputString, standardErrorString) +} + +private extension String { + func ifNotEmptyPrepend(_ prefix: String) -> Self { + isEmpty ? self : prefix + self + } +} diff --git a/Tests/MASTests/Utilities/Consequences.swift b/Tests/MASTests/Utilities/Consequences.swift index e0adc9f2..937f5009 100644 --- a/Tests/MASTests/Utilities/Consequences.swift +++ b/Tests/MASTests/Utilities/Consequences.swift @@ -6,6 +6,7 @@ // internal import Foundation +@testable private import mas struct Consequences { let value: Value? @@ -88,11 +89,8 @@ private struct StandardStreamCapture { // swiftlint:disable:this one_declaration close(outDuplicateFD) close(errDuplicateFD) - return ( // swiftlint:disable:next force_try - try! outPipe.fileHandleForReading.readToEnd().flatMap { String(data: $0, encoding: encoding) } ?? "", - try! errPipe.fileHandleForReading.readToEnd().flatMap { String(data: $0, encoding: encoding) } ?? "", - ) // swiftlint:disable:previous force_try - } + return (try! outPipe.readToEnd(encoding: encoding) ?? "", try! errPipe.readToEnd(encoding: encoding) ?? "") + } // swiftlint:disable:previous force_try } enum NoValue: Equatable { // swiftlint:disable:this one_declaration_per_file