feat(host): host-managed gamescope session at the client's mode (dynamic res + refresh)
ci / rust (push) Has been cancelled
ci / rust (push) Has been cancelled
Nested games on the Bazzite host saw the wrong display: refresh capped at 60 Hz, the box's connected TV's EDID modes leaking in (DOOM landed on 2560×1440@60), and the resolution fixed at whatever the always-on session was launched at — the client's requested mode never reached the game. Root causes: the session-plus gamescope command has no --nested-refresh (Xwayland advertises 59.96 Hz for every mode), --prefer-output HDMI-A-1 makes gamescope read the TV EDID, and the ATTACH model launches one fixed-resolution session. New vdisplay path: PUNKTFUNK_GAMESCOPE_SESSION=<client> — the host LAUNCHES gamescope-session-plus headless AT THE CLIENT'S mode and relaunches it when the mode changes. Injected via a host-written GAMESCOPE_BIN wrapper (--nested-refresh $PF_HZ, the flag session-plus doesn't expose) + DRM_MODE=cvt (gamescope generates clean CVT modes at that refresh instead of the TV's EDID). The session runs as a transient `systemd-run --user` unit (clean cgroup teardown of the Steam tree); state lives in a host-lifetime static (MANAGED_SESSION), NOT in GamescopeDisplay (which is per-client-session) — so a same-mode reconnect REUSES the running session instantly (no Steam restart) while a different mode RELAUNCHES it (games can't change output mode live; a game/Steam restart on a mode change is unavoidable and acceptable). Reuses the existing node + EIS auto-discovery (find_gamescope_node / find_gamescope_eis_socket, factored into point_injector_at_eis) and the existing mid-stream Reconfigure → vd.create(mode) machinery — no protocol or m3 control-flow change. Validated live on bazzite (RTX 4090): games' Xwayland now advertises 5120×1440 @ 239.90 Hz as the preferred mode (was 59.96), the TV's 3840×2160/4096×2160@60 modes are gone, frames stream; reconnect at 1920×1080@120 relaunches and games see that; same-mode reconnect reuses with no restart and frames flow instantly. scripts: host.env.example documents PUNKTFUNK_GAMESCOPE_SESSION (mutually exclusive with the legacy NODE=auto attach); punktfunk-steam-session.service marked deprecated (superseded — must not run alongside the host-managed path). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -18,10 +18,33 @@ use anyhow::{anyhow, Context, Result};
|
|||||||
use std::process::{Child, Command, Stdio};
|
use std::process::{Child, Command, Stdio};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
/// The gamescope virtual-display driver. Each [`create`](VirtualDisplay::create) spawns one
|
/// The gamescope virtual-display driver. Three modes by env, in precedence order:
|
||||||
/// headless gamescope process sized to the requested mode.
|
/// * `PUNKTFUNK_GAMESCOPE_SESSION=<client>` — host-MANAGE a `gamescope-session-plus` session
|
||||||
|
/// (full Steam-Deck-UI polish) headless at the CLIENT's mode; relaunch it when the mode changes.
|
||||||
|
/// * `PUNKTFUNK_GAMESCOPE_NODE=<id|auto>` — ATTACH to an already-running gamescope (capture +
|
||||||
|
/// inject, no lifecycle ownership).
|
||||||
|
/// * else — SPAWN a bare headless gamescope sized to the mode, running `PUNKTFUNK_GAMESCOPE_APP`.
|
||||||
pub struct GamescopeDisplay;
|
pub struct GamescopeDisplay;
|
||||||
|
|
||||||
|
/// A running host-managed session (its transient systemd --user unit) + the mode it was launched at.
|
||||||
|
struct SessionState {
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
refresh_hz: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The host-managed `gamescope-session-plus` session, tracked at **host lifetime** (NOT per
|
||||||
|
/// `GamescopeDisplay`, which is recreated per client session and would otherwise cold-start Steam on
|
||||||
|
/// every reconnect). A same-mode reconnect reuses the running session (no Steam restart); a
|
||||||
|
/// different mode relaunches it. Cleared/relaunched by `launch_session`; survives across client
|
||||||
|
/// connections; on host restart the next launch stops the leftover unit by name and starts fresh.
|
||||||
|
static MANAGED_SESSION: std::sync::Mutex<Option<SessionState>> = std::sync::Mutex::new(None);
|
||||||
|
|
||||||
|
/// systemd --user transient unit name for the host-managed gamescope-session-plus session.
|
||||||
|
const SESSION_UNIT: &str = "punktfunk-gamescope";
|
||||||
|
/// The gamescope-session-plus launcher script (Bazzite / SteamOS-like hosts).
|
||||||
|
const SESSION_PLUS_BIN: &str = "/usr/share/gamescope-session-plus/gamescope-session-plus";
|
||||||
|
|
||||||
impl GamescopeDisplay {
|
impl GamescopeDisplay {
|
||||||
pub fn new() -> Result<Self> {
|
pub fn new() -> Result<Self> {
|
||||||
Ok(GamescopeDisplay)
|
Ok(GamescopeDisplay)
|
||||||
@@ -34,12 +57,16 @@ impl VirtualDisplay for GamescopeDisplay {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
|
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
|
||||||
// Attach to an already-running gamescope (e.g. a headless `gamescope-session-plus` Steam
|
// Host-managed gamescope-session-plus at the CLIENT's mode (the Bazzite path): launch the
|
||||||
// session, or a debug/Steam-launched one) instead of spawning our own: capture its node
|
// full Steam-Deck-UI session headless at the client's resolution + refresh — so games SEE
|
||||||
// AND inject into its EIS socket. PUNKTFUNK_GAMESCOPE_NODE=<id|auto>; "auto" discovers the
|
// them (via the injected --nested-refresh + generated CVT modes, not the box's TV EDID) —
|
||||||
// gamescope `Video/Source` node so nothing has to be hand-wired. This is the Bazzite path:
|
// and relaunch it when the client's mode changes. Reuses the node + EIS discovery below.
|
||||||
// a persistent headless Steam-Deck-UI session (full gamescope-session-plus polish, at the
|
if let Ok(client) = std::env::var("PUNKTFUNK_GAMESCOPE_SESSION") {
|
||||||
// client's resolution) that punktfunk streams + drives, instead of nesting a second Steam.
|
return create_managed_session(&client, mode);
|
||||||
|
}
|
||||||
|
// Attach to an already-running gamescope (a foreign / externally-launched session) instead
|
||||||
|
// of spawning our own: capture its node AND inject into its EIS socket.
|
||||||
|
// PUNKTFUNK_GAMESCOPE_NODE=<id|auto>; "auto" discovers the gamescope `Video/Source` node.
|
||||||
if let Ok(id) = std::env::var("PUNKTFUNK_GAMESCOPE_NODE") {
|
if let Ok(id) = std::env::var("PUNKTFUNK_GAMESCOPE_NODE") {
|
||||||
let node_id: u32 = if id.trim().eq_ignore_ascii_case("auto") {
|
let node_id: u32 = if id.trim().eq_ignore_ascii_case("auto") {
|
||||||
find_gamescope_node().ok_or_else(|| {
|
find_gamescope_node().ok_or_else(|| {
|
||||||
@@ -52,25 +79,7 @@ impl VirtualDisplay for GamescopeDisplay {
|
|||||||
id.parse()
|
id.parse()
|
||||||
.context("PUNKTFUNK_GAMESCOPE_NODE must be a node id or 'auto'")?
|
.context("PUNKTFUNK_GAMESCOPE_NODE must be a node id or 'auto'")?
|
||||||
};
|
};
|
||||||
// Point the libei injector at the running gamescope's EIS socket: it reads the relay
|
point_injector_at_eis();
|
||||||
// file [`EI_SOCKET_FILE`], so write the live socket's name there. Best-effort — video
|
|
||||||
// still works without it (input just won't reach the attached session).
|
|
||||||
match find_gamescope_eis_socket() {
|
|
||||||
Some(sock) => match std::fs::write(EI_SOCKET_FILE, &sock) {
|
|
||||||
Ok(()) => tracing::info!(
|
|
||||||
socket = %sock,
|
|
||||||
"gamescope attach: pointed injector at the running session's EIS socket"
|
|
||||||
),
|
|
||||||
Err(e) => tracing::warn!(
|
|
||||||
error = %e,
|
|
||||||
"gamescope attach: could not write the EIS relay file — input may not reach the session"
|
|
||||||
),
|
|
||||||
},
|
|
||||||
None => tracing::warn!(
|
|
||||||
"gamescope attach: no connectable gamescope EIS socket found — input injection \
|
|
||||||
will not reach the attached session"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
tracing::info!(node_id, "gamescope: attaching to existing PipeWire node");
|
tracing::info!(node_id, "gamescope: attaching to existing PipeWire node");
|
||||||
return Ok(VirtualOutput {
|
return Ok(VirtualOutput {
|
||||||
node_id,
|
node_id,
|
||||||
@@ -105,6 +114,164 @@ impl VirtualDisplay for GamescopeDisplay {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Host-managed `gamescope-session-plus` at the client's mode (state in [`MANAGED_SESSION`], so it
|
||||||
|
/// persists across client connections — a reconnect at the same mode reuses it instantly). REUSE
|
||||||
|
/// the running session if the mode is unchanged and its node is still live (no Steam restart);
|
||||||
|
/// otherwise stop the old transient unit and RELAUNCH at the new mode (gamescope can't change output
|
||||||
|
/// mode live). Then discover the node + point the injector, exactly as the attach path does.
|
||||||
|
fn create_managed_session(client: &str, mode: Mode) -> Result<VirtualOutput> {
|
||||||
|
let mut guard = MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
let same_mode = guard.as_ref().is_some_and(|s| {
|
||||||
|
s.width == mode.width && s.height == mode.height && s.refresh_hz == mode.refresh_hz
|
||||||
|
});
|
||||||
|
if same_mode {
|
||||||
|
if let Some(node_id) = find_gamescope_node() {
|
||||||
|
point_injector_at_eis();
|
||||||
|
tracing::info!(
|
||||||
|
node_id,
|
||||||
|
w = mode.width,
|
||||||
|
h = mode.height,
|
||||||
|
hz = mode.refresh_hz,
|
||||||
|
"gamescope session: reusing the running session (same mode — no Steam restart)"
|
||||||
|
);
|
||||||
|
return Ok(VirtualOutput {
|
||||||
|
node_id,
|
||||||
|
remote_fd: None,
|
||||||
|
preferred_mode: Some((mode.width, mode.height, mode.refresh_hz)),
|
||||||
|
keepalive: Box::new(()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
tracing::warn!("gamescope session: tracked session has no live node — relaunching");
|
||||||
|
*guard = None;
|
||||||
|
}
|
||||||
|
// (Re)launch at the new mode. `launch_session` stops the old unit by name first, so there is
|
||||||
|
// exactly one gamescope `Video/Source` node for discovery.
|
||||||
|
let node_id = launch_session(client, SESSION_UNIT, mode)?;
|
||||||
|
point_injector_at_eis();
|
||||||
|
*guard = Some(SessionState {
|
||||||
|
width: mode.width,
|
||||||
|
height: mode.height,
|
||||||
|
refresh_hz: mode.refresh_hz,
|
||||||
|
});
|
||||||
|
tracing::info!(
|
||||||
|
node_id,
|
||||||
|
w = mode.width,
|
||||||
|
h = mode.height,
|
||||||
|
hz = mode.refresh_hz,
|
||||||
|
"gamescope session: launched gamescope-session-plus at the client's mode"
|
||||||
|
);
|
||||||
|
Ok(VirtualOutput {
|
||||||
|
node_id,
|
||||||
|
remote_fd: None,
|
||||||
|
preferred_mode: Some((mode.width, mode.height, mode.refresh_hz)),
|
||||||
|
keepalive: Box::new(()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Point the libei injector at the running gamescope's EIS socket (it reads the relay file
|
||||||
|
/// [`EI_SOCKET_FILE`]). Best-effort — video still works without it (input just won't reach the
|
||||||
|
/// session). Shared by the attach and host-managed-session paths.
|
||||||
|
fn point_injector_at_eis() {
|
||||||
|
match find_gamescope_eis_socket() {
|
||||||
|
Some(sock) => match std::fs::write(EI_SOCKET_FILE, &sock) {
|
||||||
|
Ok(()) => {
|
||||||
|
tracing::info!(socket = %sock, "gamescope: pointed injector at the session's EIS socket")
|
||||||
|
}
|
||||||
|
Err(e) => tracing::warn!(
|
||||||
|
error = %e,
|
||||||
|
"gamescope: could not write the EIS relay file — input may not reach the session"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
None => tracing::warn!(
|
||||||
|
"gamescope: no connectable gamescope EIS socket found — input won't reach the session"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Path of the host-written `GAMESCOPE_BIN` wrapper (per-user, in tmpfs).
|
||||||
|
fn gamescope_bin_wrapper_path() -> std::path::PathBuf {
|
||||||
|
let base = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".to_string());
|
||||||
|
std::path::Path::new(&base).join("punktfunk-gamescope-bin")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the `GAMESCOPE_BIN` wrapper that injects `--nested-refresh $PF_HZ` — the flag
|
||||||
|
/// gamescope-session-plus does NOT expose, and the one that makes games see the client's refresh
|
||||||
|
/// instead of ~60 Hz. The body is constant (the rate comes from the `PF_HZ` env per launch), so the
|
||||||
|
/// write is idempotent. Returns its path.
|
||||||
|
fn write_gamescope_bin_wrapper() -> Result<std::path::PathBuf> {
|
||||||
|
let path = gamescope_bin_wrapper_path();
|
||||||
|
std::fs::write(
|
||||||
|
&path,
|
||||||
|
"#!/bin/sh\nexec /usr/bin/gamescope --nested-refresh \"${PF_HZ:-60}\" \"$@\"\n",
|
||||||
|
)
|
||||||
|
.with_context(|| format!("write GAMESCOPE_BIN wrapper {}", path.display()))?;
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))
|
||||||
|
.with_context(|| format!("chmod the GAMESCOPE_BIN wrapper {}", path.display()))?;
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Launch `gamescope-session-plus <client>` headless at `mode` as a transient `systemd --user`
|
||||||
|
/// unit (clean cgroup teardown of the whole Steam tree on stop). Injects `--nested-refresh` (via
|
||||||
|
/// the wrapper) + `--generate-drm-mode cvt` so games see exactly `mode` (resolution + refresh) and
|
||||||
|
/// not the box's physical-display EDID. Blocks until the gamescope `Video/Source` node appears
|
||||||
|
/// (Steam Big Picture cold-start is slow), returning its id; on timeout it stops the unit and errors.
|
||||||
|
fn launch_session(client: &str, unit_name: &str, mode: Mode) -> Result<u32> {
|
||||||
|
if !std::path::Path::new(SESSION_PLUS_BIN).exists() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"PUNKTFUNK_GAMESCOPE_SESSION is set but {SESSION_PLUS_BIN} is missing — the host-managed \
|
||||||
|
session needs gamescope-session-plus (a Bazzite / SteamOS-like host)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let wrapper = write_gamescope_bin_wrapper()?;
|
||||||
|
stop_session(unit_name); // clear any stale unit + relay so a relaunch is clean
|
||||||
|
let hz = mode.refresh_hz.max(1);
|
||||||
|
let status = Command::new("systemd-run")
|
||||||
|
.args(["--user", "--collect", &format!("--unit={unit_name}")])
|
||||||
|
.arg("--setenv=BACKEND=headless")
|
||||||
|
.arg(format!("--setenv=SCREEN_WIDTH={}", mode.width))
|
||||||
|
.arg(format!("--setenv=SCREEN_HEIGHT={}", mode.height))
|
||||||
|
.arg(format!("--setenv=PF_HZ={hz}"))
|
||||||
|
.arg(format!("--setenv=GAMESCOPE_BIN={}", wrapper.display()))
|
||||||
|
.arg("--setenv=DRM_MODE=cvt")
|
||||||
|
.arg(format!("--setenv=CUSTOM_REFRESH_RATES={hz}"))
|
||||||
|
.arg("--")
|
||||||
|
.arg(SESSION_PLUS_BIN)
|
||||||
|
.arg(client)
|
||||||
|
.status()
|
||||||
|
.context(
|
||||||
|
"launch gamescope-session-plus via `systemd-run --user` (is the user systemd manager \
|
||||||
|
up with XDG_RUNTIME_DIR + DBUS_SESSION_BUS_ADDRESS set?)",
|
||||||
|
)?;
|
||||||
|
if !status.success() {
|
||||||
|
anyhow::bail!("`systemd-run --user` failed to start the gamescope session (exit {status})");
|
||||||
|
}
|
||||||
|
// Steam Big Picture cold-start is far slower than a bare app — poll the node for up to 45s.
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(45);
|
||||||
|
loop {
|
||||||
|
if let Some(id) = find_gamescope_node() {
|
||||||
|
return Ok(id);
|
||||||
|
}
|
||||||
|
if Instant::now() >= deadline {
|
||||||
|
stop_session(unit_name);
|
||||||
|
anyhow::bail!(
|
||||||
|
"gamescope-session-plus '{client}' did not publish a Video/Source node within 45s \
|
||||||
|
(Steam failed to start? — `journalctl --user -u {unit_name}`)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(500));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop the host-managed session's transient unit (best-effort) and clear the EIS relay so a dead
|
||||||
|
/// session's socket name can't be reconnected.
|
||||||
|
fn stop_session(unit_name: &str) {
|
||||||
|
let _ = Command::new("systemctl")
|
||||||
|
.args(["--user", "stop", unit_name])
|
||||||
|
.status();
|
||||||
|
let _ = std::fs::remove_file(EI_SOCKET_FILE);
|
||||||
|
}
|
||||||
|
|
||||||
/// File where the wrapper below writes gamescope's `LIBEI_SOCKET` (its EIS server socket),
|
/// File where the wrapper below writes gamescope's `LIBEI_SOCKET` (its EIS server socket),
|
||||||
/// read by the libei injector to drive input into the nested app. See [`crate::inject`].
|
/// read by the libei injector to drive input into the nested app. See [`crate::inject`].
|
||||||
pub const EI_SOCKET_FILE: &str = "/tmp/punktfunk-gamescope-ei";
|
pub const EI_SOCKET_FILE: &str = "/tmp/punktfunk-gamescope-ei";
|
||||||
|
|||||||
@@ -13,9 +13,20 @@ PUNKTFUNK_VIDEO_SOURCE=virtual
|
|||||||
# GPU zero-copy capture (EGL/Vulkan → CUDA → NVENC). Falls back to CPU automatically.
|
# GPU zero-copy capture (EGL/Vulkan → CUDA → NVENC). Falls back to CPU automatically.
|
||||||
PUNKTFUNK_ZEROCOPY=1
|
PUNKTFUNK_ZEROCOPY=1
|
||||||
|
|
||||||
|
# --- Bazzite / SteamOS-like host: host-managed Steam-Deck-UI session -----------------------
|
||||||
|
# The host LAUNCHES gamescope-session-plus headless AT THE CLIENT'S mode (so games see the
|
||||||
|
# client's exact resolution + refresh, not the box's TV), and relaunches it when the mode
|
||||||
|
# changes. Requires the headless-appliance prereqs (linger + multi-user.target — see
|
||||||
|
# punktfunk-steam-session.service header) and NO physical gaming session running.
|
||||||
|
#PUNKTFUNK_COMPOSITOR=gamescope
|
||||||
|
#PUNKTFUNK_GAMESCOPE_SESSION=steam # host owns a gamescope-session-plus session at the client mode
|
||||||
|
#PUNKTFUNK_INPUT_BACKEND=gamescope
|
||||||
|
# Mutually exclusive with the above: ATTACH to a gamescope session something ELSE owns (fixed mode):
|
||||||
|
#PUNKTFUNK_GAMESCOPE_NODE=auto # discover + capture a running gamescope (do NOT combine with SESSION)
|
||||||
|
|
||||||
# Optional overrides (apps.json is the primary mechanism for per-app settings):
|
# Optional overrides (apps.json is the primary mechanism for per-app settings):
|
||||||
#PUNKTFUNK_COMPOSITOR=kwin # kwin | mutter | gamescope | wlroots
|
#PUNKTFUNK_COMPOSITOR=kwin # kwin | mutter | gamescope | wlroots
|
||||||
#PUNKTFUNK_GAMESCOPE_APP=vkcube # nested command for ad-hoc gamescope sessions
|
#PUNKTFUNK_GAMESCOPE_APP=vkcube # nested command for ad-hoc bare-gamescope sessions
|
||||||
#PUNKTFUNK_INPUT_BACKEND=libei # wlr | libei | gamescope | uinput
|
#PUNKTFUNK_INPUT_BACKEND=libei # wlr | libei | gamescope | uinput
|
||||||
#PUNKTFUNK_FEC_PCT=20 # video FEC overhead percent
|
#PUNKTFUNK_FEC_PCT=20 # video FEC overhead percent
|
||||||
#PUNKTFUNK_PERF=1 # per-stage timing logs
|
#PUNKTFUNK_PERF=1 # per-stage timing logs
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
# punktfunk headless Steam session — systemd USER unit (Bazzite / SteamOS-like hosts).
|
# punktfunk headless Steam session — systemd USER unit (Bazzite / SteamOS-like hosts).
|
||||||
#
|
#
|
||||||
# Runs the FULL gamescope-session-plus Steam Deck UI (MangoApp/VRR/controller config — all the
|
# DEPRECATED — superseded by the host-managed session (`PUNKTFUNK_GAMESCOPE_SESSION=steam` in
|
||||||
# polish) headless, at your streaming client's resolution, with no physical display. punktfunk's
|
# host.env). The host now LAUNCHES gamescope-session-plus on demand AT THE CLIENT'S mode (so games
|
||||||
# host then ATTACHES to it (capture its PipeWire node + inject into its libei socket) instead of
|
# see the client's exact resolution + refresh, dynamically per connection) and relaunches it on a
|
||||||
# nesting a second, conflicting Steam — set the host's PUNKTFUNK_GAMESCOPE_NODE=auto +
|
# mode change. Do NOT enable this fixed-resolution unit alongside the host-managed path — two
|
||||||
# PUNKTFUNK_INPUT_BACKEND=gamescope (see scripts/host.env.example).
|
# gamescope sessions publish two Video/Source nodes and break node discovery. Kept only for the
|
||||||
|
# legacy fixed-mode ATTACH setup (`PUNKTFUNK_GAMESCOPE_NODE=auto`); it caps every game at this
|
||||||
|
# unit's resolution. The PREREQS below still apply to the host-managed path too.
|
||||||
#
|
#
|
||||||
# Prereq — free Steam from the local gaming session (so this owns it), on a headless box:
|
# Prereq — free Steam from the local gaming session (so this owns it), on a headless box:
|
||||||
# sudo loginctl enable-linger $USER # user services run without a graphical login
|
# sudo loginctl enable-linger $USER # user services run without a graphical login
|
||||||
|
|||||||
Reference in New Issue
Block a user