feat: per-compositor app config in kick, slim kick-pi

This commit is contained in:
Andrew Hamilton 2026-09-14 07:30:04 +10:00
parent 61f9227683
commit 240c1cea4f
4 changed files with 342 additions and 124 deletions

View file

@ -346,6 +346,16 @@ Wayland compositor and draws every tab inside that single window.
standalone on wlroots' headless backend (no DRM needed), useful in CI or standalone on wlroots' headless backend (no DRM needed), useful in CI or
when no TTY is available. when no TTY is available.
### Per-compositor app config
Clients see a scoped view of `~/.config`: at startup kick builds a shadow
config home (symlinked passthrough, so nothing diverges) with only GTK
window controls pinned to close-only, plus a `DCONF_PROFILE` overlay
pinning the same key (dconf wins over settings files). Kick never
minimizes for anyone, so client-drawn minimize/maximize buttons would be
dead -- other compositors sharing the home directory are unaffected.
Set `KICK_NO_CONFIG_SHADOW=1` to disable the scoped view entirely.
`test_nested_host_primary_to_kick` exercises the host->nested primary `test_nested_host_primary_to_kick` exercises the host->nested primary
selection path, which only exists over the ext-data-control bridge; it skips selection path, which only exists over the ext-data-control bridge; it skips
on hosts without it (e.g. GNOME), so use an ext-data-control host (labwc, on hosts without it (e.g. GNOME), so use an ext-data-control host (labwc,

127
kick-pi
View file

@ -2,12 +2,16 @@
# kick-pi -- Raspberry Pi session entry for kick. # kick-pi -- Raspberry Pi session entry for kick.
# #
# A labwc-pi analogue for running kick as the Pi's compositor instead of # A labwc-pi analogue for running kick as the Pi's compositor instead of
# labwc: sets up the Pi OS session context, scopes GTK window controls to # labwc: sets up the Pi OS session context, then execs kick, passing all
# close-only for kick sessions (shadow XDG_CONFIG_HOME plus a DCONF_PROFILE # arguments through untouched (screen mode, --panel, tabs, ...).
# overlay database -- see kick_shadow_config below), then execs kick,
# passing all arguments through untouched (screen mode, --panel, tabs, ...).
# Environment (KICK_MODE, KICK_LOG, ...) passes through equally untouched. # Environment (KICK_MODE, KICK_LOG, ...) passes through equally untouched.
# #
# Per-compositor GTK window controls (close-only under kick, full under
# labwc) live in kick itself now: it builds a shadow config home plus a
# dconf overlay for its own children, so this wrapper carries none of that
# machinery. (A wrapper-level copy would only duplicate it; version mixes
# still behave: new wrapper + old kick simply scopes nothing.)
#
# Deliberately NOT done here (kept minimal, by choice): # Deliberately NOT done here (kept minimal, by choice):
# - logging (see kick-session for per-boot logs), # - logging (see kick-session for per-boot logs),
# - labwc autostart files (kick-only: panels and apps come from kick's # - labwc autostart files (kick-only: panels and apps come from kick's
@ -43,121 +47,6 @@ else
chmod 700 "$XDG_RUNTIME_DIR" 2>/dev/null || true chmod 700 "$XDG_RUNTIME_DIR" 2>/dev/null || true
fi fi
# Per-compositor GTK window controls without touching shared state.
# Wayland leaves decoration policy to clients with no per-compositor
# channel, and kick never minimizes/maximizes for anyone, so client-drawn
# minimize/maximize buttons are dead under kick yet live under labwc --
# while both sessions share one $HOME. Fix it with a shadow config home:
# every entry of ~/.config is symlinked in (writes pass through to the
# real files, nothing diverges), except gtk-3.0/settings.ini and
# gtk-4.0/settings.ini, which are regenerated from the real ones with only
# gtk-decoration-layout forced to close-only. labwc sessions keep seeing
# the real ~/.config (full buttons); kick sessions see the shadow.
# Crash-safe (rebuilt every login, never authoritative) and idempotent.
kick_pin_gtk_layout() {
# $1 = settings.ini path: force gtk-decoration-layout=:close under
# [Settings], creating file/section as needed, keeping everything else.
[ -f "$1" ] || : > "$1" 2>/dev/null || return 0
tmp="$1.kick-pin-tmp"
awk '
BEGIN { in_s = 0; seen_s = 0; done = 0 }
/^\[[^]]*\]/ {
seen_s = seen_s || ($0 == "[Settings]")
in_s = ($0 == "[Settings]")
print
next
}
in_s && !done && /^[[:space:]]*gtk-decoration-layout[[:space:]]*=/ {
print "gtk-decoration-layout=:close"
done = 1
next
}
{ print }
END {
if (!done) {
if (!seen_s) print "[Settings]"
print "gtk-decoration-layout=:close"
}
}
' "$1" > "$tmp" 2>/dev/null && mv -f "$tmp" "$1" 2>/dev/null
rm -f "$tmp" 2>/dev/null || true
}
# Per-compositor GTK window controls, part two: dconf.
# GtkSettings binds gtk-decoration-layout to org.gnome.desktop.wm.preferences
# button-layout, and the dconf binding wins over settings.ini -- so the
# shadow settings.ini alone cannot hide the buttons while the database
# carries the schema default (appmenu:minimize,maximize,close). Layer a
# kick-only user database in front via DCONF_PROFILE: reads fall through
# to the real databases unchanged, writes land in the overlay file, and
# labwc sessions (no DCONF_PROFILE) never see it. Tradeoff, stated plainly:
# settings changed under kick are written to the overlay database and are
# invisible under labwc; everything merely read behaves identically.
# $1 = shadow config dir. Skipped without the dconf CLI or a session bus.
kick_dconf_overlay() {
shadow_dir=$1
command -v dconf >/dev/null 2>&1 || return 0
[ -n "${DBUS_SESSION_BUS_ADDRESS:-}" ] || return 0
profile="$shadow_dir/dconf-profile"
# NOTE: the database name must be a valid D-Bus object path element
# (alphanumerics only): dconf maps it to /ca/desrt/dconf/Writer/<name>
# and aborts on anything else -- so no hyphens here, ever.
{
echo "user-db:kickoverrides"
if [ -f /etc/dconf/profile/user ]; then
grep -v "^user-db:" /etc/dconf/profile/user 2>/dev/null || true
grep "^user-db:" /etc/dconf/profile/user 2>/dev/null || true
else
printf 'user-db:user\nsystem-db:local\nsystem-db:site\nsystem-db:distro\n'
fi
} > "$profile.kick-new" 2>/dev/null || return 0
mv -f "$profile.kick-new" "$profile" 2>/dev/null || return 0
export DCONF_PROFILE="$profile"
# Seed the pinned layout (idempotent; lands in kickoverrides, the
# first user database, leaving the real databases untouched).
dconf write /org/gnome/desktop/wm/preferences/button-layout \
"':close'" 2>/dev/null || true
}
kick_shadow_config() {
[ -n "${HOME:-}" ] || return 0
real_config="$HOME/.config"
shadow="${XDG_CACHE_HOME:-$HOME/.cache}/kick/config"
mkdir -p "$shadow" 2>/dev/null || return 0
if [ -d "$real_config" ]; then
for entry in "$real_config"/* "$real_config"/.*; do
case "$entry" in
"$real_config/."|"$real_config/..") continue ;;
esac
[ -e "$entry" ] || [ -L "$entry" ] || continue
base=${entry##*/}
[ -e "$shadow/$base" ] || [ -L "$shadow/$base" ] || \
ln -s "$entry" "$shadow/$base" 2>/dev/null || true
done
fi
# Shadow copies (real directories, never symlinks): refreshed from the
# real files on every login so theme/icons/fonts track labwc-side edits,
# then pinned -- only the layout line ever diverges. When no real file
# exists the shadow copy is left alone.
for gtkdir in gtk-3.0 gtk-4.0; do
if [ -L "$shadow/$gtkdir" ]; then
rm -f "$shadow/$gtkdir" 2>/dev/null || continue
fi
mkdir -p "$shadow/$gtkdir" 2>/dev/null || continue
if [ -f "$real_config/$gtkdir/settings.ini" ]; then
cp -f "$real_config/$gtkdir/settings.ini" \
"$shadow/$gtkdir/settings.ini" 2>/dev/null || true
fi
kick_pin_gtk_layout "$shadow/$gtkdir/settings.ini"
done
# Export last so earlier failures keep the real config rather than a
# half-built shadow.
export XDG_CONFIG_HOME="$shadow"
kick_dconf_overlay "$shadow"
}
kick_shadow_config
unset -f kick_shadow_config kick_pin_gtk_layout kick_dconf_overlay 2>/dev/null || true
# Software rendering on Pi models whose GPU lacks an MMU (same guard as # Software rendering on Pi models whose GPU lacks an MMU (same guard as
# labwc-pi; kick's wlroots renderer autocreate honours WLR_RENDERER). # labwc-pi; kick's wlroots renderer autocreate honours WLR_RENDERER).
if command -v raspi-config >/dev/null 2>&1; then if command -v raspi-config >/dev/null 2>&1; then

268
kick.c
View file

@ -41,6 +41,8 @@ const char *__ubsan_default_options(void) {
#include <sys/wait.h> #include <sys/wait.h>
#include <time.h> #include <time.h>
#include <unistd.h> #include <unistd.h>
#include <dirent.h>
#include <sys/stat.h>
#include <drm_fourcc.h> #include <drm_fourcc.h>
@ -11563,6 +11565,21 @@ frame_done:
/* ===================== Launch client ===================== */ /* ===================== Launch client ===================== */
/* Per-compositor session config shadowing (the kick-pi mechanism, owned
here so plain `kick` behaves the same everywhere): a shadow config home
where every ~/.config entry is symlinked in (writes pass through),
except gtk-3.0/gtk-4.0 settings.ini, regenerated with only
gtk-decoration-layout forced to close-only, plus a DCONF_PROFILE overlay
database pinning the same key (dconf wins over settings.ini). Labwc
sessions never see it; nested kick children do (per-compositor scoping).
Best-effort throughout: anything failing leaves config_shadow_ok false
and children inherit the plain environment. */
static char shadow_config[1024];
static char shadow_dconf_profile[1024];
static bool config_shadow_ok;
static void config_shadow_build(void);
static void config_seed_dconf(void);
/* D-Bus/systemd activation environment, mirroring what labwc does on /* D-Bus/systemd activation environment, mirroring what labwc does on
launch (DRM backend): D-Bus-activated services and systemd user units launch (DRM backend): D-Bus-activated services and systemd user units
otherwise keep whatever WAYLAND_DISPLAY etc. they were started with -- otherwise keep whatever WAYLAND_DISPLAY etc. they were started with --
@ -11585,8 +11602,11 @@ static void activation_run(bool clear) {
/* WAYLAND_DISPLAY is always exported (our socket, set below); the rest /* WAYLAND_DISPLAY is always exported (our socket, set below); the rest
only when present in our own environment, so absent values are never only when present in our own environment, so absent values are never
pushed as empty over a live session's settings -- except on clear, pushed as empty over a live session's settings -- except on clear,
where empty is exactly labwc's documented way of blanking them. */ where empty is exactly labwc's documented way of blanking them. The
const char *args[2 + 7 + 1]; shadow config paths ride along the same way when shadowing is up, so
D-Bus-activated apps see the same per-compositor view as our own
children (standalone only, like the rest of this call). */
const char *args[2 + 9 + 1];
int argc = 0; int argc = 0;
args[argc++] = "dbus-update-activation-environment"; args[argc++] = "dbus-update-activation-environment";
args[argc++] = "--systemd"; args[argc++] = "--systemd";
@ -11610,6 +11630,15 @@ static void activation_run(bool clear) {
} }
} }
} }
char shadow_values[2][1100];
if (config_shadow_ok) {
snprintf(shadow_values[0], sizeof(shadow_values[0]),
"XDG_CONFIG_HOME=%s", clear ? "" : shadow_config);
args[argc++] = shadow_values[0];
snprintf(shadow_values[1], sizeof(shadow_values[1]),
"DCONF_PROFILE=%s", clear ? "" : shadow_dconf_profile);
args[argc++] = shadow_values[1];
}
args[argc] = NULL; args[argc] = NULL;
pid_t pid = fork(); pid_t pid = fork();
if (pid == 0) { if (pid == 0) {
@ -11651,6 +11680,225 @@ static void activation_update(void) {
atexit(activation_clear_env); atexit(activation_clear_env);
} }
/* mkdir -p for the shadow tree (best-effort; failure just skips shadowing). */
static void shadow_mkdir_p(const char *path) {
char tmp[1024];
if (snprintf(tmp, sizeof(tmp), "%s", path) >= (int)sizeof(tmp))
return;
for (char *p = tmp + 1; *p; p++) {
if (*p == '/') {
*p = '\0';
mkdir(tmp, 0755);
*p = '/';
}
}
mkdir(tmp, 0755);
}
/* Force gtk-decoration-layout=:close under [Settings], creating
file/section as needed and keeping every other line byte-identical. */
static void shadow_pin_gtk_layout(const char *path) {
static const char *const key = "gtk-decoration-layout=:close";
char tmp[1024 + 32];
if (snprintf(tmp, sizeof(tmp), "%s.kick-pin-%d-tmp", path,
(int)getpid()) >= (int)sizeof(tmp))
return;
FILE *in = fopen(path, "r");
FILE *out = fopen(tmp, "w");
if (!out) {
if (in) fclose(in);
return;
}
bool in_settings = false, seen_settings = false, done = false;
char *line = NULL;
size_t cap = 0;
ssize_t n;
while (in && (n = getline(&line, &cap, in)) >= 0) {
if (line[0] == '[') {
size_t m = 0;
while (line[m] && line[m] != ']' && line[m] != '\n') m++;
in_settings = (m == 10 && memcmp(line, "[Settings]", 10) == 0);
seen_settings = seen_settings || in_settings;
fwrite(line, 1, (size_t)n, out);
continue;
}
if (in_settings && !done) {
size_t k = 0;
while (line[k] == ' ' || line[k] == '\t') k++;
size_t kl = strlen("gtk-decoration-layout");
if (strncmp(line + k, "gtk-decoration-layout", kl) == 0) {
size_t j = k + kl;
while (line[j] == ' ' || line[j] == '\t') j++;
if (line[j] == '=') {
fprintf(out, "%s\n", key);
done = true;
continue;
}
}
}
fwrite(line, 1, (size_t)n, out);
}
free(line);
if (in) fclose(in);
if (!done) {
if (!seen_settings)
fputs("[Settings]\n", out);
fprintf(out, "%s\n", key);
}
fclose(out);
rename(tmp, path);
}
/* Copy a regular file byte for byte; missing source is fine (skip). */
static void shadow_copy_file(const char *src, const char *dst) {
FILE *in = fopen(src, "rb");
if (!in) return;
FILE *out = fopen(dst, "wb");
if (!out) {
fclose(in);
return;
}
char buf[8192];
size_t n;
while ((n = fread(buf, 1, sizeof(buf), in)) > 0)
fwrite(buf, 1, n, out);
fclose(in);
fclose(out);
}
/* Write the DCONF_PROFILE overlay: our kick-only user database first, then
the system profile verbatim (user databases last, like the wrapper did).
dconf database names must stay alphanumeric: dconf maps the name into a
D-Bus object path and aborts on anything else, so no hyphens, ever. */
static void shadow_write_profile(const char *path) {
/* Atomic via temp + rename: parallel sessions share one shadow dir. */
char tmp[1024 + 32];
if (snprintf(tmp, sizeof(tmp), "%s.kick-prof-%d-tmp", path,
(int)getpid()) >= (int)sizeof(tmp))
return;
FILE *out = fopen(tmp, "w");
if (!out) return;
fputs("user-db:kickoverrides\n", out);
FILE *sys = fopen("/etc/dconf/profile/user", "r");
if (sys) {
char *line = NULL;
size_t cap = 0;
ssize_t n;
/* Non-user lines first, user lines last (stable stacking order). */
while ((n = getline(&line, &cap, sys)) >= 0)
if (strncmp(line, "user-db:", 8) != 0)
fwrite(line, 1, (size_t)n, out);
rewind(sys);
while ((n = getline(&line, &cap, sys)) >= 0)
if (strncmp(line, "user-db:", 8) == 0)
fwrite(line, 1, (size_t)n, out);
free(line);
fclose(sys);
} else {
fputs("user-db:user\nsystem-db:local\nsystem-db:site\n"
"system-db:distro\n", out);
}
fclose(out);
rename(tmp, path);
}
/* Build the shadow config home (see the block comment above
shadow_config): symlinked passthrough plus pinned GTK layouts plus the
dconf overlay profile. Runs once at startup, before first clients. */
static void config_shadow_build(void) {
if (getenv("KICK_NO_CONFIG_SHADOW"))
return;
const char *home = getenv("HOME");
if (!home || !home[0])
return;
const char *cache = getenv("XDG_CACHE_HOME");
char base[1024];
if (cache && cache[0]) {
if (snprintf(base, sizeof(base), "%s", cache) >= (int)sizeof(base))
return;
} else {
if (snprintf(base, sizeof(base), "%s/.cache", home) >= (int)sizeof(base))
return;
}
if (snprintf(shadow_config, sizeof(shadow_config), "%s/kick/config",
base) >= (int)sizeof(shadow_config))
return;
shadow_mkdir_p(shadow_config);
DIR *d = NULL;
{
char real[1024];
if (snprintf(real, sizeof(real), "%s/.config", home) >= (int)sizeof(real))
return;
d = opendir(real);
if (d) {
struct dirent *de;
while ((de = readdir(d)) != NULL) {
if (strcmp(de->d_name, ".") == 0 ||
strcmp(de->d_name, "..") == 0)
continue;
char src[2048], dst[2048];
if (snprintf(src, sizeof(src), "%s/%s", real,
de->d_name) >= (int)sizeof(src))
continue;
if (snprintf(dst, sizeof(dst), "%s/%s", shadow_config,
de->d_name) >= (int)sizeof(dst))
continue;
struct stat st;
if (lstat(dst, &st) == 0)
continue;
symlink(src, dst);
}
closedir(d);
}
}
static const char *const gtkdirs[] = { "gtk-3.0", "gtk-4.0" };
for (size_t gi = 0; gi < sizeof(gtkdirs) / sizeof(gtkdirs[0]); gi++) {
char sub[2048], rfile[2048], sfile[2048];
if (snprintf(sub, sizeof(sub), "%s/%s", shadow_config,
gtkdirs[gi]) >= (int)sizeof(sub))
continue;
struct stat st;
if (lstat(sub, &st) == 0 && S_ISLNK(st.st_mode))
unlink(sub);
shadow_mkdir_p(sub);
if (snprintf(rfile, sizeof(rfile), "%s/.config/%s/settings.ini",
home, gtkdirs[gi]) >= (int)sizeof(rfile))
continue;
if (snprintf(sfile, sizeof(sfile), "%s/settings.ini",
sub) >= (int)sizeof(sfile))
continue;
shadow_copy_file(rfile, sfile);
shadow_pin_gtk_layout(sfile);
}
if (snprintf(shadow_dconf_profile, sizeof(shadow_dconf_profile),
"%s/dconf-profile", shadow_config) >=
(int)sizeof(shadow_dconf_profile))
return;
shadow_write_profile(shadow_dconf_profile);
config_shadow_ok = true;
kick_log(KICK_LOG_INFO, "[config] shadow ready\n");
}
/* Seed the pinned dconf layout into the overlay database (idempotent;
lands in kickoverrides, leaving real databases untouched). Needs the
session bus like everything dconf; skipped silently without it. */
static void config_seed_dconf(void) {
if (!config_shadow_ok)
return;
if (!getenv("DBUS_SESSION_BUS_ADDRESS"))
return;
if (!cmd_in_path("dconf"))
return;
pid_t pid = fork();
if (pid == 0) {
setenv("DCONF_PROFILE", shadow_dconf_profile, 1);
execlp("dconf", "dconf", "write",
"/org/gnome/desktop/wm/preferences/button-layout",
"':close'", (char *)NULL);
_exit(127);
}
}
/* Does the first whitespace-delimited word of `cmd` resolve to an executable /* Does the first whitespace-delimited word of `cmd` resolve to an executable
on PATH? Mirrors how `sh -c <cmd>` resolves the first word, so this is a on PATH? Mirrors how `sh -c <cmd>` resolves the first word, so this is a
cheap pre-launch "which" check. Returns 1 if found (or uncheckable, e.g. cheap pre-launch "which" check. Returns 1 if found (or uncheckable, e.g.
@ -11715,6 +11963,13 @@ static void launch_client(struct kick_window *w) {
if (!xdg_runtime) xdg_runtime = "/tmp"; if (!xdg_runtime) xdg_runtime = "/tmp";
setenv("WAYLAND_DISPLAY", kick_socket, 1); setenv("WAYLAND_DISPLAY", kick_socket, 1);
setenv("XDG_RUNTIME_DIR", xdg_runtime, 1); setenv("XDG_RUNTIME_DIR", xdg_runtime, 1);
/* Per-compositor config view (shadow home + dconf overlay), built
at startup; falls back to the inherited environment when
shadowing is off. */
if (config_shadow_ok) {
setenv("XDG_CONFIG_HOME", shadow_config, 1);
setenv("DCONF_PROFILE", shadow_dconf_profile, 1);
}
/* Toolkits self-select their connection: Wayland-capable ones see /* Toolkits self-select their connection: Wayland-capable ones see
only our socket above, X11-only ones (emacs-gtk, xmessage) take only our socket above, X11-only ones (emacs-gtk, xmessage) take
DISPLAY below. Never force GDK_BACKEND/QT_QPA_PLATFORM to DISPLAY below. Never force GDK_BACKEND/QT_QPA_PLATFORM to
@ -13112,9 +13367,12 @@ int main(int argc, char *argv[]) {
set_active_tab(focus_tab); set_active_tab(focus_tab);
/* Fork every command-line command at once; the process-group match in /* Fork every command-line command at once; the process-group match in
handle_new_toplevel gives each its own slot on arrival. Refresh the handle_new_toplevel gives each its own slot on arrival. Build the
D-Bus/systemd activation environment first so services started from shadow config first (children inherit the scoped view), then refresh
here on see this session's socket. */ the D-Bus/systemd activation environment so services started from
here on see this session's socket (and the same scoped view). */
config_shadow_build();
config_seed_dconf();
activation_update(); activation_update();
launch_all_cli(); launch_all_cli();
/* The --panel command, if any: a layer-shell client, forked separately since /* The --panel command, if any: a layer-shell client, forked separately since

View file

@ -1300,6 +1300,11 @@ def _spawn_standalone(extra_env, *apps, log=None):
for key in ("XCURSOR_PATH", "XCURSOR_THEME"): for key in ("XCURSOR_PATH", "XCURSOR_THEME"):
if env.get(key): if env.get(key):
cmd.append("%s=%s" % (key, env[key])) cmd.append("%s=%s" % (key, env[key]))
# Opt-outs under test (e.g. KICK_NO_CONFIG_SHADOW) ride the same
# explicit-var discipline as everything else above: env -i wipes the
# dict, so each var the compositor needs must be listed.
if env.get("KICK_NO_CONFIG_SHADOW"):
cmd.append("KICK_NO_CONFIG_SHADOW=" + env["KICK_NO_CONFIG_SHADOW"])
# NOTE: env -i wipes the dict above, so every var the compositor needs # NOTE: env -i wipes the dict above, so every var the compositor needs
# must be listed explicitly -- a missing KICK_DUMP_DIR silently dumps # must be listed explicitly -- a missing KICK_DUMP_DIR silently dumps
# all standalone sessions into shared /tmp/host_N.ppm paths, and # all standalone sessions into shared /tmp/host_N.ppm paths, and
@ -2992,6 +2997,62 @@ def test_background_fullscreen_keeps_normal_layout(tmp_path):
_assert_alive(s, "compositor died") _assert_alive(s, "compositor died")
def test_standalone_shadow_config_scopes_children(tmp_path):
"""Standalone kick builds a shadow config home and scopes children to
it: a tab sees XDG_CONFIG_HOME/DCONF_PROFILE under the shadow, the
shadow gtk settings carry the pinned close-only layout plus the real
theme, and the dconf profile layers the kick-only database first."""
home = tmp_path / "home"
(home / ".config" / "gtk-3.0").mkdir(parents=True)
(home / ".config" / "gtk-3.0" / "settings.ini").write_text(
"[Settings]\ngtk-theme=Adwaita\n")
out = tmp_path / "child-env.out"
with _run_standalone(
{"HOME": str(home)},
"sh -c 'echo XDG_CONFIG_HOME=$XDG_CONFIG_HOME > \"%s\"; "
"echo DCONF_PROFILE=$DCONF_PROFILE >> \"%s\"'" % (out, out),
"%s '#ff0000'" % WL,
wait="any") as s:
assert _wait_out(out, "XDG_CONFIG_HOME=", timeout=15.0)
text = out.read_text()
shadow = os.path.join(str(home), ".cache", "kick", "config")
assert ("XDG_CONFIG_HOME=%s\n" % shadow) in text, \
f"child missed shadow config home: {text!r}"
assert ("DCONF_PROFILE=%s/dconf-profile\n" % shadow) in text, \
f"child missed dconf overlay profile: {text!r}"
pinned = open(os.path.join(shadow, "gtk-3.0",
"settings.ini")).read()
assert "gtk-decoration-layout=:close" in pinned, \
f"layout not pinned: {pinned!r}"
assert "gtk-theme=Adwaita" in pinned, \
f"real theme lost: {pinned!r}"
profile = open(os.path.join(shadow, "dconf-profile")).read()
assert "user-db:kickoverrides" in profile.splitlines(), \
f"overlay db not first: {profile!r}"
_assert_alive(s, "compositor died")
def test_standalone_shadow_config_opt_out(tmp_path):
"""KICK_NO_CONFIG_SHADOW disables the scoped view: children inherit
the plain environment with no XDG_CONFIG_HOME/DCONF_PROFILE override."""
home = tmp_path / "home"
home.mkdir()
out = tmp_path / "child-env.out"
with _run_standalone(
{"HOME": str(home), "KICK_NO_CONFIG_SHADOW": "1"},
"sh -c 'echo XDG_CONFIG_HOME=$XDG_CONFIG_HOME > \"%s\"; "
"echo DCONF_PROFILE=$DCONF_PROFILE >> \"%s\"'" % (out, out),
"%s '#ff0000'" % WL,
wait="any") as s:
assert _wait_out(out, "XDG_CONFIG_HOME=", timeout=15.0)
text = out.read_text()
assert "XDG_CONFIG_HOME=\n" in text, \
f"opt-out leaked a shadow config home: {text!r}"
assert "DCONF_PROFILE=\n" in text, \
f"opt-out leaked a dconf overlay profile: {text!r}"
_assert_alive(s, "compositor died")
def test_alt_f11_toggles_host_fullscreen(tmp_path): def test_alt_f11_toggles_host_fullscreen(tmp_path):
"""prefix+F11 toggles the kick window's fullscreen state in the host; """prefix+F11 toggles the kick window's fullscreen state in the host;
the resulting configure fullscreens the active tab only (other tabs the resulting configure fullscreens the active tab only (other tabs