From 3667007aeaedbfc21057e444f82171e0ff434bfe Mon Sep 17 00:00:00 2001 From: Aron Matoic Date: Wed, 19 Aug 2026 10:59:29 +0200 Subject: [PATCH 1/3] Detect Minecraft windows launched by any launcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The window filter only matched windows whose owning app name or title contained "minecraft". That misses the common case: Prism Launcher and MultiMC rename the game process to the *instance* name, so a Beta 1.8.1 instance reports itself as: owner="Prism Launcher: b1.8.1" bundle=com.azul.zulu.java 854x508 There is no "minecraft" anywhere in that, so the game was never found and the app was unusable with those launchers. Replace the substring match with a scored heuristic. Strong signals are "minecraft" in the title or app name, and a JVM bundle identifier — the last of which is what catches renamed instances, since Minecraft is a Java app whatever launcher starts it. Launcher keywords, the classic 854x480 geometry and on-screen state are weak signals that only affect ranking; they can never qualify a window on their own, because a launcher's own window carries the same name as the game window it spawned and any 16:9 window would otherwise look like a game. Browsers, chat clients and editors score strongly negative: a wiki tab or a channel named #minecraft would otherwise outrank the actual game. Windows are now listed ranked rather than filtered, with the full list available as a fallback, so a window the heuristic misses can still be picked manually. Also stop passing onScreenWindowsOnly: true. A window on another Space is not "on screen", so a running game could be excluded for that reason alone. Since the picker stays on screen while a capture runs, selecting a second window now tears the first one down. Previously it orphaned the old overlay window with nothing left holding a reference to it, and left the old stream writing its frames into the new overlay's view. Detach the stream in stop() before suspending, too, so a stop still in flight cannot null out a stream that a subsequent start has already installed. --- MCColorFix/ControlPanelView.swift | 77 +++++++++-- MCColorFix/OverlayController.swift | 75 ++++++++--- MCColorFix/WindowFinder.swift | 209 ++++++++++++++++++++++++++--- 3 files changed, 311 insertions(+), 50 deletions(-) diff --git a/MCColorFix/ControlPanelView.swift b/MCColorFix/ControlPanelView.swift index ab6aaab..8ef5e69 100644 --- a/MCColorFix/ControlPanelView.swift +++ b/MCColorFix/ControlPanelView.swift @@ -3,7 +3,26 @@ 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) { @@ -17,20 +36,50 @@ struct ControlPanelView: View { 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 +95,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..fb15af2 100644 --- a/MCColorFix/OverlayController.swift +++ b/MCColorFix/OverlayController.swift @@ -8,7 +8,14 @@ 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 + /// 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 +23,53 @@ final class OverlayController: NSObject, ObservableObject { private var overlayView: OverlayImageView? private var trackingTimer: Timer? private var targetWindowID: CGWindowID? - // MARK: Permissions 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." + // Triggers the system permission prompt if not already granted. + if case .permissionDenied = await WindowFinder.findWindows() { + needsScreenRecordingPermission = true + statusText = Self.permissionMessage } } } + 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.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,10 +78,15 @@ 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) } catch { statusText = "Failed to start: \(error.localizedDescription)" @@ -62,11 +97,13 @@ final class OverlayController: NSObject, ObservableObject { func stop() { trackingTimer?.invalidate() trackingTimer = nil - Task { - try? await stream?.stopCapture() - stream = nil - streamOutput = nil - } + // 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 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) } } From 5375fcb4e65492fcd4fa2f302d7e4266c3a031d3 Mon Sep 17 00:00:00 2001 From: Aron Matoic Date: Wed, 19 Aug 2026 10:59:51 +0200 Subject: [PATCH 2/3] Report permission failures instead of blaming a missing window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Screen Recording denial was swallowed and returned as an empty window list, so the UI reported "No Minecraft window found. Make sure Minecraft is running in windowed mode." That sends users to check the game when the actual problem is a permission the app never obtained — the two states were indistinguishable. Surface them separately, and address two ways the permission silently never gets granted in the first place: - The app relied on SCShareableContent to raise the prompt implicitly. That is unreliable for a menu-bar-only (.accessory) app. Use CGPreflightScreenCaptureAccess to read the current grant and CGRequestScreenCaptureAccess to raise the prompt explicitly. - macOS runs quarantined, unsigned apps from a randomized read-only path (app translocation), which is where the app lands when opened straight from Downloads. Because that path changes every launch, TCC can never persist a grant, so the app stays permanently blind with no indication why. Detect it and tell the user to move the app to Applications. Translocation is reported as a warning rather than a hard stop, and the window picker stays reachable throughout: if capture happens to work anyway, the app remains fully usable. Also show the version in the panel header. Copies of this app accumulate, and without it there is no way to tell which build is running. --- MCColorFix.xcodeproj/project.pbxproj | 8 ++-- MCColorFix/ControlPanelView.swift | 23 +++++++++- MCColorFix/OverlayController.swift | 68 +++++++++++++++++++++++++--- 3 files changed, 86 insertions(+), 13 deletions(-) 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 8ef5e69..1246145 100644 --- a/MCColorFix/ControlPanelView.swift +++ b/MCColorFix/ControlPanelView.swift @@ -26,14 +26,33 @@ struct ControlPanelView: View { 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.needsScreenRecordingPermission { diff --git a/MCColorFix/OverlayController.swift b/MCColorFix/OverlayController.swift index fb15af2..0a7b6a9 100644 --- a/MCColorFix/OverlayController.swift +++ b/MCColorFix/OverlayController.swift @@ -12,6 +12,10 @@ final class OverlayController: NSObject, ObservableObject { @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. @@ -25,16 +29,66 @@ final class OverlayController: NSObject, ObservableObject { private var targetWindowID: CGWindowID? // 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 { - // Triggers the system permission prompt if not already granted. - if case .permissionDenied = await WindowFinder.findWindows() { - needsScreenRecordingPermission = true - statusText = Self.permissionMessage - } + // 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 \ @@ -49,7 +103,7 @@ final class OverlayController: NSObject, ObservableObject { case .permissionDenied: needsScreenRecordingPermission = true allWindows = [] - statusText = Self.permissionMessage + statusText = Self.isTranslocated ? Self.translocationMessage : Self.permissionMessage case .failed(let message): needsScreenRecordingPermission = false From bd02ed03035c5ea70c7658f2255179496017ad1e Mon Sep 17 00:00:00 2001 From: Aron Matoic Date: Wed, 19 Aug 2026 11:00:13 +0200 Subject: [PATCH 3/3] Only keep the overlay on top while Minecraft is frontmost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The overlay window is .floating, which puts it above every normal window on its Space. Since its visibility never changed, it covered whatever the user switched to, and the documented workaround was to give Minecraft a Space of its own. Track the owning app's activation and show the overlay only while that app is frontmost. The overlay now behaves like part of Minecraft rather than a permanent floating layer, and the separate-Space workaround is unnecessary. Activating this app is deliberately not treated as "show": opening the menu bar panel makes this app frontmost, and forcing the overlay visible there would drag it on top of whatever the user was actually working in. Leaving visibility untouched keeps it correct in both directions. When the captured window reports no owning process there is nothing to follow, so the overlay stays on top as before — say so in the status text rather than quietly not doing what the app advertises. Also document the permission and translocation traps in SETUP.md, describe the new window picker, and record that this tracking is app-level rather than window-level. --- MCColorFix/OverlayController.swift | 77 ++++++++++++++++++++++++++++++ MCColorFix/SETUP.md | 44 ++++++++++++++++- 2 files changed, 120 insertions(+), 1 deletion(-) diff --git a/MCColorFix/OverlayController.swift b/MCColorFix/OverlayController.swift index 0a7b6a9..50669d7 100644 --- a/MCColorFix/OverlayController.swift +++ b/MCColorFix/OverlayController.swift @@ -27,6 +27,12 @@ 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 @@ -142,6 +148,7 @@ final class OverlayController: NSObject, ObservableObject { isRunning = true 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)" } @@ -151,6 +158,7 @@ final class OverlayController: NSObject, ObservableObject { func stop() { trackingTimer?.invalidate() trackingTimer = 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. @@ -161,6 +169,7 @@ final class OverlayController: NSObject, ObservableObject { overlayWindow?.orderOut(nil) overlayWindow = nil overlayView = nil + targetPID = nil isRunning = false statusText = "Stopped" } @@ -221,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.