diff --git a/app/Sources/NotProtonApp/App.swift b/app/Sources/NotProtonApp/App.swift index e431792b..04ae7ab1 100644 --- a/app/Sources/NotProtonApp/App.swift +++ b/app/Sources/NotProtonApp/App.swift @@ -19,7 +19,8 @@ struct NotProtonApp: App { .environment(status) .environment(prefixes) .frame(minWidth: 720, minHeight: 460) - .onChange(of: pane) { prefixes.forgetOutcome() } + // Status never shows the prefix banner, so a result that lands there is kept + .onChange(of: pane) { old, _ in if old != .status { prefixes.forgetOutcome() } } .onChange(of: status.snapshot?.installedRunners.map(\.id)) { if prefixes.hasLoaded { Task { await prefixes.load() } } } @@ -61,21 +62,24 @@ struct NotProtonApp: App { } CommandMenu("Setup") { - Button("Install") { Task { await status.requestInstall() } } + Button("Install") { pane = .status; Task { await status.requestInstall() } } .keyboardShortcut("i", modifiers: .command) .disabled(!status.canInstall) - Button("Set Up Compatibility Tool") { Task { await status.requestCompatibilityTool() } } - .disabled(!status.canInstall || status.setupSource == nil) + Button("Set Up Compatibility Tool") { + pane = .status + Task { await status.requestCompatibilityTool() } + } + .disabled(!status.canInstall || status.setupSource == nil) - Button("Fetch Valve Binaries") { Task { await status.fetchValveBinaries() } } + Button("Fetch Valve Binaries") { pane = .status; Task { await status.fetchValveBinaries() } } .disabled(!status.canInstall) Divider() updateBlockCommand - Button("Repair Steam") { status.pendingConfirmation = .replaceSteam } + Button("Repair Steam") { pane = .status; status.pendingConfirmation = .replaceSteam } .disabled(!status.isIdle) Divider() @@ -117,6 +121,7 @@ struct NotProtonApp: App { Button(PrefixPrompt.backUpButton(selectionTargets)) { if !selectionTargets.isEmpty { + pane = .prefixes prefixes.pendingConfirmation = .backUp(selectionTargets) } } @@ -126,6 +131,7 @@ struct NotProtonApp: App { Menu(PrefixPrompt.rebuildButton(selectionTargets)) { ForEach(prefixes.tools) { tool in Button(tool.display) { + pane = .prefixes prefixes.pendingConfirmation = .rebuild(selectionTargets, tool) } } @@ -134,6 +140,7 @@ struct NotProtonApp: App { } else { Button(PrefixPrompt.rebuildButton(selectionTargets)) { if let tool = prefixes.tools.first, !selectionTargets.isEmpty { + pane = .prefixes prefixes.pendingConfirmation = .rebuild(selectionTargets, tool) } } @@ -142,6 +149,7 @@ struct NotProtonApp: App { Button(PrefixPrompt.deleteButton(selectionTargets)) { if !selectionTargets.isEmpty { + pane = .prefixes prefixes.pendingConfirmation = .delete(selectionTargets) } } @@ -160,10 +168,10 @@ struct NotProtonApp: App { @ViewBuilder private var updateBlockCommand: some View { if status.snapshot?.updateBlocked == true { - Button("Allow Client Updates") { Task { await status.setUpdateBlock(false) } } + Button("Allow Client Updates") { pane = .status; Task { await status.setUpdateBlock(false) } } .disabled(!status.isIdle) } else { - Button("Block Client Updates") { status.pendingConfirmation = .blockUpdates } + Button("Block Client Updates") { pane = .status; status.pendingConfirmation = .blockUpdates } .disabled(!status.isIdle || status.snapshot == nil) } } diff --git a/app/Sources/NotProtonApp/Model/PrefixStore.swift b/app/Sources/NotProtonApp/Model/PrefixStore.swift index 5cd36e4e..82fb31bc 100644 --- a/app/Sources/NotProtonApp/Model/PrefixStore.swift +++ b/app/Sources/NotProtonApp/Model/PrefixStore.swift @@ -69,9 +69,8 @@ enum PrefixStore { var roots: [URL] = [] for line in text.split(separator: "\n") { - let fields = line.split(separator: "\"").map(String.init) - .filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } - guard fields.count >= 2, fields[0] == "path" else { continue } + let fields = vdfFields(line) + guard fields.count >= 2, fields[0] == "path", !fields[1].isEmpty else { continue } roots.append(URL(filePath: fields[1])) } @@ -225,23 +224,27 @@ enum PrefixStore { let manifest = library.steamapps.appending(path: "appmanifest_\(appID).acf") guard let text = try? String(contentsOf: manifest, encoding: .utf8) else { return nil } for line in text.split(separator: "\n") { - // Steam escapes \" \\ \n and \t inside quoted values. - let fields = line.matches(of: #/"((?:[^"\\]|\\.)*)"/#) - .map { - String($0.1).replacing(#/\\(.)/#) { escape -> String in - switch escape.1 { - case "n": "\n" - case "t": "\t" - case "\\", "\"": String(escape.1) - default: String(escape.0) - } - } - } + let fields = vdfFields(line) if fields.count >= 2, fields[0] == key { return fields[1] } } return nil } + // Steam escapes \" \\ \n and \t inside quoted values. + private static func vdfFields(_ line: Substring) -> [String] { + line.matches(of: #/"((?:[^"\\]|\\.)*)"/#) + .map { + String($0.1).replacing(#/\\(.)/#) { escape -> String in + switch escape.1 { + case "n": "\n" + case "t": "\t" + case "\\", "\"": String(escape.1) + default: String(escape.0) + } + } + } + } + static let backupPrefix = "pfx.previous" static func backupClock() -> DateFormatter { @@ -394,14 +397,15 @@ enum PrefixStore { _ prefix: WinePrefix, root: URL = serverRoot, lsof: String = "/usr/sbin/lsof", drainTimeout: DispatchTimeInterval = .seconds(2) ) -> Bool { + isClaimed(prefix) || serverIsBusy(prefix, root: root, lsof: lsof, drainTimeout: drainTimeout) + } + + // The lock only, without the wineserver check. A running game does not hold it. + static func isClaimed(_ prefix: WinePrefix) -> Bool { let lock = open(lockPath(of: prefix), O_RDWR | O_NOFOLLOW | O_CLOEXEC) - if lock >= 0 { - defer { FileLock.release(lock) } - guard locked(lock) else { return true } - } else if errno != ENOENT { - return true - } - return serverIsBusy(prefix, root: root, lsof: lsof, drainTimeout: drainTimeout) + guard lock >= 0 else { return errno != ENOENT } + defer { FileLock.release(lock) } + return !locked(lock) } // A launch takes this lock while it sets the prefix up. Holding it stops a launch from diff --git a/app/Sources/NotProtonApp/Model/PrefixTools.swift b/app/Sources/NotProtonApp/Model/PrefixTools.swift index 5dcd58cd..8f720c04 100644 --- a/app/Sources/NotProtonApp/Model/PrefixTools.swift +++ b/app/Sources/NotProtonApp/Model/PrefixTools.swift @@ -200,12 +200,20 @@ enum PrefixTools { ) } + // A launch setting the prefix up or a rebuild started from Steam holds the lock in another + // process. Wine started now would write into a prefix that is half made or about to be replaced. + private static func requireUnclaimed(step: String, prefix: WinePrefix) throws { + guard !PrefixStore.isClaimed(prefix) else { + throw StepFailure(step: step, detail: String(localized: "\(prefix.title) is running. Quit the game first.")) + } + } + static func launch( _ tool: WineTool, in prefix: WinePrefix, runner: URL, flavor: CompatTool.Flavor = .fex ) throws { - let loader = try readyLoader( - step: String(localized: "Open \(tool.label)"), prefix: prefix, runner: runner, flavor: flavor - ) + let step = String(localized: "Open \(tool.label)") + try requireUnclaimed(step: step, prefix: prefix) + let loader = try readyLoader(step: step, prefix: prefix, runner: runner, flavor: flavor) try Shell.detach( loader.path(percentEncoded: false), [tool.rawValue], @@ -228,6 +236,7 @@ enum PrefixTools { flavor: CompatTool.Flavor = .fex ) throws { let step = String(localized: "Run \(executable.lastPathComponent)") + try requireUnclaimed(step: step, prefix: prefix) let loader = try readyLoader(step: step, prefix: prefix, runner: runner, flavor: flavor) guard FileManager.default.fileExists(atPath: executable.path(percentEncoded: false)) else { throw StepFailure(step: step, detail: String(localized: "No file at \(executable.path(percentEncoded: false)).")) diff --git a/app/Sources/NotProtonApp/Model/RunnerInstaller.swift b/app/Sources/NotProtonApp/Model/RunnerInstaller.swift index f2882553..733d8fdc 100644 --- a/app/Sources/NotProtonApp/Model/RunnerInstaller.swift +++ b/app/Sources/NotProtonApp/Model/RunnerInstaller.swift @@ -32,10 +32,23 @@ enum RunnerInstaller { defer { try? fm.removeItem(at: staging) } try copyPayload(from: install.crossOverRoot, to: staging) try verifyClone(build: build, root: staging.appending(path: "CrossOver")) - if occupied { - try fm.removeItem(at: target) + guard occupied else { + try fm.moveItem(at: staging, to: target) + return build } - try fm.moveItem(at: staging, to: target) + // Deleting in place can fail part way and leave half a runner. A fresh name each time + // keeps a leftover that would not delete from blocking the next repair. + let trash = target.deletingLastPathComponent() + .appending(path: ".\(target.lastPathComponent).\(UUID().uuidString).removing") + removeLeftoverRemovals(runners: runners) + try fm.moveItem(at: target, to: trash) + do { + try fm.moveItem(at: staging, to: target) + } catch { + try? fm.moveItem(at: trash, to: target) + throw error + } + try? fm.removeItem(at: trash) return build } diff --git a/app/Sources/NotProtonApp/Model/SteamBundle.swift b/app/Sources/NotProtonApp/Model/SteamBundle.swift index cf88bd51..ed2793b1 100644 --- a/app/Sources/NotProtonApp/Model/SteamBundle.swift +++ b/app/Sources/NotProtonApp/Model/SteamBundle.swift @@ -44,7 +44,11 @@ enum SteamBundle { let deployedDylib = SupportPaths.Steam.deployedDylib(inBundle: app) .path(percentEncoded: false) let components = insert.split(separator: ":").map(String.init) - guard components.contains(deployedDylib) else { return .foreign(insert: insert) } + guard components.contains(deployedDylib) else { + // Our dylib at another path means Steam.app moved after install. Installing again rewrites it. + let others = components.filter { URL(filePath: $0).lastPathComponent != SupportPaths.dylibName } + return others.isEmpty ? .notInstalled : .foreign(insert: insert) + } guard files.fileExists(atPath: deployedDylib) else { return .notInstalled } diff --git a/app/Sources/NotProtonApp/Model/SteamInstaller.swift b/app/Sources/NotProtonApp/Model/SteamInstaller.swift index 96084bdc..504ad0a4 100644 --- a/app/Sources/NotProtonApp/Model/SteamInstaller.swift +++ b/app/Sources/NotProtonApp/Model/SteamInstaller.swift @@ -263,7 +263,9 @@ enum SteamInstaller { guard let insert = SteamBundle.currentInsert(at: plist), !insert.isEmpty else { return } let deployed = dylib.path(percentEncoded: false) - let foreign = insert.split(separator: ":").map(String.init).filter { $0 != deployed } + let foreign = insert.split(separator: ":").map(String.init).filter { + $0 != deployed && URL(filePath: $0).lastPathComponent != SupportPaths.dylibName + } guard foreign.isEmpty else { throw StepFailure( step: step, diff --git a/app/Sources/NotProtonApp/PrefixesModel.swift b/app/Sources/NotProtonApp/PrefixesModel.swift index a93ef004..67112990 100644 --- a/app/Sources/NotProtonApp/PrefixesModel.swift +++ b/app/Sources/NotProtonApp/PrefixesModel.swift @@ -10,7 +10,10 @@ final class PrefixesModel { private(set) var prefixes: [WinePrefix] = [] private(set) var usage: [String: PrefixUsage] = [:] - private(set) var isLoading = false + // A load that finishes in the middle of a rebuild or backup must not end the loading state + var isLoading: Bool { listing || working } + private var listing = false + private var working = false private(set) var records: [String: PrefixBuildRecord] = [:] private(set) var tools: [InstalledTool] = [] @@ -87,8 +90,8 @@ final class PrefixesModel { func load() async { loadGeneration += 1 let run = loadGeneration - isLoading = true - defer { if run == loadGeneration { isLoading = false } } + listing = true + defer { if run == loadGeneration { listing = false } } let roots = libraries() let found = await Task.detached { PrefixStore.all(libraries: roots) }.value @@ -124,14 +127,14 @@ final class PrefixesModel { } kept.sort { ($0.taken ?? .distantPast) > ($1.taken ?? .distantPast) } backups = kept + // Sizes fill in as they are measured, so a big library does not hold up the list + hasLoaded = true for prefix in found { let measured = await Task.detached { PrefixStore.usage(of: prefix) }.value guard run == loadGeneration else { return } usage[prefix.id] = measured } - - hasLoaded = true } func open(_ tool: WineTool, for prefix: WinePrefix) { @@ -205,7 +208,7 @@ final class PrefixesModel { func deleteBackups(_ targets: [PrefixBackup]) async { guard !targets.isEmpty else { return } forgetOutcome() - isLoading = true + working = true var cleared: [PrefixBackup] = [] var refused: [Error] = [] @@ -222,7 +225,7 @@ final class PrefixesModel { report = FailureReport(refused) let gone = Set(cleared.map(\.id)) backups.removeAll { gone.contains($0.id) } - isLoading = false + working = false await load() } @@ -248,7 +251,7 @@ final class PrefixesModel { ) async -> [Step] { guard !targets.isEmpty else { return [] } forgetOutcome() - isLoading = true + working = true busy = Set(targets.map(\.id)) var done: [Step] = [] @@ -267,7 +270,7 @@ final class PrefixesModel { report = FailureReport(refused) busy = [] - isLoading = false + working = false await load() return done } diff --git a/app/Sources/NotProtonApp/SystemStatus.swift b/app/Sources/NotProtonApp/SystemStatus.swift index 7451818c..15dc87a8 100644 --- a/app/Sources/NotProtonApp/SystemStatus.swift +++ b/app/Sources/NotProtonApp/SystemStatus.swift @@ -415,6 +415,8 @@ final class SystemStatus { } await perform(from: RunnerSetup.Phase.cloning.label) { progress in + // Cached sizes outlive a recopy of the same build + if case .supported(let build) = install.support { runnerSizes[build.id] = nil } let lock = try DeploymentContent.acquireInstallationLock() defer { lock.release() } try await requireInstallableContent() diff --git a/app/Sources/NotProtonApp/Views/PrefixesView.swift b/app/Sources/NotProtonApp/Views/PrefixesView.swift index 3746abb0..7cac5041 100644 --- a/app/Sources/NotProtonApp/Views/PrefixesView.swift +++ b/app/Sources/NotProtonApp/Views/PrefixesView.swift @@ -383,6 +383,7 @@ struct PrefixesView: View { if let prefix { model.chooseExecutable(for: prefix) } } Button("Winetricks…") { + winetricksVerbs = "" winetricksPrefix = prefix } Divider() diff --git a/app/Sources/NotProtonApp/Views/StatusView.swift b/app/Sources/NotProtonApp/Views/StatusView.swift index cfb3afaa..79819198 100644 --- a/app/Sources/NotProtonApp/Views/StatusView.swift +++ b/app/Sources/NotProtonApp/Views/StatusView.swift @@ -564,7 +564,7 @@ struct StatusView: View { label: String(localized: "Repair"), isProminent: true, help: String(localized: "Set up the compatibility tool again."), - isEnabled: status.isIdle && status.setupSource != nil + isEnabled: status.canInstall && status.setupSource != nil ) { Task { await status.requestCompatibilityTool() } } ) } diff --git a/app/Tests/NotProtonAppTests/PrefixStoreTests.swift b/app/Tests/NotProtonAppTests/PrefixStoreTests.swift index 93344ab3..d20c1941 100644 --- a/app/Tests/NotProtonAppTests/PrefixStoreTests.swift +++ b/app/Tests/NotProtonAppTests/PrefixStoreTests.swift @@ -50,6 +50,53 @@ struct PrefixStoreTests { #expect(libraries.last?.compatdata.lastPathComponent == "compatdata") } + @Test("A library path with an escaped quote or backslash is read whole") + func readsEscapedLibraryPaths() throws { + let dir = try scratchDirectory("pfx") + defer { try? FileManager.default.removeItem(at: dir) } + + let vdf = dir.appending(path: "libraryfolders.vdf") + try write( + #""" + "libraryfolders" + { + \#t"0" + \#t{ + \#t\#t"path"\#t\#t"/Volumes/Bob\"s Drive/Back\\Slash/SteamLibrary" + \#t} + } + """#, to: vdf) + + let libraries = PrefixStore.libraries(vdf: vdf) + #expect(libraries.map { $0.root.path(percentEncoded: false) } == [#"/Volumes/Bob"s Drive/Back\Slash/SteamLibrary"#]) + } + + // The old splitter dropped empty fields, so an empty path was never read as a library + @Test("An empty library path is skipped") + func skipsEmptyLibraryPath() throws { + let dir = try scratchDirectory("pfx") + defer { try? FileManager.default.removeItem(at: dir) } + + let vdf = dir.appending(path: "libraryfolders.vdf") + try write( + #""" + "libraryfolders" + { + \#t"0" + \#t{ + \#t\#t"path"\#t\#t"" + \#t} + \#t"1" + \#t{ + \#t\#t"path"\#t\#t"/Volumes/Games/SteamLibrary" + \#t} + } + """#, to: vdf) + + let libraries = PrefixStore.libraries(vdf: vdf) + #expect(libraries.map { $0.root.path(percentEncoded: false) } == ["/Volumes/Games/SteamLibrary"]) + } + @Test("A missing or empty file still yields the default library") func fallsBackToTheDefaultLibrary() throws { let dir = try scratchDirectory("pfx") diff --git a/app/Tests/NotProtonAppTests/PrefixToolsTests.swift b/app/Tests/NotProtonAppTests/PrefixToolsTests.swift index 5caf25c6..dfb6384d 100644 --- a/app/Tests/NotProtonAppTests/PrefixToolsTests.swift +++ b/app/Tests/NotProtonAppTests/PrefixToolsTests.swift @@ -205,6 +205,27 @@ struct PrefixToolsTests { } } + // A rebuild started from Steam's Compatibility page holds the claim in its own process + @Test("Wine tools and picked programs refuse while another process claims the prefix") + func toolsRefuseWhilePrefixIsClaimed() throws { + let (dir, runner, prefix) = try usedPrefixAndRunner() + defer { try? FileManager.default.removeItem(at: dir) } + let exe = dir.appending(path: "setup.exe") + try Data("MZ".utf8).write(to: exe) + + let claim = try #require(PrefixStore.claim(prefix)) + #expect(PrefixStore.isClaimed(prefix)) + let refused = "\(prefix.title) is running. Quit the game first." + #expect { try PrefixTools.launch(.winecfg, in: prefix, runner: runner) } throws: { error in + (error as? StepFailure)?.detail == refused + } + #expect { try PrefixTools.run(exe, in: prefix, runner: runner) } throws: { error in + (error as? StepFailure)?.detail == refused + } + claim.release() + #expect(!PrefixStore.isClaimed(prefix)) + } + @Test("Winetricks refuses before it runs when it cannot work or would leave the prefix") func winetricksRefusesEarly() throws { let dir = FileManager.default.temporaryDirectory.appending(path: "np-winetricks-\(UUID().uuidString)") diff --git a/app/Tests/NotProtonAppTests/PrefixesModelTests.swift b/app/Tests/NotProtonAppTests/PrefixesModelTests.swift index 1be90ffd..c5910f81 100644 --- a/app/Tests/NotProtonAppTests/PrefixesModelTests.swift +++ b/app/Tests/NotProtonAppTests/PrefixesModelTests.swift @@ -656,6 +656,30 @@ struct PrefixesModelTests { #expect(model.busy.isEmpty) } + // Opening the Prefixes tab loads again. That load used to end the loading state while a + // backup was still copying, which let Backups delete the half written copy. + @Test("A load that finishes during a backup leaves the model loading") + func loadDuringBackupStaysLoading() async throws { + let (dir, library) = try makeLibrary(appIDs: ["1574480"]) + defer { try? FileManager.default.removeItem(at: dir) } + + let log = Recorder(holdingEachTurn: true) + let model = PrefixesModel( + libraries: { [library] }, makeBackup: { prefix in try log.enter(prefix) ?? prefix.root }) + await model.load() + let targets = model.prefixes + + let running = Task { await model.backUp(targets) } + while log.visited.isEmpty { await Task.yield() } + + await model.load() + #expect(model.isLoading) + + log.releaseTurn() + await running.value + #expect(!model.isLoading) + } + // A rebuilt prefix has new sizes to measure and a deleted one must leave the list, so // both runs end by reading the libraries again rather than keeping what was read before. @Test("A finished rebuild reads the libraries again") diff --git a/app/Tests/NotProtonAppTests/RunnerInstallerTests.swift b/app/Tests/NotProtonAppTests/RunnerInstallerTests.swift index 1be27c0a..914f5b04 100644 --- a/app/Tests/NotProtonAppTests/RunnerInstallerTests.swift +++ b/app/Tests/NotProtonAppTests/RunnerInstallerTests.swift @@ -202,6 +202,40 @@ struct RunnerInstallerTests { ) } + // A folder inside the old clone that cannot be emptied makes deleting it fail part way. + // The old clone used to be deleted in place, so that failure left a half deleted runner. + @Test("A recopy over a clone that cannot be fully deleted still installs the new copy") + func recopyMovesOldCloneAside() throws { + let runners = try makeRunners() + let (install, build) = try makeSupportedInstall(in: runners) + _ = try RunnerInstaller.clone(from: install, runners: runners) + + let files = FileManager.default + let locked = SupportPaths.clonedRoot(forBuild: build.id, runners: runners).appending(path: "locked") + try files.createDirectory(at: locked, withIntermediateDirectories: true) + try Data("kept".utf8).write(to: locked.appending(path: "file")) + try files.setAttributes([.posixPermissions: 0o500], ofItemAtPath: locked.path(percentEncoded: false)) + defer { + let entries = (try? files.subpathsOfDirectory(atPath: runners.path(percentEncoded: false))) ?? [] + for entry in entries where entry.hasSuffix("locked") { + try? files.setAttributes( + [.posixPermissions: 0o700], ofItemAtPath: runners.appending(path: entry).path(percentEncoded: false)) + } + try? files.removeItem(at: runners) + } + + _ = try RunnerInstaller.clone(from: install, replacingExisting: true, runners: runners) + + let root = SupportPaths.clonedRoot(forBuild: build.id, runners: runners) + #expect(files.fileExists(atPath: root.appending(path: "lib/wine").path(percentEncoded: false))) + #expect(!files.fileExists(atPath: root.appending(path: "locked").path(percentEncoded: false))) + + // The old copy that would not delete is still parked. It must not block the next repair. + _ = try RunnerInstaller.clone(from: install, replacingExisting: true, runners: runners) + #expect(files.fileExists(atPath: root.appending(path: "lib/wine").path(percentEncoded: false))) + #expect(RunnerStore.clonedBuilds(in: runners) == [build.id]) + } + @Test("A rejected replacement keeps the installed runner", arguments: [false, true]) func rejectedReplacementKeepsInstalledRunner(missing: Bool) throws { let runners = try makeRunners() diff --git a/app/Tests/NotProtonAppTests/SteamBundleTests.swift b/app/Tests/NotProtonAppTests/SteamBundleTests.swift index 36273fc7..3584389c 100644 --- a/app/Tests/NotProtonAppTests/SteamBundleTests.swift +++ b/app/Tests/NotProtonAppTests/SteamBundleTests.swift @@ -136,6 +136,25 @@ struct SteamBundleTests { ) } + // The insert still names the bundle's old location, so the next install has to rewrite it + @Test("A patched bundle moved after install reads as not installed, not foreign") + func movedBundle() throws { + #expect( + try deployment(insert: .other("/Applications/Steam.app/Contents/MacOS/notproton.dylib"), dylib: true, version: "0.2.0") + == .notInstalled + ) + } + + // Install refuses any foreign entry, so Status must not offer it as a plain reinstall + @Test("A moved bundle that also carries somebody else's dylib still reads as foreign") + func movedBundleWithForeignInsert() throws { + let insert = "/opt/other/thing.dylib:/Applications/Steam.app/Contents/MacOS/notproton.dylib" + #expect( + try deployment(insert: .other(insert), dylib: true, version: "0.2.0") + == .foreign(insert: insert) + ) + } + @Test("An unpatched bundle reads as not installed") func unpatched() throws { #expect(try deployment(insert: .absent, dylib: false, version: nil) == .notInstalled) diff --git a/app/Tests/NotProtonAppTests/SteamInstallerTests.swift b/app/Tests/NotProtonAppTests/SteamInstallerTests.swift index 06740569..1ff8530c 100644 --- a/app/Tests/NotProtonAppTests/SteamInstallerTests.swift +++ b/app/Tests/NotProtonAppTests/SteamInstallerTests.swift @@ -239,6 +239,20 @@ struct SteamInstallerTests { #expect(left.contains("com.notproton.keep")) } + @Test("Our own dylib at a bundle's old location does not stop the install") + func acceptsOwnInsertFromMovedBundle() throws { + let work = try scratchDirectory("moved") + defer { try? FileManager.default.removeItem(at: work) } + let plist = work.appending(path: "Info.plist") + let old = "/Applications/Steam.app/Contents/MacOS/notproton.dylib" + let data = try PropertyListSerialization.data( + fromPropertyList: [SteamBundle.environmentKey: [SteamBundle.insertKey: old]], format: .xml, options: 0) + try data.write(to: plist) + + try SteamInstaller.assertInsertIsDeployedOrAbsent( + at: plist, dylib: URL(filePath: "/Volumes/Games/Steam.app/Contents/MacOS/notproton.dylib")) + } + @Test("An insert belonging to something else stops the install and is left alone") func refusesForeignInsert() async throws { let work = try scratchDirectory("install")