Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions asMan/asman
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
#!/bin/zsh
# save/restore windows through an aerospace restart
# - won't work for app restarts or OS reboots where window IDs are reset
# (window IDs are ephemeral; see https://github.com/nikitabobko/AeroSpace/issues/57)
# - window-layout (tiling vs floating) and pixel size are also saved and restored
# - floating window POSITION cannot be restored without Accessibility permission;
# only workspace assignment and the floating layout flag are restored for those windows

STATEFILE=~/.aerospace-windows.json

# Resolve the directory this script lives in so we can find the companion binary.
SCRIPT_DIR="${0:A:h}"
BOUNDS_BIN="${SCRIPT_DIR}/asman-get-bounds"

save()
{
if [[ ! -x "${BOUNDS_BIN}" ]]; then
echo "warning: ${BOUNDS_BIN} not found; run asman-build to compile it" >&2
echo " saving workspace assignments only (no size/position data)" >&2
aerospace list-windows --all --json --format '%{window-id}%{workspace}%{app-name}%{window-layout}' > "$STATEFILE"
echo "wrote ${STATEFILE}" >&2
return
fi

echo "writing current window state to ${STATEFILE}" >&2

# Capture window metadata from AeroSpace (workspace, layout) and pixel bounds
# from CGWindowListCopyWindowInfo, then merge them by window-id.
local aerospace_json bounds_json merged
aerospace_json="$(aerospace list-windows --all --json --format '%{window-id}%{workspace}%{app-name}%{window-layout}')"
bounds_json="$("${BOUNDS_BIN}")"

# jq merge: for each AeroSpace window entry look up its bounds by window-id key.
merged="$(
jq -n \
--argjson aero "${aerospace_json}" \
--argjson bounds "${bounds_json}" \
'$aero | map(
. as $w |
($bounds[(.["window-id"] | tostring)] // {}) as $b |
$w + { "x": ($b.x // null),
"y": ($b.y // null),
"width": ($b.width // null),
"height": ($b.height // null) }
)'
)"

echo "${merged}" > "$STATEFILE"
echo "wrote ${STATEFILE}" >&2
# Print a human-readable summary to stdout for easy inspection.
jq -r '.[] | "\(."app-name") (\(."window-id")) ws=\(."workspace") layout=\(."window-layout") \(."width")x\(."height")+\(."x")+\(."y")"' <<< "${merged}"
}

restore()
{
if [[ ! -f "${STATEFILE}" ]]; then
echo "error: ${STATEFILE} not found; run 'asman save' first" >&2
return 1
fi
echo "restoring window state from ${STATEFILE}" >&2

# Pass 1 — move every window to its saved workspace.
# aerospace can (and will) consume stdin from the read loop; redirect to /dev/null.
jq -r '.[] | [."window-id", ."workspace", ."app-name"] | @tsv' < "$STATEFILE" |
while IFS="$(printf '\t')" read -r windowid workspace appname; do
echo " workspace: ${appname} (${windowid}) -> ${workspace}"
aerospace move-node-to-workspace --window-id "${windowid}" "${workspace}" \
</dev/null >/dev/null 2>&1
done

# Pass 2 — restore layout and size.
# Floating windows: restore the floating layout flag only (pixel position requires
# Accessibility permission which is not assumed here).
# Tiled windows: restore absolute width and height via 'aerospace resize'.
jq -r '.[] | [
."window-id",
."window-layout",
(."width" // ""),
(."height" // "")
] | @tsv' < "$STATEFILE" |
while IFS="$(printf '\t')" read -r windowid layout width height; do
if [[ "${layout}" == "floating" ]]; then
echo " layout: (${windowid}) -> floating (position not restored; Accessibility not required)"
aerospace layout floating --window-id "${windowid}" </dev/null >/dev/null 2>&1
elif [[ -n "${width}" && "${width}" != "null" && -n "${height}" && "${height}" != "null" ]]; then
echo " resize: (${windowid}) ${width}x${height}"
aerospace resize width "${width}" --window-id "${windowid}" </dev/null >/dev/null 2>&1
aerospace resize height "${height}" --window-id "${windowid}" </dev/null >/dev/null 2>&1
fi
done
}

case "$1" in
save) save ;;
restore) restore ;;
*)
echo "Usage: $0 (save|restore)" >&2
;;
esac
Binary file added asMan/asman-get-bounds
Binary file not shown.
49 changes: 49 additions & 0 deletions asMan/get-bounds/asman-get-bounds.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// asman-get-bounds.swift
// Reads pixel bounds for all on-screen layer-0 windows using CGWindowListCopyWindowInfo.
// Does NOT require Accessibility permission.
// Outputs a JSON object keyed by CGWindowNumber (== AeroSpace window-id):
// { "<window-id>": { "x": N, "y": N, "width": N, "height": N }, ... }
//
// Build:
// swiftc asman-get-bounds.swift -o asman-get-bounds \
// -framework Cocoa -framework Foundation

import Cocoa
import Foundation

let options: CGWindowListOption = [.optionAll, .excludeDesktopElements]
guard let list = CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]] else {
fputs("error: CGWindowListCopyWindowInfo returned nil\n", stderr)
exit(1)
}

// CGWindowBounds values arrive as either Int or Double depending on the display;
// this helper normalises them to Int.
func intVal(_ dict: [String: Any], _ key: String) -> Int {
if let v = dict[key] as? Int { return v }
if let v = dict[key] as? Double { return Int(v) }
return 0
}

var result: [String: [String: Int]] = [:]
for window in list {
guard
let wid = window[kCGWindowNumber as String] as? Int,
let layer = window[kCGWindowLayer as String] as? Int,
layer == 0,
let bounds = window[kCGWindowBounds as String] as? [String: Any]
else { continue }

var entry: [String: Int] = [:]
entry["x"] = intVal(bounds, "X")
entry["y"] = intVal(bounds, "Y")
entry["width"] = intVal(bounds, "Width")
entry["height"] = intVal(bounds, "Height")
result[String(wid)] = entry
}

guard let json = try? JSONSerialization.data(withJSONObject: result, options: [.sortedKeys]) else {
fputs("error: JSON serialization failed\n", stderr)
exit(1)
}
print(String(data: json, encoding: .utf8)!)
44 changes: 44 additions & 0 deletions asMan/get-bounds/build.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
#!/bin/zsh
# asman-build
# Compiles asman-get-bounds.swift into a binary and installs it next to asman.
# Run from anywhere; all paths are resolved relative to this script's location.
#
# Usage:
# ./asman-build # build and install
# ./asman-build clean # remove compiled binary

set -euo pipefail

SCRIPT_DIR="${0:A:h}"
SRC="${SCRIPT_DIR}/asman-get-bounds.swift"
# Install the binary one level up (bin/) next to the asman script itself,
# so chezmoi can manage it as a regular executable file.
OUT="${SCRIPT_DIR}/../asman-get-bounds"

build() {
echo "Compiling ${SRC} -> ${OUT}" >&2
swiftc "${SRC}" \
-framework Cocoa \
-framework Foundation \
-O \
-o "${OUT}"
echo "Done: ${OUT}" >&2
}

clean() {
if [[ -f "${OUT}" ]]; then
rm -f "${OUT}"
echo "Removed ${OUT}" >&2
else
echo "Nothing to clean." >&2
fi
}

case "${1:-build}" in
build) build ;;
clean) clean ;;
*)
echo "Usage: $0 [build|clean]" >&2
exit 1
;;
esac
Loading