From 3b4a0887d287c890092fc0845eeb57e9a5b03bcd Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Sun, 24 May 2026 16:56:30 +0800
Subject: [PATCH] Fix Split View fullscreen overlay detection

---
 Apptag/ApptagApp.swift                                                |   88 +++++++++++++++++++--
 Scripts/window_logic_qa.sh                                            |   96 ++++++++++++++++++++++++
 Release/AppStore-7.6.0-20260524.1652/README.md                        |    5 
 Release/AppStore-7.6.0-20260524.1652/FIRST_TIME_MAC_APP_STORE_TODO.md |    0 
 Release/AppStore-7.6.0-20260524.1652/QA_RELEASE_EVIDENCE.md           |    7 +
 Release/AppStore-7.6.0-20260524.1652/BUILD_SIGN_UPLOAD.md             |    2 
 Release/AppStore-7.6.0-20260524.1652/APP_STORE_CONNECT_METADATA.md    |    0 
 7 files changed, 181 insertions(+), 17 deletions(-)

diff --git a/Apptag/ApptagApp.swift b/Apptag/ApptagApp.swift
index a4b4f7a..ea0f0e3 100644
--- a/Apptag/ApptagApp.swift
+++ b/Apptag/ApptagApp.swift
@@ -74,6 +74,11 @@
         let frame: NSRect
     }
 
+    private struct ForeignWindowFrame {
+        let owner: String
+        let frame: NSRect
+    }
+
     private var isOverlayVisible: Bool {
         overlayWindow?.isVisible == true
     }
@@ -882,8 +887,18 @@
 
     private func hasFullscreenWindowOnScreen(_ screen: NSScreen) -> Bool {
         let screenFrame = screen.frame
+        let windowFrames = foreignLayerZeroWindows(on: screenFrame)
+
+        if windowFrames.contains(where: { isSingleFullscreenWindow($0.frame, on: screenFrame) }) {
+            return true
+        }
+
+        return hasSplitViewFullscreenWindows(windowFrames, on: screenFrame)
+    }
+
+    private func foreignLayerZeroWindows(on screenFrame: NSRect) -> [ForeignWindowFrame] {
         let windows = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] ?? []
-        return windows.contains { info in
+        return windows.compactMap { info in
             guard let owner = info[kCGWindowOwnerName as String] as? String,
                   owner != AppIdentity.displayName,
                   owner != "Window Server",
@@ -892,20 +907,71 @@
                   let layer = info[kCGWindowLayer as String] as? Int,
                   layer == 0,
                   let bounds = info[kCGWindowBounds as String] as? NSDictionary
-            else { return false }
+            else { return nil }
 
-            let x = cgWindowDimension(bounds, "X")
-            let y = cgWindowDimension(bounds, "Y")
-            let width = cgWindowDimension(bounds, "Width")
-            let height = cgWindowDimension(bounds, "Height")
-            let windowFrame = NSRect(x: x, y: y, width: width, height: height)
-            let widthMatches = abs(windowFrame.width - screenFrame.width) <= 12
-            let heightMatches = windowFrame.height >= screenFrame.height * 0.88
-            let horizontallyAligned = abs(windowFrame.midX - screenFrame.midX) <= 12
-            return widthMatches && heightMatches && horizontallyAligned
+            let frame = NSRect(
+                x: cgWindowDimension(bounds, "X"),
+                y: cgWindowDimension(bounds, "Y"),
+                width: cgWindowDimension(bounds, "Width"),
+                height: cgWindowDimension(bounds, "Height")
+            )
+            guard frame.intersects(screenFrame) else { return nil }
+            return ForeignWindowFrame(owner: owner, frame: frame)
         }
     }
 
+    private func isSingleFullscreenWindow(_ windowFrame: NSRect, on screenFrame: NSRect) -> Bool {
+        let widthMatches = abs(windowFrame.width - screenFrame.width) <= 12
+        let heightMatches = windowFrame.height >= screenFrame.height * 0.88
+        let horizontallyAligned = abs(windowFrame.midX - screenFrame.midX) <= 12
+        let verticallyAligned = abs(windowFrame.maxY - screenFrame.maxY) <= 32
+        return widthMatches && heightMatches && horizontallyAligned && verticallyAligned
+    }
+
+    private func hasSplitViewFullscreenWindows(_ windows: [ForeignWindowFrame], on screenFrame: NSRect) -> Bool {
+        let clippedWindows = windows.map {
+            ForeignWindowFrame(owner: $0.owner, frame: $0.frame.intersection(screenFrame))
+        }
+        let tallWindows = clippedWindows
+            .filter { window in
+                let frame = window.frame
+                return frame.height >= screenFrame.height * 0.86
+                    && frame.width >= screenFrame.width * 0.20
+                    && frame.width <= screenFrame.width * 0.86
+                    && abs(frame.maxY - screenFrame.maxY) <= 32
+            }
+            .sorted { $0.frame.minX < $1.frame.minX }
+
+        guard tallWindows.count >= 2 else { return false }
+
+        for startIndex in tallWindows.indices {
+            var union = tallWindows[startIndex].frame
+            var lastMaxX = union.maxX
+            var distinctOwners = Swift.Set<String>()
+            distinctOwners.insert(tallWindows[startIndex].owner)
+
+            for window in tallWindows.dropFirst(startIndex + 1) {
+                let gap = window.frame.minX - lastMaxX
+                if gap < -32 || gap > 48 {
+                    break
+                }
+                union = union.union(window.frame)
+                lastMaxX = max(lastMaxX, window.frame.maxX)
+                distinctOwners.insert(window.owner)
+
+                let touchesLeft = abs(union.minX - screenFrame.minX) <= 32
+                let touchesRight = abs(union.maxX - screenFrame.maxX) <= 32
+                let coversWidth = union.width >= screenFrame.width * 0.92
+                let coversHeight = union.height >= screenFrame.height * 0.86
+                if distinctOwners.count >= 2 && touchesLeft && touchesRight && coversWidth && coversHeight {
+                    return true
+                }
+            }
+        }
+
+        return false
+    }
+
     private func cgWindowDimension(_ bounds: NSDictionary, _ key: String) -> CGFloat {
         if let value = bounds[key] as? CGFloat {
             return value
diff --git a/Release/AppStore-7.6.0-20260523.2335/APP_STORE_CONNECT_METADATA.md b/Release/AppStore-7.6.0-20260524.1652/APP_STORE_CONNECT_METADATA.md
similarity index 100%
rename from Release/AppStore-7.6.0-20260523.2335/APP_STORE_CONNECT_METADATA.md
rename to Release/AppStore-7.6.0-20260524.1652/APP_STORE_CONNECT_METADATA.md
diff --git a/Release/AppStore-7.6.0-20260523.2335/BUILD_SIGN_UPLOAD.md b/Release/AppStore-7.6.0-20260524.1652/BUILD_SIGN_UPLOAD.md
similarity index 98%
rename from Release/AppStore-7.6.0-20260523.2335/BUILD_SIGN_UPLOAD.md
rename to Release/AppStore-7.6.0-20260524.1652/BUILD_SIGN_UPLOAD.md
index 7bc5a21..e1e5d2c 100644
--- a/Release/AppStore-7.6.0-20260523.2335/BUILD_SIGN_UPLOAD.md
+++ b/Release/AppStore-7.6.0-20260524.1652/BUILD_SIGN_UPLOAD.md
@@ -50,7 +50,7 @@
 
 APP_STORE=1 \
 CODESIGN_IDENTITY="3rd Party Mac Developer Application: <TEAM NAME> (<TEAMID>)" \
-APP_BUILD=20260523.2335 \
+APP_BUILD=20260524.1652 \
 bash build.sh
 
 productbuild \
diff --git a/Release/AppStore-7.6.0-20260523.2335/FIRST_TIME_MAC_APP_STORE_TODO.md b/Release/AppStore-7.6.0-20260524.1652/FIRST_TIME_MAC_APP_STORE_TODO.md
similarity index 100%
rename from Release/AppStore-7.6.0-20260523.2335/FIRST_TIME_MAC_APP_STORE_TODO.md
rename to Release/AppStore-7.6.0-20260524.1652/FIRST_TIME_MAC_APP_STORE_TODO.md
diff --git a/Release/AppStore-7.6.0-20260523.2335/QA_RELEASE_EVIDENCE.md b/Release/AppStore-7.6.0-20260524.1652/QA_RELEASE_EVIDENCE.md
similarity index 90%
rename from Release/AppStore-7.6.0-20260523.2335/QA_RELEASE_EVIDENCE.md
rename to Release/AppStore-7.6.0-20260524.1652/QA_RELEASE_EVIDENCE.md
index 0aa6f5b..273d9c5 100644
--- a/Release/AppStore-7.6.0-20260523.2335/QA_RELEASE_EVIDENCE.md
+++ b/Release/AppStore-7.6.0-20260524.1652/QA_RELEASE_EVIDENCE.md
@@ -3,7 +3,7 @@
 ## Local Build
 
 - Version: `7.6.0`
-- Build: `20260523.2335`
+- Build: `20260524.1652`
 - App path: `/Users/ar/Projects/Taglauncher/build/TagLauncher.app`
 - DMG path: `/Users/ar/Projects/Taglauncher/build/TagLauncher.dmg`
 - App executable size: `1.4M`
@@ -12,8 +12,8 @@
 ## Hashes
 
 ```text
-370adb4ad671ec910cc0edf7e6f1ae3ebdd59cbd063f5d8439f2c7e8b05fd0e1  build/TagLauncher.dmg
-9f808b6fc81b587a1508aa31c261cdbe187a3b7727daf8bf163788ba49eb2333  build/TagLauncher.app/Contents/MacOS/TagLauncher
+1bf3e53410e18778038c310e1ed083cd89c27b781cc87942dd3dd9fb57d73cb2  build/TagLauncher.dmg
+13e74a774662dfec88e69b5f51e5c51c5c46e3e5f8884cbf8cbab5cd44c4442d  build/TagLauncher.app/Contents/MacOS/TagLauncher
 ```
 
 ## Commands Run
@@ -42,6 +42,7 @@
 - Fullscreen Space overlay appears above the current fullscreen app.
 - Fullscreen Space overlay is checked with `showDockIcon=true` and `showDockIcon=false`.
 - Fullscreen Space overlay bounds must match the fullscreen target display and remain stable across repeated samples.
+- Split View fullscreen geometry is checked for 50/50 and 33/67 split layouts, while ordinary side-by-side desktop windows are rejected.
 - App grid claims foreground, hides Dock, and keeps menu bar visible.
 - Settings floats above app grid.
 - File panel floats above Settings.
diff --git a/Release/AppStore-7.6.0-20260523.2335/README.md b/Release/AppStore-7.6.0-20260524.1652/README.md
similarity index 93%
rename from Release/AppStore-7.6.0-20260523.2335/README.md
rename to Release/AppStore-7.6.0-20260524.1652/README.md
index 0d5ddbf..97281f7 100644
--- a/Release/AppStore-7.6.0-20260523.2335/README.md
+++ b/Release/AppStore-7.6.0-20260524.1652/README.md
@@ -8,9 +8,9 @@
 - Platform: macOS / Mac App Store
 - Bundle ID: `com.taglauncher.app`
 - Version: `7.6.0`
-- Local QA build: `20260523.2335`
+- Local QA build: `20260524.1652`
 - Source branch at preparation time: `main`
-- Release folder: `Release/AppStore-7.6.0-20260523.2335`
+- Release folder: `Release/AppStore-7.6.0-20260524.1652`
 
 ## What Changed For This Release
 
@@ -22,6 +22,7 @@
 - Removed the broken macOS system Help menu entry.
 - Added an About-page Help PDF button that opens the online PDF matching the current app language, plus a copy-link button.
 - Hardened fullscreen Space presentation so the app grid stays above the current fullscreen app instead of moving to another Space.
+- Added Split View fullscreen detection so two apps sharing one fullscreen Space also keep the app grid on the current Space.
 - Expanded window QA to cover fullscreen Space behavior with Dock icon both enabled and disabled, including stability checks.
 - Strengthened the window logic QA script to cover fullscreen Space, duplicate Dock tiles, repeated self-launch, force quit layering, Settings/file panel layering, Quick Search, and two-display pointer-following behavior.
 
diff --git a/Scripts/window_logic_qa.sh b/Scripts/window_logic_qa.sh
index e898312..4990885 100755
--- a/Scripts/window_logic_qa.sh
+++ b/Scripts/window_logic_qa.sh
@@ -66,6 +66,11 @@
   fi
 }
 
+reset_dock_for_qa() {
+  killall Dock >/dev/null 2>&1 || true
+  sleep 1.5
+}
+
 send_keycode() {
   local keycode="$1"
   local modifiers="${2:-}"
@@ -434,6 +439,93 @@
     }
     print("PASS fullscreen overlay above target: overlayLayer=\(tag[0].layer) targetLayer=\(target.layer)")
 
+case "split-geometry":
+    func isSingleFullscreenWindow(_ windowFrame: CGRect, on screenFrame: CGRect) -> Bool {
+        let widthMatches = abs(windowFrame.width - screenFrame.width) <= 12
+        let heightMatches = windowFrame.height >= screenFrame.height * 0.88
+        let horizontallyAligned = abs(windowFrame.midX - screenFrame.midX) <= 12
+        let verticallyAligned = abs(windowFrame.maxY - screenFrame.maxY) <= 32
+        return widthMatches && heightMatches && horizontallyAligned && verticallyAligned
+    }
+
+    func hasSplitViewFullscreenWindows(_ windows: [CGRect], on screenFrame: CGRect) -> Bool {
+        let clippedWindows = windows.map { $0.intersection(screenFrame) }
+        let tallWindows = clippedWindows
+            .filter { frame in
+                frame.height >= screenFrame.height * 0.86
+                    && frame.width >= screenFrame.width * 0.20
+                    && frame.width <= screenFrame.width * 0.86
+                    && abs(frame.maxY - screenFrame.maxY) <= 32
+            }
+            .sorted { $0.minX < $1.minX }
+
+        guard tallWindows.count >= 2 else { return false }
+
+        for startIndex in tallWindows.indices {
+            var union = tallWindows[startIndex]
+            var lastMaxX = union.maxX
+
+            for window in tallWindows.dropFirst(startIndex + 1) {
+                let gap = window.minX - lastMaxX
+                if gap < -32 || gap > 48 {
+                    break
+                }
+                union = union.union(window)
+                lastMaxX = max(lastMaxX, window.maxX)
+
+                let touchesLeft = abs(union.minX - screenFrame.minX) <= 32
+                let touchesRight = abs(union.maxX - screenFrame.maxX) <= 32
+                let coversWidth = union.width >= screenFrame.width * 0.92
+                let coversHeight = union.height >= screenFrame.height * 0.86
+                if touchesLeft && touchesRight && coversWidth && coversHeight {
+                    return true
+                }
+            }
+        }
+
+        return false
+    }
+
+    let screen = CGRect(x: 0, y: 0, width: 1710, height: 1112)
+    let single = CGRect(x: 0, y: 39, width: 1710, height: 1073)
+    let splitHalf = [
+        CGRect(x: 0, y: 39, width: 853, height: 1073),
+        CGRect(x: 857, y: 39, width: 853, height: 1073)
+    ]
+    let splitThird = [
+        CGRect(x: 0, y: 39, width: 568, height: 1073),
+        CGRect(x: 572, y: 39, width: 1138, height: 1073)
+    ]
+    let desktopTiledWithLargeGap = [
+        CGRect(x: 0, y: 90, width: 700, height: 900),
+        CGRect(x: 900, y: 90, width: 700, height: 900)
+    ]
+    let desktopSideBySideBelowDock = [
+        CGRect(x: 0, y: 39, width: 856, height: 983),
+        CGRect(x: 854, y: 39, width: 856, height: 983)
+    ]
+    let halfOnly = [CGRect(x: 0, y: 39, width: 853, height: 1073)]
+
+    guard isSingleFullscreenWindow(single, on: screen) else {
+        fail("split geometry expected single fullscreen window to match")
+    }
+    guard hasSplitViewFullscreenWindows(splitHalf, on: screen) else {
+        fail("split geometry expected 50/50 Split View to match")
+    }
+    guard hasSplitViewFullscreenWindows(splitThird, on: screen) else {
+        fail("split geometry expected 33/67 Split View to match")
+    }
+    guard !hasSplitViewFullscreenWindows(desktopTiledWithLargeGap, on: screen) else {
+        fail("split geometry should not match ordinary tiled desktop windows")
+    }
+    guard !hasSplitViewFullscreenWindows(desktopSideBySideBelowDock, on: screen) else {
+        fail("split geometry should not match side-by-side desktop windows below the Dock")
+    }
+    guard !hasSplitViewFullscreenWindows(halfOnly, on: screen) else {
+        fail("split geometry should not match a single half-width window")
+    }
+    print("PASS split fullscreen geometry detection")
+
 case "screen-count":
     print("INFO screens=\(NSScreen.screens.count) frames=\(NSScreen.screens.map { NSStringFromRect($0.frame) })")
 
@@ -665,6 +757,7 @@
 
 log "==> Preparing QA defaults"
 defaults write "$DEFAULTS_DOMAIN" showDockIcon -bool true
+reset_dock_for_qa
 
 log "==> Starting clean app instance"
 prepare_isolated_app_instance
@@ -692,6 +785,9 @@
 sleep 0.4
 wait_swift_assert no-overlay
 
+log "==> QA split-view fullscreen geometry detection"
+swift_assert split-geometry
+
 run_fullscreen_space_case() {
   local dock_value="$1"
   log "==> QA fullscreen Space: overlay stays above the current fullscreen app (showDockIcon=$dock_value)"

--
Gitblit v1.9.3