Resolve cursor themes the way wlroots loads them (Inherits, left_ptr, ~/.icons)

The previous pre-check demanded a literal <icon-dir>/<theme>/cursors/default
file, which is stricter than the wlroots 0.19 loader: it loads every cursor
in cursors/, follows index.theme Inherits= chains, and falls back
default to left_ptr. On Debian/Pi 'default' is only an alias directory
whose index.theme Inherits= names the system-selected theme, so an
unconfigured run refused to start even with cursors installed.

Rewrite resolution to mirror the loader: build the exact search path
(XCURSOR_PATH, else ~/.local/share/icons + ~/.icons + the compiled-in
dirs), accept either a default or left_ptr sprite, follow Inherits=
recursively (cycle-bounded). If the requested theme (or the 'default'
alias) still doesn't resolve, auto-pick the first installed theme that
provides a sprite and log the choice; only hard-fail when no cursor theme
exists anywhere, with a message that names the searched dirs and the
Debian update-alternatives hint.

Tests: default->Inherits alias boots, left_ptr-only theme boots, auto-pick
logs its choice, and the missing-theme case still exits rc 1.
This commit is contained in:
Andrew Hamilton 2026-08-16 12:45:42 +10:00
parent 9db00591ca
commit 53dd196602
2 changed files with 311 additions and 51 deletions

View file

@ -27,6 +27,7 @@
#include <sys/wait.h>
#include <time.h>
#include <unistd.h>
#include <dirent.h>
#include <drm_fourcc.h>
@ -4489,52 +4490,155 @@ static bool backends_include(const char *env, const char *name) {
return false;
}
/* True if `theme` resolves to a real cursors file on disk. Mirrors the
loader's search: XCURSOR_PATH verbatim, else $XDG_DATA_HOME/icons (or
~/.local/share/icons) plus the compiled-in paths, each expected to contain
<dir>/<theme>/cursors/. wlroots 0.19 silently substitutes a builtin cursor
when none is found, so a missing theme must be detected here for the
hard-fail path. */
static bool standalone_cursor_theme_resolvable(const char *theme) {
/* Cursor theme resolution. wlroots 0.19 loads every file in
<path>/<theme>/cursors/, then follows Inherits= in index.theme to the
inherited themes, and finally substitutes a builtin arrow if nothing was
found -- so a missing theme can't be detected from the manager afterwards.
These helpers mirror the loader's search (cursor paths and semantics) and
resolve only against real installed sprite files, so the caller can still
hard-fail instead of silently presenting an invisible pointer. */
#define CURSOR_SEARCH_DEFAULT \
"~/.icons:/usr/share/icons:/usr/share/pixmaps:" \
"~/.cursors:/usr/share/cursors/xorg-x11:/usr/X11R6/lib/X11/icons"
/* ':'-joined search path, exactly like wlroots' xcursor_library_path(). */
static char *cursor_search_path(void) {
const char *env = getenv("XCURSOR_PATH");
char *path = NULL;
if (env && *env) {
path = strdup(env);
} else {
const char *xdg = getenv("XDG_DATA_HOME");
const char *udata = (xdg && xdg[0] == '/') ? xdg : "~/.local/share";
size_t n = strlen(udata) + strlen(
"/icons:/usr/share/icons:/usr/share/pixmaps:"
"~/.cursors:/usr/share/cursors/xorg-x11:"
"/usr/X11R6/lib/X11/icons") + 1;
path = malloc(n);
if (path)
snprintf(path, n, "%s%s", udata,
"/icons:/usr/share/icons:/usr/share/pixmaps:"
"~/.cursors:/usr/share/cursors/xorg-x11:"
"/usr/X11R6/lib/X11/icons");
if (env && *env)
return strdup(env);
const char *xdg = getenv("XDG_DATA_HOME");
const char *udata = (xdg && xdg[0] == '/') ? xdg : "~/.local/share";
size_t n = strlen(udata) + sizeof(CURSOR_SEARCH_DEFAULT) + 8;
char *path = malloc(n);
if (path)
snprintf(path, n, "%s/icons:%s", udata, CURSOR_SEARCH_DEFAULT);
return path;
}
/* Resolve a "~" prefix in `dir` into $HOME, writing into `out`. */
static int cursor_expand_dir(const char *dir, char *out, size_t outsz) {
if (dir[0] == '~') {
const char *home = getenv("HOME");
return home ? snprintf(out, outsz, "%s%s", home, dir + 1) : -1;
}
if (!path)
return snprintf(out, outsz, "%s", dir);
}
/* Does `theme` ship a default pointer file under dir? wlroots' get_cursor
falls back from "default" to "left_ptr" (wlr_xcursor.c), so either sprite
satisfies the check. */
static bool cursor_theme_has_sprite(const char *dir, const char *theme) {
char p[1024];
int n = snprintf(p, sizeof(p), "%s/%s/cursors/default", dir, theme);
if (n > 0 && n < (int)sizeof(p) && access(p, F_OK) == 0)
return true;
n = snprintf(p, sizeof(p), "%s/%s/cursors/left_ptr", dir, theme);
if (n > 0 && n < (int)sizeof(p) && access(p, F_OK) == 0)
return true;
return false;
}
/* True when an installed cursor sprite is reachable from `theme`, walking the
search path and the theme's index.theme Inherits= chain like the loader.
`depth` bounds the recursion so inherits cycles can't loop forever. */
static bool cursor_theme_resolves(const char *path, const char *theme,
int depth) {
if (depth > 8)
return false;
bool found = false;
char candid[1024];
for (char *dir = strtok(path, ":"); dir; dir = strtok(NULL, ":")) {
if (dir[0] == '~') {
const char *home = getenv("HOME");
if (!home) continue;
snprintf(candid, sizeof(candid), "%s%s", home, dir + 1);
dir = candid;
}
size_t n = snprintf(candid, sizeof(candid), "%s/%s/cursors/default",
dir, theme);
if (n < sizeof(candid) && access(candid, F_OK) == 0) {
found = true;
break;
char buffer[1024];
char p[1024];
for (const char *s = path; s && *s; ) {
const char *colon = strchr(s, ':');
size_t len = colon ? (size_t)(colon - s) : strlen(s);
if (len && len < sizeof(buffer)) {
char dir[1024];
snprintf(dir, sizeof(dir), "%.*s", (int)len, s);
if (cursor_expand_dir(dir, buffer, sizeof(buffer)) < 0)
goto next;
if (cursor_theme_has_sprite(buffer, theme))
return true;
int n = snprintf(p, sizeof(p), "%s/%s/index.theme", buffer, theme);
if (n > 0 && n < (int)sizeof(p)) {
FILE *f = fopen(p, "r");
if (f) {
char line[512];
while (fgets(line, sizeof(line), f)) {
const char *l = line;
if (strncmp(l, "Inherits", 8))
continue;
l += 8;
while (*l == ' ')
l++;
if (*l != '=')
continue;
l++;
while (*l == ' ')
l++;
/* one or more names split by whitespace , ; */
for (;;) {
while (*l == ' ' || *l == ',' || *l == ';')
l++;
if (!*l || *l == '\n')
break;
char name[128];
int k = 0;
while (*l && *l != ' ' && *l != ',' && *l != ';'
&& *l != '\n' && k < (int)sizeof(name) - 1)
name[k++] = *l++;
name[k] = '\0';
if (cursor_theme_resolves(path, name, depth + 1)) {
fclose(f);
return true;
}
}
}
fclose(f);
}
}
}
next:
s = colon ? colon + 1 : NULL;
}
free(path);
return found;
return false;
}
/* First (sorted) installed theme with a default/left_ptr sprite, in search
path order; returns 1 and fills `out` when one exists. */
static bool cursor_autopick(const char *path, char *out, size_t outsz) {
char buffer[1024];
for (const char *s = path; s && *s; ) {
const char *colon = strchr(s, ':');
size_t len = colon ? (size_t)(colon - s) : strlen(s);
if (len && len < sizeof(buffer)) {
char dir[1024];
snprintf(dir, sizeof(dir), "%.*s", (int)len, s);
if (cursor_expand_dir(dir, buffer, sizeof(buffer)) >= 0) {
struct dirent **list = NULL;
int n = scandir(buffer, &list, NULL, alphasort);
for (int i = 0; i < n; i++) {
const char *name = list[i]->d_name;
if (name[0] == '.' || name[0] == '\0')
continue;
if (cursor_theme_has_sprite(buffer, name)) {
snprintf(out, outsz, "%s", name);
for (int j = 0; j < n; j++)
free(list[j]);
free(list);
return true;
}
}
for (int j = 0; j < n; j++)
free(list[j]);
free(list);
}
}
s = colon ? colon + 1 : NULL;
}
return false;
}
/* ===================== Main ===================== */
@ -4744,7 +4848,25 @@ int main(int argc, char *argv[]) {
wlr_cursor_attach_output_layout(server.cursor, server.output_layout);
const char *theme_env = getenv("XCURSOR_THEME");
const char *theme = theme_env && *theme_env ? theme_env : "default";
/* The loader reads the theme dir via its first ':'-component, so
truncate "A:B" fallback lists to "A" to match where sprites would
actually live. */
char theme_name[128];
{
const char *raw = theme_env && *theme_env ? theme_env : "default";
size_t tl = strlen(raw);
const char *comma = strchr(raw, ':');
if (comma)
tl = (size_t)(comma - raw);
if (tl >= sizeof(theme_name))
tl = sizeof(theme_name) - 1;
memcpy(theme_name, raw, tl);
theme_name[tl] = '\0';
}
const char *theme = theme_name;
if (!theme[0])
theme = "default";
uint32_t size = 24;
const char *size_env = getenv("XCURSOR_SIZE");
if (size_env) {
@ -4753,18 +4875,40 @@ int main(int argc, char *argv[]) {
if (end != size_env && v > 0)
size = (uint32_t)v;
}
/* wlroots 0.19 silently substitutes a builtin cursor when the theme
directory holds nothing, so a missing theme can't be detected from
the manager afterwards. Check the theme dirs ourselves, following
the same search path the loader uses (XCURSOR_PATH, else the
compiled default), and require a real 'default' sprite. */
if (!standalone_cursor_theme_resolvable(theme)) {
fprintf(stderr,
"tabbed-wlroots: no cursor theme '%s' is installed (need a "
"'default' sprite in <icon-dir>/%s/cursors). Install a cursor "
"theme or set XCURSOR_THEME/XCURSOR_PATH\n", theme, theme);
/* wlroots would silently substitute a builtin cursor, so resolve the
theme ourselves (real sprites only, Inherits chains included) and
refuse to run when none exists anywhere. Themes named "default"
resolve through Debian's /usr/share/icons/default index.theme
(-> /etc/alternatives/x-cursor-theme); if even that is absent, pick
the first theme actually installed instead of failing on a naming
mismatch. */
char *search = cursor_search_path();
if (!search) {
fprintf(stderr, "tabbed-wlroots: out of memory resolving cursor "
"theme\n");
return 1;
}
char picked[128];
if (!cursor_theme_resolves(search, theme, 0)) {
if (cursor_autopick(search, picked, sizeof(picked))) {
tabbed_log(TABBED_LOG_INFO,
"[cursor] theme '%s' has no default sprite; picked "
"installed theme '%s'\n", theme, picked);
theme = picked;
} else {
fprintf(stderr,
"tabbed-wlroots: no cursor theme is installed "
"('%s' does not resolve; searched %s). Install a cursor "
"theme or set XCURSOR_THEME/XCURSOR_PATH; on Debian also "
"check `update-alternatives --config x-cursor-theme`\n",
theme, search);
free(search);
return 1;
}
}
free(search);
server.xcursor_mgr = wlr_xcursor_manager_create(theme, size);
if (server.xcursor_mgr)
wlr_xcursor_manager_load(server.xcursor_mgr, 1);

View file

@ -200,6 +200,17 @@ class Session:
time.sleep(0.1)
pytest.fail("both tabs never logged an active line")
def wait_app_active(self, index, timeout=10.0):
"""Wait until the single app `index` reports itself active (for
sessions launched with fewer than two tabs)."""
rx = re.compile(r"active app=%d\b" % index)
since = time.monotonic()
while time.monotonic() - since < timeout:
if rx.search(self.read_log()):
return True
time.sleep(0.1)
pytest.fail(f"app {index} never logged an active line")
# -- frame dumps ------------------------------------------------------
def dump_host(self, timeout=5.0):
rx = re.compile(r"\[dump\] host back buffer -> (\S+\.ppm)")
@ -559,6 +570,111 @@ def test_standalone_headless_missing_cursor_theme_refuses_to_run(tmp_path):
f"failure should name the missing theme:\n{out}"
def _write_theme(base, inherits=None):
"""Create an icon theme directory with an index.theme (and optional
Inherits= line), like an installed cursor-theme package."""
base.mkdir(parents=True, exist_ok=True)
lines = ["[Icon Theme]"]
if inherits:
lines.append("Inherits=" + inherits)
(base / "index.theme").write_text("\n".join(lines) + "\n")
def _spawn_standalone(extra_env, *apps):
"""Standalone compositor on the headless backend with a custom env for
cursor-theme tests; XCURSOR_THEME/XCURSOR_PATH come only from extra_env."""
try:
os.unlink(LOG)
except FileNotFoundError:
pass
env = dict(os.environ)
env["TABBED_LOG"] = "2"
env["TABBED_MODE"] = "standalone"
env["WLR_BACKENDS"] = "headless"
env.pop("WAYLAND_DISPLAY", None)
env.pop("XCURSOR_THEME", None)
env.pop("XCURSOR_PATH", None)
env.update(extra_env or {})
cmd = ["env", "-i",
"PATH=" + env["PATH"],
"HOME=" + env.get("HOME", "/"),
"XDG_RUNTIME_DIR=" + env.get("XDG_RUNTIME_DIR", "/tmp"),
"XDG_DATA_HOME=" + env.get("XDG_DATA_HOME", ""),
"XDG_DATA_DIRS=" + env.get("XDG_DATA_DIRS", ""),
"TABBED_LOG=2",
"TABBED_MODE=standalone",
"WLR_BACKENDS=headless",
"TABBED_FONT=" + (env.get("TABBED_FONT") or _pick_font() or "")]
for key in ("XCURSOR_PATH", "XCURSOR_THEME"):
if env.get(key):
cmd.append("%s=%s" % (key, env[key]))
cmd += ["stdbuf", "-oL", BIN]
cmd += list(apps)
proc = subprocess.Popen(
cmd, stdin=subprocess.PIPE,
stdout=open(LOG, "ab"), stderr=subprocess.STDOUT)
return Session(proc)
def test_standalone_headless_cursor_theme_default_inherits_aliased(tmp_path):
"""A theme literally named 'default' is an alias on Debian:
/usr/share/icons/default carries only index.theme whose Inherits= names
the system-selected theme (via the x-cursor-theme alternatives). The
resolution must follow that Inherits chain, so a 'default' alias pointing
at a real theme must boot."""
_write_theme(tmp_path / "default", "MyCursorTheme")
base = tmp_path / "MyCursorTheme"
_write_theme(base)
(base / "cursors").mkdir()
(base / "cursors" / "left_ptr").touch()
s = _spawn_standalone({"XCURSOR_PATH": str(tmp_path)},
"build/wltest '#ff0000'")
try:
s.wait_app_active(0)
assert s.proc.poll() is None, f"log tail:\n{s.read_log()[-400:]}"
finally:
s.close()
def test_standalone_headless_cursor_theme_left_ptr_only(tmp_path):
"""Legacy themes name the pointer left_ptr, not default; wlroots'
get_cursor("default") falls back to it. A theme dir with only left_ptr
must boot."""
_write_theme(tmp_path / "LegacyOnly")
cur = tmp_path / "LegacyOnly" / "cursors"
cur.mkdir()
(cur / "left_ptr").touch()
s = _spawn_standalone({"XCURSOR_PATH": str(tmp_path),
"XCURSOR_THEME": "LegacyOnly"},
"build/wltest '#ff0000'")
try:
s.wait_app_active(0)
assert s.proc.poll() is None, f"log tail:\n{s.read_log()[-400:]}"
finally:
s.close()
def test_standalone_headless_cursor_theme_auto_pick(tmp_path):
"""No XCURSOR_THEME, no 'default' alias: pick the first installed theme
(sorted) that provides a sprite, log the choice, and boot rather than
failing on the naming mismatch."""
_write_theme(tmp_path / "AlphaTheme")
(tmp_path / "AlphaTheme" / "cursors").mkdir()
(tmp_path / "AlphaTheme" / "cursors" / "default").touch()
_write_theme(tmp_path / "BetaTheme")
(tmp_path / "BetaTheme" / "cursors").mkdir()
(tmp_path / "BetaTheme" / "cursors" / "default").touch()
s = _spawn_standalone({"XCURSOR_PATH": str(tmp_path)},
"build/wltest '#ff0000'")
try:
s.wait_app_active(0)
assert s.proc.poll() is None, f"log tail:\n{s.read_log()[-400:]}"
assert "picked installed theme 'AlphaTheme'" in s.read_log(), \
f"auto-pick should be logged:\n{s.read_log()[-400:]}"
finally:
s.close()
def test_standalone_headless_last_tab_exits_cleanly(standalone_session):
"""Closing the final tab exits standalone with rc 0 instead of crashing
on the NULL host-window proxies (which only exist in nested mode)."""