Files
Gabriel Brown 8b59b78d9f Settle process-signal races across the services layer
A Process's exited and streamFinished signals aren't guaranteed to
fire in order, and several services decided an outcome on whichever
fired first: KdeConnect could report a successful file transfer as
failed if exited landed before the real stdout payload; Clipboard
could present a failed history query as an empty-but-healthy one;
Brightness could strand the last queued write of a drag; SoundFeedback
and SystemLocale could drop or misapply a rapid second toggle/click
because re-arming an already-running Process is a no-op. All five now
wait for both signals and let the authoritative one decide, matching
the pattern HomeAssistantConfig.qml already used correctly.

Health's "copy report" never enabled stdin, so it copied nothing
while claiming success. Capture announced every recording as saved
regardless of the recorder's actual exit code. Connectivity never
restarted Bluetooth discovery when the adapter was enabled from an
already-open page. CalendarAgenda left the UI in "loading" forever if
its helper died at startup, and the helper itself could crash
unguarded instead of reporting unavailable. Geocoding silently
dropped a query typed while the previous one was still in flight.
Notifs leaked tracked-but-undisplayed notifications under Do Not
Disturb, and dismissAll() skipped them.

Claude-Session: https://claude.ai/code/session_01E6TJUAh41HaP25MVHWkhRZ
2026-08-18 21:23:07 -04:00

375 lines
11 KiB
Python
Executable File

#!/usr/bin/env python3
"""Read GNOME's Evolution Data Server calendars for the Panama shell."""
from __future__ import annotations
import json
import re
import sys
import time
from collections.abc import Iterable
from typing import Any
from urllib.parse import urlsplit
try:
import gi
gi.require_version("ECal", "2.0")
gi.require_version("EDataServer", "1.2")
gi.require_version("ICalGLib", "3.0")
from gi.repository import ECal, EDataServer, Gio, GLib, ICalGLib
EDS_READY = True
except (ImportError, ValueError):
ECal = EDataServer = Gio = GLib = ICalGLib = None
EDS_READY = False
MEETING_HOST_SUFFIXES = (
"meet.google.com",
"zoom.us",
"teams.microsoft.com",
"teams.live.com",
"webex.com",
"meet.jit.si",
)
URL_PATTERN = re.compile(r"https?://[^\s<>\"']+", re.IGNORECASE)
COLOR_PATTERN = re.compile(r"^#[0-9a-fA-F]{6}(?:[0-9a-fA-F]{2})?$")
FALLBACK_COLOR = "#82aaff"
def stable_event_id(source_id: str, uid: str, recurrence_id: str) -> str:
return f"{source_id}:{uid}:{recurrence_id}"
def sort_events(events: list[dict[str, object]]) -> list[dict[str, object]]:
return sorted(
events,
key=lambda event: (
not bool(event["allDay"]),
int(event["start"]),
str(event["id"]),
),
)
def is_allowed_meeting_host(host: str) -> bool:
normalized = host.lower().rstrip(".")
return any(
normalized == suffix or normalized.endswith("." + suffix)
for suffix in MEETING_HOST_SUFFIXES
)
def find_join_url(values: Iterable[str | None]) -> str:
for value in values:
if not value:
continue
for match in URL_PATTERN.findall(value):
candidate = match.rstrip("),.;!?]}")
try:
parsed = urlsplit(candidate)
except ValueError:
continue
if parsed.scheme.lower() != "https" or not parsed.hostname:
continue
if is_allowed_meeting_host(parsed.hostname):
return candidate
return ""
def _safe_component_value(component: Any, getter: str) -> str:
try:
return (getattr(component, getter)() or "").strip()
except (AttributeError, TypeError):
return ""
def normalize_instance(
source_meta: dict[str, str],
component: Any,
instance_start: Any,
instance_end: Any,
) -> dict[str, object]:
uid = (component.get_uid() or "").strip()
if not uid:
raise ValueError("calendar component has no UID")
start = int(instance_start.as_timet())
end = int(instance_end.as_timet())
if end < start:
raise ValueError("calendar component ends before it starts")
recurrence = component.get_recurrenceid()
recurrence_id = str(start)
if recurrence is not None and not recurrence.is_null_time():
recurrence_id = recurrence.as_ical_string()
summary = _safe_component_value(component, "get_summary") or "Untitled event"
location = _safe_component_value(component, "get_location")
description = _safe_component_value(component, "get_description")
component_url = ""
try:
url_property = component.get_first_property(ICalGLib.PropertyKind.URL_PROPERTY)
if url_property is not None:
component_url = (url_property.get_url() or "").strip()
except (AttributeError, TypeError):
component_url = ""
all_day = bool(instance_start.is_date() or instance_end.is_date())
return {
"id": stable_event_id(source_meta["id"], uid, recurrence_id),
"sourceId": source_meta["id"],
"uid": uid,
"summary": summary,
"start": start,
"end": end,
"allDay": all_day,
"location": location,
"joinUrl": find_join_url([component_url, location, description]),
}
def _source_meta(source: Any) -> dict[str, str]:
extension = source.get_extension(EDataServer.SOURCE_EXTENSION_CALENDAR)
color = extension.get_color() or FALLBACK_COLOR
if not COLOR_PATTERN.fullmatch(color):
color = FALLBACK_COLOR
return {
"id": source.get_uid(),
"name": (source.get_display_name() or "Calendar").strip(),
"color": color.lower(),
}
def _unavailable_snapshot() -> dict[str, object]:
return {
"ok": False,
"generatedAt": int(time.time()),
"sources": [],
"events": [],
"errors": [{"code": "eds-unavailable"}],
}
def probe() -> dict[str, object]:
if not EDS_READY:
return {"eds": False, "sourceRegistry": False, "enabledSources": 0}
try:
registry = EDataServer.SourceRegistry.new_sync(None)
sources = registry.list_enabled(EDataServer.SOURCE_EXTENSION_CALENDAR)
except Exception:
return {"eds": True, "sourceRegistry": False, "enabledSources": 0}
return {
"eds": True,
"sourceRegistry": True,
"enabledSources": len(sources),
}
def collect_snapshot(start: int, end: int) -> dict[str, object]:
if not EDS_READY:
return _unavailable_snapshot()
if start < 0 or end <= start:
return {
**_unavailable_snapshot(),
"errors": [{"code": "invalid-range"}],
}
try:
registry = EDataServer.SourceRegistry.new_sync(None)
enabled_sources = registry.list_enabled(EDataServer.SOURCE_EXTENSION_CALENDAR)
except Exception:
return _unavailable_snapshot()
sources: list[dict[str, str]] = []
events_by_id: dict[str, dict[str, object]] = {}
errors: list[dict[str, str]] = []
for source in enabled_sources:
source_meta = _source_meta(source)
sources.append(source_meta)
malformed_event = False
def on_instance(
component: Any,
instance_start: Any,
instance_end: Any,
_cancellable: Any,
_callback_data: Any,
) -> bool:
nonlocal malformed_event
try:
event = normalize_instance(
source_meta,
component,
instance_start,
instance_end,
)
events_by_id[str(event["id"])] = event
except (AttributeError, TypeError, ValueError):
malformed_event = True
return True
try:
client = ECal.Client.connect_sync(
source,
ECal.ClientSourceType.EVENTS,
5,
None,
)
if client is None:
raise RuntimeError("calendar client unavailable")
client.generate_instances_sync(start, end, None, on_instance, None)
except Exception:
errors.append({"sourceId": source_meta["id"], "code": "unavailable"})
continue
if malformed_event:
errors.append({"sourceId": source_meta["id"], "code": "event-invalid"})
sources.sort(key=lambda source: (source["name"].casefold(), source["id"]))
return {
"ok": True,
"generatedAt": int(time.time()),
"sources": sources,
"events": sort_events(list(events_by_id.values())),
"errors": errors,
}
def _write_snapshot(snapshot: dict[str, object]) -> bool:
try:
sys.stdout.write(json.dumps(snapshot, separators=(",", ":")) + "\n")
sys.stdout.flush()
except BrokenPipeError:
return False
return True
def watch(start: int, end: int) -> int:
if not EDS_READY:
_write_snapshot(_unavailable_snapshot())
return 1
loop = GLib.MainLoop()
try:
registry = EDataServer.SourceRegistry.new_sync(None)
except Exception:
_write_snapshot(_unavailable_snapshot())
return 1
debounce_source = 0
subscriptions: list[int] = []
def emit_snapshot() -> bool:
nonlocal debounce_source
debounce_source = 0
if not _write_snapshot(collect_snapshot(start, end)):
loop.quit()
return GLib.SOURCE_REMOVE
def schedule_refresh(*_args: Any) -> None:
nonlocal debounce_source
if debounce_source:
GLib.source_remove(debounce_source)
debounce_source = GLib.timeout_add(350, emit_snapshot)
def periodic_refresh() -> bool:
emit_snapshot()
return GLib.SOURCE_CONTINUE
for signal_name in ("source-added", "source-removed", "source-changed"):
try:
registry.connect(signal_name, schedule_refresh)
except TypeError:
pass
try:
bus = Gio.bus_get_sync(Gio.BusType.SESSION, None)
for signal_name in (
"EventsAddedOrUpdated",
"EventsRemoved",
"ClientDisappeared",
):
subscriptions.append(
bus.signal_subscribe(
"org.gnome.Shell.CalendarServer",
"org.gnome.Shell.CalendarServer",
signal_name,
"/org/gnome/Shell/CalendarServer",
None,
Gio.DBusSignalFlags.NONE,
schedule_refresh,
None,
)
)
proxy = Gio.DBusProxy.new_sync(
bus,
Gio.DBusProxyFlags.NONE,
None,
"org.gnome.Shell.CalendarServer",
"/org/gnome/Shell/CalendarServer",
"org.gnome.Shell.CalendarServer",
None,
)
proxy.call_sync(
"SetTimeRange",
GLib.Variant("(xxb)", (start, end, True)),
Gio.DBusCallFlags.NONE,
-1,
None,
)
except Exception:
bus = None
if not _write_snapshot(collect_snapshot(start, end)):
return 0
GLib.timeout_add_seconds(600, periodic_refresh)
try:
loop.run()
except KeyboardInterrupt:
pass
finally:
if bus is not None:
for subscription in subscriptions:
bus.signal_unsubscribe(subscription)
return 0
def _usage_error() -> dict[str, object]:
return {
"ok": False,
"generatedAt": int(time.time()),
"sources": [],
"events": [],
"errors": [{"code": "usage"}],
}
def main(argv: list[str]) -> int:
command = argv[1] if len(argv) > 1 else ""
if command == "probe" and len(argv) == 2:
_write_snapshot(probe())
return 0
if command in {"query", "watch"} and len(argv) == 4:
try:
start = int(argv[2])
end = int(argv[3])
except ValueError:
_write_snapshot(_usage_error())
return 64
if command == "query":
snapshot = collect_snapshot(start, end)
_write_snapshot(snapshot)
return 0 if snapshot["ok"] else 1
return watch(start, end)
_write_snapshot(_usage_error())
return 64
if __name__ == "__main__":
raise SystemExit(main(sys.argv))