Ariver
2026-06-12 ca6f9be3818641e87d854a82646d2ea2aff6e545
Add Quick Switch view mode entry
3 files modified
1 files added
366 ■■■■■ changed files
C1.source/Resources/Aligner-Info.plist 4 ●●●● patch | view | raw | blame | history
C1.source/Sources/Aligner/AlignerApplicationDelegate.swift 141 ●●●●● patch | view | raw | blame | history
C1.source/Sources/Aligner/QuickSwitchSessionController.swift 19 ●●●●● patch | view | raw | blame | history
C3.tools/round1-view-mode-entry-qa.sh 202 ●●●●● patch | view | raw | blame | history
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>
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()),
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
C3.tools/round1-view-mode-entry-qa.sh
New file
@@ -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"