diff --git a/Whisky.xcodeproj/project.pbxproj b/Whisky.xcodeproj/project.pbxproj index d99bb7dce..f214853b3 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; }; @@ -799,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 = ""; @@ -818,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; @@ -839,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 = ""; @@ -858,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; @@ -928,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; @@ -944,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 = ""; @@ -963,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; @@ -979,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 = ""; 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) + } }