101 lines
6.5 KiB
Python
101 lines
6.5 KiB
Python
"""Capture minimale, sans nom de fenêtre, chemin de document ou argv."""
|
|
import time
|
|
from .errors import require
|
|
from .schema import validate, PROVIDER
|
|
from .ipc import walk, location
|
|
|
|
|
|
def capture(sway, catalog, provider, reason="manual"):
|
|
tree = sway.tree()
|
|
def topology(node):
|
|
# Les titres, tokens et métadonnées applicatives ne participent pas à
|
|
# cette empreinte éphémère. Détecter aussi déplacement/focus durant capture.
|
|
return (node.get("id"), node.get("layout"), node.get("name") if node.get("type") in ("workspace", "output") else None,
|
|
node.get("app_id"), node.get("window"), node.get("pid"), node.get("scratchpad_state", "none"), node.get("focused"), node.get("fullscreen_mode"), node.get("floating"), node.get("rect"),
|
|
tuple(topology(c) for c in node.get("nodes", [])), tuple(topology(c) for c in node.get("floating_nodes", [])))
|
|
nodes = [n for n in walk(tree) if n.get("app_id") or n.get("window")]
|
|
require(len(nodes) <= 256, "WINDOW_LIMIT")
|
|
specs = {}
|
|
for n in nodes:
|
|
require(not n.get("fullscreen_mode"), "CAPABILITY_UNSUPPORTED")
|
|
require(n.get("scratchpad_state", "none") in ("none", "fresh", "changed"), "SCRATCHPAD_STATE_UNSUPPORTED")
|
|
specs[n["id"]] = catalog.classify(n)
|
|
apps = {}
|
|
identities = {}
|
|
for s in specs.values():
|
|
if s.application_id in apps: continue
|
|
members = [n for n in nodes if specs[n["id"]].application_id == s.application_id]
|
|
apps[s.application_id] = {"application_id": s.application_id, "desktop_entry": s.desktop_entry,
|
|
"strategy": s.strategy, "expected_windows": len(members),
|
|
"managed_by": (s.managed_by or "autostart") if s.strategy == "managed-autostart" else None, "identity_provider": s.provider}
|
|
if s.provider == PROVIDER:
|
|
require(provider is not None, "FIREFOX_IDENTITY_PROVIDER_UNAVAILABLE")
|
|
identities.update({v: k for k, v in provider.capture(members).items()})
|
|
slots, workspaces = [], []
|
|
focused = None
|
|
selected = [n for n in walk(tree) if n.get("focused")]
|
|
# Une layer-shell (notamment le dialogue de logout) peut prendre le focus
|
|
# et ne laisser aucune feuille/workspace marqué focused dans GET_TREE.
|
|
# Capturer explicitement l'inconnu, jamais déduire une cible d'une pile IPC.
|
|
require(len(selected) <= 1, "SNAPSHOT_FOCUS_AMBIGUOUS")
|
|
focus_workspace = None
|
|
if selected:
|
|
focus_location = location(tree, selected[0]["id"])
|
|
require(focus_location and focus_location[0] and focus_location[0] != "__i3_scratch", "SNAPSHOT_FOCUS_AMBIGUOUS")
|
|
focus_workspace = focus_location[0]
|
|
def leaf(n, workspace, path, floating):
|
|
nonlocal focused
|
|
app = specs[n["id"]]
|
|
slot_id = "slot-" + str(len(slots) + 1)
|
|
if app.provider == PROVIDER:
|
|
require(n["id"] in identities, "EXACT_WINDOW_IDENTITY_MISSING")
|
|
requirement, evidence = "exact", {"type": PROVIDER, "id": identities[n["id"]]}
|
|
elif apps[app.application_id]["expected_windows"] == 1:
|
|
requirement, evidence = "exact", {"type": "application-singleton", "id": app.application_id}
|
|
else: requirement, evidence = "interchangeable", None
|
|
if n.get("focused"): focused = slot_id
|
|
scratchpad = "none" if n.get("scratchpad_state", "none") == "none" else ("hidden" if workspace is None else "shown")
|
|
require(workspace is not None or scratchpad == "hidden", "SCRATCHPAD_STATE_UNSUPPORTED")
|
|
require(scratchpad == "none" or floating, "SCRATCHPAD_FLOATING_REQUIRED")
|
|
if floating:
|
|
require(type(n.get("rect")) is dict and all(k in n["rect"] for k in ("x", "y", "width", "height")), "SNAPSHOT_GEOMETRY_MISSING")
|
|
geometry = {k: n["rect"][k] for k in ("x", "y", "width", "height")} if floating else None
|
|
slots.append({"slot_id": slot_id, "application_id": app.application_id, "workspace": workspace,
|
|
"tree_path": list(path), "state": {"floating": floating, "geometry": geometry, "fullscreen": False, "scratchpad": scratchpad},
|
|
"identity_requirement": requirement, "identity_evidence": evidence})
|
|
return {"slot_id": slot_id}
|
|
def convert(n, workspace, path=()):
|
|
if n.get("app_id") or n.get("window"): return leaf(n, workspace, path, False)
|
|
children = n.get("nodes", [])
|
|
if not children: return None
|
|
if len(children) == 1:
|
|
require(n["layout"] in ("splith", "splitv", "none"), "SINGLE_CHILD_LAYOUT_UNSUPPORTED")
|
|
return convert(children[0], workspace, path)
|
|
return {"layout": n["layout"], "children": [convert(c, workspace, path + (i,)) for i, c in enumerate(children)]}
|
|
outputs = []
|
|
for output in tree.get("nodes", []):
|
|
for ws in output.get("nodes", []):
|
|
if ws.get("type") != "workspace" or not (ws.get("nodes") or ws.get("floating_nodes") or ws.get("name") == focus_workspace): continue
|
|
if ws["name"] == "__i3_scratch":
|
|
require(not ws.get("nodes"), "SCRATCHPAD_CONTAINER_UNSUPPORTED")
|
|
for f in ws.get("floating_nodes", []):
|
|
require(f.get("app_id") or f.get("window"), "SCRATCHPAD_CONTAINER_UNSUPPORTED")
|
|
leaf(f, None, (), True)
|
|
continue
|
|
root = convert(ws, ws["name"])
|
|
for f in ws.get("floating_nodes", []):
|
|
children = [n for n in walk(f) if n.get("app_id") or n.get("window")]
|
|
require(len(children) == 1, "FLOATING_CONTAINER_UNSUPPORTED")
|
|
require(children[0] is f, "FLOATING_CONTAINER_UNSUPPORTED")
|
|
leaf(children[0], ws["name"], (), True)
|
|
workspaces.append({"workspace": ws["name"], "output": output["name"], "root": root})
|
|
if output["name"] not in outputs: outputs.append(output["name"])
|
|
require(len(slots) == len(nodes), "SNAPSHOT_UNPLACED_WINDOW")
|
|
# Relecture : aucun checkpoint hybride si une fenêtre apparaît/disparaît.
|
|
require({n["id"] for n in sway.windows()} == set(specs), "SNAPSHOT_CHANGED")
|
|
require(topology(sway.tree()) == topology(tree), "SNAPSHOT_CHANGED")
|
|
data = {"schema": "labfy.sway.session-v2", "version": 2,
|
|
"metadata": {"created_ns": time.time_ns(), "sway_session": sway.session, "reason": reason},
|
|
"outputs": [{"name": n} for n in outputs], "applications": list(apps.values()),
|
|
"window_slots": slots, "workspaces": workspaces, "focus": focused, "focus_workspace": focus_workspace}
|
|
return validate(data)
|