Skip to content
Open
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
3 changes: 2 additions & 1 deletion app/Sources/NotProtonApp/Model/CrossOverRow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ struct CrossOverRow: Identifiable, Equatable {
var isManual: Bool { install?.isManual ?? false }

static func == (a: CrossOverRow, b: CrossOverRow) -> Bool {
a.buildID == b.buildID && a.install?.id == b.install?.id && a.copy == b.copy
a.buildID == b.buildID && a.install?.id == b.install?.id && a.install?.d3dmetal == b.install?.d3dmetal
&& a.copy == b.copy
&& a.licensed == b.licensed && a.unsupportedVersion == b.unsupportedVersion
}

Expand Down
37 changes: 33 additions & 4 deletions app/Sources/NotProtonApp/Model/CrossOverSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,14 @@ struct CrossOverInstall: Sendable, Identifiable {
// Selected by the user rather than found on disk by tool.
var isManual = false

var id: String { bundle.path(percentEncoded: false) }
// A toolkit directory (relative to the CrossOver root) the runner loads
// instead of apple_gptk. Nil means the bundle as it is.
var toolkit: String? = nil

// The D3DMetal major this install's runner gets, shown on its row.
var d3dmetal: String? = nil

var id: String { bundle.path(percentEncoded: false) + (toolkit.map { "#\($0)" } ?? "") }
var name: String { bundle.deletingPathExtension().lastPathComponent }
var crossOverRoot: URL { SupportPaths.crossOverRoot(inBundle: bundle) }

Expand Down Expand Up @@ -86,15 +93,16 @@ enum CrossOverSource {
let key = bundle.standardizedFileURL.path(percentEncoded: false)
guard !seen.contains(key), looksLikeCrossOver(bundle) else { return }
seen.insert(key)
found.append(inspect(bundle: bundle, isManual: isManual))
found += inspectAll(bundle: bundle, isManual: isManual)
}

for manual in manualBundles() where !isSearched(manual) { consider(manual, isManual: true) }

for root in searchRoots {
let entries = (try? fm.contentsOfDirectory(at: root, includingPropertiesForKeys: nil)) ?? []
for entry in entries where entry.pathExtension == "app" {
guard entry.deletingPathExtension().lastPathComponent.hasPrefix("CrossOver") else { continue }
// Recognised by what it carries, not by its name: copies made by
// other tools are called whatever those tools call them.
consider(entry, isManual: false)
}
}
Expand All @@ -110,6 +118,25 @@ enum CrossOverSource {
return a.id < b.id
}

// The bundle as it is, plus one install per other D3DMetal toolkit in it.
// CrossOver 26 only loads apple_gptk, so another D3DMetal needs another runner.
static func inspectAll(bundle: URL, isManual: Bool = false) -> [CrossOverInstall] {
var own = inspect(bundle: bundle, isManual: isManual)
// The pinned build with this copy's hashes, which differ for a rebuild such as China's.
guard case .supported(let build) = own.support,
let base = SupportedRunners.build(id: build.baseID)?.matching(loaderSHA256: build.loaderSHA256)
else { return [own] }
own.d3dmetal = RunnerVariant.activeMajor(crossOverRoot: own.crossOverRoot)
return [own] + RunnerVariant.otherToolkits(crossOverRoot: own.crossOverRoot).map { toolkit in
let variant = [build.variant, "d3dm\(toolkit.major)"].compactMap { $0 }.joined(separator: "-")
var install = CrossOverInstall(bundle: bundle, releaseVersion: own.releaseVersion,
support: .supported(base.withVariant(variant)), isManual: isManual)
install.toolkit = toolkit.path
install.d3dmetal = toolkit.major
return install
}
}

static func inspect(bundle: URL, isManual: Bool = false) -> CrossOverInstall {
guard let version = releaseVersion(of: bundle) else {
return CrossOverInstall(bundle: bundle, releaseVersion: nil, support: .unreadable, isManual: isManual)
Expand All @@ -127,8 +154,10 @@ enum CrossOverSource {
)
}

let variant = RunnerVariant.declared(crossOverRoot: SupportPaths.crossOverRoot(inBundle: bundle))
return CrossOverInstall(
bundle: bundle, releaseVersion: version, support: .supported(build), isManual: isManual
bundle: bundle, releaseVersion: version, support: .supported(build.withVariant(variant)),
isManual: isManual
)
}

Expand Down
3 changes: 2 additions & 1 deletion app/Sources/NotProtonApp/Model/NtdllPatcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,8 @@ enum NtdllPatcher {
]

static func patches(for build: RunnerBuild) -> [NtdllPatch] {
byBuild[build.id] ?? []
// A variant has its build's ntdll, so it has the same hooks.
byBuild[build.baseID] ?? []
}

static func patch(for arch: WineArch, in build: RunnerBuild) -> NtdllPatch? {
Expand Down
25 changes: 25 additions & 0 deletions app/Sources/NotProtonApp/Model/RunnerInstaller.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ enum RunnerInstaller {
.appending(path: ".\(target.lastPathComponent).new")
try? fm.removeItem(at: staging)
try copyPayload(from: install.crossOverRoot, to: staging)
if let toolkit = install.toolkit {
try useToolkit(toolkit, in: staging.appending(path: "CrossOver"))
}
if occupied {
try fm.removeItem(at: target)
}
Expand Down Expand Up @@ -259,13 +262,35 @@ enum RunnerInstaller {
scrubDownloadMarkers(at: landing)
}

// Copies `toolkit` over the apple_gptk beside it, in the runner's copy only.
static func useToolkit(_ toolkit: String, in root: URL) throws {
let chosen = root.appending(path: toolkit)
let active = chosen.deletingLastPathComponent().appending(path: "apple_gptk")
guard chosen.lastPathComponent != "apple_gptk" else { return }
try? FileManager.default.removeItem(at: active)
let copied = try Shell.run("/bin/cp", ["-c", "-R", chosen.path(percentEncoded: false), active.path(percentEncoded: false)])
guard copied.status == 0 else {
throw StepFailure(step: step, detail: "Could not use \(toolkit) in the runner. \(copied.stderr)")
}
}

static func scrubDownloadMarkers(at payload: URL) {
for marker in ["com.apple.quarantine", "com.apple.provenance"] {
_ = try? Shell.run("/usr/bin/xattr", ["-r", "-d", marker, payload.path(percentEncoded: false)])
}
}

static func verifyClone(build: RunnerBuild, root: URL) throws {
if let wanted = RunnerVariant.d3dmetal(of: build) {
let found = RunnerVariant.activeMajor(crossOverRoot: root)
guard found == wanted else {
throw StepFailure(
step: step,
detail: "The clone runs D3DMetal \(found ?? "unknown"), not \(wanted). Reinstall it."
)
}
}

let loader = Clean.copy(of: CrossOverSource.unixLoader(inRoot: root))
guard let hash = Digest.sha256IfPresent(loader) else {
throw StepFailure(step: step, detail: "The clone has no Wine loader at \(loader.lastPathComponent).")
Expand Down
5 changes: 4 additions & 1 deletion app/Sources/NotProtonApp/Model/RunnerStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,10 @@ enum CompatToolList {
let builds = RunnerStore.installedBuilds(in: runners)
let listed = try? String(contentsOf: file, encoding: .utf8)
let holder = legacyHolder(builds: builds, listed: listed, runners: runners)
return (builds, listed, SupportedRunners.tools(for: builds, legacy: holder))
let tools = SupportedRunners.tools(for: builds, legacy: holder) {
RunnerVariant.activeMajor(crossOverRoot: SupportPaths.clonedRoot(forBuild: $0.id, runners: runners))
}
return (builds, listed, tools)
}

// 1.0.x used the single 'notproton' tool name and wrote no tool list, so the
Expand Down
30 changes: 30 additions & 0 deletions app/Sources/NotProtonApp/Model/SteamCompatMapping.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import Foundation

// Which compatibility tool Steam runs a game with, read from Steam's
// config.vdf. Only read: Steam rewrites that file when it quits.
enum SteamCompatMapping {

static var configVDF: URL { SupportPaths.Steam.userData.appending(path: "config/config.vdf") }

static func tool(forApp appID: String, file: URL = configVDF) -> String? {
(try? String(contentsOf: file, encoding: .utf8)).flatMap { tool(forApp: appID, in: $0) }
}

// The "name" of the app's entry inside the CompatToolMapping block.
static func tool(forApp appID: String, in text: String) -> String? {
guard let key = text.range(of: "\"CompatToolMapping\""),
let open = text[key.upperBound...].firstIndex(of: "{") else { return nil }
var depth = 0
var end = text.endIndex
for i in text[open...].indices {
if text[i] == "{" { depth += 1 }
if text[i] == "}" { depth -= 1; if depth == 0 { end = i; break } }
}
let block = String(text[open..<end])
let pattern = "\"\(NSRegularExpression.escapedPattern(for: appID))\"\\s*\\{[^}]*?\"name\"\\s*\"([^\"]*)\""
guard let regex = try? NSRegularExpression(pattern: pattern),
let match = regex.firstMatch(in: block, range: NSRange(block.startIndex..., in: block)),
let name = Range(match.range(at: 1), in: block) else { return nil }
return String(block[name])
}
}
109 changes: 100 additions & 9 deletions app/Sources/NotProtonApp/Model/SupportedRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,21 +32,100 @@ struct RunnerBuild: Sendable, Equatable, Identifiable {

var rebuilds: [RunnerRebuild] = []

var id: String { flavor.map { "\(bundleVersion)-\($0)" } ?? bundleVersion }
// Set when the runner comes from a modified copy of this build. The copy has
// the same loader and ntdll, so it is checked and patched the same way, but it
// gets its own runner directory and tools.
var variant: String? = nil

var baseID: String { flavor.map { "\(bundleVersion)-\($0)" } ?? bundleVersion }

var id: String { variant.map { "\(baseID)-\($0)" } ?? baseID }

func matching(loaderSHA256 hash: String) -> RunnerBuild? {
if hash == loaderSHA256 { return self }
guard let rebuild = rebuilds.first(where: { $0.loaderSHA256 == hash }) else { return nil }
return RunnerBuild(
bundleVersion: bundleVersion, releaseVersion: releaseVersion, flavor: flavor,
loaderSHA256: rebuild.loaderSHA256, cleanNtdll: rebuild.cleanNtdll,
patchedNtdll: rebuild.patchedNtdll, tools: tools, rebuilds: rebuilds
patchedNtdll: rebuild.patchedNtdll, tools: tools, rebuilds: rebuilds, variant: variant
)
}

var flavorName: String { flavor?.uppercased() ?? "Rosetta" }

var displayVersion: String { "\(releaseVersion) \(flavorName)" }
var displayVersion: String {
let version = "\(releaseVersion) \(flavorName)"
return variant.map { "\(version) · \(RunnerVariant.label($0))" } ?? version
}

func withVariant(_ variant: String?) -> RunnerBuild {
guard let variant else { return self }
var copy = self
copy.variant = variant
let label = RunnerVariant.label(variant)
copy.tools = tools.map {
CompatTool(name: "\($0.name)-\(variant)", flavor: $0.flavor, display: "\($0.display) · \(label)")
}
return copy
}
}

enum RunnerVariant {
// A copy names itself with this file at the root of its CrossOver directory.
static let markerName = "notproton-variant"

// The first line of the marker as an id: lower-case letters and digits.
static func declared(crossOverRoot root: URL) -> String? {
guard let text = try? String(contentsOf: root.appending(path: markerName), encoding: .utf8),
let line = text.split(whereSeparator: \.isNewline).first else { return nil }
let id = String(line.lowercased().filter { $0.isASCII && ($0.isLetter || $0.isNumber) })
// "fex" would read as the FEX build's id.
return id.isEmpty || id == "fex" ? nil : id
}

static func isVariant(_ suffix: String) -> Bool {
suffix.wholeMatch(of: #/[a-z0-9]+(-[a-z0-9]+)?/#) != nil
}

static func label(_ variant: String) -> String {
variant.split(separator: "-")
.map { $0.hasPrefix("d3dm") ? "D3DMetal \($0.dropFirst(4))" : String($0) }
.joined(separator: " · ")
}

// The D3DMetal major a toolkit runner was set up with: 3 for "<build>-d3dm3".
static func d3dmetal(of build: RunnerBuild) -> String? {
guard let last = build.variant?.split(separator: "-").last, last.hasPrefix("d3dm") else { return nil }
return String(last.dropFirst(4))
}

static func d3dmetalMajor(_ toolkit: URL) -> String? {
let plist = toolkit.appending(path: "external/D3DMetal.framework/Versions/A/Resources/Info.plist")
let version = NSDictionary(contentsOf: plist)?["CFBundleShortVersionString"] as? String
return version?.split(separator: ".").first.map(String.init)
}

// The D3DMetal major in lib64/apple_gptk, the one CrossOver 26 loads.
static func activeMajor(crossOverRoot root: URL) -> String? {
d3dmetalMajor(root.appending(path: "lib64/apple_gptk"))
}

// Toolkit directories beside lib64/apple_gptk (apple_gptk_3, apple_gptk_4, ...),
// one per D3DMetal major that apple_gptk does not already have. Only lib64,
// where CrossOver 26 keeps its toolkit: CrossOver 27 (lib/) already picks a
// D3DMetal per game with CX_GRAPHICS_BACKEND_VERSION.
static func otherToolkits(crossOverRoot root: URL) -> [(path: String, major: String)] {
let dir = root.appending(path: "lib64")
guard let names = try? FileManager.default.contentsOfDirectory(atPath: dir.path(percentEncoded: false))
else { return [] }
var seen = Set([activeMajor(crossOverRoot: root)].compactMap { $0 })
var found: [(path: String, major: String)] = []
for name in names.sorted() where name.hasPrefix("apple_gptk") && name != "apple_gptk" {
guard let major = d3dmetalMajor(dir.appending(path: name)), seen.insert(major).inserted else { continue }
found.append(("lib64/\(name)", major))
}
return found
}
}

// The version of CrossOver offered in China has different hashes but is identical in the ways that matter
Expand Down Expand Up @@ -101,17 +180,24 @@ enum SupportedRunners {
case nobody
}

static func tools(for builds: [RunnerBuild], legacy: LegacyHolder = .nobody) -> [InstalledTool] {
let installed = Set(builds.map(\.id))
// d3dmetal gives the D3DMetal major a build's copy runs, named on tools that do
// not already carry it in their variant.
static func tools(
for builds: [RunnerBuild], legacy: LegacyHolder = .nobody, d3dmetal: (RunnerBuild) -> String? = { _ in nil }
) -> [InstalledTool] {
let holder: String? = switch legacy {
case .build(let id): id
case .nobody: nil
}
let served = all.filter { installed.contains($0.id) }.flatMap { build in
build.tools.enumerated().map { index, tool in
// The builds given, not `all`: variants are installed but never pinned.
let pinned = Set(all.map(\.id))
let served = builds.filter { pinned.contains($0.baseID) }.flatMap { build in
let label = RunnerVariant.d3dmetal(of: build) == nil ? d3dmetal(build).map { " · D3DMetal \($0)" } : nil
return build.tools.enumerated().map { index, tool in
let name = build.id == holder && index == 0 ? legacyToolName : tool.name
return InstalledTool(
tool: CompatTool(name: name, flavor: tool.flavor, display: tool.display), build: build.id
tool: CompatTool(name: name, flavor: tool.flavor, display: tool.display + (label ?? "")),
build: build.id
)
}
}
Expand Down Expand Up @@ -236,7 +322,12 @@ enum SupportedRunners {
}

static func build(id: String) -> RunnerBuild? {
all.first { $0.id == id }
if let exact = all.first(where: { $0.id == id }) { return exact }
// "<pinned id>-<variant>". The longest match wins, so a FEX variant stays FEX.
guard let base = all.filter({ id.hasPrefix($0.id + "-") }).max(by: { $0.id.count < $1.id.count })
else { return nil }
let variant = String(id.dropFirst(base.id.count + 1))
return RunnerVariant.isVariant(variant) ? base.withVariant(variant) : nil
}

static func displayVersion(forID id: String) -> String {
Expand Down
24 changes: 19 additions & 5 deletions app/Sources/NotProtonApp/PrefixesModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ final class PrefixesModel {
private let libraries: @Sendable () -> [SteamLibrary]
private let installedTools: @Sendable () -> [InstalledTool]
private let rebuild: @Sendable (WinePrefix, InstalledTool, Bool) throws -> URL?
private let mappedTool: @Sendable (String) -> String?
private let makeBackup: @Sendable (WinePrefix) throws -> URL

init(
Expand All @@ -76,12 +77,14 @@ final class PrefixesModel {
},
makeBackup: @escaping @Sendable (WinePrefix) throws -> URL = {
try PrefixTools.backUp($0)
}
},
mappedTool: @escaping @Sendable (String) -> String? = { SteamCompatMapping.tool(forApp: $0) }
) {
self.libraries = libraries
self.installedTools = installedTools
self.rebuild = rebuild
self.makeBackup = makeBackup
self.mappedTool = mappedTool
}

private var loadGeneration = 0
Expand Down Expand Up @@ -174,20 +177,31 @@ final class PrefixesModel {

func recreate(_ targets: [WinePrefix], as tool: InstalledTool, keepBackup: Bool = true) async {
let make = rebuild
let mapped = mappedTool
let installed = installedTools()
await eachInTurn(targets, { try make($0, tool, keepBackup) }) { rebuilt in
let kept = rebuilt.compactMap(\.made)
// Steam launches with the tool it has for the game, and the prefix now
// belongs to `tool`. Say so when they differ, or the next launch fails.
let notes = rebuilt.compactMap { step -> String? in
guard let current = mapped(step.prefix.appID), current != tool.name else { return nil }
let shown = installed.first { $0.name == current }?.display ?? current
return "Steam still runs \(step.prefix.title) with \(shown). "
+ "Choose \(tool.display) in its Properties > Compatibility."
}
let after = notes.map { " " + $0 }.joined()
if rebuilt.count == 1 {
let title = rebuilt[0].prefix.title
guard let backup = kept.first else { return "Rebuilt the prefix for \(title)." }
guard let backup = kept.first else { return "Rebuilt the prefix for \(title)." + after }
return "Rebuilt the prefix for \(title). The original is at "
+ "\(backup.path(percentEncoded: false)). Check that your save is present in "
+ "the game, then delete the backup to save space."
+ "the game, then delete the backup to save space." + after
}
guard rebuilt.count > 1 else { return nil }
guard !kept.isEmpty else { return "Rebuilt \(rebuilt.count) prefixes." }
guard !kept.isEmpty else { return "Rebuilt \(rebuilt.count) prefixes." + after }
return "Rebuilt \(rebuilt.count) prefixes. Each original is kept beside the game's "
+ "prefix. Check that your saves are present in the games, then delete the "
+ "backups to save space."
+ "backups to save space." + after
}
}

Expand Down
Loading