diff --git a/Sources/GamePorter/Managers/EngineManager.swift b/Sources/GamePorter/Managers/EngineManager.swift index 0270e4b..1b31f53 100644 --- a/Sources/GamePorter/Managers/EngineManager.swift +++ b/Sources/GamePorter/Managers/EngineManager.swift @@ -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//. 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 } @@ -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 diff --git a/Sources/GamePorter/Managers/WineRunner.swift b/Sources/GamePorter/Managers/WineRunner.swift index 41b50ae..d016c2a 100644 --- a/Sources/GamePorter/Managers/WineRunner.swift +++ b/Sources/GamePorter/Managers/WineRunner.swift @@ -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 { @@ -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) } } diff --git a/Sources/GamePorter/Models/Bottle.swift b/Sources/GamePorter/Models/Bottle.swift index 6111b95..ff05655 100644 --- a/Sources/GamePorter/Models/Bottle.swift +++ b/Sources/GamePorter/Models/Bottle.swift @@ -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") diff --git a/Sources/GamePorter/Models/Engine.swift b/Sources/GamePorter/Models/Engine.swift index b5934c9..1b083e0 100644 --- a/Sources/GamePorter/Models/Engine.swift +++ b/Sources/GamePorter/Models/Engine.swift @@ -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. @@ -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 } } @@ -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" } } diff --git a/Sources/GamePorter/Views/ContentView.swift b/Sources/GamePorter/Views/ContentView.swift index a7bec0d..9610a2a 100644 --- a/Sources/GamePorter/Views/ContentView.swift +++ b/Sources/GamePorter/Views/ContentView.swift @@ -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 + } } } diff --git a/Sources/GamePorter/Views/EnginesView.swift b/Sources/GamePorter/Views/EnginesView.swift index a42f41b..17a759f 100644 --- a/Sources/GamePorter/Views/EnginesView.swift +++ b/Sources/GamePorter/Views/EnginesView.swift @@ -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) @@ -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 @@ -68,6 +94,6 @@ struct EnginesView: View { Spacer() } .padding(24) - .frame(width: 540, height: 460) + .frame(width: 540, height: 560) } }