diff --git a/MCColorFix.xcodeproj/project.pbxproj b/MCColorFix.xcodeproj/project.pbxproj index 71adaf9..e493fce 100644 --- a/MCColorFix.xcodeproj/project.pbxproj +++ b/MCColorFix.xcodeproj/project.pbxproj @@ -263,7 +263,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; ENABLE_APP_SANDBOX = NO; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -273,7 +273,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 0.2.0; PRODUCT_BUNDLE_IDENTIFIER = "Coder-Stevie.MCColorFix"; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -293,7 +293,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; ENABLE_APP_SANDBOX = NO; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -303,7 +303,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 0.2.0; PRODUCT_BUNDLE_IDENTIFIER = "Coder-Stevie.MCColorFix"; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; diff --git a/MCColorFix/ControlPanelView.swift b/MCColorFix/ControlPanelView.swift index ab6aaab..1246145 100644 --- a/MCColorFix/ControlPanelView.swift +++ b/MCColorFix/ControlPanelView.swift @@ -3,34 +3,102 @@ import ScreenCaptureKit struct ControlPanelView: View { @EnvironmentObject var overlay: OverlayController - @State private var selected: TargetWindow? + @State private var showAllWindows = false + + /// Show the full list when the user asked for it, or automatically when + /// the heuristics found nothing — otherwise there would be no way to + /// select a window the scoring missed. + private var windowsToShow: [TargetWindow] { + let likely = overlay.likelyWindows + if showAllWindows || likely.isEmpty { return overlay.allWindows } + return likely + } + + private static let rowHeight: CGFloat = 44 + private static let maxListHeight: CGFloat = 264 + + /// A ScrollView reports an ideal height of 0, and MenuBarExtra sizes its + /// window to the content's ideal height — so a maxHeight-only constraint + /// collapses the list to nothing. Give it a definite height instead. + private var listHeight: CGFloat { + min(CGFloat(windowsToShow.count) * Self.rowHeight, Self.maxListHeight) + } var body: some View { VStack(alignment: .leading, spacing: 12) { - Text("Minecraft Color Fix") - .font(.headline) + HStack(alignment: .firstTextBaseline) { + Text("Minecraft Color Fix") + .font(.headline) + Spacer() + // Makes it unambiguous which build is running — several copies + // of this app tend to accumulate in Downloads. + Text(OverlayController.versionString) + .font(.caption2) + .foregroundStyle(.secondary) + } Text(overlay.statusText) .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) + if overlay.needsScreenRecordingPermission { + Button("Open Screen Recording Settings") { + overlay.openScreenRecordingSettings() + } + } + if overlay.isTranslocated { + Button("Reveal app in Finder") { + overlay.revealAppInFinder() + } + } + Divider() - if overlay.availableWindows.isEmpty { - Button("Find Minecraft Window") { - overlay.refreshWindowList() - } - } else { - ForEach(overlay.availableWindows, id: \.scWindow.windowID) { window in - Button(window.title) { - overlay.start(target: window) + if !overlay.needsScreenRecordingPermission { + if windowsToShow.isEmpty { + Button("Find Minecraft Window") { + overlay.refreshWindowList() } + } else { + ScrollView { + VStack(alignment: .leading, spacing: 2) { + ForEach(windowsToShow, id: \.scWindow.windowID) { window in + Button { + overlay.start(target: window) + } label: { + VStack(alignment: .leading, spacing: 1) { + Text(window.displayName) + Text(window.subtitle) + .font(.caption2) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + .padding(.vertical, 4) + .padding(.horizontal, 6) + .frame(height: Self.rowHeight) + .contentShape(Rectangle()) + } + } + } + .frame(height: listHeight) + + HStack { + Button("Refresh") { + overlay.refreshWindowList() + } + Spacer() + // Hidden when the list is already showing everything. + if !overlay.likelyWindows.isEmpty { + Button(showAllWindows ? "Show likely only" : "Show all windows") { + showAllWindows.toggle() + } + } + } + .font(.caption) } - Button("Refresh") { - overlay.refreshWindowList() - } - .font(.caption) } if overlay.isRunning { @@ -46,7 +114,7 @@ struct ControlPanelView: View { } } .padding() - .frame(width: 280) + .frame(width: 300) .onAppear { overlay.refreshWindowList() } diff --git a/MCColorFix/OverlayController.swift b/MCColorFix/OverlayController.swift index 22b3047..50669d7 100644 --- a/MCColorFix/OverlayController.swift +++ b/MCColorFix/OverlayController.swift @@ -8,7 +8,18 @@ final class OverlayController: NSObject, ObservableObject { @Published var isRunning = false @Published var statusText = "Not running" - @Published var availableWindows: [TargetWindow] = [] + /// Every capturable window, best guess first. + @Published var allWindows: [TargetWindow] = [] + /// True when the last lookup failed because Screen Recording is not granted. + @Published var needsScreenRecordingPermission = false + /// True when the app is running from a translocated (randomized) path, + /// which makes granting Screen Recording permission impossible. + @Published var isTranslocated = OverlayController.isTranslocated + + /// Windows the heuristics think are the game. Empty is a meaningful state: + /// it means "we saw windows but none looked like Minecraft", which is when + /// the UI should offer the full list instead. + var likelyWindows: [TargetWindow] { allWindows.filter(\.isLikelyMinecraft) } private var stream: SCStream? private var streamOutput: CaptureOutput? @@ -16,30 +27,109 @@ final class OverlayController: NSObject, ObservableObject { private var overlayView: OverlayImageView? private var trackingTimer: Timer? private var targetWindowID: CGWindowID? + /// PID of the app owning the captured window, used to show/hide the + /// overlay in step with that app's focus. + private var targetPID: pid_t? + private var activationObserver: NSObjectProtocol? + private var deactivationObserver: NSObjectProtocol? // MARK: Permissions + /// Short version string shown in the UI, so it is always obvious which + /// build is actually running. + static var versionString: String { + let info = Bundle.main.infoDictionary + let short = info?["CFBundleShortVersionString"] as? String ?? "?" + let build = info?["CFBundleVersion"] as? String ?? "?" + return "v\(short) (\(build))" + } + + /// macOS runs quarantined, unsigned apps from a randomized read-only path + /// ("app translocation"). Because that path changes on every launch, TCC + /// can never persist a Screen Recording grant for it — the app silently + /// stays blind to every window, and the permission prompt may never appear + /// at all. Moving the app out of Downloads in Finder clears the quarantine + /// flag and stops translocation. + static var isTranslocated: Bool { + Bundle.main.bundlePath.contains("/AppTranslocation/") + } + func requestScreenRecordingPermissionIfNeeded() { - Task { - do { - // Triggers the system permission prompt if not already granted. - _ = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true) - } catch { - statusText = "Screen Recording permission needed. Enable it in System Settings > Privacy & Security > Screen Recording, then relaunch." - } + // CGPreflightScreenCaptureAccess reports the current grant without + // prompting; CGRequestScreenCaptureAccess raises the system prompt + // explicitly. Relying on SCShareableContent to prompt implicitly is + // unreliable for a menu-bar-only (.accessory) app, which is how this + // app can end up permanently unable to see windows having never shown + // the user a prompt to accept. + isTranslocated = Self.isTranslocated + + // Translocation is reported as a warning rather than a hard stop: + // if capture happens to work anyway, the app stays fully usable. + guard !CGPreflightScreenCaptureAccess() else { + needsScreenRecordingPermission = false + return } + + needsScreenRecordingPermission = true + statusText = isTranslocated ? Self.translocationMessage : Self.permissionMessage + // Returns false when the prompt was already answered (or dismissed) in + // a previous run; macOS only ever shows it once per app. + _ = CGRequestScreenCaptureAccess() + } + + /// Opens Finder at the real app bundle so the user can drag it out of + /// Downloads, which is what stops translocation. + func revealAppInFinder() { + NSWorkspace.shared.activateFileViewerSelecting([Bundle.main.bundleURL]) + } + + func openScreenRecordingSettings() { + let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture")! + NSWorkspace.shared.open(url) } + static let translocationMessage = """ + This app is running from a temporary randomized location, so macOS \ + cannot remember Screen Recording permission for it. Quit the app, move \ + MCColorFix.app to your Applications folder in Finder, then open it from \ + there. + """ + + private static let permissionMessage = """ + Screen Recording permission is required. Enable MCColorFix in System \ + Settings > Privacy & Security > Screen Recording, then quit and reopen \ + this app — macOS only applies the change after a relaunch. + """ + // MARK: Discovery func refreshWindowList() { Task { - let windows = await WindowFinder.findMinecraftWindows() - self.availableWindows = windows - if windows.isEmpty { - statusText = "No Minecraft window found. Make sure Minecraft is running in windowed mode." - } else { - statusText = "Found \(windows.count) window(s). Select one to start." + switch await WindowFinder.findWindows() { + case .permissionDenied: + needsScreenRecordingPermission = true + allWindows = [] + statusText = Self.isTranslocated ? Self.translocationMessage : Self.permissionMessage + + case .failed(let message): + needsScreenRecordingPermission = false + allWindows = [] + statusText = "Could not list windows: \(message)" + + case .success(let windows): + needsScreenRecordingPermission = false + allWindows = windows + let likely = windows.filter(\.isLikelyMinecraft) + if !likely.isEmpty { + statusText = "Found \(likely.count) likely Minecraft window(s)." + } else if windows.isEmpty { + statusText = "No capturable windows found." + } else { + statusText = """ + No window looked like Minecraft. Pick it manually from \ + the full list below. + """ + } } } } @@ -48,11 +138,17 @@ final class OverlayController: NSObject, ObservableObject { func start(target: TargetWindow) { Task { + // Without this, picking a second window orphans the previous + // overlay (an ordered-in .floating window nothing holds a + // reference to any more) and leaves its stream writing that + // window's frames into the new overlay's view. + if stream != nil || overlayWindow != nil { stop() } do { try await startCapture(target: target) isRunning = true - statusText = "Running — overlay active on \(target.title)" + statusText = "Running — overlay active on \(target.displayName)" startWindowTracking(windowID: target.scWindow.windowID) + startFocusTracking(pid: target.scWindow.owningApplication?.processID) } catch { statusText = "Failed to start: \(error.localizedDescription)" } @@ -62,14 +158,18 @@ final class OverlayController: NSObject, ObservableObject { func stop() { trackingTimer?.invalidate() trackingTimer = nil - Task { - try? await stream?.stopCapture() - stream = nil - streamOutput = nil - } + stopFocusTracking() + // Detach the stream *before* suspending. Awaiting first would let a + // start() that happens during the suspension have its new stream + // nulled out by this teardown. + let outgoingStream = stream + stream = nil + streamOutput = nil + Task { try? await outgoingStream?.stopCapture() } overlayWindow?.orderOut(nil) overlayWindow = nil overlayView = nil + targetPID = nil isRunning = false statusText = "Stopped" } @@ -130,6 +230,74 @@ final class OverlayController: NSObject, ObservableObject { self.overlayWindow = window } + // MARK: Focus tracking (only cover Minecraft, not everything else) + + /// A `.floating` window sits above every normal window on its Space, so a + /// static overlay hides whatever you switch to. Tying its visibility to the + /// target app's focus makes it behave like part of that app instead. + private func startFocusTracking(pid: pid_t?) { + targetPID = pid + stopFocusTracking() + guard pid != nil else { + // Without an owning PID there is nothing to follow, so the overlay + // stays permanently on top. Say so instead of quietly not doing + // what the app claims to do. + statusText += " (overlay stays on top: owning app could not be identified)" + return + } + + let center = NSWorkspace.shared.notificationCenter + activationObserver = center.addObserver( + forName: NSWorkspace.didActivateApplicationNotification, + object: nil, + queue: .main + ) { [weak self] note in + let app = note.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication + let frontPID = app?.processIdentifier + guard let self else { return } + Task { @MainActor in self.syncOverlayVisibility(frontmostPID: frontPID) } + } + // Covers the app quitting or being hidden, which does not always come + // through as another app activating. + deactivationObserver = center.addObserver( + forName: NSWorkspace.didDeactivateApplicationNotification, + object: nil, + queue: .main + ) { [weak self] _ in + guard let self else { return } + Task { @MainActor in + self.syncOverlayVisibility( + frontmostPID: NSWorkspace.shared.frontmostApplication?.processIdentifier + ) + } + } + + syncOverlayVisibility(frontmostPID: NSWorkspace.shared.frontmostApplication?.processIdentifier) + } + + private func stopFocusTracking() { + let center = NSWorkspace.shared.notificationCenter + if let activationObserver { center.removeObserver(activationObserver) } + if let deactivationObserver { center.removeObserver(deactivationObserver) } + activationObserver = nil + deactivationObserver = nil + } + + private func syncOverlayVisibility(frontmostPID: pid_t?) { + guard let overlayWindow, let targetPID else { return } + // Opening our own menu bar panel makes this app frontmost. Leave the + // overlay exactly as it was rather than forcing it visible — forcing + // it would drag the overlay on top of whatever app the user was + // actually using when they opened the panel. + if frontmostPID == ProcessInfo.processInfo.processIdentifier { return } + + if frontmostPID == targetPID { + if !overlayWindow.isVisible { overlayWindow.orderFrontRegardless() } + } else if overlayWindow.isVisible { + overlayWindow.orderOut(nil) + } + } + // MARK: Window tracking (follow Minecraft if moved/resized) private func startWindowTracking(windowID: CGWindowID) { diff --git a/MCColorFix/SETUP.md b/MCColorFix/SETUP.md index f4f53e0..df299f7 100644 --- a/MCColorFix/SETUP.md +++ b/MCColorFix/SETUP.md @@ -53,13 +53,52 @@ you're not sure which OS you're on). grant it in System Settings → Privacy & Security → Screen Recording, then relaunch the app (macOS requires a relaunch after granting this permission) 4. Click the eye icon in the menu bar -5. Click "Find Minecraft Window", then click the window name that appears +5. Pick your game window from the list. Windows are ranked with the most + likely Minecraft window first; each row shows the owning app and pixel + size so similarly-named windows stay distinguishable. If your window + isn't in the list, click **Show all windows** and pick it manually. 6. A color-corrected overlay window will appear directly on top of Minecraft, tracking its position/size automatically +The overlay hides itself whenever Minecraft is not the frontmost app, and +comes back when you switch to it, so it does not cover your other windows. + Click "Stop Overlay" from the menu bar to remove it and interact with Minecraft normally again. +## Troubleshooting + +### "No Minecraft window found" / the window list is empty + +Almost always a Screen Recording permission problem rather than an actual +missing window. The app tells the two apart and will say which it is. + +**If it says the app is in a temporary randomized location:** macOS applies +"app translocation" to quarantined, unsigned apps opened from Downloads, +running them from a path that changes on every launch. Permission can never +persist for such a path, and the prompt may never appear at all. Quit the +app, move `MCColorFix.app` into `/Applications` **in Finder** (that is what +clears the quarantine flag), and open it from there. + +**If it asks for Screen Recording permission:** grant it, then quit and +reopen the app — macOS only applies the change after a relaunch. + +Because release builds are ad-hoc signed, macOS identifies them by code +hash rather than a stable developer identity. Every new build therefore +counts as a different app and needs permission granted again, even though +the old entry still shows as enabled in System Settings. Toggling the entry +off and on, or removing it with `-` and re-granting, fixes it. + +### My launcher's window isn't detected + +Click **Show all windows** and pick it manually. Scoring only ranks the +list, it never removes anything from it, so a window the heuristic scores +badly is still selectable. + +Windows that are not plausible capture targets at all are excluded before +ranking: those on a non-zero window level (menus, panels, floating chrome) +and anything smaller than 200x150. + ## Known limitations - There will be a small amount of latency (roughly one frame, more under @@ -73,3 +112,6 @@ Minecraft normally again. - This does **not** click through to Minecraft — you're interacting directly with the overlay window itself, which is intentional and avoids any of the fragile always-on-top-transparent-passthrough tricks. +- The overlay tracks which *app* is frontmost, not which window. If Minecraft + is frontmost, the overlay shows; it does not additionally detect the game + window being covered by another window of the same app. diff --git a/MCColorFix/WindowFinder.swift b/MCColorFix/WindowFinder.swift index ee822ff..8d11134 100644 --- a/MCColorFix/WindowFinder.swift +++ b/MCColorFix/WindowFinder.swift @@ -5,34 +5,209 @@ struct TargetWindow { let scWindow: SCWindow let frame: CGRect let title: String + let appName: String + /// Heuristic confidence that this window is the Minecraft game window. + /// See `WindowFinder.score(for:)` for how it is derived. + let score: Int + + var isLikelyMinecraft: Bool { score >= TargetWindow.likelyThreshold } + + /// Shown in the picker. Window titles are only readable once Screen + /// Recording is granted, so fall back to the owning app name. + var displayName: String { + if !title.isEmpty { return title } + return appName + } + + /// Secondary line in the picker, so two same-named windows stay tellable apart. + var subtitle: String { + let size = "\(Int(frame.width))×\(Int(frame.height))" + if title.isEmpty || title == appName { return size } + return "\(appName) — \(size)" + } + + static let likelyThreshold = 60 +} + +/// Why a window lookup produced no usable list. Callers need to tell these +/// apart: "permission denied" and "nothing matched" require completely +/// different things from the user, and conflating them is misleading. +enum WindowLookupResult { + case success([TargetWindow]) + case permissionDenied + case failed(String) } enum WindowFinder { - /// Finds candidate windows whose owning app name or window title contains "minecraft". - static func findMinecraftWindows() async -> [TargetWindow] { + // MARK: Heuristics + + /// Bundle identifiers of JVM runtimes. Minecraft is a Java app, so the + /// window's owning process is a JVM no matter which launcher started it. + private static let jvmBundlePrefixes = [ + "com.azul.zulu", // Zulu — what Prism ships by default + "net.java.openjdk", + "net.adoptopenjdk", + "net.temurin", + "org.openjdk", + "com.oracle.java", + "com.microsoft.openjdk", + "com.amazon.corretto", + "org.graalvm", + "net.minecraft" // official launcher's bundled runtime + ] + + /// The owning process is literally named after a Java runtime. Strong + /// signal on its own — real Java desktop apps (IntelliJ, etc.) ship a + /// branded bundle rather than presenting as bare "java". + private static let jvmNameHints = ["java", "jdk", "jre", "openjdk"] + + /// Launcher / client names that show up as the owning app name. Prism and + /// MultiMC rename the game process to the *instance* name, so the app name + /// is frequently something like `"Prism Launcher: b1.8.1"` with no + /// "minecraft" anywhere in it — which is exactly why a plain substring + /// match on "minecraft" misses the window entirely. + /// + /// Deliberately a *weak* signal: the launcher's own window carries the same + /// name as the game window it spawned, so this can never qualify a window + /// on its own. + private static let launcherKeywords = [ + "prism", "multimc", "polymc", "atlauncher", "gdlauncher", + "modrinth", "curseforge", "technic", "ftb", "badlion", + "lunar", "feather", "salwyrr" + ] + + /// Apps that routinely carry "minecraft" in a window title without being + /// the game: a wiki tab, a chat channel, a source folder. Without this a + /// browser window outranks the real game window in the picker. + private static let nonGameBundlePrefixes = [ + "com.apple.safari", "com.google.chrome", "org.mozilla", "com.microsoft.edge", + "com.brave.browser", "company.thebrowser", "com.operasoftware", + "com.hnc.discord", "com.tinyspeck", "com.microsoft.teams", + "com.microsoft.vscode", "com.apple.dt.xcode", "com.jetbrains", + "com.apple.terminal", "com.googlecode.iterm2", "dev.warp", + "com.apple.finder", "md.obsidian", "notion.id" + ] + + /// Minecraft's default window is 854×480 of content. Launchers usually keep + /// that default, and the framed window comes out a little taller. + private static func hasClassicMinecraftSize(_ frame: CGRect) -> Bool { + let w = frame.width, h = frame.height + guard w >= 640, h >= 480 else { return false } + // 854 wide is the giveaway; height varies with the title bar. + if abs(w - 854) < 2 && h >= 480 && h <= 560 { return true } + // 16:9-ish, but only at a plausible *windowed* game size — without the + // width bound this matches every maximized window on the display. + let ratio = w / h + return w <= 1600 && ratio > 1.6 && ratio < 1.85 + } + + private static func score(for window: SCWindow) -> Int { + score( + appName: window.owningApplication?.applicationName ?? "", + title: window.title ?? "", + bundleID: window.owningApplication?.bundleIdentifier ?? "", + frame: window.frame, + isOnScreen: window.isOnScreen + ) + } + + /// Value-based so it can be exercised directly; `SCWindow` cannot be + /// constructed outside of ScreenCaptureKit. + static func score( + appName rawAppName: String, + title rawTitle: String, + bundleID rawBundleID: String, + frame: CGRect, + isOnScreen: Bool + ) -> Int { + let appName = rawAppName.lowercased() + let title = rawTitle.lowercased() + let bundleID = rawBundleID.lowercased() + var score = 0 + + // --- Strong signals: any one of these alone clears `likelyThreshold`. + // The game names its own window "Minecraft ", but the title is + // only readable once Screen Recording is granted. + if title.contains("minecraft") { score += 100 } + if appName.contains("minecraft") { score += 80 } + // Minecraft is a Java app, so its window is always owned by a JVM + // process no matter which launcher started it. This is what catches + // Prism/MultiMC instances renamed to something with no "minecraft" in it. + if jvmBundlePrefixes.contains(where: { bundleID.hasPrefix($0) }) { + score += 60 + } else { + // Whole-word match: a substring test would fire on unrelated names + // that merely contain "jre" or "jdk". + let words = Set(appName.split { !$0.isLetter && !$0.isNumber }.map(String.init)) + if !words.isDisjoint(with: jvmNameHints) { score += 60 } + } + + // Strong negative: these apps show "minecraft" in titles constantly and + // are never the game. + if nonGameBundlePrefixes.contains(where: { bundleID.hasPrefix($0) }) { score -= 150 } + + // --- Weak signals: ranking nudges only, never enough to qualify a + // window by themselves. Any 16:9 window would otherwise look like a + // game, and every launcher's own window shares the launcher's name. + if launcherKeywords.contains(where: { appName.contains($0) || title.contains($0) }) { score += 15 } + if hasClassicMinecraftSize(frame) { score += 15 } + if isOnScreen { score += 5 } + + return score + } + + /// Windows we should never offer: our own overlay, menu bars, tiny + /// utility panels and other chrome. + private static func isPlausibleTarget(_ window: SCWindow) -> Bool { + guard window.windowLayer == 0 else { return false } + guard window.frame.width >= 200, window.frame.height >= 150 else { return false } + let bundleID = window.owningApplication?.bundleIdentifier ?? "" + return bundleID != Bundle.main.bundleIdentifier + } + + // MARK: Lookup + + /// Enumerates every capturable window, scored and sorted so the most + /// likely Minecraft window comes first. + /// + /// Off-screen windows are included on purpose: a window living on another + /// Space is not "on screen", and excluding those is a common reason a + /// running game appears undetectable. + static func findWindows() async -> WindowLookupResult { do { let content = try await SCShareableContent.excludingDesktopWindows( false, - onScreenWindowsOnly: true + onScreenWindowsOnly: false ) - let matches = content.windows.filter { window in - let appName = window.owningApplication?.applicationName.lowercased() ?? "" - let title = window.title?.lowercased() ?? "" - return appName.contains("minecraft") || title.contains("minecraft") - } + let candidates = content.windows + .filter(isPlausibleTarget) + .map { window in + TargetWindow( + scWindow: window, + frame: window.frame, + title: window.title ?? "", + appName: window.owningApplication?.applicationName ?? "Unknown app", + score: score(for: window) + ) + } + .sorted { lhs, rhs in + if lhs.score != rhs.score { return lhs.score > rhs.score } + return lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) == .orderedAscending + } - return matches.map { window in - TargetWindow( - scWindow: window, - frame: window.frame, - title: window.title ?? window.owningApplication?.applicationName ?? "Minecraft" - ) - } + return .success(candidates) + } catch let error as SCStreamError where error.code == .userDeclined { + return .permissionDenied } catch { - print("Failed to enumerate windows: \(error)") - return [] + // -3801 is also surfaced without being bridged to SCStreamError + // on some macOS versions, so check the raw code too. + let nsError = error as NSError + if nsError.domain.contains("ScreenCaptureKit"), nsError.code == -3801 { + return .permissionDenied + } + return .failed(error.localizedDescription) } }