#!/bin/bash
|
# Real-desktop QA for Obsidian windows that share one process but live on
|
# different fullscreen Spaces. This catches WindowServer "activated" results
|
# that do not actually switch to the clicked window's Space.
|
|
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"
|
RUN_ID="$(date +%Y%m%d_%H%M%S)"
|
REPORT_DIR="$BUILD_REPORT_ROOT/round01-obsidian-space-activation-$RUN_ID"
|
|
fail() {
|
echo "Round01 Obsidian Space activation 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"
|
}
|
|
set_current_space_for_target() {
|
local target_space_id="$1"
|
swift - "$target_space_id" <<'SWIFT'
|
import Foundation
|
import Darwin
|
|
typealias MainConnection = @convention(c) () -> UInt32
|
typealias CopySpaces = @convention(c) (UInt32) -> Unmanaged<CFArray>?
|
typealias SetCurrentSpace = @convention(c) (UInt32, CFString, UInt64) -> Void
|
|
func symbol<T>(_ name: String, in handle: UnsafeMutableRawPointer) -> T? {
|
guard let raw = dlsym(handle, name) else { return nil }
|
return unsafeBitCast(raw, to: T.self)
|
}
|
|
func spaceID(_ record: [String: Any]) -> UInt64? {
|
if let number = record["id64"] as? NSNumber { return number.uint64Value }
|
if let number = record["id"] as? NSNumber { return number.uint64Value }
|
return nil
|
}
|
|
let targetSpaceID = UInt64(CommandLine.arguments[1])!
|
guard let handle = dlopen("/System/Library/PrivateFrameworks/SkyLight.framework/SkyLight", RTLD_LAZY),
|
let mainConnection: MainConnection = symbol("CGSMainConnectionID", in: handle),
|
let copySpaces: CopySpaces = symbol("CGSCopyManagedDisplaySpaces", in: handle),
|
let setCurrentSpace: SetCurrentSpace = symbol("CGSManagedDisplaySetCurrentSpace", in: handle),
|
let displayRecords = copySpaces(mainConnection())?.takeRetainedValue() as? [[String: Any]]
|
else {
|
fputs("SkyLight Space APIs unavailable\n", stderr)
|
exit(2)
|
}
|
|
for displayRecord in displayRecords {
|
let displayIdentifier = displayRecord["Display Identifier"] as? String ?? ""
|
let spaces = displayRecord["Spaces"] as? [[String: Any]] ?? []
|
guard spaces.contains(where: { spaceID($0) == targetSpaceID }) else { continue }
|
guard !displayIdentifier.isEmpty else {
|
fputs("display identifier missing for target Space\n", stderr)
|
exit(3)
|
}
|
setCurrentSpace(mainConnection(), displayIdentifier as CFString, targetSpaceID)
|
Thread.sleep(forTimeInterval: 0.5)
|
print(targetSpaceID)
|
exit(0)
|
}
|
|
fputs("target Space not found: \(targetSpaceID)\n", stderr)
|
exit(4)
|
SWIFT
|
}
|
|
current_space_for_target_display() {
|
local target_space_id="$1"
|
swift - "$target_space_id" <<'SWIFT'
|
import Foundation
|
import Darwin
|
|
typealias MainConnection = @convention(c) () -> UInt32
|
typealias CopySpaces = @convention(c) (UInt32) -> Unmanaged<CFArray>?
|
|
func symbol<T>(_ name: String, in handle: UnsafeMutableRawPointer) -> T? {
|
guard let raw = dlsym(handle, name) else { return nil }
|
return unsafeBitCast(raw, to: T.self)
|
}
|
|
func spaceID(_ record: [String: Any]) -> UInt64? {
|
if let number = record["id64"] as? NSNumber { return number.uint64Value }
|
if let number = record["id"] as? NSNumber { return number.uint64Value }
|
return nil
|
}
|
|
let targetSpaceID = UInt64(CommandLine.arguments[1])!
|
guard let handle = dlopen("/System/Library/PrivateFrameworks/SkyLight.framework/SkyLight", RTLD_LAZY),
|
let mainConnection: MainConnection = symbol("CGSMainConnectionID", in: handle),
|
let copySpaces: CopySpaces = symbol("CGSCopyManagedDisplaySpaces", in: handle),
|
let displayRecords = copySpaces(mainConnection())?.takeRetainedValue() as? [[String: Any]]
|
else {
|
fputs("SkyLight Space APIs unavailable\n", stderr)
|
exit(2)
|
}
|
|
for displayRecord in displayRecords {
|
let spaces = displayRecord["Spaces"] as? [[String: Any]] ?? []
|
guard spaces.contains(where: { spaceID($0) == targetSpaceID }) else { continue }
|
guard let currentSpace = displayRecord["Current Space"] as? [String: Any],
|
let currentSpaceID = spaceID(currentSpace)
|
else {
|
fputs("current Space missing for target display\n", stderr)
|
exit(3)
|
}
|
print(currentSpaceID)
|
exit(0)
|
}
|
|
fputs("target Space not found: \(targetSpaceID)\n", stderr)
|
exit(4)
|
SWIFT
|
}
|
|
run_report() {
|
local report="$1"
|
local log="$2"
|
stop_current_aligner
|
ALIGNER_DIAGNOSTICS_INCLUDE_SENSITIVE=1 \
|
"$APP/Contents/MacOS/Aligner" \
|
--round0-skip-permissions \
|
--round01-open-quick-switch \
|
--round01-disable-screenshot-refresh \
|
--round01-quick-switch-auto-hide-after=0.8 \
|
--round01-quick-switch-quit-after=1.2 \
|
--round01-quick-switch-report="$report" >"$log" 2>&1
|
}
|
|
run_click() {
|
local app_group_index="$1"
|
local window_index="$2"
|
local report="$3"
|
local log="$4"
|
stop_current_aligner
|
ALIGNER_DIAGNOSTICS_INCLUDE_SENSITIVE=1 \
|
"$APP/Contents/MacOS/Aligner" \
|
--round0-skip-permissions \
|
--round01-open-quick-switch \
|
--round01-disable-screenshot-refresh \
|
--round01-debug-mouse-sequence="click-card:$app_group_index:$window_index" \
|
--round01-quick-switch-quit-after=1.5 \
|
--round01-quick-switch-report="$report" >"$log" 2>&1
|
}
|
|
extract_pair() {
|
local report="$1"
|
/usr/bin/python3 - "$report" <<'PY'
|
import json
|
import sys
|
|
with open(sys.argv[1], "r", encoding="utf-8") as file:
|
report = json.load(file)
|
|
columns = [
|
column
|
for column in report.get("rootView", {}).get("waterfallColumns", [])
|
if column.get("bundleIdentifier") == "md.obsidian"
|
]
|
if not columns:
|
print("Obsidian app group not found", file=sys.stderr)
|
sys.exit(2)
|
|
cards = [
|
card for card in columns[0].get("cards", [])
|
if card.get("primarySpaceID") is not None
|
]
|
fullscreen_cards = [card for card in cards if card.get("isFullscreen") is True]
|
pool = fullscreen_cards if len({card.get("primarySpaceID") for card in fullscreen_cards}) >= 2 else cards
|
|
for source in pool:
|
for target in pool:
|
if source.get("windowID") == target.get("windowID"):
|
continue
|
if source.get("primarySpaceID") == target.get("primarySpaceID"):
|
continue
|
print(f"SOURCE_WINDOW_ID={source['windowID']}")
|
print(f"SOURCE_SPACE_ID={source['primarySpaceID']}")
|
print(f"TARGET_WINDOW_ID={target['windowID']}")
|
print(f"TARGET_SPACE_ID={target['primarySpaceID']}")
|
sys.exit(0)
|
|
print("Obsidian needs two candidate windows on distinct Spaces", file=sys.stderr)
|
sys.exit(3)
|
PY
|
}
|
|
find_card_indices() {
|
local report="$1"
|
local window_id="$2"
|
/usr/bin/python3 - "$report" "$window_id" <<'PY'
|
import json
|
import sys
|
|
with open(sys.argv[1], "r", encoding="utf-8") as file:
|
report = json.load(file)
|
target_window_id = int(sys.argv[2])
|
|
for column in report.get("rootView", {}).get("waterfallColumns", []):
|
if column.get("bundleIdentifier") != "md.obsidian":
|
continue
|
for card in column.get("cards", []):
|
if card.get("windowID") == target_window_id:
|
print(f"APP_GROUP_INDEX={column['appGroupIndex']}")
|
print(f"WINDOW_INDEX={card['windowIndex']}")
|
print(f"WINDOW_SPACE_ID={card['primarySpaceID']}")
|
sys.exit(0)
|
|
print(f"Obsidian target window not found: {target_window_id}", file=sys.stderr)
|
sys.exit(4)
|
PY
|
}
|
|
assert_click_report() {
|
local report="$1"
|
local target_window_id="$2"
|
/usr/bin/python3 - "$report" "$target_window_id" <<'PY'
|
import json
|
import sys
|
|
with open(sys.argv[1], "r", encoding="utf-8") as file:
|
report = json.load(file)
|
target_window_id = int(sys.argv[2])
|
|
def require(condition, message):
|
if not condition:
|
print(message, file=sys.stderr)
|
print(json.dumps({
|
"lastActivationWindowID": report.get("lastActivationWindowID"),
|
"lastActivationResult": report.get("lastActivationResult"),
|
"lastCommittedWindowID": report.get("lastCommittedWindowID"),
|
}, indent=2, ensure_ascii=False), file=sys.stderr)
|
sys.exit(5)
|
|
require(report.get("lastCommittedWindowID") == target_window_id, "committed window must match target")
|
require(report.get("lastActivationWindowID") == target_window_id, "activation window must match target")
|
require(report.get("lastActivationResult") == "activated", "activation result must be activated")
|
PY
|
}
|
|
run_direction() {
|
local source_space_id="$1"
|
local target_window_id="$2"
|
local target_space_id="$3"
|
local label="$4"
|
local before_report="$REPORT_DIR/$label-before.json"
|
local before_log="$REPORT_DIR/$label-before.log"
|
local click_report="$REPORT_DIR/$label-click.json"
|
local click_log="$REPORT_DIR/$label-click.log"
|
local current_space_id
|
|
set_current_space_for_target "$source_space_id" >/dev/null
|
run_report "$before_report" "$before_log"
|
eval "$(find_card_indices "$before_report" "$target_window_id")"
|
[ "$WINDOW_SPACE_ID" = "$target_space_id" ] || fail "$label target card Space changed: expected $target_space_id, got $WINDOW_SPACE_ID"
|
|
run_click "$APP_GROUP_INDEX" "$WINDOW_INDEX" "$click_report" "$click_log"
|
assert_click_report "$click_report" "$target_window_id"
|
sleep 0.8
|
current_space_id="$(current_space_for_target_display "$target_space_id")"
|
[ "$current_space_id" = "$target_space_id" ] || fail "$label did not switch to target Space: expected $target_space_id, got $current_space_id"
|
|
echo "PASS $label targetWindow=$target_window_id targetSpace=$target_space_id"
|
}
|
|
mkdir -p "$REPORT_DIR"
|
INITIAL_REPORT="$REPORT_DIR/initial.json"
|
INITIAL_LOG="$REPORT_DIR/initial.log"
|
PAIR_OUTPUT="$REPORT_DIR/extract-pair.out"
|
PAIR_ERROR="$REPORT_DIR/extract-pair.err"
|
|
run_report "$INITIAL_REPORT" "$INITIAL_LOG"
|
if ! extract_pair "$INITIAL_REPORT" >"$PAIR_OUTPUT" 2>"$PAIR_ERROR"; then
|
echo "Round01 Obsidian Space activation QA skipped"
|
echo "Reason: $(cat "$PAIR_ERROR")"
|
echo "Log directory: $REPORT_DIR"
|
stop_current_aligner
|
exit 0
|
fi
|
eval "$(cat "$PAIR_OUTPUT")"
|
|
run_direction "$SOURCE_SPACE_ID" "$TARGET_WINDOW_ID" "$TARGET_SPACE_ID" "forward"
|
run_direction "$TARGET_SPACE_ID" "$SOURCE_WINDOW_ID" "$SOURCE_SPACE_ID" "reverse"
|
|
stop_current_aligner
|
|
cat <<EOF
|
Round01 Obsidian Space activation QA passed
|
Log directory: $REPORT_DIR
|
Covered: Obsidian same-process windows on distinct Spaces switch to the clicked
|
target Space in both directions, not just lastActivationResult=activated.
|
EOF
|