From af79aba21bb9b18a3b61dfdbbaf01b9aea309eee Mon Sep 17 00:00:00 2001 From: ekklesia Date: Fri, 4 Sep 2026 17:06:43 -0500 Subject: [PATCH] feat: minimize and restore windows the way a desktop does The scratchpad technically minimized Chrome, but sway hands scratchpad windows back floating, so restoring gave a draggable window instead of the maximized one the user put away. Park windows on a hidden workspace as ordinary tiled containers instead, and move them back to the visible workspace on restore. classroom-window carries the whole state machine, so the dock buttons and Super+M share one implementation: not running -> launch unfocused -> focus focused -> minimize minimized -> restore, tiled and maximized Fullscreen is dropped on the way out, since a container parked while still fullscreen keeps drawing over everything else. floating disable on the way back also rescues windows left floating by the old scratchpad path. Super+Shift+M restores the newest minimized window, so nothing can get stranded on the hidden workspace. Co-Authored-By: Claude Opus 5 --- config/sway/config | 9 +- packaging/debian/postinst | 1 + scripts/classroom-browser | 59 +++----- scripts/classroom-window | 302 ++++++++++++++++++++++++++++++++++++++ tools/build-release.sh | 36 +++++ 5 files changed, 366 insertions(+), 41 deletions(-) create mode 100755 scripts/classroom-window diff --git a/config/sway/config b/config/sway/config index e4826bb..d60c6bc 100644 --- a/config/sway/config +++ b/config/sway/config @@ -14,5 +14,10 @@ exec --no-startup-id /usr/local/bin/classroom-dock include /etc/sway/config.d/* -# Minimize focused window to scratchpad -bindsym $mod+m move container to workspace __ekklesia_hidden +# Minimize and restore. +# +# classroom-window parks the window on a hidden workspace as an ordinary +# tiled container, so restoring brings it back maximised instead of as a +# floating, draggable window the way the scratchpad did. +bindsym $mod+m exec /usr/local/bin/classroom-window minimize +bindsym $mod+Shift+m exec /usr/local/bin/classroom-window restore-last diff --git a/packaging/debian/postinst b/packaging/debian/postinst index ed04b9b..e2f202f 100755 --- a/packaging/debian/postinst +++ b/packaging/debian/postinst @@ -18,6 +18,7 @@ chown classroom:classroom /home/classroom/.bash_profile chmod 0644 /home/classroom/.bash_profile chmod 0755 /usr/local/bin/classroom-browser +chmod 0755 /usr/local/bin/classroom-window if [ ! -f /etc/ekklesia-classroom/device.conf ]; then cat > /etc/ekklesia-classroom/device.conf <<'CONFIG' diff --git a/scripts/classroom-browser b/scripts/classroom-browser index 2bed771..80cd4b4 100755 --- a/scripts/classroom-browser +++ b/scripts/classroom-browser @@ -1,44 +1,25 @@ #!/bin/bash -set -e +set -euo pipefail -APP_ID='google-chrome' -HIDDEN_WS='__ekklesia_hidden' +# The dock's Browser button. classroom-window decides whether this is a +# launch, a focus, a minimise or a restore. +# +# Chrome stays a full browser: Wonder Ink is only the starting page, and +# the profile in ~/.config/google-chrome is never touched here so logins +# and cookies survive reboots and updates. -if ! pgrep -x chrome >/dev/null 2>&1 && - ! pgrep -x google-chrome >/dev/null 2>&1; then - exec google-chrome \ +HOME_PAGE='https://app.wonderink.org/' + +exec /usr/local/bin/classroom-window toggle \ + --match 'google-chrome' \ + --match 'chrome' \ + --match 'chrome-.*' \ + --match 'google-chrome-.*' \ + -- \ + google-chrome \ --ozone-platform=wayland \ --start-maximized \ - https://app.wonderink.org/ -fi - -focused_app="$( - swaymsg -t get_tree -r | - python3 -c ' -import json, sys - -tree = json.load(sys.stdin) - -def find_focused(node): - if node.get("focused"): - return node - for child in node.get("nodes", []) + node.get("floating_nodes", []): - result = find_focused(child) - if result: - return result - return None - -node = find_focused(tree) -print((node or {}).get("app_id") or "") -' -)" - -if [ "$focused_app" = "$APP_ID" ]; then - swaymsg '[app_id="google-chrome"] move container to workspace __ekklesia_hidden' >/dev/null - exit 0 -fi - -swaymsg '[app_id="google-chrome"] move container to workspace current' >/dev/null 2>&1 || true -swaymsg '[app_id="google-chrome"] focus' >/dev/null 2>&1 || true -swaymsg '[app_id="google-chrome"] fullscreen disable' >/dev/null 2>&1 || true -swaymsg '[app_id="google-chrome"] floating disable' >/dev/null 2>&1 || true + --no-first-run \ + --no-default-browser-check \ + --password-store=basic \ + "$HOME_PAGE" diff --git a/scripts/classroom-window b/scripts/classroom-window new file mode 100755 index 0000000..9d7a75c --- /dev/null +++ b/scripts/classroom-window @@ -0,0 +1,302 @@ +#!/usr/bin/env python3 + +"""Classroom window control. + +Sway has no real minimise concept, so the classroom uses a dedicated +hidden workspace. A minimised window is parked there as an ordinary +tiled container and moved back to the visible workspace on restore, +which is why it returns maximised instead of floating the way the old +scratchpad approach did. +""" + +import argparse +import json +import os +import re +import subprocess +import sys + +HIDDEN_WORKSPACE = "__ekklesia_hidden" + +STATE_DIR = os.path.join( + os.environ.get("XDG_RUNTIME_DIR") or "/tmp", + "ekklesia-classroom", +) +STATE_FILE = os.path.join(STATE_DIR, "minimized") + + +def swaymsg(*arguments): + """Run swaymsg, returning stdout or None when sway is unavailable.""" + try: + result = subprocess.run( + ["swaymsg", *arguments], + capture_output=True, + text=True, + ) + except (OSError, subprocess.SubprocessError): + return None + + if result.returncode != 0: + return None + + return result.stdout + + +def command(argument): + """Run a sway command, ignoring criteria that match nothing.""" + swaymsg("--", argument) + + +def query(kind): + output = swaymsg("-t", kind, "-r") + + if not output: + return None + + try: + return json.loads(output) + except ValueError: + return None + + +def collect(node, workspace, found): + """Flatten the sway tree into the windows the classroom cares about.""" + if node.get("type") == "workspace": + workspace = node.get("name") + + is_window = node.get("pid") is not None and not node.get("nodes") + + if is_window: + properties = node.get("window_properties") or {} + + found.append( + { + "id": node.get("id"), + "app_id": node.get("app_id") or "", + "class": properties.get("class") or "", + "instance": properties.get("instance") or "", + "focused": bool(node.get("focused")), + "workspace": workspace, + "hidden": workspace == HIDDEN_WORKSPACE, + } + ) + + for child in node.get("nodes", []) + node.get("floating_nodes", []): + collect(child, workspace, found) + + return found + + +def windows(): + tree = query("get_tree") + + if tree is None: + return [] + + return collect(tree, None, []) + + +def matches(window, patterns): + """Match a window against app_id / X11 class / X11 instance.""" + identities = [ + window["app_id"], + window["class"], + window["instance"], + ] + + for pattern in patterns: + expression = re.compile(pattern, re.IGNORECASE) + + for identity in identities: + if identity and expression.fullmatch(identity): + return True + + return False + + +def visible_workspace(): + """The workspace a restored window should come back to.""" + workspaces = query("get_workspaces") or [] + + for workspace in workspaces: + if workspace.get("focused") and workspace.get("name") != HIDDEN_WORKSPACE: + return workspace.get("name") + + for workspace in workspaces: + if workspace.get("visible") and workspace.get("name") != HIDDEN_WORKSPACE: + return workspace.get("name") + + return "1" + + +def read_state(): + try: + with open(STATE_FILE, "r", encoding="utf-8") as handle: + return [int(line) for line in handle.read().split() if line.strip()] + except (OSError, ValueError): + return [] + + +def write_state(identifiers): + try: + os.makedirs(STATE_DIR, exist_ok=True) + + with open(STATE_FILE, "w", encoding="utf-8") as handle: + handle.write("\n".join(str(item) for item in identifiers)) + except OSError: + pass + + +def remember(identifier): + """Track minimise order so restore-last picks the newest window.""" + state = [item for item in read_state() if item != identifier] + state.append(identifier) + write_state(state[-32:]) + + +def forget(identifier): + write_state([item for item in read_state() if item != identifier]) + + +def minimize(window): + # A fullscreen container keeps rendering over everything else if it + # is parked while still fullscreen, so always drop out of it first. + command(f'[con_id={window["id"]}] fullscreen disable') + command(f'[con_id={window["id"]}] move container to workspace {HIDDEN_WORKSPACE}') + + remember(window["id"]) + + +def restore(window): + workspace = visible_workspace() + + command(f'[con_id={window["id"]}] move container to workspace {workspace}') + + # Windows parked by older classroom builds came back floating because + # they had been through the scratchpad. Force them back into the tiling + # layout so a restored browser is maximised like the user expects. + command(f'[con_id={window["id"]}] floating disable') + command(f'[con_id={window["id"]}] focus') + + forget(window["id"]) + + +def launch(argv): + if not argv: + return + + try: + subprocess.Popen(argv, start_new_session=True) + except OSError as error: + print(f"classroom-window: unable to launch {argv[0]}: {error}", file=sys.stderr) + + +def action_toggle(patterns, argv): + """Launch, focus, minimise or restore, in the order a user expects.""" + candidates = [window for window in windows() if matches(window, patterns)] + + if not candidates: + launch(argv) + return 0 + + hidden = [window for window in candidates if window["hidden"]] + + if hidden: + for window in hidden: + restore(window) + return 0 + + if any(window["focused"] for window in candidates): + for window in candidates: + minimize(window) + return 0 + + command(f'[con_id={candidates[-1]["id"]}] focus') + return 0 + + +def action_minimize(): + for window in windows(): + if window["focused"] and not window["hidden"]: + minimize(window) + return 0 + + return 0 + + +def action_restore_last(): + hidden = {window["id"]: window for window in windows() if window["hidden"]} + + if not hidden: + return 0 + + for identifier in reversed(read_state()): + if identifier in hidden: + restore(hidden[identifier]) + return 0 + + restore(list(hidden.values())[-1]) + return 0 + + +def action_list(): + for window in windows(): + state = "minimized" if window["hidden"] else "visible" + state = "focused" if window["focused"] else state + + print( + f'{window["id"]}\t{state}\t' + f'{window["app_id"] or window["class"]}\t{window["workspace"]}' + ) + + return 0 + + +def main(): + parser = argparse.ArgumentParser( + description="Minimise, restore and toggle classroom windows.", + ) + subparsers = parser.add_subparsers(dest="action", required=True) + + toggle = subparsers.add_parser( + "toggle", + help="Launch, focus, minimise or restore an application", + ) + toggle.add_argument( + "--match", + action="append", + required=True, + metavar="REGEX", + help="app_id or X11 class to match (may be repeated)", + ) + toggle.add_argument( + "launch", + nargs=argparse.REMAINDER, + help="Command to run when the application is not running", + ) + + subparsers.add_parser("minimize", help="Minimise the focused window") + subparsers.add_parser("restore-last", help="Restore the newest minimised window") + subparsers.add_parser("list", help="List classroom windows and their state") + + arguments = parser.parse_args() + + if arguments.action == "toggle": + argv = arguments.launch + + if argv and argv[0] == "--": + argv = argv[1:] + + return action_toggle(arguments.match, argv) + + if arguments.action == "minimize": + return action_minimize() + + if arguments.action == "restore-last": + return action_restore_last() + + return action_list() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/build-release.sh b/tools/build-release.sh index 7453fef..0ebf8d8 100755 --- a/tools/build-release.sh +++ b/tools/build-release.sh @@ -20,6 +20,38 @@ fi STAGE="$ROOT/packaging/root" OUTPUT="$ROOT/dist/ekklesia-classroom_${VERSION}_amd64.deb" +# Syntax check everything the package ships, by shebang, so this cannot +# drift out of date as classroom commands are added. +echo "Validating sources" + +for script in "$ROOT/packaging/debian/postinst" "$ROOT"/scripts/*; do + [[ -f "$script" ]] || continue + + case "$(head -n 1 "$script")" in + *python3*) + python3 -m py_compile "$script" + ;; + *bash*|*bin/sh*) + bash -n "$script" + ;; + esac +done + +for module in "$ROOT"/lib/*.py; do + [[ -f "$module" ]] || continue + + python3 -m py_compile "$module" +done + +for policy in "$ROOT"/config/chrome/policies/*/*.json; do + [[ -f "$policy" ]] || continue + + python3 -c 'import json, sys; json.load(open(sys.argv[1]))' "$policy" +done + +find "$ROOT/scripts" "$ROOT/lib" \ + -name '__pycache__' -type d -prune -exec rm -rf {} + 2>/dev/null || true + echo "Building Ekklesia Classroom ${VERSION}" # Always construct the package from clean canonical sources. @@ -54,6 +86,10 @@ install -Dm755 \ "$ROOT/scripts/classroom-browser" \ "$STAGE/usr/local/bin/classroom-browser" +install -Dm755 \ + "$ROOT/scripts/classroom-window" \ + "$STAGE/usr/local/bin/classroom-window" + install -Dm644 \ "$ROOT/config/wallpapers/classroom-default.png" \ "$STAGE/usr/share/backgrounds/ekklesia/classroom-default.png"