From c2ad6aa6bdabceb02b645fe49d475a1efb5e8a46 Mon Sep 17 00:00:00 2001 From: Jatinjit Singh Date: Thu, 29 Oct 2026 17:20:36 +0900 Subject: [PATCH 1/2] Fix Steam setup, bottle deletion, duplicate pins and D3D11 games MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Steam setup: the first update started Steam with -noverifyfiles, which skips the check that makes Steam download its client, so it failed with "Failed to load steamui.dll". The flag is now only passed once the first update is done, and setup fails with a retry if Steam exits mid-update instead of waiting forever. Bottle deletion: deleting reused the creating flag, and reloading kept bottles in creation that weren't in the saved list, so a deleted bottle stayed in the sidebar as "Creating…". Bottles now have a creating, moving or deleting activity, and deletion stops the bottle's programs and removes the folder off the main thread. Pins: setup pinned steam.exe while the Start Menu pinned Steam.exe. Pins now compare paths case-insensitively, and existing duplicates collapse on load. The Steam pin opens through the single Steam session, and pins show when their program is running. D3D11 games: DXVK is on by default and setup turns it on for the Steam bottle; without it Unity games failed with "Failed to initialize graphics". --- Whisky.xcodeproj/project.pbxproj | 4 ++ Whisky/Extensions/Bottle+Extensions.swift | 47 ++++++++++++++----- Whisky/Localizable.xcstrings | 30 ++++++++++++ Whisky/Utils/RunningApplications.swift | 36 ++++++++++++++ Whisky/View Models/BottleVM.swift | 2 +- Whisky/Views/Bottle/BottleView.swift | 9 +--- .../Views/Bottle/Pins/PinCreationView.swift | 2 +- Whisky/Views/Bottle/Pins/PinView.swift | 28 ++++++++++- Whisky/Views/ContentView.swift | 6 +-- Whisky/Views/Onboarding/OnboardingModel.swift | 21 +++++---- .../WhiskyKit/Extensions/URL+Extensions.swift | 10 ++++ .../WhiskyKit/Steam/SteamInstaller.swift | 36 +++++++++----- .../WhiskyKit/Whisky/Bottle+Creation.swift | 4 +- .../Sources/WhiskyKit/Whisky/Bottle.swift | 24 ++++++---- .../WhiskyKit/Whisky/BottleSettings.swift | 11 ++++- .../Sources/WhiskyKit/Whisky/Program.swift | 10 +++- .../WhiskyKitTests/BottleSettingsTests.swift | 4 ++ .../Tests/WhiskyKitTests/ProgramTests.swift | 19 ++++++++ .../WhiskyKitTests/SteamInstallerTests.swift | 16 +++++-- 19 files changed, 254 insertions(+), 65 deletions(-) create mode 100644 Whisky/Utils/RunningApplications.swift diff --git a/Whisky.xcodeproj/project.pbxproj b/Whisky.xcodeproj/project.pbxproj index d99bb7dce..a7bf2c750 100644 --- a/Whisky.xcodeproj/project.pbxproj +++ b/Whisky.xcodeproj/project.pbxproj @@ -63,6 +63,7 @@ 7A14B0D5D96E14098322DFC0 /* WhiskyWineUpdate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A3E5CD4F34E72EE880BF131 /* WhiskyWineUpdate.swift */; }; 7AA2C89FB1934624B6FFA190 /* SteamSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7ACB6C31E47142A637015EA4 /* SteamSession.swift */; }; 7A5E34BEDDA87936C50FB0A2 /* ErrorAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A5CAFC811A7E1E60978D49E /* ErrorAlert.swift */; }; + 7C475A0F130182E5715D2E4C /* RunningApplications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C30F92290954D9FE1EE36A8 /* RunningApplications.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -164,6 +165,7 @@ 7A3E5CD4F34E72EE880BF131 /* WhiskyWineUpdate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhiskyWineUpdate.swift; sourceTree = ""; }; 7ACB6C31E47142A637015EA4 /* SteamSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SteamSession.swift; sourceTree = ""; }; 7A5CAFC811A7E1E60978D49E /* ErrorAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorAlert.swift; sourceTree = ""; }; + 1C30F92290954D9FE1EE36A8 /* RunningApplications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunningApplications.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -357,6 +359,7 @@ 6763D8F52BC6314100651D27 /* Constants.swift */, 7A3E5CD4F34E72EE880BF131 /* WhiskyWineUpdate.swift */, 7A5CAFC811A7E1E60978D49E /* ErrorAlert.swift */, + 1C30F92290954D9FE1EE36A8 /* RunningApplications.swift */, ); path = Utils; sourceTree = ""; @@ -634,6 +637,7 @@ 7A14B0D5D96E14098322DFC0 /* WhiskyWineUpdate.swift in Sources */, 7AA2C89FB1934624B6FFA190 /* SteamSession.swift in Sources */, 7A5E34BEDDA87936C50FB0A2 /* ErrorAlert.swift in Sources */, + 7C475A0F130182E5715D2E4C /* RunningApplications.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Whisky/Extensions/Bottle+Extensions.swift b/Whisky/Extensions/Bottle+Extensions.swift index 30767c066..3d7843069 100644 --- a/Whisky/Extensions/Bottle+Extensions.swift +++ b/Whisky/Extensions/Bottle+Extensions.swift @@ -114,7 +114,8 @@ extension Bottle { Self.executables(in: driveC, excluding: hidden) }.value - let pinnedElsewhere = settings.pins.compactMap(\.url).filter { !installed.contains($0) } + let installedKeys = Set(installed.map(\.windowsPathKey)) + let pinnedElsewhere = settings.pins.compactMap(\.url).filter { !installedKeys.contains($0.windowsPathKey) } programs = (installed + pinnedElsewhere) .map { Program(url: $0, bottle: self) } .sorted { $0.name.lowercased() < $1.name.lowercased() } @@ -138,11 +139,11 @@ extension Bottle { @MainActor func move(destination: URL) { guard let bottle = BottleVM.shared.bottles.first(where: { $0.url == url }) else { return } - bottle.inFlight = true + bottle.activity = .moving do { try FileManager.default.moveItem(at: url, to: destination) } catch { - bottle.inFlight = false + bottle.activity = nil ErrorAlert.show(String(localized: "error.moveBottle"), error: error) return } @@ -167,23 +168,36 @@ extension Bottle { try Tar.tar(folder: url, toURL: destination) } + /// Takes the bottle off the list and, if `delete`, deletes its folder first. A bottle with games + /// can be many gigabytes, so the folder is deleted in the background while the row shows it. @MainActor func remove(delete: Bool) { - let bottle = BottleVM.shared.bottles.first(where: { $0.url == url }) - bottle?.inFlight = true - if delete { + guard delete else { + removeFromBottleList() + return + } + + activity = .deleting + Task { do { - try FileManager.default.removeItem(at: url) + if !WineProcesses.processIDs(in: self).isEmpty { + try await Wine.stopAllProcesses(in: self) + } + try await Task.detached(priority: .userInitiated) { [url] in + try FileManager.default.removeItem(at: url) + }.value } catch { - bottle?.inFlight = false + activity = nil ErrorAlert.show(String(localized: "error.removeBottle"), error: error) return } + removeFromBottleList() } + } - if let path = BottleVM.shared.bottlesList.paths.firstIndex(of: url) { - BottleVM.shared.bottlesList.paths.remove(at: path) - } + @MainActor + private func removeFromBottleList() { + BottleVM.shared.bottlesList.paths.removeAll { $0 == url } BottleVM.shared.loadBottles() } @@ -205,3 +219,14 @@ extension BottleSettings { return enabled.formatted(.list(type: .and, width: .narrow)) } } + +extension BottleActivity { + /// Shown in the sidebar row in place of the bottle's details + var statusText: String { + switch self { + case .creating: String(localized: "main.creatingBottle") + case .moving: String(localized: "main.movingBottle") + case .deleting: String(localized: "main.deletingBottle") + } + } +} diff --git a/Whisky/Localizable.xcstrings b/Whisky/Localizable.xcstrings index 68ce1b674..f2d5bb3cd 100644 --- a/Whisky/Localizable.xcstrings +++ b/Whisky/Localizable.xcstrings @@ -9307,6 +9307,16 @@ } } }, + "main.deletingBottle" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deleting…" + } + } + } + }, "main.empty.title" : { "localizations" : { "en" : { @@ -9317,6 +9327,16 @@ } } }, + "main.movingBottle" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Moving…" + } + } + } + }, "main.noSelection" : { "localizations" : { "en" : { @@ -10802,6 +10822,16 @@ } } }, + "pin.running" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Running" + } + } + } + }, "pin.title" : { "localizations" : { "ar" : { diff --git a/Whisky/Utils/RunningApplications.swift b/Whisky/Utils/RunningApplications.swift new file mode 100644 index 000000000..da8fdea74 --- /dev/null +++ b/Whisky/Utils/RunningApplications.swift @@ -0,0 +1,36 @@ +// +// RunningApplications.swift +// Whisky +// +// This file is part of Whisky. +// +// Whisky is free software: you can redistribute it and/or modify it under the terms +// of the GNU General Public License as published by the Free Software Foundation, +// either version 3 of the License, or (at your option) any later version. +// +// Whisky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +// without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +// See the GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License along with Whisky. +// If not, see https://www.gnu.org/licenses/. +// + +import AppKit +import Combine + +/// Wine shows each Windows program that opens a window as a Mac app, so its launch and exit +/// arrive as workspace notifications; Brandy coming forward covers programs without windows. +enum RunningApplications { + static var changes: AnyPublisher { + let workspace = NSWorkspace.shared.notificationCenter + return Publishers.MergeMany( + workspace.publisher(for: NSWorkspace.didLaunchApplicationNotification), + workspace.publisher(for: NSWorkspace.didTerminateApplicationNotification), + NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification) + ) + .map { _ in } + .receive(on: DispatchQueue.main) + .eraseToAnyPublisher() + } +} diff --git a/Whisky/View Models/BottleVM.swift b/Whisky/View Models/BottleVM.swift index 32aedf931..79365b98d 100644 --- a/Whisky/View Models/BottleVM.swift +++ b/Whisky/View Models/BottleVM.swift @@ -38,7 +38,7 @@ final class BottleVM: ObservableObject, @unchecked Sendable { /// Bottles still being created are not in the bottle list yet, so they are kept across reloads @MainActor func loadBottles() { - let bottlesInCreation = bottles.filter { $0.inFlight && !bottlesList.paths.contains($0.url) } + let bottlesInCreation = bottles.filter { $0.activity == .creating && !bottlesList.paths.contains($0.url) } bottles = bottlesList.loadBottles() + bottlesInCreation for bottle in bottles where bottle.isAvailable && bottle.settings.hasLegacyWindowsVersion { diff --git a/Whisky/Views/Bottle/BottleView.swift b/Whisky/Views/Bottle/BottleView.swift index b664b9a9d..408c47189 100644 --- a/Whisky/Views/Bottle/BottleView.swift +++ b/Whisky/Views/Bottle/BottleView.swift @@ -143,15 +143,8 @@ struct BottleView: View { let startMenuPrograms = bottle.getStartMenuPrograms() for startMenuProgram in startMenuPrograms { - for program in bottle.programs where - // For some godforsaken reason "foo/bar" != "foo/Bar" so... - program.url.path().caseInsensitiveCompare(startMenuProgram.url.path()) == .orderedSame { + for program in bottle.programs where program.url.isSameWindowsPath(as: startMenuProgram.url) { program.pinned = true - guard !bottle.settings.pins.contains(where: { $0.url == program.url }) else { continue } - bottle.settings.pins.append(PinnedProgram( - name: program.url.deletingPathExtension().lastPathComponent, - url: program.url - )) } } } diff --git a/Whisky/Views/Bottle/Pins/PinCreationView.swift b/Whisky/Views/Bottle/Pins/PinCreationView.swift index bea4db428..a21e7fb92 100644 --- a/Whisky/Views/Bottle/Pins/PinCreationView.swift +++ b/Whisky/Views/Bottle/Pins/PinCreationView.swift @@ -101,7 +101,7 @@ struct PinCreationView: View { guard let newPinURL else { return } // Ensure this pin doesn't already exist - guard !bottle.settings.pins.contains(where: { $0.url == newPinURL }) + guard !bottle.settings.pins.contains(where: { $0.opens(newPinURL) }) else { isDuplicate = true return diff --git a/Whisky/Views/Bottle/Pins/PinView.swift b/Whisky/Views/Bottle/Pins/PinView.swift index 216e5f977..594744abc 100644 --- a/Whisky/Views/Bottle/Pins/PinView.swift +++ b/Whisky/Views/Bottle/Pins/PinView.swift @@ -29,6 +29,11 @@ struct PinView: View { @State private var name: String = "" @State private var opening: Bool = false @State private var hovered: Bool = false + @State private var isRunning: Bool = false + + private var isSteam: Bool { + SteamInstaller.isSteam(program.url, in: bottle) + } var body: some View { PinTile(title: name, isHighlighted: hovered) { @@ -48,9 +53,22 @@ struct PinView: View { .opacity(hovered ? 1 : 0) .help("button.run") } + .overlay(alignment: .bottom) { + if isRunning { + Circle() + .fill(.secondary) + .frame(width: 5, height: 5) + .offset(y: 8) + .help("pin.running") + } + } } .onHover { hovered = $0 } .help(program.displayLocation) + .onAppear(perform: refreshRunning) + .onReceive(RunningApplications.changes) { _ in + refreshRunning() + } .contextMenu { ProgramMenuView(program: program, path: $path) @@ -93,6 +111,14 @@ struct PinView: View { } } - program.run() + if isSteam { + SteamSession.session(for: bottle).open() + } else { + program.run() + } + } + + private func refreshRunning() { + isRunning = program.isRunning } } diff --git a/Whisky/Views/ContentView.swift b/Whisky/Views/ContentView.swift index 7370c33cc..c4578a3b0 100644 --- a/Whisky/Views/ContentView.swift +++ b/Whisky/Views/ContentView.swift @@ -133,9 +133,9 @@ struct ContentView: View { Section("main.bottles") { ForEach(filteredBottles) { bottle in Group { - if bottle.inFlight { + if let activity = bottle.activity { BottleRowLabel(name: bottle.settings.name, - subtitle: String(localized: "main.creatingBottle"), + subtitle: activity.statusText, systemImage: "wineglass") .badge(Text(Image(systemName: "hourglass"))) .foregroundStyle(.secondary) @@ -168,7 +168,7 @@ struct ContentView: View { if let bottle = selected { if let bottle = bottleVM.bottles.first(where: { $0.url == bottle }) { BottleView(bottle: bottle) - .disabled(bottle.inFlight) + .disabled(bottle.activity != nil) .id(bottle.url) } } else if bottlesLoaded { diff --git a/Whisky/Views/Onboarding/OnboardingModel.swift b/Whisky/Views/Onboarding/OnboardingModel.swift index efe213feb..734bfbdc1 100644 --- a/Whisky/Views/Onboarding/OnboardingModel.swift +++ b/Whisky/Views/Onboarding/OnboardingModel.swift @@ -207,17 +207,22 @@ final class OnboardingModel: ObservableObject { }.value } + /// Games started from Steam need DXVK for Direct3D 11, so it is turned on even for an existing bottle private func findOrCreateSteamBottle() async throws { + let bottle = if let existing = existingSteamBottle() { existing } else { try await newSteamBottle() } + bottle.settings.dxvk = true + steamBottle = bottle + } + + private func existingSteamBottle() -> Bottle? { BottleVM.shared.loadBottles() - if let existing = SteamInstaller.steamBottle(in: BottleVM.shared.bottles) - ?? BottleVM.shared.bottles.first(where: { $0.isAvailable && $0.settings.name == Self.steamBottleName }) { - steamBottle = existing - return - } + return SteamInstaller.steamBottle(in: BottleVM.shared.bottles) + ?? BottleVM.shared.bottles.first(where: { $0.isAvailable && $0.settings.name == Self.steamBottleName }) + } + private func newSteamBottle() async throws -> Bottle { let location = BottleData.preferredBottleLocation.appending(path: UUID().uuidString) - steamBottle = try await BottleVM.shared.createBottle(at: location, name: Self.steamBottleName, - winVersion: .win10) + return try await BottleVM.shared.createBottle(at: location, name: Self.steamBottleName, winVersion: .win10) } private func downloadSteam() async throws { @@ -237,7 +242,7 @@ final class OnboardingModel: ObservableObject { } let steam = SteamInstaller.steamExecutable(in: steamBottle) - if !steamBottle.settings.pins.contains(where: { $0.url == steam }) { + if !steamBottle.settings.pins.contains(where: { $0.opens(steam) }) { steamBottle.settings.pins.append(PinnedProgram(name: Self.steamBottleName, url: steam)) } } diff --git a/WhiskyKit/Sources/WhiskyKit/Extensions/URL+Extensions.swift b/WhiskyKit/Sources/WhiskyKit/Extensions/URL+Extensions.swift index 37fb9eea2..66a7aafc1 100644 --- a/WhiskyKit/Sources/WhiskyKit/Extensions/URL+Extensions.swift +++ b/WhiskyKit/Sources/WhiskyKit/Extensions/URL+Extensions.swift @@ -32,6 +32,16 @@ extension String { extension URL { + /// Windows ignores case in paths, so `Steam.exe` and `steam.exe` name the same program + public func isSameWindowsPath(as other: URL) -> Bool { + windowsPathKey == other.windowsPathKey + } + + /// Equal for every spelling of the same Windows path + public var windowsPathKey: String { + standardizedFileURL.path(percentEncoded: false).lowercased() + } + public func prettyPath() -> String { var prettyPath = path(percentEncoded: false) prettyPath = prettyPath diff --git a/WhiskyKit/Sources/WhiskyKit/Steam/SteamInstaller.swift b/WhiskyKit/Sources/WhiskyKit/Steam/SteamInstaller.swift index 6063aa2b2..a91b91c89 100644 --- a/WhiskyKit/Sources/WhiskyKit/Steam/SteamInstaller.swift +++ b/WhiskyKit/Sources/WhiskyKit/Steam/SteamInstaller.swift @@ -24,6 +24,7 @@ public enum SteamInstallerError: LocalizedError { case webHelperMissing case webHelperShimMissing case firstUpdateStalled + case firstUpdateExited public var errorDescription: String? { switch self { @@ -35,6 +36,8 @@ public enum SteamInstallerError: LocalizedError { return "The installed BrandyWine has no Steam web helper shim. Update BrandyWine, then try again." case .firstUpdateStalled: return "Steam's first update stopped downloading." + case .firstUpdateExited: + return "Steam closed before its first update finished." } } } @@ -54,10 +57,14 @@ public enum SteamInstaller { private static let realWebHelperName = "steamwebhelper_real.exe" /// Embedded in the shim (scripts/steamwebhelper-shim.c) to tell it apart from Steam's helper private static let shimMarker = Data("BRANDY-SHIM-1".utf8) - /// Steam restores its own web helper when it verifies its files, replacing the shim + /// Steam restores its own web helper when it verifies its files, replacing the shim. + /// Never passed before the first update: verifying is what makes Steam download its client. private static let launchArguments = ["-noverifyfiles"] /// How long the first update may go without downloading anything before it counts as stalled private static let firstUpdateStallTimeout: Duration = .seconds(180) + /// How long Steam may be gone during the first update; it restarts itself once it has unpacked + private static let firstUpdateExitGrace: Duration = .seconds(30) + private static let firstUpdatePollInterval: Duration = .seconds(2) /// Built into the runtime by scripts/build-whiskywine.sh public static let webHelperShim = WhiskyWineInstaller.libraryFolder @@ -93,9 +100,7 @@ public enum SteamInstaller { /// Whether `program` is the Steam client of its bottle public static func isSteam(_ program: URL, in bottle: Bottle) -> Bool { - program.standardizedFileURL.path(percentEncoded: false) - .caseInsensitiveCompare(steamExecutable(in: bottle).standardizedFileURL.path(percentEncoded: false)) - == .orderedSame + program.isSameWindowsPath(as: steamExecutable(in: bottle)) } public static func isInstalled(in bottle: Bottle) -> Bool { @@ -131,13 +136,13 @@ public enum SteamInstaller { /// Runs Steam until its first self-update finishes, then stops it and installs the web helper shim. /// Steam reports no progress, so the size of its download folder is checked every few seconds. + /// Fails if Steam stops downloading or closes, e.g. after an error dialog, instead of waiting forever. public static func completeFirstUpdate( in bottle: Bottle, progress: @escaping @Sendable (_ downloadedBytes: Int64) -> Void ) async throws { if !isFirstUpdateComplete(in: bottle) { let steam = Task { - try await Wine.runWine([steamExecutable(in: bottle).path(percentEncoded: false)] + launchArguments, - bottle: bottle) + try await Wine.runWine([steamExecutable(in: bottle).path(percentEncoded: false)], bottle: bottle) } do { try await waitForFirstUpdate(in: bottle, progress: progress) @@ -157,8 +162,9 @@ public enum SteamInstaller { let downloads = steamFolder(in: bottle).appending(path: "package") var downloaded: Int64 = -1 var lastGrowth = ContinuousClock.now + var lastSeenRunning = ContinuousClock.now while !isFirstUpdateComplete(in: bottle) { - try await Task.sleep(for: .seconds(2)) + try await Task.sleep(for: firstUpdatePollInterval) let size = folderSize(downloads) if size != downloaded { downloaded = size @@ -167,6 +173,11 @@ public enum SteamInstaller { } else if ContinuousClock.now - lastGrowth > firstUpdateStallTimeout { throw SteamInstallerError.firstUpdateStalled } + if isRunning(in: bottle) { + lastSeenRunning = .now + } else if ContinuousClock.now - lastSeenRunning > firstUpdateExitGrace { + throw SteamInstallerError.firstUpdateExited + } } } @@ -218,12 +229,11 @@ public enum SteamInstaller { try Data(contentsOf: file, options: .alwaysMapped).range(of: shimMarker) != nil } - /// Arguments to add when starting `program`: for Steam, after making sure its interface can draw - public static func prepareLaunch(of program: URL, in bottle: Bottle) throws -> [String] { - guard isSteam(program, in: bottle) else { return [] } - if isFirstUpdateComplete(in: bottle) { - try installWebHelperShim(in: bottle) - } + /// Arguments to add when starting `program`: for Steam, after making sure its interface can draw. + /// Until the first update is done Steam starts plainly, so it downloads its client. + public static func prepareLaunch(of program: URL, in bottle: Bottle, shim: URL = webHelperShim) throws -> [String] { + guard isSteam(program, in: bottle), isFirstUpdateComplete(in: bottle) else { return [] } + try installWebHelperShim(in: bottle, shim: shim) return launchArguments } } diff --git a/WhiskyKit/Sources/WhiskyKit/Whisky/Bottle+Creation.swift b/WhiskyKit/Sources/WhiskyKit/Whisky/Bottle+Creation.swift index 7c68fb289..3a1acd2b1 100644 --- a/WhiskyKit/Sources/WhiskyKit/Whisky/Bottle+Creation.swift +++ b/WhiskyKit/Sources/WhiskyKit/Whisky/Bottle+Creation.swift @@ -55,7 +55,7 @@ extension Bottle { try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) do { - let bottle = Bottle(bottleUrl: url, inFlight: true) + let bottle = Bottle(bottleUrl: url, activity: .creating) whenStarted(bottle) bottle.settings.windowsVersion = windowsVersion bottle.settings.name = name @@ -68,7 +68,7 @@ extension Bottle { let wineVersion = try await Wine.wineVersion() bottle.settings.wineVersion = SemanticVersion(wineVersion) ?? SemanticVersion(0, 0, 0) - bottle.inFlight = false + bottle.activity = nil return bottle } catch { removeIncompleteBottle(at: url) diff --git a/WhiskyKit/Sources/WhiskyKit/Whisky/Bottle.swift b/WhiskyKit/Sources/WhiskyKit/Whisky/Bottle.swift index 5e7397986..b458e013c 100644 --- a/WhiskyKit/Sources/WhiskyKit/Whisky/Bottle.swift +++ b/WhiskyKit/Sources/WhiskyKit/Whisky/Bottle.swift @@ -20,6 +20,13 @@ import Foundation import SwiftUI import os.log +/// Long-running work on a bottle, during which it can't be opened +public enum BottleActivity: Sendable { + case creating + case moving + case deleting +} + // swiftlint:disable:next todo // TODO: Should not be unchecked! public final class Bottle: ObservableObject, Equatable, Hashable, Identifiable, Comparable, @unchecked Sendable { @@ -29,23 +36,24 @@ public final class Bottle: ObservableObject, Equatable, Hashable, Identifiable, didSet { saveSettings() } } @Published public var programs: [Program] = [] - @Published public var inFlight: Bool = false + @Published public var activity: BottleActivity? public var isAvailable: Bool = false /// All pins with their associated programs public var pinnedPrograms: [(pin: PinnedProgram, program: Program, // swiftlint:disable:this large_tuple id: String)] { return settings.pins.compactMap { pin in - let exists = FileManager.default.fileExists(atPath: pin.url?.path(percentEncoded: false) ?? "") - guard let program = programs.first(where: { $0.url == pin.url && exists }) else { return nil } + guard let pinURL = pin.url, + FileManager.default.fileExists(atPath: pinURL.path(percentEncoded: false)), + let program = programs.first(where: { $0.url.isSameWindowsPath(as: pinURL) }) else { return nil } return (pin, program, "\(pin.name)//\(program.url)") } } - public init(bottleUrl: URL, inFlight: Bool = false, isAvailable: Bool = false) { + public init(bottleUrl: URL, activity: BottleActivity? = nil, isAvailable: Bool = false) { let metadataURL = bottleUrl.appending(path: "Metadata").appendingPathExtension("plist") self.url = bottleUrl - self.inFlight = inFlight + self.activity = activity self.isAvailable = isAvailable self.metadataURL = metadataURL @@ -59,11 +67,9 @@ public final class Bottle: ObservableObject, Equatable, Hashable, Identifiable, } // Get rid of duplicates and pins that reference removed files - var found: Set = [] + var found: Set = [] self.settings.pins = self.settings.pins.filter { pin in - guard let url = pin.url else { return false } - guard !found.contains(url) else { return false } - found.insert(url) + guard let url = pin.url, found.insert(url.windowsPathKey).inserted else { return false } let urlPath = url.path(percentEncoded: false) let volume: URL? do { diff --git a/WhiskyKit/Sources/WhiskyKit/Whisky/BottleSettings.swift b/WhiskyKit/Sources/WhiskyKit/Whisky/BottleSettings.swift index d3485a849..e8af196cd 100644 --- a/WhiskyKit/Sources/WhiskyKit/Whisky/BottleSettings.swift +++ b/WhiskyKit/Sources/WhiskyKit/Whisky/BottleSettings.swift @@ -42,6 +42,11 @@ public struct PinnedProgram: Codable, Hashable, Equatable { self.url = try container.decodeIfPresent(URL.self, forKey: .url) self.removable = try container.decodeIfPresent(Bool.self, forKey: .removable) ?? false } + + /// Whether this pin opens `program`, however the case of its path is written + public func opens(_ program: URL) -> Bool { + url?.isSameWindowsPath(as: program) ?? false + } } public struct BottleInfo: Codable, Equatable { @@ -123,7 +128,9 @@ public enum DXVKHUD: Codable, Equatable { } public struct BottleDXVKConfig: Codable, Equatable { - var dxvk: Bool = false + /// On unless turned off: the runtime has no other way to run Direct3D 10/11, which Wine's + /// own OpenGL-based Direct3D cannot do on macOS, so games such as Unity ones fail to start + var dxvk: Bool = true var dxvkAsync: Bool = true var dxvkHud: DXVKHUD = .off @@ -131,7 +138,7 @@ public struct BottleDXVKConfig: Codable, Equatable { public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - self.dxvk = try container.decodeIfPresent(Bool.self, forKey: .dxvk) ?? false + self.dxvk = try container.decodeIfPresent(Bool.self, forKey: .dxvk) ?? true self.dxvkAsync = try container.decodeIfPresent(Bool.self, forKey: .dxvkAsync) ?? true self.dxvkHud = try container.decodeIfPresent(DXVKHUD.self, forKey: .dxvkHud) ?? .off } diff --git a/WhiskyKit/Sources/WhiskyKit/Whisky/Program.swift b/WhiskyKit/Sources/WhiskyKit/Whisky/Program.swift index b49d056cf..5e14f555c 100644 --- a/WhiskyKit/Sources/WhiskyKit/Whisky/Program.swift +++ b/WhiskyKit/Sources/WhiskyKit/Whisky/Program.swift @@ -31,19 +31,25 @@ public final class Program: ObservableObject, Equatable, Hashable, Identifiable, url.lastPathComponent } + /// Whether the program is running in its bottle, however it was started + public var isRunning: Bool { + !WineProcesses.processIDs(in: bottle, named: name).isEmpty + } + @Published public var settings: ProgramSettings { didSet { saveSettings() } } @Published public var pinned: Bool { didSet { + guard pinned != oldValue else { return } if pinned { bottle.settings.pins.append(PinnedProgram( name: name.replacingOccurrences(of: ".exe", with: ""), url: url )) } else { - bottle.settings.pins.removeAll(where: { $0.url == url }) + bottle.settings.pins.removeAll(where: { $0.opens(url) }) } } } @@ -54,7 +60,7 @@ public final class Program: ObservableObject, Equatable, Hashable, Identifiable, let name = url.lastPathComponent self.bottle = bottle self.url = url - self.pinned = bottle.settings.pins.contains(where: { $0.url == url }) + self.pinned = bottle.settings.pins.contains(where: { $0.opens(url) }) // Warning: This will break if two programs share the same name such as "Launch.exe" // Best to add some sort of UUID in the path or file diff --git a/WhiskyKit/Tests/WhiskyKitTests/BottleSettingsTests.swift b/WhiskyKit/Tests/WhiskyKitTests/BottleSettingsTests.swift index 3726cdd01..4b847333a 100644 --- a/WhiskyKit/Tests/WhiskyKitTests/BottleSettingsTests.swift +++ b/WhiskyKit/Tests/WhiskyKitTests/BottleSettingsTests.swift @@ -76,4 +76,8 @@ struct BottleSettingsTests { #expect(decoded.windowsVersion == .win11) #expect(!decoded.hasLegacyWindowsVersion) } + + @Test func newBottlesUseDXVK() { + #expect(BottleSettings().dxvk) + } } diff --git a/WhiskyKit/Tests/WhiskyKitTests/ProgramTests.swift b/WhiskyKit/Tests/WhiskyKitTests/ProgramTests.swift index 091c35e34..d6acba58b 100644 --- a/WhiskyKit/Tests/WhiskyKitTests/ProgramTests.swift +++ b/WhiskyKit/Tests/WhiskyKitTests/ProgramTests.swift @@ -57,4 +57,23 @@ struct ProgramTests { #expect(!FileManager.default.fileExists(atPath: bottle.url.path(percentEncoded: false))) } + + @Test func treatsPinsThatDifferOnlyInCaseAsOne() throws { + let folder = try TemporaryFolder() + let bottle = try makeBottle(in: folder) + let steamFolder = bottle.url.appending(path: "drive_c/Program Files (x86)/Steam") + try FileManager.default.createDirectory(at: steamFolder, withIntermediateDirectories: true) + try Data().write(to: steamFolder.appending(path: "Steam.exe")) + bottle.settings.pins = [ + PinnedProgram(name: "Steam", url: steamFolder.appending(path: "steam.exe")), + PinnedProgram(name: "Steam", url: steamFolder.appending(path: "Steam.exe")) + ] + + let reloaded = Bottle(bottleUrl: bottle.url, isAvailable: true) + let program = Program(url: steamFolder.appending(path: "Steam.exe"), bottle: reloaded) + program.pinned = true + + #expect(reloaded.settings.pins.count == 1) + #expect(program.pinned) + } } diff --git a/WhiskyKit/Tests/WhiskyKitTests/SteamInstallerTests.swift b/WhiskyKit/Tests/WhiskyKitTests/SteamInstallerTests.swift index 658fb92a7..aeb260261 100644 --- a/WhiskyKit/Tests/WhiskyKitTests/SteamInstallerTests.swift +++ b/WhiskyKit/Tests/WhiskyKitTests/SteamInstallerTests.swift @@ -118,16 +118,24 @@ struct SteamInstallerTests { } } - @Test func addsLaunchArgumentsOnlyForSteam() throws { + @Test func addsLaunchArgumentsOnlyForUpdatedSteam() throws { let folder = try TemporaryFolder() - let bottle = try makeBottle("Installed", in: folder, withSteam: true) + let (bottle, _) = try makeUpdatedSteam(in: folder) + let shim = try makeShim(in: folder) let steam = SteamInstaller.steamExecutable(in: bottle) let game = bottle.url.appending(path: "drive_c/Games/game.exe") let differentCase = URL(filePath: steam.path(percentEncoded: false).replacingOccurrences(of: "steam.exe", with: "Steam.exe")) - #expect(try SteamInstaller.prepareLaunch(of: game, in: bottle).isEmpty) - #expect(try SteamInstaller.prepareLaunch(of: steam, in: bottle) == ["-noverifyfiles"]) + #expect(try SteamInstaller.prepareLaunch(of: game, in: bottle, shim: shim).isEmpty) + #expect(try SteamInstaller.prepareLaunch(of: steam, in: bottle, shim: shim) == ["-noverifyfiles"]) #expect(SteamInstaller.isSteam(differentCase, in: bottle)) } + + @Test func startsSteamPlainlyUntilItsFirstUpdate() throws { + let folder = try TemporaryFolder() + let bottle = try makeBottle("Installed", in: folder, withSteam: true) + + #expect(try SteamInstaller.prepareLaunch(of: SteamInstaller.steamExecutable(in: bottle), in: bottle).isEmpty) + } } From 35cd7c2f724352ec7e293e2975c1596abce2da3e Mon Sep 17 00:00:00 2001 From: Jatinjit Singh Date: Thu, 29 Oct 2026 17:20:43 +0900 Subject: [PATCH 2/2] Set the version to 1.0.1 (45) --- Whisky.xcodeproj/project.pbxproj | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Whisky.xcodeproj/project.pbxproj b/Whisky.xcodeproj/project.pbxproj index a7bf2c750..f214853b3 100644 --- a/Whisky.xcodeproj/project.pbxproj +++ b/Whisky.xcodeproj/project.pbxproj @@ -803,7 +803,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Developer ID Application"; CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 44; + CURRENT_PROJECT_VERSION = 45; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"Whisky/Preview Content\""; DEVELOPMENT_TEAM = ""; @@ -822,7 +822,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.0.0; + MARKETING_VERSION = 1.0.1; PRODUCT_BUNDLE_IDENTIFIER = com.istorry.Brandy; PRODUCT_MODULE_NAME = Whisky; PRODUCT_NAME = Brandy; @@ -843,7 +843,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Developer ID Application"; CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 44; + CURRENT_PROJECT_VERSION = 45; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"Whisky/Preview Content\""; DEVELOPMENT_TEAM = ""; @@ -862,7 +862,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.0.0; + MARKETING_VERSION = 1.0.1; PRODUCT_BUNDLE_IDENTIFIER = com.istorry.Brandy; PRODUCT_MODULE_NAME = Whisky; PRODUCT_NAME = Brandy; @@ -932,7 +932,7 @@ CODE_SIGN_ENTITLEMENTS = WhiskyThumbnail/WhiskyThumbnail.entitlements; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Developer ID Application"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 44; + CURRENT_PROJECT_VERSION = 45; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; @@ -948,7 +948,7 @@ "@executable_path/../../../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.0.0; + MARKETING_VERSION = 1.0.1; PRODUCT_BUNDLE_IDENTIFIER = com.istorry.Brandy.Thumbnail; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -967,7 +967,7 @@ CODE_SIGN_ENTITLEMENTS = WhiskyThumbnail/WhiskyThumbnail.entitlements; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Developer ID Application"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 44; + CURRENT_PROJECT_VERSION = 45; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; @@ -983,7 +983,7 @@ "@executable_path/../../../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.0.0; + MARKETING_VERSION = 1.0.1; PRODUCT_BUNDLE_IDENTIFIER = com.istorry.Brandy.Thumbnail; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = "";