Skip to content
Closed
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
65 changes: 65 additions & 0 deletions app/Sources/NotProtonApp/Model/PrefixTools.swift
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,71 @@ enum PrefixTools {
)
}

// Winetricks is LGPL, so it is downloaded on first use rather than shipped in the app.
static let winetricksRelease = "20260125"
static let winetricksSHA256 = "431f82fc74000e6c864409f1d8fb495d696c03928808e3e8acffc45179312a7b"
static let winetricksLogName = "winetricks.log"

// Splits what the user typed into verbs, e.g. "vcrun2022 corefonts".
static func winetricksVerbs(_ text: String) -> [String] {
text.split(whereSeparator: \.isWhitespace).map(String.init)
}

static func winetricks(_ verbs: [String], in prefix: WinePrefix) async throws -> URL {
let step = "Run Winetricks"
let chosen = try resolvedTool(step: step, for: prefix)
let script = try await PinnedDownload.obtain(
file: "winetricks",
sha256: winetricksSHA256,
bases: [URL(string: "https://raw.githubusercontent.com/Winetricks/winetricks/\(winetricksRelease)/src")!],
into: SupportPaths.winetricksDownloads,
step: step,
from: "GitHub"
)
return try winetricks(
verbs, in: prefix, script: script,
runner: SupportPaths.clonedRoot(forBuild: chosen.build), flavor: chosen.tool.flavor
)
}

// Runs winetricks unattended and waits for it. Returns the log in the prefix root.
static func winetricks(
_ verbs: [String], in prefix: WinePrefix, script: URL, runner: URL,
flavor: CompatTool.Flavor = .fex
) throws -> URL {
let step = "Run Winetricks"
guard !verbs.isEmpty else {
throw StepFailure(step: step, detail: "Enter at least one verb, like vcrun2022.")
}
// annihilate wipes the prefix and its saves without asking under -q, and prefix= points
// winetricks at another prefix. Delete Prefix covers the first, so neither is offered.
if let refused = verbs.first(where: { $0 == "annihilate" || $0.hasPrefix("prefix=") }) {
throw StepFailure(step: step, detail: "NotProton does not run the \(refused) verb.")
}
guard !PrefixStore.isInUse(prefix) else {
throw StepFailure(step: step, detail: "\(prefix.title) is running. Quit the game first.")
}
let loader = try readyLoader(step: step, prefix: prefix, runner: runner, flavor: flavor)

// The runner's bin, which environment() puts on PATH, has the cabextract winetricks needs.
var environment = environment(prefix: prefix, runner: runner, flavor: flavor)
environment["WINE"] = loader.path(percentEncoded: false)

let log = prefix.root.appending(path: winetricksLogName)
let status = try Shell.logged(
"/bin/sh", [script.path(percentEncoded: false), "-q"] + verbs,
environment: environment, to: log
)
guard status == 0 else {
throw StepFailure(
step: step,
detail: "Winetricks stopped with status \(status). "
+ "Its output is in \(log.path(percentEncoded: false))."
)
}
return log
}

static func arguments(for executable: URL) -> [String]? {
let path = executable.path(percentEncoded: false)
switch executable.pathExtension.lowercased() {
Expand Down
12 changes: 10 additions & 2 deletions app/Sources/NotProtonApp/PrefixesModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ final class PrefixesModel {
@discardableResult
private func eachInTurn<Made: Sendable>(
_ targets: [WinePrefix],
_ work: @escaping @Sendable (WinePrefix) throws -> Made,
_ work: @escaping @Sendable (WinePrefix) async throws -> Made,
saying sentence: ([Step<Made>]) -> String?
) async -> [Step<Made>] {
guard !targets.isEmpty else { return [] }
Expand All @@ -253,7 +253,7 @@ final class PrefixesModel {
var refused: [Error] = []
for prefix in targets {
do {
let made = try await Task.detached { try work(prefix) }.value
let made = try await Task.detached { try await work(prefix) }.value
done.append((prefix: prefix, made: made))
} catch {
refused.append(error)
Expand Down Expand Up @@ -293,6 +293,14 @@ final class PrefixesModel {
}
}

func winetricks(_ text: String, in prefix: WinePrefix) async {
let verbs = PrefixTools.winetricksVerbs(text)
await eachInTurn([prefix], { try await PrefixTools.winetricks(verbs, in: $0) }) { done in
guard !done.isEmpty else { return nil }
return "Winetricks ran \(verbs.joined(separator: " ")) in \(prefix.title)."
}
}

private func act(on prefix: WinePrefix, _ body: () throws -> String) {
forgetOutcome()
busy = [prefix.id]
Expand Down
9 changes: 5 additions & 4 deletions app/Sources/NotProtonApp/Support/PinnedDownload.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ enum PinnedDownload {
bases: [URL],
into downloads: URL,
step: String,
from source: String = "Valve",
report: @Sendable (Progress) -> Void = { _ in }
) async throws -> URL {
let archive = downloads.appending(path: file)
Expand Down Expand Up @@ -65,7 +66,7 @@ enum PinnedDownload {
}

AppLog.note("\(file) refused by all \(bases.count) hosts: \(refusals.joined(separator: ". "))")
throw StepFailure(step: step, detail: reason(kinds))
throw StepFailure(step: step, detail: reason(kinds, from: source))
}

private enum Refusal {
Expand All @@ -74,14 +75,14 @@ enum PinnedDownload {
case answered
}

private static func reason(_ kinds: Set<Refusal>) -> String {
private static func reason(_ kinds: Set<Refusal>, from source: String) -> String {
if kinds == [.mismatch] {
return "Something is wrong with the downloaded files. Please check for a NotProton update."
}
if kinds == [.unreachable] {
return "NotProton could not reach Valve's servers. Please check your internet "
return "NotProton could not reach \(source)'s servers. Please check your internet "
+ "connection and try again."
}
return "NotProton could not download the files it needs from Valve. Please try again later."
return "NotProton could not download the files it needs from \(source). Please try again later."
}
}
24 changes: 24 additions & 0 deletions app/Sources/NotProtonApp/Support/Shell.swift
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,30 @@ enum Shell {
try process.run()
}

// Waits for the command and writes everything it prints to the log file.
static func logged(
_ executable: String,
_ arguments: [String],
environment: [String: String],
to log: URL
) throws -> Int32 {
guard FileManager.default.createFile(atPath: log.path(percentEncoded: false), contents: nil)
else { throw CocoaError(.fileWriteUnknown, userInfo: [NSFilePathErrorKey: log.path(percentEncoded: false)]) }
let handle = try FileHandle(forWritingTo: log)
defer { try? handle.close() }

let process = Process()
process.executableURL = URL(filePath: executable)
process.arguments = arguments
process.environment = environment
process.standardInput = FileHandle.nullDevice
process.standardOutput = handle
process.standardError = handle
try process.run()
process.waitUntilExit()
return process.terminationStatus
}

static func processIsRunning(
named name: String, pgrep: String = "/usr/bin/pgrep",
drainTimeout: DispatchTimeInterval = outputDrainTimeout
Expand Down
4 changes: 4 additions & 0 deletions app/Sources/NotProtonApp/Support/SupportPaths.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ enum SupportPaths {
home.appending(path: "Library/Caches/\(supportDirName)/valve-packages")
}

static var winetricksDownloads: URL {
home.appending(path: "Library/Caches/\(supportDirName)/winetricks")
}

static var deployedVersion: URL { support.appending(path: "dylib.version") }

// Read by the dylib at Steam launch, in np_compat_load_tool_list.
Expand Down
24 changes: 24 additions & 0 deletions app/Sources/NotProtonApp/Views/PrefixesView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ struct PrefixesView: View {
@Environment(PrefixesModel.self) private var model
@Environment(\.colorSchemeContrast) private var contrast
@State private var sortOrder = [KeyPathComparator(\PrefixRow.lastUsed, order: .reverse)]
@State private var winetricksPrefix: WinePrefix?
@State private var winetricksVerbs = ""

var body: some View {
Group {
Expand Down Expand Up @@ -104,6 +106,25 @@ struct PrefixesView: View {
} message: {
Text(PrefixPrompt.backUpMessage(backingUp))
}
.alert(
"Winetricks",
isPresented: Binding(
get: { winetricksPrefix != nil },
set: { if !$0 { winetricksPrefix = nil } }
),
presenting: winetricksPrefix
) { prefix in
TextField("vcrun2022 corefonts", text: $winetricksVerbs)
Button("Install") {
let verbs = winetricksVerbs
Task { await model.winetricks(verbs, in: prefix) }
}
.keyboardShortcut(.defaultAction)
.disabled(PrefixTools.winetricksVerbs(winetricksVerbs).isEmpty)
Button("Cancel", role: .cancel) {}
} message: { prefix in
Text("Enter the Winetricks verbs to install in \(prefix.title), separated by spaces.")
}
}

private enum Question {
Expand Down Expand Up @@ -354,6 +375,9 @@ struct PrefixesView: View {
Button("Run Program…") {
if let prefix { model.chooseExecutable(for: prefix) }
}
Button("Winetricks…") {
winetricksPrefix = prefix
}
Divider()
ForEach(WineTool.allCases, id: \.self) { tool in
Button(tool.label) {
Expand Down
27 changes: 27 additions & 0 deletions app/Tests/NotProtonAppTests/PrefixToolsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,33 @@ struct PrefixToolsTests {
}
}

@Test("Winetricks refuses before it runs when it cannot work or would leave the prefix")
func winetricksRefusesEarly() throws {
let dir = FileManager.default.temporaryDirectory.appending(path: "np-winetricks-\(UUID().uuidString)")
defer { try? FileManager.default.removeItem(at: dir) }
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
let script = dir.appending(path: "winetricks")

#expect(PrefixTools.winetricksVerbs(" vcrun2022\tcorefonts \n") == ["vcrun2022", "corefonts"])

func refusal(_ verbs: String) -> String? {
do {
_ = try PrefixTools.winetricks(
PrefixTools.winetricksVerbs(verbs), in: samplePrefix(), script: script, runner: dir)
return nil
} catch {
return (error as? StepFailure)?.detail
}
}
#expect(refusal(" ")?.contains("at least one verb") == true)
#expect(refusal("vcrun2022 annihilate")?.contains("annihilate") == true)
#expect(refusal("prefix=other corefonts")?.contains("prefix=other") == true)
// No runner, which is the same refusal opening a tool gets.
#expect(refusal("corefonts")?.contains("No compatibility tool") == true)
#expect(!FileManager.default.fileExists(
atPath: samplePrefix().root.appending(path: PrefixTools.winetricksLogName).path(percentEncoded: false)))
}

@Test("Every kind offered by the file picker is a kind that can be run")
func pickerTypesMatchWhatRuns() {
let extensions = PrefixTools.runnableTypes.compactMap(\.preferredFilenameExtension)
Expand Down
Loading