From 67a1a21bd50fcf9c0f3fa7a40d0a63d435c3274b Mon Sep 17 00:00:00 2001 From: Ethan Uppal <113849268+ethanuppal@users.noreply.github.com> Date: Sat, 15 Mar 2025 01:48:47 -0400 Subject: [PATCH 1/4] feat: Start work on monitor Signed-off-by: Ethan Uppal <113849268+ethanuppal@users.noreply.github.com> --- Whisky/Localizable.xcstrings | 25 ++++ Whisky/Views/Monitor/MonitorView.swift | 164 +++++++++++++++++++++++++ Whisky/Views/WhiskyApp.swift | 13 ++ 3 files changed, 202 insertions(+) create mode 100644 Whisky/Views/Monitor/MonitorView.swift diff --git a/Whisky/Localizable.xcstrings b/Whisky/Localizable.xcstrings index b1cf42214..988154fe4 100644 --- a/Whisky/Localizable.xcstrings +++ b/Whisky/Localizable.xcstrings @@ -19319,6 +19319,31 @@ } } }, + "view.monitor" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "View Wine Process Monitor" + } + } + } + }, + "Whisky Monitor" : { + + }, + "why.monitor" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wine processes are always named wine64-preloader, making it hard to know what's what in the system monitor. This monitor allows you to see the apps each process corresponds to." + } + } + } + }, "wine.clearShaderCaches" : { "localizations" : { "ar" : { diff --git a/Whisky/Views/Monitor/MonitorView.swift b/Whisky/Views/Monitor/MonitorView.swift new file mode 100644 index 000000000..66804b6f2 --- /dev/null +++ b/Whisky/Views/Monitor/MonitorView.swift @@ -0,0 +1,164 @@ +// +// MonitorView.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 Foundation +import SwiftUI + +struct ProcessInfo: Identifiable { + let id: Int + let name: String + let message: String +} + +private func fetchProcesses() -> [ProcessInfo]? { + var processes: [ProcessInfo] = [] + + // sysctl my beloved + var name: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_ALL] + var size = 0 + + let sysctlForSizeResult = sysctl(&name, UInt32(name.count), nil, &size, nil, 0) + if sysctlForSizeResult != 0 { + print("Error getting size of process list") + return nil + } + + let processCount = size / MemoryLayout.stride + let processListStart = UnsafeMutablePointer.allocate(capacity: processCount) + defer { processListStart.deallocate() } + + let sysctlForListResult = sysctl(&name, UInt32(name.count), processListStart, &size, nil, 0) + if sysctlForListResult != 0 { + print("Error getting process list") + return nil + } + + let processList = UnsafeBufferPointer(start: processListStart, count: processCount) + + for rawProcessInfo in processList { + let comm = withUnsafePointer(to: rawProcessInfo.kp_proc.p_comm) { + $0.withMemoryRebound(to: CChar.self, capacity: Int(MAXCOMLEN)) { + String(cString: $0) + } + } + + if comm == "wine64-preloader" { + let id = Int(rawProcessInfo.kp_proc.p_pid) + + let message = withUnsafePointer(to: rawProcessInfo.kp_proc.p_wmesg) { + $0.withMemoryRebound(to: CChar.self, capacity: Int(100)) { + String(cString: $0) + } + } + + processes.append(ProcessInfo(id: id, name: comm, message: message)) + } + } + + return processes +} + +@MainActor +class ProcessMonitor: ObservableObject { + @Published var processes: [ProcessInfo] = [] + +// private var timer: DispatchSourceTimer? +// private let timerQueue = DispatchQueue(label: "processmonitor", attributes: .concurrent) + + init() { + manualUpdate() + } + + func startFetching() { +// if timer != nil { return } +// +// timer = DispatchSource.makeTimerSource(queue: timerQueue) +// timer?.schedule(deadline: .now(), repeating: 5.0) +// timer?.setEventHandler { +// if let newProcesses = fetchProcesses() { +// print(newProcesses) +// } +// } +// timer?.resume() + } + + // Stop the timer + func stopFetching() { +// print("stopFetching") +// timer?.cancel() +// timer = nil + } + + func manualUpdate() { + Task.detached(priority: .userInitiated) { + if let newProcesses = fetchProcesses() { + await MainActor.run { + self.processes = newProcesses + } + } + } + } + + deinit { +// timer?.cancel() + } +} + +struct MonitorView: View { + @StateObject private var monitor = ProcessMonitor() + + var body: some View { + VStack(alignment: .leading) { + Text("why.monitor") + List(monitor.processes) { process in + HStack { + Text(process.id.description) + .frame(width: 100, alignment: .leading) + .selectionDisabled(false) + Text(process.name) + .selectionDisabled(false) + } + .selectionDisabled(false) + } + .selectionDisabled(false) + } + .selectionDisabled(false) + .padding() + .bottomBar { + HStack { + Spacer() + Button("button.refresh") { + monitor.manualUpdate() + } + } + .padding() + } +// .onAppear { +// print("onAppear") +// monitor.startFetching() +// } +// .onDisappear { +// print("onDisappear") +// monitor.stopFetching() +// } + } +} + +#Preview { + MonitorView() +} diff --git a/Whisky/Views/WhiskyApp.swift b/Whisky/Views/WhiskyApp.swift index 52ce3ebab..7a5b7e688 100644 --- a/Whisky/Views/WhiskyApp.swift +++ b/Whisky/Views/WhiskyApp.swift @@ -25,8 +25,12 @@ struct WhiskyApp: App { @State var showSetup: Bool = false @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @Environment(\.openURL) var openURL + @Environment(\.openWindow) var openWindow + private let updaterController: SPUStandardUpdaterController + private let monitorWindowId = "wine-process-monitor" + init() { updaterController = SPUStandardUpdaterController(startingUpdater: true, updaterDelegate: nil, @@ -95,6 +99,11 @@ struct WhiskyApp: App { WhiskyApp.killBottles() // Better not make things more complicated for ourselves WhiskyApp.wipeShaderCaches() } + + Button("view.monitor") { + openWindow(id: monitorWindowId) + } + .keyboardShortcut("M", modifiers: [.command, .shift]) } CommandGroup(replacing: .help) { Button("help.website") { @@ -117,6 +126,10 @@ struct WhiskyApp: App { Settings { SettingsView() } + + Window("Whisky Monitor", id: monitorWindowId) { + MonitorView() + } } static func killBottles() { From 4f67868b1ad642854c4b3e14d93fcb8cd2db55ad Mon Sep 17 00:00:00 2001 From: Ethan Uppal <113849268+ethanuppal@users.noreply.github.com> Date: Sat, 15 Mar 2025 02:48:43 -0400 Subject: [PATCH 2/4] feat: Extract workdir and invocation command!!! Signed-off-by: Ethan Uppal <113849268+ethanuppal@users.noreply.github.com> --- Whisky/Views/Monitor/MonitorView.swift | 178 +++++++++++++++++++------ 1 file changed, 139 insertions(+), 39 deletions(-) diff --git a/Whisky/Views/Monitor/MonitorView.swift b/Whisky/Views/Monitor/MonitorView.swift index 66804b6f2..d92c272b6 100644 --- a/Whisky/Views/Monitor/MonitorView.swift +++ b/Whisky/Views/Monitor/MonitorView.swift @@ -17,57 +17,152 @@ // import Foundation +import Darwin import SwiftUI -struct ProcessInfo: Identifiable { - let id: Int - let name: String - let message: String -} +typealias PID = Int32 -private func fetchProcesses() -> [ProcessInfo]? { - var processes: [ProcessInfo] = [] +// ethan: sysctl my beloved +enum SysctlHelper { + static func getWine64PreloaderPids() -> [PID]? { + var sysctlName = [CTL_KERN, KERN_PROC, KERN_PROC_ALL] + var size = 0 - // sysctl my beloved - var name: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_ALL] - var size = 0 + let sysctlForSizeResult = sysctl(&sysctlName, UInt32(sysctlName.count), nil, &size, nil, 0) + if sysctlForSizeResult != 0 { + print("Error getting size of process list") + return nil + } - let sysctlForSizeResult = sysctl(&name, UInt32(name.count), nil, &size, nil, 0) - if sysctlForSizeResult != 0 { - print("Error getting size of process list") - return nil - } + let processCount = size / MemoryLayout.stride + let processListStart = UnsafeMutablePointer.allocate(capacity: processCount) + defer { processListStart.deallocate() } - let processCount = size / MemoryLayout.stride - let processListStart = UnsafeMutablePointer.allocate(capacity: processCount) - defer { processListStart.deallocate() } + let sysctlForListResult = sysctl(&sysctlName, UInt32(sysctlName.count), processListStart, &size, nil, 0) + if sysctlForListResult != 0 { + print("Error getting process list") + return nil + } - let sysctlForListResult = sysctl(&name, UInt32(name.count), processListStart, &size, nil, 0) - if sysctlForListResult != 0 { - print("Error getting process list") - return nil + let processList = UnsafeBufferPointer(start: processListStart, count: processCount) + var ids: [PID] = [] + + for rawProcessInfo in processList { + let name = withUnsafePointer(to: rawProcessInfo.kp_proc.p_comm) { + $0.withMemoryRebound(to: CChar.self, capacity: Int(MAXCOMLEN)) { + String(cString: $0) + } + } + + if name == "wine64-preloader" { + ids.append(rawProcessInfo.kp_proc.p_pid) + } + } + + return ids } - let processList = UnsafeBufferPointer(start: processListStart, count: processCount) + static func workingDirectory(for pid: PID) -> String? { + var vnodeInfo = proc_vnodepathinfo() + let size = MemoryLayout.size + let resultingSize = proc_pidinfo(pid, PROC_PIDVNODEPATHINFO, 0, &vnodeInfo, Int32(size)) - for rawProcessInfo in processList { - let comm = withUnsafePointer(to: rawProcessInfo.kp_proc.p_comm) { - $0.withMemoryRebound(to: CChar.self, capacity: Int(MAXCOMLEN)) { + guard resultingSize == Int32(size) else { + print("proc_pidinfo failed for pid \(pid) with result \(resultingSize)") + return nil + } + + let workingDirectory: String = withUnsafePointer(to: &vnodeInfo.pvi_cdir.vip_path) { ptr in + ptr.withMemoryRebound(to: CChar.self, capacity: Int(MAXPATHLEN)) { String(cString: $0) } } - if comm == "wine64-preloader" { - let id = Int(rawProcessInfo.kp_proc.p_pid) + return workingDirectory + } - let message = withUnsafePointer(to: rawProcessInfo.kp_proc.p_wmesg) { - $0.withMemoryRebound(to: CChar.self, capacity: Int(100)) { - String(cString: $0) - } + static func commandLine(for pid: PID) -> [String]? { + var sysctlName = [CTL_KERN, KERN_PROCARGS2, Int32(pid)] + + var size = 0 + if sysctl(&sysctlName, UInt32(sysctlName.count), nil, &size, nil, 0) != 0 { + print("Failed to get size of sysctl output buffer for process command") + return nil + } + + if size == 0 { + return nil + } + + var buffer = [CChar](repeating: 0, count: size) + if sysctl(&sysctlName, UInt32(sysctlName.count), &buffer, &size, nil, 0) != 0 { + perror("Failed to invoke sysctl to write to output buffer for process command") + return nil + } + + // todo(ethan): Idk how endianness works here. + // I'm just gonna take the first byte and pray that there are less than 256 arguments. + let argc = CUnsignedChar(bitPattern: buffer[0]) + + guard argc > 0 else { return nil } + + return parseSysctlArguments(argc: argc, buffer: buffer) + } + + private static func parseSysctlArguments(argc: CUnsignedChar, buffer: [CChar]) -> [String]? { + var index = MemoryLayout.size // skip past argc + + guard index < buffer.count else { return nil } + + var arguments: [String] = [] + for _ in 0 ..< argc { + guard index < buffer.count else { break } + let argumentStart = index + + if buffer[argumentStart] == 0 { + index += 1 + continue } - processes.append(ProcessInfo(id: id, name: comm, message: message)) + while index < buffer.count && buffer[index] != 0 { + index += 1 + } + guard let argument = buffer.withUnsafeBufferPointer({ ptr -> String? in + if let base = ptr.baseAddress { + return String(cString: base.advanced(by: argumentStart)) + } else { + return nil + } + }) else { + return nil + } + arguments.append(argument) + index += 1 } + + return arguments + } +} + +struct ProcessInfo: Identifiable { + let id: PID + let workingDirectory: String? + let command: [String]? +} + +private func fetchProcesses() -> [ProcessInfo]? { + guard let pids = SysctlHelper.getWine64PreloaderPids() else { + return nil + } + + var processes: [ProcessInfo] = [] + for id in pids { + let process = ProcessInfo( + id: id, + workingDirectory: SysctlHelper.workingDirectory(for: id), + command: SysctlHelper.commandLine(for: id) + ) + processes.append(process) } return processes @@ -77,6 +172,7 @@ private func fetchProcesses() -> [ProcessInfo]? { class ProcessMonitor: ObservableObject { @Published var processes: [ProcessInfo] = [] + // todo(ethan): can't get timer to work // private var timer: DispatchSourceTimer? // private let timerQueue = DispatchQueue(label: "processmonitor", attributes: .concurrent) @@ -126,12 +222,16 @@ struct MonitorView: View { VStack(alignment: .leading) { Text("why.monitor") List(monitor.processes) { process in - HStack { - Text(process.id.description) - .frame(width: 100, alignment: .leading) - .selectionDisabled(false) - Text(process.name) - .selectionDisabled(false) + VStack { + Text("PID: " + process.id.description) + if let directory = process.workingDirectory { + Text("D: " + directory) + .selectionDisabled(false) + } + if let command = process.command { + Text("C: " + command.description) + .selectionDisabled(false) + } } .selectionDisabled(false) } From d37f4d450753cb2090581f8cc52bd1bfdfec7364 Mon Sep 17 00:00:00 2001 From: Ethan Uppal <113849268+ethanuppal@users.noreply.github.com> Date: Sat, 15 Mar 2025 03:24:55 -0400 Subject: [PATCH 3/4] feat: Show nice table view with everything Signed-off-by: Ethan Uppal <113849268+ethanuppal@users.noreply.github.com> --- Whisky/Localizable.xcstrings | 29 +++++++- Whisky/Views/Monitor/MonitorView.swift | 96 +++++++++++++++++++++----- Whisky/Views/WhiskyApp.swift | 2 +- 3 files changed, 109 insertions(+), 18 deletions(-) diff --git a/Whisky/Localizable.xcstrings b/Whisky/Localizable.xcstrings index 988154fe4..268fb2c0c 100644 --- a/Whisky/Localizable.xcstrings +++ b/Whisky/Localizable.xcstrings @@ -11430,6 +11430,16 @@ } } }, + "Process ID: %d" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Process Id: %d" + } + } + } + }, "process.table.executable" : { "localizations" : { "ar" : { @@ -19331,7 +19341,14 @@ } }, "Whisky Monitor" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Whisky Monitor" + } + } + } }, "why.monitor" : { "extractionState" : "manual", @@ -20709,6 +20726,16 @@ } } } + }, + "Working Directory: %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Working Directory: %@" + } + } + } } }, "version" : "1.0" diff --git a/Whisky/Views/Monitor/MonitorView.swift b/Whisky/Views/Monitor/MonitorView.swift index d92c272b6..e171e5550 100644 --- a/Whisky/Views/Monitor/MonitorView.swift +++ b/Whisky/Views/Monitor/MonitorView.swift @@ -140,27 +140,104 @@ enum SysctlHelper { index += 1 } + arguments.removeFirst() return arguments } } struct ProcessInfo: Identifiable { let id: PID + let appNameAndConfig: (String, [String])? let workingDirectory: String? let command: [String]? } +struct ProcessInfoView: View { + let processInfo: ProcessInfo + + var body: some View { + VStack(alignment: .leading, spacing: 15) { + let boxTitle = Text(processInfo.appNameAndConfig?.0 ?? "Anonymous Wine Process") + .font(.title) + if processInfo.appNameAndConfig == nil { + boxTitle + } else { + boxTitle.fontWeight(.bold) + } + + Divider() + + Text("Process ID: \(processInfo.id)") + .font(.subheadline) + if let workingDirectory = processInfo.workingDirectory { + Text("Working Directory: \(workingDirectory)") + .font(.body) + } + + if let config = processInfo.appNameAndConfig?.1 { + ProcessConfigView(config: config) + } + + } + .padding() + .background( + RoundedRectangle(cornerRadius: 15) + .fill(Color.clear) + .stroke(Color.black) + .shadow(radius: 5) + ) + .padding() + } +} + +struct ProcessConfigView: View { + let config: [String] + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + ForEach(config, id: \.self) { flag in + let flag = Array(flag.split(separator: "=", maxSplits: 2, omittingEmptySubsequences: false)) + let flagName = flag[0] + let flagValue = flag.count == 1 ? "" : flag[1] + + HStack { + Text(flagName) + .font(.body) + .fontWeight(.semibold) + Text(flagValue) + .font(.body) + .foregroundColor(.gray) + Spacer() + } + .padding(.horizontal) + Divider() + } + } + } +} + private func fetchProcesses() -> [ProcessInfo]? { guard let pids = SysctlHelper.getWine64PreloaderPids() else { return nil } var processes: [ProcessInfo] = [] + for id in pids { + let workingDirectory = SysctlHelper.workingDirectory(for: id) + let command = SysctlHelper.commandLine(for: id) + let appNameAndConfig: (String, [String])? = command.flatMap { + if $0.count <= 1 { + return nil + } else { + return ($0[1], Array($0.suffix(from: 2))) + } + } let process = ProcessInfo( id: id, - workingDirectory: SysctlHelper.workingDirectory(for: id), - command: SysctlHelper.commandLine(for: id) + appNameAndConfig: appNameAndConfig, + workingDirectory: workingDirectory, + command: command ) processes.append(process) } @@ -222,22 +299,9 @@ struct MonitorView: View { VStack(alignment: .leading) { Text("why.monitor") List(monitor.processes) { process in - VStack { - Text("PID: " + process.id.description) - if let directory = process.workingDirectory { - Text("D: " + directory) - .selectionDisabled(false) - } - if let command = process.command { - Text("C: " + command.description) - .selectionDisabled(false) - } - } - .selectionDisabled(false) + ProcessInfoView(processInfo: process) } - .selectionDisabled(false) } - .selectionDisabled(false) .padding() .bottomBar { HStack { diff --git a/Whisky/Views/WhiskyApp.swift b/Whisky/Views/WhiskyApp.swift index 7a5b7e688..2fcf4bacf 100644 --- a/Whisky/Views/WhiskyApp.swift +++ b/Whisky/Views/WhiskyApp.swift @@ -129,7 +129,7 @@ struct WhiskyApp: App { Window("Whisky Monitor", id: monitorWindowId) { MonitorView() - } + }.defaultSize(width: 500, height: 400) } static func killBottles() { From bd9d60e3735abb2bf7bb99c4933117f7d264da8f Mon Sep 17 00:00:00 2001 From: Ethan Uppal <113849268+ethanuppal@users.noreply.github.com> Date: Sun, 16 Mar 2025 02:13:09 -0400 Subject: [PATCH 4/4] feat: Ugly but awesome bottle monitor with collapsible sections Signed-off-by: Ethan Uppal <113849268+ethanuppal@users.noreply.github.com> --- Whisky/Localizable.xcstrings | 52 ++-- Whisky/Views/Bottle/BottleView.swift | 7 + Whisky/Views/Common/CollapsibleView.swift | 45 ++++ Whisky/Views/Monitor/MonitorView.swift | 287 ++++++++++++++-------- Whisky/Views/WhiskyApp.swift | 14 +- 5 files changed, 267 insertions(+), 138 deletions(-) create mode 100644 Whisky/Views/Common/CollapsibleView.swift diff --git a/Whisky/Localizable.xcstrings b/Whisky/Localizable.xcstrings index 268fb2c0c..61628a321 100644 --- a/Whisky/Localizable.xcstrings +++ b/Whisky/Localizable.xcstrings @@ -272,6 +272,12 @@ } } } + }, + "Anonymous Wine Process" : { + + }, + "Bottle Monitor" : { + }, "button.cDrive" : { "localizations" : { @@ -1229,25 +1235,25 @@ "localizations" : { "ar" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "تحديث" } }, "cs" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Obnovit" } }, "da" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Opfrisk" } }, "de" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Aktualisieren" } }, @@ -1259,103 +1265,103 @@ }, "es" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Actualizar" } }, "fi" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Päivitä" } }, "fr" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Rafraîchir" } }, "it" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Aggiorna" } }, "ja" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "再読み込み" } }, "ko" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "새로 고침" } }, "nl" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Vernieuw" } }, "pl" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Odśwież" } }, "pt-BR" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Atualizar" } }, "pt-PT" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Atualizar" } }, "ro" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Reîmprospătează" } }, "ru" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Обновить" } }, "tr" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Yenile" } }, "uk" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Оновити" } }, "vi" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Làm cho khỏe lại" } }, "zh-Hans" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "刷新" } }, "zh-Hant" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "重新整理" } } @@ -10068,6 +10074,9 @@ } } } + }, + "Manual Debug Refresh" : { + }, "open.bottle" : { "localizations" : { @@ -19341,6 +19350,7 @@ } }, "Whisky Monitor" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { diff --git a/Whisky/Views/Bottle/BottleView.swift b/Whisky/Views/Bottle/BottleView.swift index 25e34f40f..b323491a8 100644 --- a/Whisky/Views/Bottle/BottleView.swift +++ b/Whisky/Views/Bottle/BottleView.swift @@ -35,6 +35,10 @@ struct BottleView: View { private let gridLayout = [GridItem(.adaptive(minimum: 100, maximum: .infinity))] var body: some View { +// Window("Whisky Monitor", id: "whisky-monitor-" + bottle.settings.name) { +// MonitorView(bottle: bottle) +// }.defaultSize(width: 500, height: 400) + NavigationStack(path: $path) { ScrollView { LazyVGrid(columns: gridLayout, alignment: .center) { @@ -59,6 +63,9 @@ struct BottleView: View { } .formStyle(.grouped) .scrollDisabled(true) + + Text("Bottle Monitor").font(.headline) + MonitorView(bottle: bottle) } .bottomBar { HStack { diff --git a/Whisky/Views/Common/CollapsibleView.swift b/Whisky/Views/Common/CollapsibleView.swift new file mode 100644 index 000000000..39a1feb82 --- /dev/null +++ b/Whisky/Views/Common/CollapsibleView.swift @@ -0,0 +1,45 @@ +// +// CollapsibleView.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 SwiftUI + +struct CollapsibleView: View { + @State private var isExpanded: Bool = false + let header: () -> Header + let content: () -> Content + + var body: some View { + VStack(alignment: .leading) { + Button(action: { + isExpanded.toggle() + }, label: { + HStack { + header() + Spacer() + Image(systemName: isExpanded ? "chevron.up" : "chevron.down") + } + .padding() + }) + if isExpanded { + content() + .padding([.leading, .trailing, .bottom]) + } + } + .background(RoundedRectangle(cornerRadius: 10).stroke()) + } +} diff --git a/Whisky/Views/Monitor/MonitorView.swift b/Whisky/Views/Monitor/MonitorView.swift index e171e5550..8ce8bc9f0 100644 --- a/Whisky/Views/Monitor/MonitorView.swift +++ b/Whisky/Views/Monitor/MonitorView.swift @@ -19,6 +19,7 @@ import Foundation import Darwin import SwiftUI +import WhiskyKit typealias PID = Int32 @@ -145,52 +146,159 @@ enum SysctlHelper { } } -struct ProcessInfo: Identifiable { +struct ProcessInfo: Identifiable, Equatable { + static func == (lhs: ProcessInfo, rhs: ProcessInfo) -> Bool { + lhs.id == rhs.id + } + let id: PID let appNameAndConfig: (String, [String])? let workingDirectory: String? let command: [String]? } -struct ProcessInfoView: View { - let processInfo: ProcessInfo +private func fetchProcesses() -> [ProcessInfo]? { + guard let pids = SysctlHelper.getWine64PreloaderPids() else { + return nil + } - var body: some View { - VStack(alignment: .leading, spacing: 15) { - let boxTitle = Text(processInfo.appNameAndConfig?.0 ?? "Anonymous Wine Process") - .font(.title) - if processInfo.appNameAndConfig == nil { - boxTitle + var processes: [ProcessInfo] = [] + + for id in pids { + let workingDirectory = SysctlHelper.workingDirectory(for: id) + let command = SysctlHelper.commandLine(for: id) + let appNameAndConfig: (String, [String])? = command.flatMap { + if $0.count <= 1 { + return nil } else { - boxTitle.fontWeight(.bold) + return ($0[1], Array($0.suffix(from: 2))) } + } + let process = ProcessInfo( + id: id, + appNameAndConfig: appNameAndConfig, + workingDirectory: workingDirectory, + command: command + ) + processes.append(process) + } - Divider() + return processes +} - Text("Process ID: \(processInfo.id)") - .font(.subheadline) - if let workingDirectory = processInfo.workingDirectory { - Text("Working Directory: \(workingDirectory)") - .font(.body) - } +enum AppInfo { + case app(String, PEFile?, [ProcessInfo]) + case anonymous(ProcessInfo) +} - if let config = processInfo.appNameAndConfig?.1 { - ProcessConfigView(config: config) - } +extension AppInfo: Comparable { + static func < (lhs: AppInfo, rhs: AppInfo) -> Bool { + switch (lhs, rhs) { + case (.app(let name1, _, _), .app(let name2, _, _)): + return name1 < name2 + case (.anonymous(let process1), .anonymous(let process2)): + return process1.id < process2.id + case (.app, .anonymous): + return true + case (.anonymous, .app): + return false + } + } +} +extension AppInfo: Identifiable { + var id: String { + switch self { + case .app(let name, _, _): + return name + case .anonymous(let processInfo): + return processInfo.id.description + } + } +} + +struct AppInfoView: View { + let appInfo: AppInfo + @State var image: Image? + + var body: some View { + VStack(alignment: .leading, spacing: 15) { + switch appInfo { + case .app(let name, let icon, let processInfos): + CollapsibleView(header: { + HStack { + if let icon = image { + icon + .resizable() + .frame(width: 25, height: 25) + } else { + Image(systemName: "app.dashed") + .resizable() + .frame(width: 25, height: 25) + } + Text(name) + .font(.title) + .fontWeight(.bold) + } + }, content: { + ForEach(processInfos) { processInfo in + CollapsibleView(header: + { Text("Process ID: \(processInfo.id)") + .font(.body)} + , content: { + VStack { + if let workingDirectory = processInfo.workingDirectory { + Text("Working Directory: \(workingDirectory)") + .font(.body) + } + + if let config = processInfo.appNameAndConfig?.1 { + AppConfigView(config: config) + } + } + }) + } + }) + + case .anonymous(let processInfo): + CollapsibleView(header: + {Text("Anonymous Wine Process") + .font(.title)}, content: { + CollapsibleView(header: + {Text("Process ID: \(processInfo.id)") + .font(.body)}, content: { + VStack { + if let workingDirectory = processInfo.workingDirectory { + Text("Working Directory: \(workingDirectory)") + .font(.body) + } + + if let config = processInfo.appNameAndConfig?.1 { + AppConfigView(config: config) + } + } + }) + }) + } + } + .task { + switch appInfo { + case .app(let string, let peFile, let array): + guard let peFile = peFile else { return } + let task = Task.detached { + guard let image = peFile.bestIcon() else { return nil as Image? } + return Image(nsImage: image) + } + self.image = await task.value + default: + break + } } - .padding() - .background( - RoundedRectangle(cornerRadius: 15) - .fill(Color.clear) - .stroke(Color.black) - .shadow(radius: 5) - ) .padding() } } -struct ProcessConfigView: View { +struct AppConfigView: View { let config: [String] var body: some View { @@ -216,113 +324,76 @@ struct ProcessConfigView: View { } } -private func fetchProcesses() -> [ProcessInfo]? { - guard let pids = SysctlHelper.getWine64PreloaderPids() else { - return nil - } - - var processes: [ProcessInfo] = [] - - for id in pids { - let workingDirectory = SysctlHelper.workingDirectory(for: id) - let command = SysctlHelper.commandLine(for: id) - let appNameAndConfig: (String, [String])? = command.flatMap { - if $0.count <= 1 { - return nil - } else { - return ($0[1], Array($0.suffix(from: 2))) - } - } - let process = ProcessInfo( - id: id, - appNameAndConfig: appNameAndConfig, - workingDirectory: workingDirectory, - command: command - ) - processes.append(process) - } - - return processes -} - @MainActor class ProcessMonitor: ObservableObject { + let prefixFilter: String + let programs: [Program] @Published var processes: [ProcessInfo] = [] - + @Published var organizedView: [AppInfo] = [] // todo(ethan): can't get timer to work -// private var timer: DispatchSourceTimer? -// private let timerQueue = DispatchQueue(label: "processmonitor", attributes: .concurrent) - init() { + init(bottle: Bottle) { + self.prefixFilter = bottle.url.path() + self.programs = bottle.programs manualUpdate() } - func startFetching() { -// if timer != nil { return } -// -// timer = DispatchSource.makeTimerSource(queue: timerQueue) -// timer?.schedule(deadline: .now(), repeating: 5.0) -// timer?.setEventHandler { -// if let newProcesses = fetchProcesses() { -// print(newProcesses) -// } -// } -// timer?.resume() - } - - // Stop the timer - func stopFetching() { -// print("stopFetching") -// timer?.cancel() -// timer = nil - } - func manualUpdate() { Task.detached(priority: .userInitiated) { if let newProcesses = fetchProcesses() { await MainActor.run { - self.processes = newProcesses + self.processes = newProcesses.filter { ($0.workingDirectory ?? "").starts(with: self.prefixFilter) } + self.organizeProcesses() } } } } - deinit { -// timer?.cancel() + func organizeProcesses() { + var named = [String: [ProcessInfo]]() + var unnamed = [ProcessInfo]() + for process in processes { + if let name = process.appNameAndConfig?.0 { + if named[name] == nil { + named[name] = [] + } + named[name]?.append(process) + } else { + unnamed.append(process) + } + } + + organizedView = [] + for named in named { + organizedView.append(AppInfo.app(named.key, programs.first(where: { + return $0.url.path().hasSuffix( + named.key.replacingOccurrences(of: "C:", with: "").replacingOccurrences(of: "\\", with: "/")) + })?.peFile, named.value)) + } + for unnamed in unnamed { + organizedView.append(.anonymous(unnamed)) + } + organizedView.sort() } } struct MonitorView: View { - @StateObject private var monitor = ProcessMonitor() + @StateObject private var monitor: ProcessMonitor + + init(bottle: Bottle) { + _monitor = StateObject(wrappedValue: ProcessMonitor(bottle: bottle)) + } var body: some View { VStack(alignment: .leading) { Text("why.monitor") - List(monitor.processes) { process in - ProcessInfoView(processInfo: process) + Button("Manual Debug Refresh") { + monitor.manualUpdate() } - } - .padding() - .bottomBar { - HStack { - Spacer() - Button("button.refresh") { - monitor.manualUpdate() - } + ForEach(monitor.organizedView) { appInfo in + AppInfoView(appInfo: appInfo) } - .padding() } -// .onAppear { -// print("onAppear") -// monitor.startFetching() -// } -// .onDisappear { -// print("onDisappear") -// monitor.stopFetching() -// } + .padding() } } - -#Preview { - MonitorView() -} diff --git a/Whisky/Views/WhiskyApp.swift b/Whisky/Views/WhiskyApp.swift index 2fcf4bacf..2283bfef7 100644 --- a/Whisky/Views/WhiskyApp.swift +++ b/Whisky/Views/WhiskyApp.swift @@ -29,7 +29,7 @@ struct WhiskyApp: App { private let updaterController: SPUStandardUpdaterController - private let monitorWindowId = "wine-process-monitor" +// private let monitorWindowId = "wine-process-monitor" init() { updaterController = SPUStandardUpdaterController(startingUpdater: true, @@ -100,10 +100,10 @@ struct WhiskyApp: App { WhiskyApp.wipeShaderCaches() } - Button("view.monitor") { - openWindow(id: monitorWindowId) - } - .keyboardShortcut("M", modifiers: [.command, .shift]) +// Button("view.monitor") { +// openWindow(id: monitorWindowId) +// } +// .keyboardShortcut("M", modifiers: [.command, .shift]) } CommandGroup(replacing: .help) { Button("help.website") { @@ -126,10 +126,6 @@ struct WhiskyApp: App { Settings { SettingsView() } - - Window("Whisky Monitor", id: monitorWindowId) { - MonitorView() - }.defaultSize(width: 500, height: 400) } static func killBottles() {