Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .swiftlint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
128 changes: 33 additions & 95 deletions Sources/mas/AppStore/AppStoreAction+download.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -289,15 +289,39 @@ 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)")
}
guard let receiptHardLinkURL else {
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
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
14 changes: 14 additions & 0 deletions Sources/mas/Utilities/Pipe.swift
Original file line number Diff line number Diff line change
@@ -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) }
}
}
62 changes: 62 additions & 0 deletions Sources/mas/Utilities/Process.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
8 changes: 3 additions & 5 deletions Tests/MASTests/Utilities/Consequences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
//

internal import Foundation
@testable private import mas

struct Consequences<Value> {
let value: Value?
Expand Down Expand Up @@ -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
Expand Down
Loading