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
76 changes: 74 additions & 2 deletions Sources/GamePorter/Managers/EngineManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,17 @@ final class EngineManager: ObservableObject {
// our own binaries + free MoltenVK / Apple D3DMetal.
if let gp = Self.detectGamePorterWine() { found.append(gp) }

// A copy of the user's installed CrossOver (Import from the Engines panel), if present.
// Runs entirely off our own copy under Engines/crossover — never touches CrossOver.app.
if let cx = Self.detectCrossOver() { found.append(cx) }

// Everything else lives under Engines/<id>/.
if let dirs = try? FileManager.default.contentsOfDirectory(
at: AppPaths.engines, includingPropertiesForKeys: [.isDirectoryKey],
options: [.skipsHiddenFiles]) {
for dir in dirs {
// gpwine is handled by detectGamePorterWine() (needs its special env).
if dir.lastPathComponent == "gpwine" { continue }
// gpwine / crossover are handled above (they need their special env).
if dir.lastPathComponent == "gpwine" || dir.lastPathComponent == "crossover" { continue }
guard let bin = Self.findLoader(under: dir) else { continue }
let id = dir.lastPathComponent
let catalog = EngineCatalogEntry.all.first { $0.id == id }
Expand Down Expand Up @@ -117,6 +121,74 @@ final class EngineManager: ObservableObject {
wineBin: loader, kind: .gpwine, extraEnv: env)
}

// MARK: - CrossOver (copied)

/// The user's installed CrossOver Wine tree, if present. Read-only source for Import.
nonisolated static var installedCrossOverRoot: URL? {
let cx = URL(fileURLWithPath:
"/Applications/CrossOver.app/Contents/SharedSupport/CrossOver")
return FileManager.default.isExecutableFile(
atPath: cx.appendingPathComponent("bin/wineloader").path) ? cx : nil
}

/// Can we still offer the Import (CrossOver installed and not yet copied)?
var canImportCrossOver: Bool {
Self.installedCrossOverRoot != nil && !engines.contains { $0.id == "crossover" }
}

/// Build an Engine from CrossOver copied into GamePorter's own Engines/crossover tree.
/// Every path points into our copy — nothing under /Applications/CrossOver.app is
/// read or written at runtime. Its wineloader has proper 32-bit support (installs
/// 32-bit InnoSetup installers vanilla Wine can't) and CrossOver's apple_gptk D3DMetal.
nonisolated static func detectCrossOver() -> Engine? {
let root = AppPaths.engines.appendingPathComponent("crossover")
let loader = root.appendingPathComponent("bin/wineloader")
guard FileManager.default.isExecutableFile(atPath: loader.path) else { return nil }
var env = [
"WINELOADER": loader.path,
"WINESERVER": root.appendingPathComponent("bin/wineserver").path,
// Match CrossOver's own DLL search order: PE builtins ahead of the plain dir.
"WINEDLLPATH": "\(root.path)/lib/wine/x86_64-windows:\(root.path)/lib/wine/i386-windows:\(root.path)/lib/wine",
"DYLD_FALLBACK_LIBRARY_PATH": "\(root.path)/lib:\(root.path)/lib64",
// CX_ROOT lets CrossOver's Wine locate its GPTK / support libs — required for
// anti-tamper (ARXAN) titles to pass their Rosetta self-modifying-code checks.
"CX_ROOT": root.path,
]
// libd3dshared backs CrossOver's D3DMetal and helps anti-tamper titles run under
// Rosetta. Only set it if present in the copy.
let libd3d = root.appendingPathComponent("lib64/apple_gptk/external/libd3dshared.dylib")
if FileManager.default.fileExists(atPath: libd3d.path) {
env["CX_APPLEGPTK_LIBD3DSHARED_PATH"] = libd3d.path
}
return Engine(id: "crossover", name: "CrossOver (copied)",
wineBin: loader, kind: .crossover, extraEnv: env)
}

/// One-time copy of the installed CrossOver's Wine into Engines/crossover, so GamePorter
/// runs on its own self-contained copy. Read-only on the source: copyItem only reads
/// /Applications/CrossOver.app and never modifies it. Symlinks and the D3DMetal.framework
/// bundle are preserved. Progress is reported as the indeterminate (-1) state.
func importCrossOver() {
guard installing["crossover"] == nil,
let src = Self.installedCrossOverRoot else { return }
installing["crossover"] = -1 // working (indeterminate)
let dest = AppPaths.engines.appendingPathComponent("crossover")
Task.detached {
let fm = FileManager.default
do {
try fm.createDirectory(at: AppPaths.engines, withIntermediateDirectories: true)
if fm.fileExists(atPath: dest.path) { try fm.removeItem(at: dest) }
try fm.copyItem(at: src, to: dest) // source untouched; preserves symlinks
await MainActor.run { self.installing["crossover"] = nil; self.detect() }
} catch {
await MainActor.run {
self.installing["crossover"] = nil
self.lastError = "Importing CrossOver failed: \(error.localizedDescription)"
}
}
}
}

/// Rewrite the CrossOver bundle identity baked into the Wine loader's embedded
/// Info.plist. The loader (built from CrossOver's LGPL source) ships a link-time
/// CFBundleIdentifier of "com.codeweavers.CrossOver.wineloader"; macOS keys the Dock
Expand Down
20 changes: 17 additions & 3 deletions Sources/GamePorter/Managers/WineRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,22 @@ struct WineRunner {
return p
}

/// Remap an absolute path baked into a pin (e.g. from another Mac or a different data
/// root) onto THIS bottle's drive_c, so bottles stay portable. No-op if it already exists.
static func resolveInBottle(_ path: String, bottle: Bottle) -> String {
let fm = FileManager.default
if fm.fileExists(atPath: path) { return path }
if let r = path.range(of: "/drive_c/") {
let remapped = bottle.driveC.appendingPathComponent(String(path[r.upperBound...])).path
if fm.fileExists(atPath: remapped) { return remapped }
}
return path
}

/// Launch a Windows program. `start /unix` lets Wine resolve launchers/lnk targets properly.
func launch(exe unixPath: String, arguments: String, workingDir: String? = nil, bottle: Bottle) throws {
func launch(exe unixPathIn: String, arguments: String, workingDir workingDirIn: String? = nil, bottle: Bottle) throws {
let unixPath = Self.resolveInBottle(unixPathIn, bottle: bottle)
let workingDir = workingDirIn.map { Self.resolveInBottle($0, bottle: bottle) }
let extra = arguments.isEmpty ? [] : arguments.split(separator: " ").map(String.init)
let log = AppPaths.logs.appendingPathComponent("\(bottle.name)-\(Int(Date().timeIntervalSince1970)).log")
if let workingDir {
Expand Down Expand Up @@ -151,8 +165,8 @@ struct WineRunner {
func disableCrashDebugger(bottle: Bottle) throws {
for hive in [#"HKLM\Software\Microsoft\Windows NT\CurrentVersion\AeDebug"#,
#"HKLM\Software\Wow6432Node\Microsoft\Windows NT\CurrentVersion\AeDebug"#] {
try? run(["reg", "add", hive, "/v", "Auto", "/t", "REG_SZ", "/d", "0", "/f"],
bottle: bottle, wait: true, plainGraphics: true)
_ = try? run(["reg", "add", hive, "/v", "Auto", "/t", "REG_SZ", "/d", "0", "/f"],
bottle: bottle, wait: true, plainGraphics: true)
}
}

Expand Down
29 changes: 26 additions & 3 deletions Sources/GamePorter/Models/Bottle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,32 @@ struct InstalledApp: Identifiable, Hashable {
}

enum AppPaths {
static let root = FileManager.default
.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("GamePorter")
/// Data root. Portable mode: if a "GamePorterData" folder sits next to the app
/// bundle (the layout of the portable SSD bundle) — or GAMEPORTER_DATA is set —
/// use it, so the app + engines + bottles run straight from a USB/SSD with no
/// install. Otherwise the standard Application Support location.
static let root: URL = {
let fm = FileManager.default
func hasData(_ dir: URL) -> Bool {
fm.fileExists(atPath: dir.appendingPathComponent("Engines").path)
|| fm.fileExists(atPath: dir.appendingPathComponent("Bottles").path)
}
if let env = ProcessInfo.processInfo.environment["GAMEPORTER_DATA"], !env.isEmpty {
return URL(fileURLWithPath: env, isDirectory: true)
}
// Self-contained: data carried inside the app bundle (Contents/Resources/GamePorterData) —
// a single .app holds the porter, engines and bottles.
if let res = Bundle.main.resourceURL {
let inside = res.appendingPathComponent("GamePorterData", isDirectory: true)
if hasData(inside) { return inside }
}
// Portable: a GamePorterData folder next to the app bundle.
let sibling = Bundle.main.bundleURL.deletingLastPathComponent()
.appendingPathComponent("GamePorterData", isDirectory: true)
if hasData(sibling) { return sibling }
return fm.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("GamePorter")
}()
static let toolkit = root.appendingPathComponent("Toolkit")
static let engines = root.appendingPathComponent("Engines")
static let components = root.appendingPathComponent("Components")
Expand Down
4 changes: 3 additions & 1 deletion Sources/GamePorter/Models/Engine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ struct Engine: Identifiable, Hashable {
let kind: Kind
var extraEnv: [String: String] = [:] // engine-specific env (loader paths, etc.)

enum Kind: String { case gptk, vanilla, gpwine }
enum Kind: String { case gptk, vanilla, crossover, gpwine }

var wineserver: URL { wineBin.deletingLastPathComponent().appendingPathComponent("wineserver") }
/// …/Resources/wine — lib/external holds D3DMetal on GPTK builds.
Expand All @@ -53,6 +53,7 @@ struct Engine: Identifiable, Hashable {
switch kind {
case .gptk: return [.d3dmetal, .dxvk, .wined3d] // old Wine, has Apple D3DMetal
case .vanilla: return [.vkd3d, .dxmt, .dxvk, .wined3d] // Wine 11 + our MoltenVK: DX12 via VKD3D, plus DXMT/DXVK
case .crossover: return [.d3dmetal, .dxvk, .wined3d] // copied CrossOver: D3DMetal + proper 32-bit
case .gpwine: return [.builtin, .vkd3d, .dxvk, .wined3d] // self-built Wine 11: D3DMetal (builtin) + MoltenVK
}
}
Expand All @@ -69,6 +70,7 @@ struct Engine: Identifiable, Hashable {
switch kind {
case .gptk: return "older Wine, Apple D3DMetal"
case .vanilla: return "modern Wine, best installer compatibility"
case .crossover: return "copied from your installed CrossOver — installs stubborn 32-bit games"
case .gpwine: return "self-built Wine 11 — Metal rendering, runs the widest range of games"
}
}
Expand Down
10 changes: 10 additions & 0 deletions Sources/GamePorter/Views/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,16 @@ struct ContentView: View {
} message: {
Text(bottleManager.lastError ?? "")
}
// Show a bottle right away instead of the empty "No bottle selected" pane.
.onAppear { selectFirstIfNeeded() }
.onChange(of: bottleManager.bottles.count) { selectFirstIfNeeded() }
}

/// Pick the first bottle when nothing valid is selected.
private func selectFirstIfNeeded() {
if selection == nil || !bottleManager.bottles.contains(where: { $0.id == selection }) {
selection = bottleManager.bottles.first?.id
}
}
}

Expand Down
32 changes: 29 additions & 3 deletions Sources/GamePorter/Views/EnginesView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,10 @@ struct EnginesView: View {
VStack(spacing: 0) {
ForEach(engines.engines) { e in
HStack {
Image(systemName: e.kind == .gptk ? "sparkles" : "bolt.fill")
.foregroundStyle(e.kind == .gptk ? .purple : .teal)
Image(systemName: e.kind == .gptk ? "sparkles"
: e.kind == .crossover ? "shippingbox.fill" : "bolt.fill")
.foregroundStyle(e.kind == .gptk ? .purple
: e.kind == .crossover ? .orange : .teal)
VStack(alignment: .leading) {
Text(e.name)
Text(e.versionNote).font(.caption2).foregroundStyle(.tertiary)
Expand All @@ -38,6 +40,30 @@ struct EnginesView: View {
}
}

if engines.canImportCrossOver {
GroupBox("From your installed CrossOver") {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 2) {
Text("Import CrossOver's Wine").fontWeight(.medium)
Text("Copies your installed CrossOver's engine (~930 MB) into GamePorter so it runs on its own copy — best for stubborn 32-bit installers and anti-tamper games. Your CrossOver install is only read, never modified.")
.font(.caption2).foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer()
if engines.installing["crossover"] != nil {
HStack(spacing: 6) {
ProgressView().controlSize(.small)
Text("Copying…").font(.caption2).foregroundStyle(.secondary)
}
} else {
Button("Import") { engines.importCrossOver() }
.controlSize(.small)
}
}
.padding(6)
}
}

GroupBox("Available to install") {
VStack(spacing: 0) {
ForEach(EngineCatalogEntry.all) { entry in
Expand Down Expand Up @@ -68,6 +94,6 @@ struct EnginesView: View {
Spacer()
}
.padding(24)
.frame(width: 540, height: 460)
.frame(width: 540, height: 560)
}
}