#!/bin/sh
# Imprion OS — one panel per graphics card, before anything draws on it (F76).
#
# WHAT THIS IS FOR, MEASURED ON THE OWNER'S TERMINAL `pos-ksea0714`, 2026-09-11:
#
#     /sys/class/drm/card0-LVDS-1  connected  edid=0   modes: 1024x768
#     /sys/class/drm/card0-VGA-1   connected  edid=0   modes: 1024x768 800x600 800x600
#
# Two connectors say `connected` and neither has an EDID. The panel is LVDS-1.
# VGA-1 is a PHANTOM: nothing is plugged into it, and the driver's analogue
# load-detect — which is all it has without an EDID — guesses "connected".
#
# Every compositor on this image then believes the terminal has two screens:
#
#   * `cage` defaults to `-m extend`, which spans ONE logical output across
#     every connected output. `chromium --kiosk` fullscreens to the span, so a
#     1024x768 panel gets a 2048x768 surface and shows the LEFT HALF of the
#     enrolment page — the join code is in the half nobody can see. That is
#     F76 verbatim: *"scaling is totally off — shows just the left half"*.
#   * `mutter` in the `pos` session extends the desktop onto it in the same
#     way, so after enrolment the Service window can open on a screen that
#     does not exist.
#
# The fix is one level below both compositors, in DRM, so that it is the same
# fix for both: tell the KERNEL the phantom is not there, and every client that
# asks — cage, mutter, gnome-shell, Chromium, a future compositor — is told the
# terminal has one screen.
#
# THE RULE, AND THE ONE THING IT MUST NEVER DO.
#
#   * Per card, the connector that is KEPT is the first `connected` panel
#     connector (LVDS, eDP or DSI — the three names a built-in panel gets), and
#     failing that the first `connected` connector in DRM order.
#   * Every OTHER connector that is `connected` **and has a zero-length EDID**
#     is forced off.
#   * A connector WITH AN EDID IS NEVER TOUCHED. A service monitor a technician
#     plugs into the VGA or HDMI socket answers DDC and has an EDID, so it keeps
#     working exactly as before. This is the line that makes the fix safe to
#     ship to a fleet, and it is why the rule is "connected with no EDID"
#     rather than "not the panel".
#
# This is deliberately NOT `cage -m last`: "last" is DRM connector order, so on
# a terminal with a real service monitor it puts the enrolment screen on the
# monitor and leaves the till dark. And it is deliberately not the kernel's own
# `video=VGA-1:d` command-line switch — see docs/runbook/panel-geometry.md for
# why that is not generic (the connector name differs per terminal, it is baked
# into the bootloader, and it cannot tell a phantom from a plugged-in monitor).
#
# HOW "FORCE OFF" IS WRITTEN, AND WHY THE ORDER IS THIS WAY ROUND.
#
# Both routes set the same kernel state — `connector->force = DRM_FORCE_OFF`,
# which `drm_helper_probe_single_connector_modes()` honours ahead of any
# detect, so it is sticky across every later probe and across the DRM poll that
# would otherwise flip the phantom back to `connected`:
#
#   1. `printf off > /sys/class/drm/<connector>/status` — drm_sysfs.c's
#      `status_store()`. It sets the force AND re-probes in the same write
#      (`fill_modes`), so `status` reads `disconnected` and `modes` is empty the
#      instant it returns. This is the route used.
#   2. `printf off > /sys/kernel/debug/dri/<minor>/<Connector>/force` — the
#      debugfs file. Same flag, but it does NOT re-probe, so it only takes
#      effect at the next probe (which is the compositor's first enumeration,
#      so it still works — it is just invisible until then).
#
# Route 1 is first because route 2 fails outright on a locked-down kernel:
# **measured on the build host (6.12.73+deb13-amd64, Secure Boot,
# `/sys/kernel/security/lockdown` = `[integrity]`) every writable debugfs file
# returns EPERM**, because `debugfs_locked_down()` refuses any file whose mode
# is wider than 0444 under `LOCKDOWN_DEBUGFS`. The sysfs `status` write has no
# such gate and worked on the same host. A terminal that a site enables Secure
# Boot on must still come up with one screen, so the route that survives it is
# the primary one and debugfs is the fallback.
#
# WHEN THIS RUNS. imprion-drm-outputs.service runs it before
# imprion-enrollment.service, imprion-calibration.service and
# display-manager.service; 70-imprion-drm-outputs.rules runs it again on every
# DRM hotplug, because the owner also reported the page looking RIGHT during the
# `updating` phase and stretched afterwards — which is a connector flipping to
# `connected` on a later DRM poll, after the unit had already run.
#
# IT IS ALSO THE TEST SUBJECT. `packaging/tests/drm-outputs-cases.sh` runs it
# against fake sysfs trees through the three variables below, which is the only
# way to cover a panel nobody in the lab owns.
set -u

SYSFS="${IMPRION_DRM_SYSFS:-/sys/class/drm}"
DEBUGFS="${IMPRION_DRM_DEBUGFS:-/sys/kernel/debug/dri}"
TAG="${IMPRION_DRM_TAG:-drm-outputs}"

# Journal-bound, one line per decision, so that `journalctl -u
# imprion-drm-outputs.service -b` is the whole account of why a terminal has
# the number of screens it has.
log() {
	echo "$TAG: $*"
}

read_file() {
	# `cat` on a sysfs attribute that the driver refuses returns nothing and a
	# non-zero status; a missing attribute is the same to this script.
	cat "$1" 2>/dev/null || true
}

edid_bytes() {
	if [ -r "$1/edid" ]; then
		wc -c < "$1/edid" 2>/dev/null | tr -d ' \t' || echo 0
	else
		echo 0
	fi
}

# The DRM minor, for the debugfs fallback. /sys/class/drm/cardN/dev is
# "226:<minor>" and the debugfs directory is named after the minor.
card_minor() {
	dev="$(read_file "$SYSFS/$1/dev")"

	case "$dev" in
		*:*) echo "${dev##*:}" ;;
		*)   echo "" ;;
	esac
}

# A built-in panel, by the only thing the kernel gives us to go on: the
# connector type in its name. DRM spells them LVDS-n, eDP-n and DSI-n.
is_panel_connector() {
	case "$(echo "$1" | tr '[:upper:]' '[:lower:]')" in
		lvds-*|edp-*|dsi-*) return 0 ;;
		*)                  return 1 ;;
	esac
}

# Connectors of one card, in DRM order. `connector_id` is the kernel's own
# object id and is allocated in registration order, so sorting on it is DRM
# order; a connector that does not expose one (it has since 4.x) sorts last
# rather than being dropped.
connectors_of_card() {
	card="$1"

	for path in "$SYSFS/$card"-*; do
		[ -d "$path" ] || continue
		[ -e "$path/status" ] || continue

		id="$(read_file "$path/connector_id")"

		case "$id" in
			''|*[!0-9]*) id=999999 ;;
		esac

		printf '%s\t%s\n' "$id" "${path##*/}"
	done | sort -n -k1,1 | cut -f2
}

force_off() {
	card="$1"
	connector="$2"
	path="$SYSFS/$connector"
	# The debugfs name is the connector without the `cardN-` prefix.
	short="${connector#"$card"-}"

	if printf 'off\n' > "$path/status" 2>/dev/null; then
		log "$card $short forced off via sysfs status; now status=$(read_file "$path/status") modes=$(read_file "$path/modes" | grep -c . || true)"

		return 0
	fi

	minor="$(card_minor "$card")"
	force="$DEBUGFS/$minor/$short/force"

	if [ -n "$minor" ] && printf 'off\n' > "$force" 2>/dev/null; then
		log "$card $short forced off via debugfs force (sysfs status write refused); takes effect on the next probe"

		return 0
	fi

	log "$card $short COULD NOT be forced off: sysfs status and $force both refused the write"

	return 1
}

if [ ! -d "$SYSFS" ]; then
	log "no $SYSFS — this kernel has no DRM, nothing to decide"

	exit 0
fi

if [ -d "$DEBUGFS" ]; then
	log "debugfs is mounted at ${DEBUGFS%/dri} (fallback route available)"
else
	log "no $DEBUGFS — debugfs fallback unavailable; the sysfs status route does not need it"
fi

cards=""

for path in "$SYSFS"/card*; do
	name="${path##*/}"

	# Connectors are `cardN-<type>-<n>`; the card itself has no dash.
	case "$name" in
		card*-*) continue ;;
		card*) ;;
		*) continue ;;
	esac

	[ -d "$path" ] || continue

	cards="$cards $name"
done

if [ -z "$cards" ]; then
	log "no DRM card in $SYSFS"

	exit 0
fi

for card in $cards; do
	all="$(connectors_of_card "$card")"

	if [ -z "$all" ]; then
		log "$card has no connectors"

		continue
	fi

	# Pass one: the inventory, and the connector to keep. Two passes, because
	# the decision for every other connector depends on which one was kept,
	# and a single pass would have to guess.
	keep=""

	for connector in $all; do
		[ "$(read_file "$SYSFS/$connector/status")" = "connected" ] || continue

		short="${connector#"$card"-}"

		if is_panel_connector "$short"; then
			keep="$connector"

			break
		fi

		[ -n "$keep" ] || keep="$connector"
	done

	if [ -z "$keep" ]; then
		log "$card has no connected connector — nothing to keep, nothing to force off"

		continue
	fi

	for connector in $all; do
		path="$SYSFS/$connector"
		short="${connector#"$card"-}"
		status="$(read_file "$path/status")"
		bytes="$(edid_bytes "$path")"

		if [ "$connector" = "$keep" ]; then
			log "$card $short kept (status=$status edid=${bytes}B$(is_panel_connector "$short" && echo ', panel connector'))"

			continue
		fi

		if [ "$status" != "connected" ]; then
			log "$card $short left alone (status=$status)"

			continue
		fi

		if [ "$bytes" -gt 0 ] 2>/dev/null; then
			log "$card $short left alone: it has an EDID (${bytes}B) — a real monitor, not a phantom"

			continue
		fi

		log "$card $short is connected with a zero-length EDID and is not the kept output — phantom, forcing off"

		force_off "$card" "$connector" || true
	done
done

exit 0
