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
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
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,

127
kick-pi
View file

@ -2,12 +2,16 @@
# kick-pi -- Raspberry Pi session entry for kick.
#
# 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
# close-only for kick sessions (shadow XDG_CONFIG_HOME plus a DCONF_PROFILE
# overlay database -- see kick_shadow_config below), then execs kick,
# passing all arguments through untouched (screen mode, --panel, tabs, ...).
# labwc: sets up the Pi OS session context, then execs kick, passing all
# arguments through untouched (screen mode, --panel, tabs, ...).
# 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):
# - logging (see kick-session for per-boot logs),
# - 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
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
# labwc-pi; kick's wlroots renderer autocreate honours WLR_RENDERER).
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 <time.h>
#include <unistd.h>
#include <dirent.h>
#include <sys/stat.h>
#include <drm_fourcc.h>
@ -11563,6 +11565,21 @@ frame_done:
/* ===================== 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
launch (DRM backend): D-Bus-activated services and systemd user units
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
only when present in our own environment, so absent values are never
pushed as empty over a live session's settings -- except on clear,
where empty is exactly labwc's documented way of blanking them. */
const char *args[2 + 7 + 1];
where empty is exactly labwc's documented way of blanking them. The
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;
args[argc++] = "dbus-update-activation-environment";
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;
pid_t pid = fork();
if (pid == 0) {
@ -11651,6 +11680,225 @@ static void activation_update(void) {
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
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.
@ -11715,6 +11963,13 @@ static void launch_client(struct kick_window *w) {
if (!xdg_runtime) xdg_runtime = "/tmp";
setenv("WAYLAND_DISPLAY", kick_socket, 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
only our socket above, X11-only ones (emacs-gtk, xmessage) take
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);
/* Fork every command-line command at once; the process-group match in
handle_new_toplevel gives each its own slot on arrival. Refresh the
D-Bus/systemd activation environment first so services started from
here on see this session's socket. */
handle_new_toplevel gives each its own slot on arrival. Build the
shadow config first (children inherit the scoped view), then refresh
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();
launch_all_cli();
/* 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"):
if env.get(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
# must be listed explicitly -- a missing KICK_DUMP_DIR silently dumps
# 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")
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):
"""prefix+F11 toggles the kick window's fullscreen state in the host;
the resulting configure fullscreens the active tab only (other tabs