Skip to content
Merged
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
17 changes: 16 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
phase with the resolved node, and the wizard drew both the same way; a node
that hands off to the escalation fragment now shows the escalation screen
with its export and retry options.
- Applying a game configuration now sets the graphics backend it lists. The
entry's legacy DXVK flag was written after the backend and its "off" value
meant "back to Recommended", so every apply ended on Recommended while the
toast said it had applied. The preview also no longer lists Sequoia
Compatibility Mode, a setting that no longer exists.
- Export as Archive writes the bottle as `<folder>/...` entries instead of
its absolute path, so the archive no longer carries the user's home
directory name and extracts where it is opened. Neither export carries
AppleDouble `._` files any more.
- The Duplicate Bottle sheet's confirm button says Duplicate, not Rename.
- Guided troubleshooting's install step names the verb it will install after
a resumed session, and its game-database check sees the program the wizard
was opened from.
- Cancelling a dependency install stops it. Cancel only closed the sheet,
leaving winetricks and the installer it had spawned running in the bottle;
it now cancels the install and ends the bottle's Wine processes.
- Diagnostic exports with "Include sensitive details" off no longer carry
credentials in plain sight. Launch arguments were written to the archive
verbatim regardless of the toggle, and log redaction only rewrote the home
Expand All @@ -139,7 +155,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
-2147483648 no longer crashes Whisky while the library renders its icon.
The parser took the absolute value of that height before checking its
range, and that value has no absolute value in 32 bits.
||||||| parent of a96a2bb8 (fix(diagnostics): scrub credential shapes from exported arguments and logs)
- Installing the Visual C++ Runtime from the Dependencies panel no longer
fails silently on a stale checksum. Microsoft rotates the vc_redist
binaries in place, so the SHA256 sums pinned in the bundled winetricks go
Expand Down
16 changes: 16 additions & 0 deletions Whisky/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -47474,6 +47474,22 @@
}
}
},
"duplicate.bottle.confirm" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Duplicate"
}
},
"en-GB" : {
"stringUnit" : {
"state" : "translated",
"value" : "Duplicate"
}
}
}
},
"duplicate.bottle.title" : {
"localizations" : {
"ar" : {
Expand Down
19 changes: 15 additions & 4 deletions Whisky/Utils/Winetricks+Install.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,12 @@ extension Winetricks {
timeout: TimeInterval = 600
) -> AsyncStream<WinetricksInstallProgress> {
AsyncStream { continuation in
Task {
let task = Task {
await executeVerbInstall(verb, for: bottle, timeout: timeout, continuation: continuation)
}
// A consumer that stops iterating (the install sheet's Cancel)
// cancels the install rather than leaving it running headless.
continuation.onTermination = { _ in task.cancel() }
}
}

Expand All @@ -79,14 +82,15 @@ extension Winetricks {
for bottle: Bottle
) -> AsyncStream<(verb: String, progress: WinetricksInstallProgress)> {
AsyncStream { continuation in
Task {
for verb in verbs {
let task = Task {
for verb in verbs where !Task.isCancelled {
for await progress in installVerb(verb, for: bottle) {
continuation.yield((verb: verb, progress: progress))
}
}
continuation.finish()
}
continuation.onTermination = { _ in task.cancel() }
}
}

Expand Down Expand Up @@ -191,7 +195,14 @@ extension Winetricks {
return
}

await awaitProcessCompletion(process, verb: verb, timeout: timeout)
await withTaskCancellationHandler {
await awaitProcessCompletion(process, verb: verb, timeout: timeout)
} onCancel: {
if process.isRunning {
logger.info("winetricks install '\(verb)' cancelled, terminating")
process.terminate()
}
}
stdoutPipe.fileHandleForReading.readabilityHandler = nil
stderrPipe.fileHandleForReading.readabilityHandler = nil

Expand Down
3 changes: 2 additions & 1 deletion Whisky/Views/Bottle/BottleListEntry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,8 @@ struct BottleListEntry: View {
name: BottleOperations.nextDuplicateName(
baseName: name,
existingNames: BottleVM.shared.bottles.map(\.settings.name)
)
),
confirmTitle: "duplicate.bottle.confirm"
) { newName in
Task {
do {
Expand Down
3 changes: 2 additions & 1 deletion Whisky/Views/Bottle/BottleView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,8 @@ struct BottleView: View {
name: BottleOperations.nextDuplicateName(
baseName: bottle.settings.name,
existingNames: BottleVM.shared.bottles.map(\.settings.name)
)
),
confirmTitle: "duplicate.bottle.confirm"
) { newName in
Task {
do {
Expand Down
11 changes: 9 additions & 2 deletions Whisky/Views/Common/RenameView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,20 @@ import SwiftUI

struct RenameView: View {
let title: Text
let confirmTitle: LocalizedStringKey
var renameAction: (String) -> Void

@State private var name: String = ""
@Environment(\.dismiss) private var dismiss

init(_ title: LocalizedStringKey, name: String, renameAction: @escaping (String) -> Void) {
init(
_ title: LocalizedStringKey,
name: String,
confirmTitle: LocalizedStringKey = "rename.rename",
renameAction: @escaping (String) -> Void
) {
self.title = Text(title)
self.confirmTitle = confirmTitle
self._name = State(initialValue: name)
self.renameAction = renameAction
}
Expand All @@ -46,7 +53,7 @@ struct RenameView: View {
.keyboardShortcut(.cancelAction)
}
ToolbarItem(placement: .primaryAction) {
Button("rename.rename") {
Button(confirmTitle) {
submit()
}
.keyboardShortcut(.defaultAction)
Expand Down
22 changes: 21 additions & 1 deletion Whisky/Views/Install/DependencyInstallSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ struct DependencyInstallSheet: View {
@ObservedObject var bottle: Bottle
@Environment(\.dismiss) private var dismiss

/// The running install, kept so Cancel can stop it. Dismissing the sheet
/// alone left winetricks and the redist installer running in the bottle.
@State private var installTask: Task<Void, Never>?

@State private var stage: InstallStage = .info
@State private var logLines: [String] = []
@State private var isInstalling: Bool = false
Expand Down Expand Up @@ -337,6 +341,7 @@ extension DependencyInstallSheet {
HStack {
if stage != .verify {
Button("Cancel") {
cancelInstall()
dismiss()
}
.keyboardShortcut(.cancelAction)
Expand Down Expand Up @@ -395,7 +400,7 @@ extension DependencyInstallSheet {
isInstalling = true
logLines = []

Task {
installTask = Task {
let verbStream = Winetricks.installVerbs(definition.winetricksVerbs, for: bottle)
var lastExitCode: Int32 = 0
var hadError = false
Expand All @@ -417,6 +422,10 @@ extension DependencyInstallSheet {
}
}

// Cancelled: the sheet is gone and the processes are being killed;
// there is nothing to verify or record.
guard !Task.isCancelled else { return }

let result: InstallResult = if hadError {
.error("One or more verbs failed")
} else if lastExitCode == 0 {
Expand All @@ -436,6 +445,17 @@ extension DependencyInstallSheet {
}
}

/// Stops a running install. Cancelling the task terminates winetricks
/// through the stream, and the bottle's wine processes are killed so the
/// redist installer it spawned does not keep running headless.
private func cancelInstall() {
guard isInstalling else { return }
installTask?.cancel()
installTask = nil
isInstalling = false
Wine.killBottle(bottle: bottle)
}

private func runVerification() async {
let statuses = await DependencyManager.checkDependencies(
for: bottle,
Expand Down
18 changes: 17 additions & 1 deletion Whisky/Views/Troubleshooting/FixPreviewView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -223,13 +223,29 @@ extension FixPreviewView {
private var resolvedParams: [String: String] {
var params = node.params ?? [:]
if node.fixId == "install-winetricks-verb", params["verb"] == nil,
let missing = engine.lastCheckResult?.evidence["missing"],
let missing = missingVerbsEvidence,
let first = missing.split(separator: ",").first {
params["verb"] = first.trimmingCharacters(in: .whitespaces)
}
return params
}

/// The `missing` evidence of the most recent check on the path here.
///
/// Read from the session rather than `lastCheckResult`: that one is not
/// persisted, so a resumed session offered to install "unknown".
private var missingVerbsEvidence: String? {
if let missing = engine.lastCheckResult?.evidence["missing"] {
return missing
}
for step in engine.session.stepHistory.reversed() {
if let missing = engine.session.checkResults[step.nodeId]?.evidence["missing"] {
return missing
}
}
return nil
}

private func loadPreview() {
guard let fixId = node.fixId else { return }
fixPreview = FixApplicator.preview(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -440,7 +440,7 @@ extension DiagnosticExporter {
process.arguments = [
"-c",
"-k",
"--sequesterRsrc",
"--norsrc",
contentDir.path(percentEncoded: false),
zipURL.path(percentEncoded: false)
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ public enum GameConfigApplicator {
))
}

if let dxvk = settings.dxvk, dxvk != bottle.settings.dxvk {
if settings.graphicsBackend == nil, let dxvk = settings.dxvk, dxvk != bottle.settings.dxvk {
changes.append(ConfigChange(
category: "Graphics",
settingName: "DXVK",
Expand All @@ -246,15 +246,6 @@ public enum GameConfigApplicator {
))
}

if let sequoiaCompat = settings.sequoiaCompatMode, sequoiaCompat != bottle.settings.sequoiaCompatMode {
changes.append(ConfigChange(
category: "Graphics",
settingName: "Sequoia Compatibility Mode",
currentValue: bottle.settings.sequoiaCompatMode ? "Enabled" : "Disabled",
newValue: sequoiaCompat ? "Enabled" : "Disabled"
))
}

// Performance settings
if let enhancedSync = settings.enhancedSync, enhancedSync != bottle.settings.enhancedSync {
changes.append(ConfigChange(
Expand Down Expand Up @@ -370,11 +361,13 @@ public enum GameConfigApplicator {
/// Applies variant settings to the bottle's settings.
@MainActor
private static func applyVariantSettings(_ settings: GameConfigVariantSettings, to bottle: Bottle) {
// `dxvk` is the legacy backend switch: its setter maps `false` back to
// Recommended, so applying it after an explicit backend undid the
// backend. An explicit backend wins; `dxvk` only speaks when there is
// none.
if let graphicsBackend = settings.graphicsBackend {
bottle.settings.graphicsBackend = graphicsBackend
}

if let dxvk = settings.dxvk {
} else if let dxvk = settings.dxvk {
bottle.settings.dxvk = dxvk
}

Expand Down Expand Up @@ -402,10 +395,6 @@ public enum GameConfigApplicator {
if let avxEnabled = settings.avxEnabled {
bottle.settings.avxEnabled = avxEnabled
}

if let sequoiaCompatMode = settings.sequoiaCompatMode {
bottle.settings.sequoiaCompatMode = sequoiaCompatMode
}
}

/// Appends DLL overrides to the bottle, deduplicating by DLL name (variant value wins).
Expand Down
11 changes: 10 additions & 1 deletion WhiskyKit/Sources/WhiskyKit/Tar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,16 @@ public class Tar {
let pipe = Pipe()

process.executableURL = tarBinary
process.arguments = ["-zcf", "\(toURL.path)", "\(folder.path)"]
// Archive the folder by name from its parent so the entries are
// `<folder>/...` rather than the absolute path it happened to live at
// (which also put the user's home directory name into every export).
// COPYFILE_DISABLE keeps bsdtar from adding `._` AppleDouble entries.
process.arguments = [
"-zcf", toURL.path, "-C", folder.deletingLastPathComponent().path, folder.lastPathComponent
]
var environment = ProcessInfo.processInfo.environment
environment["COPYFILE_DISABLE"] = "1"
process.environment = environment
process.standardOutput = pipe
process.standardError = pipe

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,11 @@ public final class TroubleshootingFlowEngine: ObservableObject {
bottleURL: session.preflightSnapshot?.bottleURL ?? session.bottleURL ?? URL(filePath: "/"),
bottleName: session.preflightSnapshot?.bottleName ?? "Unknown",
programURL: session.preflightSnapshot?.programURL ?? session.programURL,
programName: session.preflightSnapshot?.programName,
// The name is what the game-database check matches on; a
// wizard opened from a program page has the URL but not always
// the name, so fall back to the executable's filename.
programName: session.preflightSnapshot?.programName
?? (session.preflightSnapshot?.programURL ?? session.programURL)?.lastPathComponent,
preflight: session.preflightSnapshot ?? PreflightData(
bottleURL: session.bottleURL ?? URL(filePath: "/"),
bottleName: "Unknown",
Expand Down
17 changes: 17 additions & 0 deletions WhiskyKit/Tests/WhiskyKitTests/GameApplicatorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,23 @@ final class GameApplicatorTests: XCTestCase {
XCTAssertEqual(loaded?.appliedEntryId, "test-game")
}

@MainActor
func testExplicitBackendSurvivesTheLegacyDXVKFlag() throws {
let bottle = try makeTestBottle()
defer { cleanupBottle(bottle) }

// Real entries pair an explicit backend with `dxvk: false`; the legacy
// setter maps that `false` to Recommended, which used to undo the backend.
let variant = makeTestVariant(graphicsBackend: .d3dMetal, dxvk: false)
let entry = makeTestEntry(variant: variant)

_ = try GameConfigApplicator.apply(entry: entry, variant: variant, to: bottle)

XCTAssertEqual(bottle.settings.graphicsBackend, .d3dMetal)
let changes = GameConfigApplicator.previewChanges(variant: variant, bottle: bottle)
XCTAssertFalse(changes.contains { $0.settingName == "DXVK" }, "no DXVK row when the backend is explicit")
}

// MARK: - Test 2: Apply Mutates Bottle Settings

@MainActor
Expand Down
23 changes: 23 additions & 0 deletions WhiskyKit/Tests/WhiskyKitTests/TarTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,29 @@ final class TarIntegrationTests: XCTestCase {
}
}

func testTarEntriesAreRelativeToTheFolder() throws {
let sourceDir = tempDir.appendingPathComponent("relative-source")
try FileManager.default.createDirectory(at: sourceDir, withIntermediateDirectories: true)
try "x".write(to: sourceDir.appendingPathComponent("file.txt"), atomically: true, encoding: .utf8)
let tarURL = tempDir.appendingPathComponent("relative.tar.gz")

try Tar.tar(folder: sourceDir, toURL: tarURL)

let process = Process()
let pipe = Pipe()
process.executableURL = URL(fileURLWithPath: "/usr/bin/tar")
process.arguments = ["-tzf", tarURL.path]
process.standardOutput = pipe
try process.run()
let listing = try String(data: pipe.fileHandleForReading.readToEnd() ?? Data(), encoding: .utf8) ?? ""
process.waitUntilExit()

let entries = listing.split(separator: "\n").map(String.init)
XCTAssertTrue(entries.contains("relative-source/file.txt"), "entries: \(entries)")
XCTAssertFalse(entries.contains { $0.hasPrefix("/") || $0.hasPrefix("Users/") || $0.hasPrefix("private/") })
XCTAssertFalse(entries.contains { $0.contains("._") }, "no AppleDouble entries: \(entries)")
}

func testTarWithMultipleFiles() throws {
try Data("File 1 content".utf8).write(to: sourceDir.appending(path: "file1.txt"))
try Data("File 2 content".utf8).write(to: sourceDir.appending(path: "file2.txt"))
Expand Down
Loading