diff --git a/app/Sources/NotProtonApp/Model/PrefixTools.swift b/app/Sources/NotProtonApp/Model/PrefixTools.swift index 8f720c04..9e149c4a 100644 --- a/app/Sources/NotProtonApp/Model/PrefixTools.swift +++ b/app/Sources/NotProtonApp/Model/PrefixTools.swift @@ -273,21 +273,25 @@ enum PrefixTools { """# - static func winetricksVerbs(_ text: String) -> [String] { - text.split(whereSeparator: \.isWhitespace).map(String.init) - } - - static func winetricks(_ verbs: [String], in prefix: WinePrefix) async throws -> URL { - let step = String(localized: "Run Winetricks") - let chosen = try resolvedTool(step: step, for: prefix) - let script = try await PinnedDownload.obtain( + static func winetricksScript( + step: String = String(localized: "List Winetricks packages"), + report: @Sendable (PinnedDownload.Progress) -> Void = { _ in } + ) async throws -> URL { + try await PinnedDownload.obtain( file: "winetricks", sha256: winetricksSHA256, bases: [URL(string: "https://raw.githubusercontent.com/Winetricks/winetricks/\(winetricksRelease)/src")!], into: SupportPaths.winetricksDownloads, step: step, - from: "GitHub" + from: "GitHub", + report: report ) + } + + static func winetricks(_ verbs: [String], in prefix: WinePrefix) async throws -> URL { + let step = String(localized: "Run Winetricks") + let chosen = try resolvedTool(step: step, for: prefix) + let script = try await winetricksScript(step: step) return try winetricks( verbs, in: prefix, script: script, runner: SupportPaths.clonedRoot(forBuild: chosen.build), flavor: chosen.tool.flavor @@ -300,11 +304,12 @@ enum PrefixTools { ) throws -> URL { let step = String(localized: "Run Winetricks") guard !verbs.isEmpty else { - throw StepFailure(step: step, detail: String(localized: "Enter at least one verb, like vcrun2022.")) + throw StepFailure(step: step, detail: String(localized: "Choose at least one package.")) } // Under -q annihilate wipes the prefix and its saves without asking, prefix= switches to // another prefix, and options like --self-update change winetricks itself. - if let refused = verbs.first(where: { $0 == "annihilate" || $0.hasPrefix("prefix=") || $0.hasPrefix("-") }) { + let damaging: Set = ["annihilate", "isolate_home", "sandbox"] + if let refused = verbs.first(where: { damaging.contains($0) || $0.hasPrefix("prefix=") || $0.hasPrefix("-") }) { throw StepFailure(step: step, detail: String(localized: "NotProton does not run \(refused) with Winetricks.")) } guard let claim = PrefixStore.claim(prefix) else { @@ -371,6 +376,15 @@ enum PrefixTools { NSWorkspace.shared.open(log.url(for: prefix)) } + static func hasWinetricksLog(for prefix: WinePrefix) -> Bool { + FileManager.default.fileExists( + atPath: prefix.root.appending(path: winetricksLogName).path(percentEncoded: false)) + } + + static func openWinetricksLog(for prefix: WinePrefix) { + NSWorkspace.shared.open(prefix.root.appending(path: winetricksLogName)) + } + static func reveal(at url: URL) { NSWorkspace.shared.selectFile( url.path(percentEncoded: false), diff --git a/app/Sources/NotProtonApp/Model/WinetricksCatalog.swift b/app/Sources/NotProtonApp/Model/WinetricksCatalog.swift new file mode 100644 index 00000000..292ed59d --- /dev/null +++ b/app/Sources/NotProtonApp/Model/WinetricksCatalog.swift @@ -0,0 +1,223 @@ +import Foundation + +struct WinetricksVerb: Identifiable, Sendable, Equatable { + let name: String + let category: String + let title: String + let publisher: String + let year: String + let media: String + let conflicts: [String] + let installedFiles: [String] + var win32Only = false + var win64Only = false + // Trying to avoid confusing the user with regards to if these settings are applied to DXMT/DXVK/D3DMetal or not (they are not). + var wined3dOnly = false + + var id: String { name } + var isManualDownload: Bool { media == "manual_download" } +} + +enum WinetricksCatalog { + + static let categories = ["dlls", "fonts", "settings"] + + private static let hiddenFamilies: Set = [ + "graphics", + "windowmanagerdecorated", "windowmanagermanaged", "usetakefocus", "useegl", + "grabfullscreen", + "autostart_winedbg", "nocrashdialog", + "set_userpath", "set_mididevice", + "bad", "good", + "vd", + "isolate_home", "sandbox", + ] + private static let hiddenVerbs: Set = ["sound=alsa", "sound=oss", "sound=pulse"] + + static func isOffered(_ verb: WinetricksVerb, prefixIs32Bit: Bool = false) -> Bool { + guard categories.contains(verb.category) else { return false } + if verb.win32Only && !prefixIs32Bit { return false } + if verb.win64Only && prefixIs32Bit { return false } + let family = String(verb.name.prefix { $0 != "=" }) + return !hiddenFamilies.contains(family) && !hiddenVerbs.contains(verb.name) + } + + static func verbs(in script: String) -> [WinetricksVerb] { + var found: [WinetricksVerb] = [] + var open: (name: String, category: String, fields: [String: String])? + func close() { + if let block = open { + found.append(verb(name: block.name, category: block.category, fields: block.fields)) + } + open = nil + } + for line in script.split(separator: "\n", omittingEmptySubsequences: false) { + if let head = header(of: line) { + close() + open = (head.name, head.category, [:]) + } else if open != nil, let field = field(of: line) { + open?.fields[field.key] = field.value + } else { + close() + } + } + close() + let win32Only = loaders(in: script) { $0 == "w_package_unsupported_win64" } + let win64Only = loaders(in: script) { $0 == "w_package_unsupported_win32" } + let wined3dOnly = loaders(in: script) { + $0.hasPrefix("winetricks_set_wined3d_var ") || $0 == #"[HKEY_CURRENT_USER\\Software\\Wine\\Direct3D]"# + } + for index in found.indices { + let family = String(found[index].name.prefix { $0 != "=" }) + found[index].win32Only = win32Only.contains(family) + found[index].win64Only = win64Only.contains(family) + found[index].wined3dOnly = wined3dOnly.contains(family) + } + return found + } + + private static func loaders(in script: String, where matches: (String) -> Bool) -> Set { + var names: Set = [] + var current: String? + for line in script.split(separator: "\n", omittingEmptySubsequences: false) { + if line.hasPrefix("load_"), line.hasSuffix("()") { + current = String(line.dropFirst("load_".count).dropLast(2)) + } else if line == "}" { + current = nil + } else if let name = current, matches(line.trimmingCharacters(in: .whitespaces)) { + names.insert(name) + } + } + return names + } + + private static func header(of line: Substring) -> (name: String, category: String)? { + guard line.hasPrefix("w_metadata ") else { return nil } + let words = line.split(whereSeparator: \.isWhitespace) + guard words.count >= 3 else { return nil } + return (name: String(words[1]), category: String(words[2])) + } + + private static func field(of line: Substring) -> (key: String, value: String)? { + guard let equals = line.range(of: "=\"") else { return nil } + let key = line[.. WinetricksVerb { + // file1..file6 name cached downloads and not installed files. + let probed = [fields["installed_file1"], fields["installed_exe1"]] + .compactMap { $0 } + .flatMap { $0.split(separator: "|").map(String.init) } + return WinetricksVerb( + name: name, + category: category, + title: fields["title"] ?? "", + publisher: fields["publisher"] ?? "", + year: displayYear(fields["year"] ?? ""), + media: fields["media"] ?? "", + conflicts: (fields["conflicts"] ?? "") + .split(whereSeparator: \.isWhitespace).map(String.init), + installedFiles: probed + ) + } + + static func displayYear(_ raw: String) -> String { + guard raw.first?.isNumber == true else { return "" } + return raw.replacingOccurrences(of: "-", with: "\u{2013}") + } + + static func installedVerbs(among verbs: [WinetricksVerb], in prefix: WinePrefix) -> Set { + let ran = ranVerbs(in: prefix) + var installed: Set = [] + for verb in verbs where ran.contains(verb.name) && !verb.installedFiles.isEmpty { + if verb.installedFiles.contains(where: { isRealFile($0, in: prefix) }) { + installed.insert(verb.name) + } + } + return installed + } + + // w_conflicts checks this log even if the installed file is gone. + static func ranVerbs(in prefix: WinePrefix) -> Set { + let log = prefix.pfx.appending(path: "winetricks.log") + guard let text = try? String(contentsOf: log, encoding: .utf8) else { return [] } + return Set(text.split(whereSeparator: \.isWhitespace).map(String.init)) + } + + static func clash( + among verbs: [WinetricksVerb], checked: Set, ran: Set + ) -> (verb: String, with: String)? { + for verb in verbs where checked.contains(verb.name) { + if let other = verb.conflicts.first(where: { + $0 != verb.name && (ran.contains($0) || checked.contains($0)) + }) { + return (verb.name, other) + } + } + return nil + } + + private static func isRealFile(_ path: String, in prefix: WinePrefix) -> Bool { + guard let url = driveCFile(path, in: prefix) else { return true } + guard let handle = try? FileHandle(forReadingFrom: url), + let head = try? handle.read(upToCount: 128 * 1024) + else { return false } + try? handle.close() + return !String(decoding: head, as: UTF8.self).contains("Wine placeholder DLL") + } + + static func driveCFile(_ path: String, in prefix: WinePrefix) -> URL? { + let arch = PrefixStore.arch(of: prefix) + var windows = winetricksWindowsPath(path, arch: arch) + windows = windows.replacingOccurrences(of: "\\", with: "/") + while windows.contains("//") { + windows = windows.replacingOccurrences(of: "//", with: "/") + } + guard let colon = windows.firstIndex(of: ":"), windows.distance( + from: windows.startIndex, to: colon) == 1 + else { return nil } + guard windows[windows.startIndex].lowercased() == "c" else { return nil } + return prefix.pfx + .appending(path: "drive_c") + .appending(path: String(windows[windows.index(after: colon)...])) + } + + static func winetricksWindowsPath(_ path: String, arch: PrefixArch?) -> String { + let win64 = arch != .i386 + let variables: [String: String] = [ + "W_WINDIR_WIN": "C:/windows", + "W_FONTSDIR_WIN": "C:/windows/Fonts", + "W_PROGRAMS_WIN": "C:/Program Files", + "W_PROGRAMS_X86_WIN": win64 ? "C:/Program Files (x86)" : "C:/Program Files", + "W_COMMONFILES_WIN": "C:/Program Files/Common Files", + "W_COMMONFILES_X86_WIN": win64 + ? "C:/Program Files (x86)/Common Files" : "C:/Program Files/Common Files", + "W_SYSTEM32_DLLS_WIN": win64 ? "C:/windows/syswow64" : "C:/windows/system32", + "W_SYSTEM64_DLLS_WIN64": win64 ? "C:/windows/system32" : "C:/does-not-exist-on-win32", + "W_SYSTEM64_DLLS_WIN64:-does_not_exist": win64 + ? "C:/windows/system32" : "C:/does-not-exist", + ] + var expanded = path + for (name, value) in variables { + expanded = expanded.replacingOccurrences(of: "${\(name)}", with: value) + } + return expanded + } +} diff --git a/app/Sources/NotProtonApp/PrefixesModel.swift b/app/Sources/NotProtonApp/PrefixesModel.swift index 67112990..cc5708c0 100644 --- a/app/Sources/NotProtonApp/PrefixesModel.swift +++ b/app/Sources/NotProtonApp/PrefixesModel.swift @@ -298,8 +298,7 @@ final class PrefixesModel { } } - func winetricks(_ text: String, in prefix: WinePrefix) async { - let verbs = PrefixTools.winetricksVerbs(text) + func winetricks(_ verbs: [String], in prefix: WinePrefix) async { await eachInTurn([prefix], { try await PrefixTools.winetricks(verbs, in: $0) }) { done in guard !done.isEmpty else { return nil } return String(localized: "Winetricks ran \(verbs.joined(separator: " ")) in \(prefix.title).") diff --git a/app/Sources/NotProtonApp/Resources/Localization/Localizable.xcstrings b/app/Sources/NotProtonApp/Resources/Localization/Localizable.xcstrings index 90f0a137..36bfe3e9 100644 --- a/app/Sources/NotProtonApp/Resources/Localization/Localizable.xcstrings +++ b/app/Sources/NotProtonApp/Resources/Localization/Localizable.xcstrings @@ -81,6 +81,22 @@ } } }, + "%@ conflicts with %@." : { + "localizations" : { + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ è in conflitto con %2$@." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ 与 %2$@ 冲突。" + } + } + } + }, "%@ could not be copied out of the prefix. The prefix has been left as it was and nothing has been lost. Check free space and permissions, then try again." : { "localizations" : { "it" : { @@ -732,6 +748,22 @@ } } }, + "%lld selected: %@" : { + "localizations" : { + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld selezionati: %2$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已选择 %1$lld 个:%2$@" + } + } + } + }, ", copy damaged" : { "localizations" : { "it" : { @@ -1260,6 +1292,22 @@ } } }, + "Ascending" : { + "localizations" : { + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Crescente" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "升序" + } + } + } + }, "Back Up %lld Prefixes" : { "localizations" : { "it" : { @@ -1628,6 +1676,22 @@ } } }, + "Category" : { + "localizations" : { + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Categoria" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "类别" + } + } + } + }, "Check for Updates…" : { "localizations" : { "it" : { @@ -1740,6 +1804,22 @@ } } }, + "Choose at least one package." : { + "localizations" : { + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scegli almeno un pacchetto." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "请至少选择一个软件包。" + } + } + } + }, "Choose…" : { "localizations" : { "it" : { @@ -1804,6 +1884,22 @@ } } }, + "Close" : { + "localizations" : { + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chiudi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "关闭" + } + } + } + }, "Close Steam before continuing." : { "localizations" : { "it" : { @@ -2255,6 +2351,22 @@ } } }, + "DLLs" : { + "localizations" : { + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "DLL" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "DLL" + } + } + } + }, "Delete" : { "localizations" : { "it" : { @@ -2591,6 +2703,22 @@ } } }, + "Descending" : { + "localizations" : { + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Decrescente" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "降序" + } + } + } + }, "Detaching from Steam" : { "localizations" : { "it" : { @@ -2687,38 +2815,6 @@ } } }, - "Enter at least one verb, like vcrun2022." : { - "localizations" : { - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Inserisci almeno un verbo, ad esempio vcrun2022." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "请至少输入一个组件,例如 vcrun2022。" - } - } - } - }, - "Enter the Winetricks verbs to install in %@, separated by spaces." : { - "localizations" : { - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Inserisci i verbi di Winetricks da installare in %@, separati da spazi." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "输入要安装到“%@”中的 Winetricks 组件,用空格分隔。" - } - } - } - }, "Extracting" : { "localizations" : { "it" : { @@ -2831,6 +2927,22 @@ } } }, + "Fonts" : { + "localizations" : { + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Caratteri" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "字体" + } + } + } + }, "Game" : { "localizations" : { "it" : { @@ -2847,6 +2959,22 @@ } } }, + "General" : { + "localizations" : { + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Generali" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "常规" + } + } + } + }, "If NotProton is installed there, it will stay installed. Continue anyway?" : { "localizations" : { "it" : { @@ -3039,6 +3167,22 @@ } } }, + "Installing…" : { + "localizations" : { + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installazione…" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在安装…" + } + } + } + }, "It will replace the DLLs used by CrossOver with ones that match your compatibility tool." : { "localizations" : { "it" : { @@ -3199,6 +3343,22 @@ } } }, + "List Winetricks packages" : { + "localizations" : { + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Elenca i pacchetti di Winetricks" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "列出 Winetricks 软件包" + } + } + } + }, "List the backups again and update their sizes." : { "localizations" : { "it" : { @@ -3279,6 +3439,22 @@ } } }, + "Manual download" : { + "localizations" : { + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Download manuale" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "手动下载" + } + } + } + }, "Missing components: %@. Run make app-payload and build the app again." : { "localizations" : { "it" : { @@ -3375,6 +3551,22 @@ } } }, + "Name" : { + "localizations" : { + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nome" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "名称" + } + } + } + }, "Needs rebuilding" : { "localizations" : { "it" : { @@ -3906,6 +4098,22 @@ } } }, + "Order" : { + "localizations" : { + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ordine" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "顺序" + } + } + } + }, "Overlay shim" : { "localizations" : { "it" : { @@ -4082,6 +4290,22 @@ } } }, + "Publisher" : { + "localizations" : { + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Autore" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "发行商" + } + } + } + }, "RVA %@ falls in no mapped section of the %@ ntdll, so the %@ cannot be located." : { "localizations" : { "it" : { @@ -4850,6 +5074,22 @@ } } }, + "Retry" : { + "localizations" : { + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Riprova" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "重试" + } + } + } + }, "Reveal Log in Finder" : { "localizations" : { "it" : { @@ -4978,6 +5218,22 @@ } } }, + "Search" : { + "localizations" : { + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cerca" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "搜索" + } + } + } + }, "Select a copy of CrossOver." : { "localizations" : { "it" : { @@ -5090,6 +5346,22 @@ } } }, + "Settings" : { + "localizations" : { + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impostazioni" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "设置" + } + } + } + }, "Setup" : { "localizations" : { "it" : { @@ -5106,6 +5378,22 @@ } } }, + "Show Log" : { + "localizations" : { + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostra log" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示日志" + } + } + } + }, "Show in Finder" : { "localizations" : { "it" : { @@ -5202,6 +5490,38 @@ } } }, + "Sort By" : { + "localizations" : { + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ordina per" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "排序方式" + } + } + } + }, + "Sort by %@" : { + "localizations" : { + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ordina per %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "按%@排序" + } + } + } + }, "Staging components" : { "localizations" : { "it" : { @@ -6229,6 +6549,22 @@ } } }, + "This package needs its files downloaded by hand, which NotProton cannot do." : { + "localizations" : { + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Questo pacchetto richiede file da scaricare a mano, cosa che NotProton non può fare." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此软件包的文件需要手动下载,NotProton 无法代劳。" + } + } + } + }, "This tool is intended to repair a prefix after switching between Rosetta/FEX CrossOver." : { "localizations" : { "it" : { @@ -6549,9 +6885,28 @@ } } }, + "WineD3D" : { + "shouldTranslate" : false + }, "Winetricks" : { "shouldTranslate" : false }, + "Winetricks keeps running. The Prefixes window shows when it is done." : { + "localizations" : { + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Winetricks continua a funzionare. La finestra Prefissi mostra quando ha finito." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Winetricks 会继续运行。完成后会在“游戏容器”窗口中显示。" + } + } + } + }, "Winetricks ran %@ in %@." : { "localizations" : { "it" : { @@ -6587,6 +6942,22 @@ "Winetricks…" : { "shouldTranslate" : false }, + "Year" : { + "localizations" : { + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anno" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "年份" + } + } + } + }, "You will not lose saves by using this tool." : { "localizations" : { "it" : { @@ -6890,9 +7261,6 @@ } } } - }, - "vcrun2022 corefonts" : { - "shouldTranslate" : false } }, "version" : "1.0" diff --git a/app/Sources/NotProtonApp/Views/PrefixesView.swift b/app/Sources/NotProtonApp/Views/PrefixesView.swift index 7cac5041..03939643 100644 --- a/app/Sources/NotProtonApp/Views/PrefixesView.swift +++ b/app/Sources/NotProtonApp/Views/PrefixesView.swift @@ -8,7 +8,6 @@ struct PrefixesView: View { @Environment(\.colorSchemeContrast) private var contrast @State private var sortOrder = [KeyPathComparator(\PrefixRow.lastUsed, order: .reverse)] @State private var winetricksPrefix: WinePrefix? - @State private var winetricksVerbs = "" var body: some View { Group { @@ -106,24 +105,8 @@ struct PrefixesView: View { } message: { Text(PrefixPrompt.backUpMessage(backingUp)) } - .alert( - "Winetricks", - isPresented: Binding( - get: { winetricksPrefix != nil }, - set: { if !$0 { winetricksPrefix = nil } } - ), - presenting: winetricksPrefix - ) { prefix in - TextField("vcrun2022 corefonts", text: $winetricksVerbs) - Button("Install") { - let verbs = winetricksVerbs - Task { await model.winetricks(verbs, in: prefix) } - } - .keyboardShortcut(.defaultAction) - .disabled(PrefixTools.winetricksVerbs(winetricksVerbs).isEmpty) - Button("Cancel", role: .cancel) {} - } message: { prefix in - Text("Enter the Winetricks verbs to install in \(prefix.title), separated by spaces.") + .sheet(item: $winetricksPrefix) { prefix in + WinetricksView(prefix: prefix) } } @@ -383,7 +366,6 @@ struct PrefixesView: View { if let prefix { model.chooseExecutable(for: prefix) } } Button("Winetricks…") { - winetricksVerbs = "" winetricksPrefix = prefix } Divider() diff --git a/app/Sources/NotProtonApp/Views/WinetricksView.swift b/app/Sources/NotProtonApp/Views/WinetricksView.swift new file mode 100644 index 00000000..3254a96d --- /dev/null +++ b/app/Sources/NotProtonApp/Views/WinetricksView.swift @@ -0,0 +1,449 @@ +import AppKit +import SwiftUI + +struct WinetricksView: View { + + let prefix: WinePrefix + + @Environment(PrefixesModel.self) private var model + @Environment(\.dismiss) private var dismiss + + @State private var verbs: [WinetricksVerb] = [] + @State private var loadFailed: String? + @State private var downloadLabel: String? + @State private var installing = false + @State private var ranOnce = false + @State private var category = "dlls" + @State private var search = "" + @State private var checked = Set() + @State private var selection = Set() + @State private var installed = Set() + @State private var ran = Set() + @State private var tail = "" + @State private var watcher: Task? + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + if let failure = loadFailed { + failureView(failure) + } else if verbs.isEmpty { + ProgressView { + Text(downloadLabel ?? String(localized: "Preparing")) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(20) + } else { + Text(verbatim: "Winetricks") + .font(.headline) + .padding([.top, .horizontal], 20) + controls + .padding(.horizontal, 20) + .padding(.top, 12) + table + .padding(.horizontal, 20) + .padding(.top, 10) + if installing { + logView + .padding(.horizontal, 20) + .padding(.top, 10) + } + footer + .padding(20) + } + } + .frame(minWidth: 760, idealWidth: 820, minHeight: 520, idealHeight: 600) + .task { + model.forgetOutcome() + await load() + } + .onDisappear { watcher?.cancel() } + } + + private func load() async { + loadFailed = nil + downloadLabel = String(localized: "Preparing") + do { + let script = try await PrefixTools.winetricksScript { progress in + Task { @MainActor in downloadLabel = progress.label } + } + let text = try await Task.detached { + try String(contentsOf: script, encoding: .utf8) + }.value + let target = prefix + verbs = await Task.detached { + let is32Bit = PrefixStore.arch(of: target) == .i386 + return WinetricksCatalog.verbs(in: text) + .filter { WinetricksCatalog.isOffered($0, prefixIs32Bit: is32Bit) } + }.value + await refreshInstalled() + } catch { + loadFailed = (error as? StepFailure)?.detail ?? error.localizedDescription + } + } + + private func refreshInstalled() async { + let found = verbs + let target = prefix + (installed, ran) = await Task.detached { + (WinetricksCatalog.installedVerbs(among: found, in: target), + WinetricksCatalog.ranVerbs(in: target)) + }.value + } + + private func install() { + let chosen = selectedVerbs + guard !chosen.isEmpty, !installing else { return } + installing = true + ranOnce = true + tail = "" + watcher = Task { await watchLog() } + Task { + await model.winetricks(chosen, in: prefix) + watcher?.cancel() + installing = false + if model.failure == nil { checked.removeAll() } + await refreshInstalled() + } + } + + private func watchLog() async { + let log = prefix.root.appending(path: PrefixTools.winetricksLogName) + while !Task.isCancelled { + tail = Self.tail(of: log) + try? await Task.sleep(for: .milliseconds(500)) + } + } + + private static func tail(of log: URL) -> String { + guard let handle = try? FileHandle(forReadingFrom: log) else { return "" } + defer { try? handle.close() } + let window: UInt64 = 4096 + let size = (try? handle.seekToEnd()) ?? 0 + if size > window { handle.seek(toFileOffset: size - window) } + let data = (try? handle.read(upToCount: Int(window))) ?? Data() + return String(decoding: data, as: UTF8.self) + } + + private func failureView(_ failure: String) -> some View { + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle") + .font(.largeTitle) + .foregroundStyle(.secondary) + Text(failure) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + HStack { + Button("Cancel") { dismiss() } + .keyboardShortcut(.cancelAction) + Button("Retry") { Task { await load() } } + .keyboardShortcut(.defaultAction) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(20) + } + + private var controls: some View { + HStack(spacing: 12) { + Picker("Category", selection: $category) { + ForEach(WinetricksCatalog.categories, id: \.self) { name in + Text(categoryLabel(name)).tag(name) + } + } + .pickerStyle(.segmented) + .labelsHidden() + .fixedSize() + Spacer(minLength: 12) + Menu { + Picker("Sort By", selection: $sort) { + ForEach(SortKey.allCases, id: \.self) { key in + Text(key.label).tag(key) + } + } + .pickerStyle(.inline) + Picker("Order", selection: $ascending) { + Text("Ascending").tag(true) + Text("Descending").tag(false) + } + .pickerStyle(.inline) + } label: { + Label( + String(localized: "Sort by \(sort.label)"), + systemImage: ascending ? "arrow.up" : "arrow.down") + .labelStyle(.titleAndIcon) + } + .fixedSize() + SearchField(text: $search, prompt: String(localized: "Search")) + .frame(width: 200) + } + .disabled(installing) + } + + private enum SortKey: CaseIterable { + case name, publisher, year + + var label: String { + switch self { + case .name: String(localized: "Name") + case .publisher: String(localized: "Publisher") + case .year: String(localized: "Year") + } + } + } + + @State private var sort = SortKey.name + @State private var ascending = true + + private var rows: [WinetricksVerb] { + let query = search.trimmingCharacters(in: .whitespaces).lowercased() + let shown = verbs + .filter { $0.category == category } + .filter { verb in + query.isEmpty + || verb.name.lowercased().contains(query) + || verb.title.lowercased().contains(query) + || verb.publisher.lowercased().contains(query) + } + func key(_ verb: WinetricksVerb) -> String { + switch sort { + case .name: verb.name + case .publisher: verb.publisher + case .year: verb.year + } + } + return shown.sorted { + let (a, b) = (key($0), key($1)) + if a.isEmpty != b.isEmpty { return b.isEmpty } + let order = a.localizedStandardCompare(b) + if order == .orderedSame { + return $0.name.localizedStandardCompare($1.name) == .orderedAscending + } + return (order == .orderedAscending) == ascending + } + } + + private var table: some View { + let all = rows + let wined3d = all.filter(\.wined3dOnly) + return List(selection: $selection) { + if wined3d.isEmpty { + rowList(all) + } else { + let general = all.filter { !$0.wined3dOnly } + if !general.isEmpty { + Section("General") { rowList(general) } + } + Section("WineD3D") { rowList(wined3d) } + } + } + .listStyle(.bordered(alternatesRowBackgrounds: true)) + .onKeyPress(.space) { + toggleSelected() + return .handled + } + } + + private func rowList(_ verbs: [WinetricksVerb]) -> some View { + ForEach(verbs) { verb in + row(verb).padding(.vertical, 4) + } + } + + private func row(_ verb: WinetricksVerb) -> some View { + HStack(alignment: .top, spacing: 12) { + Toggle(isOn: check(verb)) { + VStack(alignment: .leading, spacing: 2) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(verbatim: verb.name).fontWeight(.semibold) + status(verb).font(.callout) + } + if !verb.title.isEmpty { + Text(verbatim: readable(verb.title)) + .fixedSize(horizontal: false, vertical: true) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .toggleStyle(.checkbox) + .disabled(verb.isManualDownload || installing) + + VStack(alignment: .trailing, spacing: 2) { + if !verb.publisher.isEmpty { + Text(verbatim: publisher(verb)) + } + if !verb.year.isEmpty { + Text(verbatim: verb.year).monospacedDigit() + } + } + .font(.callout) + .foregroundStyle(.secondary) + .lineLimit(1) + .fixedSize() + } + } + + private func publisher(_ verb: WinetricksVerb) -> String { + verb.publisher.prefix(1).uppercased() + verb.publisher.dropFirst() + } + + private func readable(_ title: String) -> String { + title.replacingOccurrences(of: #",(?=\S)"#, with: ", ", options: .regularExpression) + } + + @ViewBuilder + private func status(_ verb: WinetricksVerb) -> some View { + if installed.contains(verb.name) { + Label { + Text("Installed").foregroundStyle(.secondary) + } icon: { + Image(systemName: "checkmark.circle.fill").foregroundStyle(.green) + } + } else if verb.isManualDownload { + Text("Manual download") + .foregroundStyle(.tertiary) + .help("This package needs its files downloaded by hand, which NotProton cannot do.") + } + } + + private func toggleSelected() { + guard !installing else { return } + let targets = rows.filter { selection.contains($0.name) && !$0.isManualDownload } + guard !targets.isEmpty else { return } + let turnOn = targets.contains { !checked.contains($0.name) } + for verb in targets { + if turnOn { checked.insert(verb.name) } else { checked.remove(verb.name) } + } + } + + private var logView: some View { + ScrollView { + Text(verbatim: String(tail.split(separator: "\n").suffix(10).joined(separator: "\n"))) + .font(Font.system(size: 11).monospaced()) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + .padding(8) + } + .frame(height: 110) + .background(Color(nsColor: .textBackgroundColor)) + .overlay(RoundedRectangle(cornerRadius: 1).stroke(Color(nsColor: .separatorColor))) + } + + private var dismissLabel: String { + if installing { return String(localized: "Close") } + return ranOnce ? String(localized: "Done") : String(localized: "Cancel") + } + + private var footer: some View { + HStack(alignment: .center, spacing: 8) { + status + + Spacer(minLength: 12) + + Button(dismissLabel) { dismiss() } + .keyboardShortcut(.cancelAction) + .help(installing ? String(localized: "Winetricks keeps running. The Prefixes window shows when it is done.") : "") + Button("Install") { install() } + .keyboardShortcut(.defaultAction) + .disabled(selectedVerbs.isEmpty || installing || clash != nil) + } + } + + @ViewBuilder + private var status: some View { + if installing { + HStack(spacing: 6) { + ProgressView().controlSize(.small) + Text("Installing…").foregroundStyle(.secondary) + } + } else if let clash { + statusLine( + String(localized: "\(clash.verb) conflicts with \(clash.with)."), + symbol: "exclamationmark.triangle.fill", color: .orange, showLog: false) + } else if let failed = model.failure { + statusLine(failed, symbol: "xmark.circle.fill", color: .red, showLog: true) + } else if checked.isEmpty, let outcome = model.outcome { + statusLine(outcome, symbol: "checkmark.circle.fill", color: .green, showLog: true) + } else if let summary { + Text(summary) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + .help(summary) + } + } + + private func statusLine(_ text: String, symbol: String, color: Color, showLog: Bool) -> some View { + HStack(spacing: 6) { + Image(systemName: symbol).foregroundStyle(color) + Text(text) + .lineLimit(2) + .truncationMode(.tail) + .help(text) + if showLog, PrefixTools.hasWinetricksLog(for: prefix) { + Button("Show Log") { PrefixTools.openWinetricksLog(for: prefix) } + .buttonStyle(.link) + } + } + } + + private func check(_ verb: WinetricksVerb) -> Binding { + Binding( + get: { checked.contains(verb.name) }, + set: { on in + if on { checked.insert(verb.name) } else { checked.remove(verb.name) } + } + ) + } + + private var selectedVerbs: [String] { + verbs.filter { checked.contains($0.name) }.map(\.name) + } + + private var summary: String? { + let names = selectedVerbs + guard !names.isEmpty else { return nil } + return String(localized: "\(names.count) selected: \(names.joined(separator: " "))") + } + + private var clash: (verb: String, with: String)? { + WinetricksCatalog.clash(among: verbs, checked: checked, ran: ran) + } + + private func categoryLabel(_ category: String) -> String { + switch category { + case "dlls": String(localized: "DLLs") + case "fonts": String(localized: "Fonts") + default: String(localized: "Settings") + } + } +} + +private struct SearchField: NSViewRepresentable { + @Binding var text: String + let prompt: String + + func makeNSView(context: Context) -> NSSearchField { + let field = NSSearchField() + field.placeholderString = prompt + field.delegate = context.coordinator + field.sendsSearchStringImmediately = true + return field + } + + func updateNSView(_ field: NSSearchField, context: Context) { + if field.stringValue != text { field.stringValue = text } + } + + func makeCoordinator() -> Coordinator { Coordinator(text: $text) } + + final class Coordinator: NSObject, NSSearchFieldDelegate { + let text: Binding + init(text: Binding) { self.text = text } + + func controlTextDidChange(_ note: Notification) { + guard let field = note.object as? NSSearchField else { return } + text.wrappedValue = field.stringValue + } + } +} diff --git a/app/Tests/NotProtonAppTests/PrefixToolsTests.swift b/app/Tests/NotProtonAppTests/PrefixToolsTests.swift index dfb6384d..abb99d14 100644 --- a/app/Tests/NotProtonAppTests/PrefixToolsTests.swift +++ b/app/Tests/NotProtonAppTests/PrefixToolsTests.swift @@ -233,19 +233,19 @@ struct PrefixToolsTests { try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) let script = dir.appending(path: "winetricks") - #expect(PrefixTools.winetricksVerbs(" vcrun2022\tcorefonts \n") == ["vcrun2022", "corefonts"]) - func refusal(_ verbs: String) -> String? { do { _ = try PrefixTools.winetricks( - PrefixTools.winetricksVerbs(verbs), in: samplePrefix(), script: script, runner: dir) + verbs.split(separator: " ").map(String.init), in: samplePrefix(), script: script, runner: dir) return nil } catch { return (error as? StepFailure)?.detail } } - #expect(refusal(" ")?.contains("at least one verb") == true) + #expect(refusal(" ")?.contains("at least one package") == true) #expect(refusal("vcrun2022 annihilate")?.contains("annihilate") == true) + #expect(refusal("sandbox")?.contains("sandbox") == true) + #expect(refusal("corefonts isolate_home")?.contains("isolate_home") == true) #expect(refusal("prefix=other corefonts")?.contains("prefix=other") == true) #expect(refusal("--self-update")?.contains("--self-update") == true) #expect(refusal("corefonts")?.contains("No compatibility tool") == true) diff --git a/app/Tests/NotProtonAppTests/WinetricksCatalogTests.swift b/app/Tests/NotProtonAppTests/WinetricksCatalogTests.swift new file mode 100644 index 00000000..7afd393b --- /dev/null +++ b/app/Tests/NotProtonAppTests/WinetricksCatalogTests.swift @@ -0,0 +1,308 @@ +import Foundation +import Testing + +@testable import NotProtonApp + +@Suite("WinetricksCatalog") +struct WinetricksCatalogTests { + + static let script = #""" + w_metadata vcrun2022 dlls \ + title="Visual C++ 2015-2022 libraries (msvcp140.dll)" \ + publisher="Microsoft" \ + year="2022" \ + media="download" \ + conflicts="vcrun2015 vcrun2019" \ + installed_file1="c:/windows/system32/msvcp140.dll" + + w_metadata d3dx9 dlls \ + title="d3dx9_43.dll and friends" \ + publisher="Microsoft" \ + year="2011" \ + media="download" \ + installed_file1="${W_SYSTEM32_DLLS_WIN}/d3dx9_43.dll|${W_SYSTEM32_DLLS_WIN}/d3dx10_43.dll" + + w_metadata atmlib dlls \ + title="Adobe Type Manager" \ + publisher="Adobe" \ + year="2012" \ + media="manual_download" \ + installed_file1="${W_PROGRAMS_X86_WIN}/atmlib.dll" + + w_metadata 7zip apps \ + title="7-Zip" \ + publisher="Igor Pavlov" \ + year="2024" \ + media="download" \ + installed_exe1="C:\\Program Files\\7-Zip\\7zFM.exe" + + w_metadata corefonts fonts \ + title="MS Arial, Courier, Times fonts" \ + publisher="Microsoft" \ + year="2008" \ + media="download" \ + installed_file1="${W_FONTSDIR_WIN}/Arial.ttf" + + w_metadata csmt=on settings \ + title="Enable Command Stream Multithreading (default)" + + # a comment is not metadata + load_vcrun2022() + { + w_try "$WINE" VC_redist.x64.exe /q + } + """# + + private func verbs() -> [WinetricksVerb] { + WinetricksCatalog.verbs(in: Self.script) + } + + @Test("The w_metadata blocks are read, junk between them is not") + func parsesBlocks() { + let found = verbs() + #expect(found.map(\.name) == [ + "vcrun2022", "d3dx9", "atmlib", "7zip", "corefonts", "csmt=on" + ]) + #expect(found.map(\.category) == ["dlls", "dlls", "dlls", "apps", "fonts", "settings"]) + } + + @Test("Fields come out with quotes stripped and lists split") + func parsesFields() { + let vcrun = verbs()[0] + #expect(vcrun.title == "Visual C++ 2015-2022 libraries (msvcp140.dll)") + #expect(vcrun.publisher == "Microsoft") + #expect(vcrun.year == "2022") + #expect(vcrun.media == "download") + #expect(vcrun.conflicts == ["vcrun2015", "vcrun2019"]) + #expect(vcrun.installedFiles == ["c:/windows/system32/msvcp140.dll"]) + #expect(!vcrun.isManualDownload) + + let atmlib = verbs()[2] + #expect(atmlib.isManualDownload) + + let d3dx9 = verbs()[1] + #expect(d3dx9.installedFiles.count == 2) + + let csmt = verbs()[5] + #expect(csmt.installedFiles.isEmpty) + #expect(csmt.title == "Enable Command Stream Multithreading (default)") + } + + @Test("Pinned path suffixes and shell defaults resolve to installed files") + func resolvesPinnedInstalledFiles() throws { + let script = #""" + w_metadata wmp9 dlls \ + title="Windows Media Player 9" \ + installed_file1="${W_SYSTEM32_DLLS_WIN}"/l3codeca.acm + + w_metadata xact_x64 dlls \ + title="MS XACT Engine (64-bit only)" \ + installed_file1="${W_SYSTEM64_DLLS_WIN64:-does_not_exist}/xactengine2_0.dll" + """# + let found = WinetricksCatalog.verbs(in: script) + #expect(found[0].installedFiles == ["${W_SYSTEM32_DLLS_WIN}/l3codeca.acm"]) + + let prefix = try prefixWithLog(ran: ["wmp9", "xact_x64"]) + #expect(WinetricksCatalog.installedVerbs(among: found, in: prefix).isEmpty) + + for path in ["drive_c/windows/syswow64/l3codeca.acm", + "drive_c/windows/system32/xactengine2_0.dll"] { + let url = prefix.pfx.appending(path: path) + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data("MZ real dll".utf8).write(to: url) + } + #expect(WinetricksCatalog.installedVerbs(among: found, in: prefix) == ["wmp9", "xact_x64"]) + } + + @Test("Winetricks path variables and doubled backslashes resolve under drive_c") + func resolvesPaths() throws { + let prefix = try prefixWithLog(ran: []) + #expect(WinetricksCatalog.driveCFile( + "c:/windows/system32/msvcp140.dll", in: prefix)? + .standardizedFileURL.path(percentEncoded: false) + .hasSuffix("drive_c/windows/system32/msvcp140.dll") == true) + #expect(WinetricksCatalog.driveCFile( + "C:\\Program Files\\7-Zip\\7zFM.exe", in: prefix)? + .standardizedFileURL.path(percentEncoded: false) + .hasSuffix("drive_c/Program Files/7-Zip/7zFM.exe") == true) + #expect(WinetricksCatalog.driveCFile( + "${W_SYSTEM32_DLLS_WIN}/d3dx9_43.dll", in: prefix)? + .standardizedFileURL.path(percentEncoded: false) + .hasSuffix("drive_c/windows/syswow64/d3dx9_43.dll") == true) + #expect(WinetricksCatalog.driveCFile( + "${W_PROGRAMS_X86_WIN}/atmlib.dll", in: prefix)? + .standardizedFileURL.path(percentEncoded: false) + .hasSuffix("drive_c/Program Files (x86)/atmlib.dll") == true) + + #expect(WinetricksCatalog.driveCFile("d:/data/setup.exe", in: prefix) == nil) + #expect(WinetricksCatalog.driveCFile("/tmp/thing.exe", in: prefix) == nil) + } + + @Test("Installed means the verb ran and its file is present and real") + func installedNeedsLogAndFile() throws { + let prefix = try prefixWithLog(ran: ["vcrun2022", "d3dx9", "atmlib", "csmt=on"]) + let found = verbs() + + func make(_ path: String, _ bytes: String) throws { + let url = prefix.pfx.appending(path: path) + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data(bytes.utf8).write(to: url) + } + try make("drive_c/windows/system32/msvcp140.dll", "MZ real dll") + try make("drive_c/windows/syswow64/d3dx9_43.dll", "MZ real dll") + try make("drive_c/Program Files (x86)/atmlib.dll", "Wine placeholder DLL") + + let installed = WinetricksCatalog.installedVerbs(among: found, in: prefix) + #expect(installed.contains("vcrun2022")) + #expect(installed.contains("d3dx9")) + #expect(!installed.contains("atmlib")) + #expect(!installed.contains("csmt=on")) + #expect(!installed.contains("corefonts")) + } + + @Test("Linux-only, overridden and prefix-breaking settings are left out of the list") + func hidesUselessSettings() { + func offered(_ name: String, _ category: String = "settings") -> Bool { + WinetricksCatalog.isOffered(WinetricksVerb( + name: name, category: category, title: "", publisher: "", year: "", + media: "", conflicts: [], installedFiles: [])) + } + for name in ["graphics=x11", "graphics=mac", "sound=alsa", "sound=pulse", "sound=oss", + "usetakefocus=y", "grabfullscreen=y", "nocrashdialog", + "autostart_winedbg=enabled", "isolate_home", "sandbox", "bad", "good", + "set_userpath", "set_mididevice", "vd=1024x768", "vd=off"] { + #expect(!offered(name), "\(name) should be hidden") + } + for name in ["win10", "fontsmooth=rgb", "mackeyremap=both", "sound=coreaudio", + "sound=disabled", "renderer=gl", "csmt=off"] { + #expect(offered(name), "\(name) should be listed") + } + #expect(offered("vcrun2022", "dlls")) + #expect(offered("corefonts", "fonts")) + #expect(!offered("7zip", "apps")) + #expect(!offered("3dmark2000", "benchmarks")) + } + + @Test("Verbs that refuse a 64-bit prefix are hidden unless the prefix is 32-bit") + func hidesWin32Only() { + let script = """ + w_metadata win98 settings \\ + title="Set Windows version to Windows 98" + load_win98() + { + w_package_unsupported_win64 + w_set_winver win98 + } + + w_metadata xact_x64 dlls \\ + title="MS XACT Engine (64-bit only)" + load_xact_x64() + { + w_package_unsupported_win32 + } + + w_metadata fontsmooth=rgb settings \\ + title="Enable subpixel font smoothing for LCD (RGB)" + load_fontsmooth() + { + arg="$1" + } + + w_metadata dotnet11 dlls \\ + title="MS .NET 1.1" \\ + media="download" + load_dotnet11() + { + w_package_unsupported_win64 + w_call remove_mono internal + } + """ + let found = WinetricksCatalog.verbs(in: script) + #expect(found.filter(\.win32Only).map(\.name) == ["win98", "dotnet11"]) + #expect(found.filter(\.win64Only).map(\.name) == ["xact_x64"]) + #expect(found.filter { WinetricksCatalog.isOffered($0) }.map(\.name) == ["xact_x64", "fontsmooth=rgb"]) + #expect(found.filter { WinetricksCatalog.isOffered($0, prefixIs32Bit: true) }.map(\.name) == [ + "win98", "fontsmooth=rgb", "dotnet11" + ]) + } + + @Test("Settings that only set wined3d's Direct3D keys are marked") + func marksWined3dOnly() { + let script = #""" + w_metadata renderer=vulkan settings \ + title="Set renderer to Vulkan" + load_renderer() + { + winetricks_set_wined3d_var renderer "$1" + } + + w_metadata csmt=off settings \ + title="Disable Command Stream Multithreading" + load_csmt() + { + cat > "${W_TMP}"/set-csmt.reg <<_EOF_ + REGEDIT4 + + [HKEY_CURRENT_USER\\Software\\Wine\\Direct3D] + "csmt"=dword:0 + _EOF_ + } + + w_metadata fontsmooth=rgb settings \ + title="Enable subpixel font smoothing for LCD (RGB)" + load_fontsmooth() + { + cat > "${W_TMP}"/fontsmooth.reg <<_EOF_ + [HKEY_CURRENT_USER\\Control Panel\\Desktop] + _EOF_ + } + """# + let found = WinetricksCatalog.verbs(in: script) + #expect(found.filter(\.wined3dOnly).map(\.name) == ["renderer=vulkan", "csmt=off"]) + } + + @Test("Year ranges get an en dash and placeholders are dropped") + func cleansYears() { + #expect(WinetricksCatalog.displayYear("2022") == "2022") + #expect(WinetricksCatalog.displayYear("1995-2009") == "1995\u{2013}2009") + #expect(WinetricksCatalog.displayYear("FIXME") == "") + #expect(WinetricksCatalog.displayYear("") == "") + } + + @Test("A pick winetricks would refuse for a conflict is caught") + func catchesConflicts() throws { + let found = verbs() + #expect(WinetricksCatalog.clash(among: found, checked: ["vcrun2022"], ran: []) == nil) + let fromLog = WinetricksCatalog.clash(among: found, checked: ["vcrun2022"], ran: ["vcrun2019"]) + #expect(fromLog?.verb == "vcrun2022") + #expect(fromLog?.with == "vcrun2019") + #expect(WinetricksCatalog.clash( + among: found, checked: ["vcrun2022", "vcrun2015"], ran: [])?.with == "vcrun2015") + #expect(WinetricksCatalog.clash(among: found, checked: ["d3dx9", "corefonts"], ran: ["vcrun2019"]) == nil) + + let prefix = try prefixWithLog(ran: ["vcrun2019", "corefonts"]) + #expect(WinetricksCatalog.ranVerbs(in: prefix) == ["vcrun2019", "corefonts"]) + } + + @Test("A missing log means nothing is installed") + func noLogInstallsNothing() throws { + let dir = try scratchDirectory("winetricks-nolog") + let prefix = WinePrefix( + appID: "2075800", name: "Game", library: SteamLibrary(root: dir), lastUsed: nil) + try FileManager.default.createDirectory(at: prefix.pfx, withIntermediateDirectories: true) + #expect(WinetricksCatalog.installedVerbs(among: verbs(), in: prefix).isEmpty) + } + + private func prefixWithLog(ran: [String]) throws -> WinePrefix { + let dir = try scratchDirectory("winetricks-installed") + let prefix = WinePrefix( + appID: "2075800", name: "Game", library: SteamLibrary(root: dir), lastUsed: nil) + try FileManager.default.createDirectory( + at: prefix.pfx, withIntermediateDirectories: true) + try ran.joined(separator: "\n").write( + to: prefix.pfx.appending(path: "winetricks.log"), atomically: true, encoding: .utf8) + return prefix + } +}