From ca6f9be3818641e87d854a82646d2ea2aff6e545 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Fri, 12 Jun 2026 12:52:43 +0800
Subject: [PATCH] Add Quick Switch view mode entry
---
C3.tools/round1-view-mode-entry-qa.sh | 202 ++++++++++++++++++++++++++++
C1.source/Resources/Aligner-Info.plist | 4
C1.source/Sources/Aligner/AlignerApplicationDelegate.swift | 141 ++++++++++++++++++-
C1.source/Sources/Aligner/QuickSwitchSessionController.swift | 19 ++
4 files changed, 353 insertions(+), 13 deletions(-)
diff --git a/C1.source/Resources/Aligner-Info.plist b/C1.source/Resources/Aligner-Info.plist
index ce77d23..9baef85 100644
--- a/C1.source/Resources/Aligner-Info.plist
+++ b/C1.source/Resources/Aligner-Info.plist
@@ -17,9 +17,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
- <string>0.0.57</string>
+ <string>0.0.58</string>
<key>CFBundleVersion</key>
- <string>20260612.1151</string>
+ <string>20260612.1249</string>
<key>LSMinimumSystemVersion</key>
<string>26.0</string>
<key>NSHighResolutionCapable</key>
diff --git a/C1.source/Sources/Aligner/AlignerApplicationDelegate.swift b/C1.source/Sources/Aligner/AlignerApplicationDelegate.swift
index f021a10..ad68641 100644
--- a/C1.source/Sources/Aligner/AlignerApplicationDelegate.swift
+++ b/C1.source/Sources/Aligner/AlignerApplicationDelegate.swift
@@ -3,10 +3,11 @@
import AlignerCore
@MainActor
-final class AlignerApplicationDelegate: NSObject, NSApplicationDelegate {
+final class AlignerApplicationDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
private static let quickSwitchConfirmBeforeCloseDefaultsKey = "QuickSwitchConfirmBeforeClose"
private let permissionChecker = SystemPermissionChecker()
+ private let preferenceStore = UserDefaultsPreferenceStore()
private let launchOptions = Round0DebugLaunchOptions.parse(arguments: ProcessInfo.processInfo.arguments)
private let round1PoCOptions = Round1PerformancePoCOptions.parse(arguments: ProcessInfo.processInfo.arguments)
private let round1TriggerProbeOptions = Round1TriggerProbeOptions.parse(arguments: ProcessInfo.processInfo.arguments)
@@ -21,6 +22,8 @@
private var quickSwitchTriggerService: CGEventTapTriggerService?
private var isRunningQuickSwitchLifecycle = false
private var statusItem: NSStatusItem?
+ private var verticalWaterfallViewModeMenuItem: NSMenuItem?
+ private var horizontalWaterfallViewModeMenuItem: NSMenuItem?
func applicationDidFinishLaunching(_ notification: Notification) {
let bundlePathSummary = DevelopmentDiagnostics.pathSummary(Bundle.main.bundlePath)
@@ -91,21 +94,68 @@
item.button?.title = "A"
let menu = NSMenu()
- menu.addItem(NSMenuItem(title: "Check Permissions...", action: #selector(showPermissions), keyEquivalent: ","))
+ menu.delegate = self
+ menu.addItem(menuItem(title: "About Aligner...", action: #selector(showAbout), keyEquivalent: ""))
menu.addItem(.separator())
- menu.addItem(NSMenuItem(title: "Show Quick Switch", action: #selector(showQuickSwitchFromMenu), keyEquivalent: ""))
- menu.addItem(NSMenuItem(title: "Hide Quick Switch", action: #selector(hideQuickSwitchFromMenu), keyEquivalent: ""))
+ menu.addItem(menuItem(title: "Check Permissions...", action: #selector(showPermissions), keyEquivalent: ","))
menu.addItem(.separator())
- menu.addItem(NSMenuItem(title: "Show Round0 Debug Overlay", action: #selector(showRound0DebugOverlay), keyEquivalent: "d"))
- menu.addItem(NSMenuItem(title: "Hide Round0 Debug Overlay", action: #selector(hideRound0DebugOverlay), keyEquivalent: ""))
+ menu.addItem(menuItem(title: "Show Quick Switch", action: #selector(showQuickSwitchFromMenu), keyEquivalent: ""))
+ menu.addItem(menuItem(title: "Hide Quick Switch", action: #selector(hideQuickSwitchFromMenu), keyEquivalent: ""))
+ menu.addItem(quickSwitchViewModeMenuItem())
menu.addItem(.separator())
- menu.addItem(NSMenuItem(title: "Quit Aligner", action: #selector(quit), keyEquivalent: "q"))
+ menu.addItem(menuItem(title: "Show Round0 Debug Overlay", action: #selector(showRound0DebugOverlay), keyEquivalent: "d"))
+ menu.addItem(menuItem(title: "Hide Round0 Debug Overlay", action: #selector(hideRound0DebugOverlay), keyEquivalent: ""))
+ menu.addItem(.separator())
+ menu.addItem(menuItem(title: "Quit Aligner", action: #selector(quit), keyEquivalent: "q"))
item.menu = menu
statusItem = item
+ updateWaterfallViewModeMenuState()
DevelopmentDiagnostics.log("app.statusItem.configure.end", [
- "hasButton": item.button != nil
+ "hasButton": item.button != nil,
+ "hasAboutItem": true,
+ "hasWaterfallViewModeItems": verticalWaterfallViewModeMenuItem != nil
+ && horizontalWaterfallViewModeMenuItem != nil
])
+ }
+
+ func menuWillOpen(_ menu: NSMenu) {
+ updateWaterfallViewModeMenuState()
+ }
+
+ private func menuItem(title: String, action: Selector?, keyEquivalent: String) -> NSMenuItem {
+ let item = NSMenuItem(title: title, action: action, keyEquivalent: keyEquivalent)
+ item.target = self
+ return item
+ }
+
+ private func quickSwitchViewModeMenuItem() -> NSMenuItem {
+ let submenu = NSMenu(title: "Quick Switch View")
+ let verticalItem = menuItem(
+ title: "Vertical Columns",
+ action: #selector(useVerticalWaterfallViewMode),
+ keyEquivalent: ""
+ )
+ let horizontalItem = menuItem(
+ title: "Horizontal Waterfall",
+ action: #selector(useHorizontalWaterfallViewMode),
+ keyEquivalent: ""
+ )
+ submenu.addItem(verticalItem)
+ submenu.addItem(horizontalItem)
+
+ verticalWaterfallViewModeMenuItem = verticalItem
+ horizontalWaterfallViewModeMenuItem = horizontalItem
+
+ let rootItem = NSMenuItem(title: "Quick Switch View", action: nil, keyEquivalent: "")
+ rootItem.submenu = submenu
+ return rootItem
+ }
+
+ private func updateWaterfallViewModeMenuState() {
+ let mode = quickSwitchWaterfallViewMode()
+ verticalWaterfallViewModeMenuItem?.state = mode == .verticalColumns ? .on : .off
+ horizontalWaterfallViewModeMenuItem?.state = mode == .horizontalMasonry ? .on : .off
}
private func showPermissionsIfNeeded() {
@@ -468,13 +518,19 @@
guard !isRunningQuickSwitchLifecycle else { return }
let reportPathSummary = DevelopmentDiagnostics.pathSummary(reportPath)
- let report = quickSwitchSessionController?.reportDictionary() ?? [
+ var report: [String: Any] = quickSwitchSessionController?.reportDictionary() ?? [
"snapshotLoaded": false,
"quickSwitchVisible": false,
"permissionStatus": "\(accessibilityStatus())",
"diagnosticLogLocationKind": DevelopmentDiagnostics.pathSummary(DevelopmentDiagnostics.logPath)["locationKind"] ?? "unknown",
"diagnosticLogBasename": DevelopmentDiagnostics.pathSummary(DevelopmentDiagnostics.logPath)["basename"] ?? "aligner-dev.log"
]
+ let versionBuild = appVersionBuild()
+ report["preferenceWaterfallViewMode"] = preferenceStore.read().quickSwitchWaterfallViewMode.rawValue
+ report["resolvedWaterfallViewMode"] = quickSwitchWaterfallViewMode().rawValue
+ report["aboutVersion"] = versionBuild.version
+ report["aboutBuild"] = versionBuild.build
+ report["statusMenu"] = statusMenuReportDictionary()
do {
let data = try JSONSerialization.data(withJSONObject: report, options: [.prettyPrinted, .sortedKeys])
@@ -552,7 +608,7 @@
private func quickSwitchWaterfallViewMode() -> QuickSwitchWaterfallViewMode {
round1QuickSwitchOptions.waterfallViewMode
- ?? UserDefaultsPreferenceStore().read().quickSwitchWaterfallViewMode
+ ?? preferenceStore.read().quickSwitchWaterfallViewMode
}
private func quickSwitchSpaceActivationService() -> any SpaceActivationServiceProtocol {
@@ -583,6 +639,71 @@
])
}
+ private func setQuickSwitchWaterfallViewMode(_ mode: QuickSwitchWaterfallViewMode) {
+ let preferences = preferenceStore.read()
+ let updatedPreferences = AlignerPreferences(
+ showMinimizedWindows: preferences.showMinimizedWindows,
+ showFullscreenWindows: preferences.showFullscreenWindows,
+ quickSwitchWaterfallViewMode: mode,
+ theme: preferences.theme,
+ language: preferences.language,
+ spaceDisplayStrategy: preferences.spaceDisplayStrategy
+ )
+ preferenceStore.write(updatedPreferences)
+ quickSwitchSessionController?.setWaterfallViewMode(mode)
+ updateWaterfallViewModeMenuState()
+ writeQuickSwitchReportIfNeeded()
+ DevelopmentDiagnostics.log("app.quickSwitch.waterfallViewModePreferenceChanged", [
+ "mode": mode.rawValue,
+ "quickSwitchVisible": quickSwitchSessionController?.isVisible ?? false
+ ])
+ }
+
+ private func appVersionBuild() -> (version: String, build: String) {
+ let info = Bundle.main.infoDictionary ?? [:]
+ let version = info["CFBundleShortVersionString"] as? String ?? "Unknown"
+ let build = info["CFBundleVersion"] as? String ?? "Unknown"
+ return (version, build)
+ }
+
+ private func statusMenuReportDictionary() -> [String: Any] {
+ updateWaterfallViewModeMenuState()
+ return [
+ "hasAboutItem": statusItem?.menu?.item(withTitle: "About Aligner...") != nil,
+ "hasQuickSwitchViewSubmenu": statusItem?.menu?.item(withTitle: "Quick Switch View")?.submenu != nil,
+ "hasVerticalColumnsItem": verticalWaterfallViewModeMenuItem != nil,
+ "hasHorizontalWaterfallItem": horizontalWaterfallViewModeMenuItem != nil,
+ "verticalColumnsState": verticalWaterfallViewModeMenuItem?.state == .on ? "on" : "off",
+ "horizontalWaterfallState": horizontalWaterfallViewModeMenuItem?.state == .on ? "on" : "off"
+ ]
+ }
+
+ @objc private func showAbout() {
+ let versionBuild = appVersionBuild()
+ DevelopmentDiagnostics.log("app.menu.showAbout", [
+ "version": versionBuild.version,
+ "build": versionBuild.build
+ ])
+
+ NSApp.setActivationPolicy(.regular)
+ let alert = NSAlert()
+ alert.messageText = "Aligner"
+ alert.informativeText = "Version / Build: \(versionBuild.version) / \(versionBuild.build)"
+ alert.alertStyle = .informational
+ alert.addButton(withTitle: "OK")
+ alert.icon = NSApp.applicationIconImage
+ NSApp.activate(ignoringOtherApps: true)
+ alert.runModal()
+ }
+
+ @objc private func useVerticalWaterfallViewMode() {
+ setQuickSwitchWaterfallViewMode(.verticalColumns)
+ }
+
+ @objc private func useHorizontalWaterfallViewMode() {
+ setQuickSwitchWaterfallViewMode(.horizontalMasonry)
+ }
+
@objc private func showPermissions() {
DevelopmentDiagnostics.log("app.permissions.show", [
"accessibility": permissionStatusString(accessibilityStatus()),
diff --git a/C1.source/Sources/Aligner/QuickSwitchSessionController.swift b/C1.source/Sources/Aligner/QuickSwitchSessionController.swift
index 51ff663..5a406f5 100644
--- a/C1.source/Sources/Aligner/QuickSwitchSessionController.swift
+++ b/C1.source/Sources/Aligner/QuickSwitchSessionController.swift
@@ -19,7 +19,7 @@
private let windowCloseService: any WindowCloseServiceProtocol
private let systemCriticalWindowDetector: any SystemCriticalWindowDetecting
private let disableScreenshotRefresh: Bool
- private let waterfallViewMode: QuickSwitchWaterfallViewMode
+ private var waterfallViewMode: QuickSwitchWaterfallViewMode
private var closeConfirmationRequired: Bool
private let onCloseConfirmationDisabled: (() -> Void)?
private let debugOverlayWidth: CGFloat?
@@ -279,6 +279,23 @@
show()
}
+ func setWaterfallViewMode(_ mode: QuickSwitchWaterfallViewMode) {
+ guard waterfallViewMode != mode else { return }
+
+ waterfallViewMode = mode
+ DevelopmentDiagnostics.log("quickSwitch.session.waterfallViewModeChanged", [
+ "mode": mode.rawValue,
+ "visible": isVisible,
+ "hasViewModel": currentViewModel != nil
+ ])
+
+ view.setWaterfallViewMode(mode)
+ if isVisible, let currentViewModel {
+ view.applyProjected(viewModel: currentViewModel)
+ }
+ onSnapshotUpdated?()
+ }
+
func reportDictionary() -> [String: Any] {
let spaceCount = currentViewModel?.spaceCount ?? 0
let appCount = currentViewModel?.appShelf.count ?? 0
diff --git a/C3.tools/round1-view-mode-entry-qa.sh b/C3.tools/round1-view-mode-entry-qa.sh
new file mode 100755
index 0000000..87491ee
--- /dev/null
+++ b/C3.tools/round1-view-mode-entry-qa.sh
@@ -0,0 +1,202 @@
+#!/bin/bash
+# Round01.1 view-mode entry QA. It verifies that the installed-app path exposes
+# a discoverable status-menu entry for switching Quick Switch Waterfall view
+# modes and reports visible About version/build metadata.
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+OUTPUT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
+# shellcheck source=build-output-paths.sh
+source "$SCRIPT_DIR/build-output-paths.sh"
+APP="$BUILD_CURRENT_APP"
+REPORT_VERTICAL="$BUILD_REPORT_ROOT/round01-view-mode-entry-vertical-report.json"
+REPORT_HORIZONTAL="$BUILD_REPORT_ROOT/round01-view-mode-entry-horizontal-report.json"
+DOMAIN="com.ar.Aligner"
+WATERFALL_MODE_KEY="com.ar.Aligner.preferences.quickSwitch.waterfallViewMode"
+REPORT_WAIT="${ALIGNER_ROUND1_VIEW_MODE_ENTRY_REPORT_WAIT:-6.0}"
+OLD_WATERFALL_MODE_SET=0
+OLD_WATERFALL_MODE=""
+
+fail() {
+ echo "Round01.1 view-mode entry QA failed: $*" >&2
+ exit 1
+}
+
+aligner_pids_for_current_app() {
+ ps -axo pid=,args= | while read -r pid command; do
+ case "$command" in
+ "$APP/Contents/MacOS/Aligner"*) echo "$pid" ;;
+ esac
+ done
+}
+
+stop_current_aligner() {
+ for pid in $(aligner_pids_for_current_app); do
+ kill "$pid" 2>/dev/null || true
+ done
+
+ for _ in {1..30}; do
+ [ -z "$(aligner_pids_for_current_app)" ] && return
+ sleep 0.1
+ done
+
+ fail "current Aligner app did not exit before QA"
+}
+
+preserve_user_preference() {
+ if OLD_WATERFALL_MODE="$(/usr/bin/defaults read "$DOMAIN" "$WATERFALL_MODE_KEY" 2>/dev/null)"; then
+ OLD_WATERFALL_MODE_SET=1
+ else
+ OLD_WATERFALL_MODE_SET=0
+ OLD_WATERFALL_MODE=""
+ fi
+}
+
+restore_user_preference() {
+ if [ "$OLD_WATERFALL_MODE_SET" -eq 1 ]; then
+ /usr/bin/defaults write "$DOMAIN" "$WATERFALL_MODE_KEY" -string "$OLD_WATERFALL_MODE"
+ else
+ /usr/bin/defaults delete "$DOMAIN" "$WATERFALL_MODE_KEY" >/dev/null 2>&1 || true
+ fi
+}
+
+wait_for_loaded_report() {
+ local report="$1"
+ local expected_mode="$2"
+
+ /usr/bin/python3 - "$report" "$expected_mode" "$REPORT_WAIT" <<'PY'
+import json
+import sys
+import time
+
+path = sys.argv[1]
+expected_mode = sys.argv[2]
+timeout = float(sys.argv[3])
+deadline = time.monotonic() + timeout
+last_report = None
+
+while time.monotonic() < deadline:
+ try:
+ with open(path, "r", encoding="utf-8") as file:
+ report = json.load(file)
+ last_report = report
+ root = report.get("rootView", {})
+ if (
+ report.get("snapshotLoaded") is True
+ and report.get("resolvedWaterfallViewMode") == expected_mode
+ and root.get("waterfallViewMode") == expected_mode
+ ):
+ sys.exit(0)
+ except FileNotFoundError:
+ pass
+ except json.JSONDecodeError:
+ pass
+ time.sleep(0.2)
+
+if last_report is not None:
+ print(json.dumps(last_report, indent=2, ensure_ascii=False), file=sys.stderr)
+print(f"view-mode entry report did not reach {expected_mode} within {timeout:.1f}s", file=sys.stderr)
+sys.exit(1)
+PY
+}
+
+assert_report() {
+ local report="$1"
+ local expected_mode="$2"
+ local expected_vertical_state="$3"
+ local expected_horizontal_state="$4"
+ local app_version="$5"
+ local app_build="$6"
+
+ /usr/bin/python3 - "$report" "$expected_mode" "$expected_vertical_state" "$expected_horizontal_state" "$app_version" "$app_build" <<'PY'
+import json
+import re
+import sys
+
+path = sys.argv[1]
+expected_mode = sys.argv[2]
+expected_vertical_state = sys.argv[3]
+expected_horizontal_state = sys.argv[4]
+app_version = sys.argv[5]
+app_build = sys.argv[6]
+
+with open(path, "r", encoding="utf-8") as file:
+ report = json.load(file)
+
+def require(condition, message):
+ if not condition:
+ print(message, file=sys.stderr)
+ print(json.dumps(report, indent=2, ensure_ascii=False), file=sys.stderr)
+ sys.exit(1)
+
+status_menu = report.get("statusMenu", {})
+root = report.get("rootView", {})
+
+require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true")
+require(report.get("quickSwitchVisible") is True, "Quick Switch must be visible during entry QA")
+require(report.get("preferenceWaterfallViewMode") == expected_mode, "preference Waterfall mode must match expected mode")
+require(report.get("resolvedWaterfallViewMode") == expected_mode, "resolved Waterfall mode must match expected mode")
+require(root.get("waterfallViewMode") == expected_mode, "root view Waterfall mode must match expected mode")
+require(status_menu.get("hasAboutItem") is True, "status menu must expose About Aligner")
+require(status_menu.get("hasQuickSwitchViewSubmenu") is True, "status menu must expose Quick Switch View submenu")
+require(status_menu.get("hasVerticalColumnsItem") is True, "status menu must expose Vertical Columns item")
+require(status_menu.get("hasHorizontalWaterfallItem") is True, "status menu must expose Horizontal Waterfall item")
+require(status_menu.get("verticalColumnsState") == expected_vertical_state, "Vertical Columns check state must match expected mode")
+require(status_menu.get("horizontalWaterfallState") == expected_horizontal_state, "Horizontal Waterfall check state must match expected mode")
+require(report.get("aboutVersion") == app_version, "About version must match app Info.plist")
+require(report.get("aboutBuild") == app_build, "About build must match app Info.plist")
+require(re.match(r"^[0-9]+\.[0-9]+\.[0-9]+$", app_version) is not None, "version must be MAJOR.MINOR.PATCH")
+require(re.match(r"^[0-9]{8}\.[0-9]{4}$", app_build) is not None, "build must be YYYYMMDD.HHMM")
+
+print(json.dumps({
+ "mode": expected_mode,
+ "aboutVersion": report.get("aboutVersion"),
+ "aboutBuild": report.get("aboutBuild"),
+ "statusMenu": status_menu
+}, indent=2, ensure_ascii=False))
+PY
+}
+
+run_case() {
+ local mode="$1"
+ local report="$2"
+ local vertical_state="$3"
+ local horizontal_state="$4"
+ local app_version="$5"
+ local app_build="$6"
+
+ stop_current_aligner
+ rm -f "$report"
+ /usr/bin/defaults write "$DOMAIN" "$WATERFALL_MODE_KEY" -string "$mode"
+
+ "$APP/Contents/MacOS/Aligner" \
+ --round0-skip-permissions \
+ --round01-open-quick-switch \
+ --round01-fixture-app-count=4 \
+ --round01-fixture-windows-per-app=2 \
+ --round01-disable-screenshot-refresh \
+ --round01-quick-switch-report="$report" \
+ --round01-quick-switch-auto-hide-after=1.0 \
+ --round01-quick-switch-quit-after=1.2 &
+
+ wait_for_loaded_report "$report" "$mode"
+ assert_report "$report" "$mode" "$vertical_state" "$horizontal_state" "$app_version" "$app_build"
+ wait "$!" || true
+ stop_current_aligner
+}
+
+[ -x "$APP/Contents/MacOS/Aligner" ] || fail "missing executable app at $APP"
+INFO_PLIST="$APP/Contents/Info.plist"
+APP_VERSION="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$INFO_PLIST" 2>/dev/null)" \
+ || fail "unable to read CFBundleShortVersionString from $INFO_PLIST"
+APP_BUILD="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$INFO_PLIST" 2>/dev/null)" \
+ || fail "unable to read CFBundleVersion from $INFO_PLIST"
+
+preserve_user_preference
+trap 'stop_current_aligner; restore_user_preference' EXIT
+
+run_case "verticalColumns" "$REPORT_VERTICAL" "on" "off" "$APP_VERSION" "$APP_BUILD"
+run_case "horizontalMasonry" "$REPORT_HORIZONTAL" "off" "on" "$APP_VERSION" "$APP_BUILD"
+
+echo "Round01.1 view-mode entry QA passed"
--
Gitblit v1.9.3