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:
parent
9db00591ca
commit
53dd196602
2 changed files with 311 additions and 51 deletions
246
tabbed-wlroots.c
246
tabbed-wlroots.c
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)."""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue