Add spawn-sh, spawn-at-startup-sh

Our top 10 most confusing config moments
This commit is contained in:
Ivan Molodetskikh
2025-08-20 14:31:34 +03:00
parent 1013147ba3
commit e81f356908
9 changed files with 125 additions and 9 deletions

View File

@@ -206,7 +206,8 @@ binds {
> }
> ```
Currently, niri *does not* use a shell to run commands, which means that you need to manually separate arguments.
For `spawn`, niri *does not* use a shell to run commands, which means that you need to manually separate arguments.
See [`spawn-sh`](#spawn-sh) below for an action that uses a shell.
```kdl
binds {
@@ -249,6 +250,37 @@ binds {
}
```
#### `spawn-sh`
<sup>Since: next release</sup>
Run a command through the shell.
The argument is a single string that is passed verbatim to `sh`.
You can use shell variables, pipelines, `~` expansion, and everything else as expected.
```kdl
binds {
// Works with spawn-sh: all arguments in the same string.
Mod+D { spawn-sh "alacritty -e /usr/bin/fish"; }
// Works with spawn-sh: shell variable ($MAIN_OUTPUT), ~ expansion.
Mod+T { spawn-sh "grim -o $MAIN_OUTPUT ~/screenshot.png"; }
// Works with spawn-sh: process substitution.
Mod+Q { spawn-sh "notify-send clipboard \"$(wl-paste)\""; }
// Works with spawn-sh: multiple commands.
Super+Alt+S { spawn-sh "pkill orca || exec orca"; }
}
```
`spawn-sh "some command"` is equivalent to `spawn "sh" "-c" "some command"`—it's just a less confusing shorthand.
Keep in mind that going through the shell incurs a tiny performance penalty compared to directly `spawn`ing some binary.
Using `sh` is hardcoded, consistent with other compositors.
If you want a different shell, write it out using `spawn`, e.g. `spawn "fish" "-c" "some fish command"`.
#### `quit`
Exit niri after showing a confirmation dialog to avoid accidentally triggering it.

View File

@@ -5,6 +5,7 @@ Here are all of these options at a glance:
```kdl
spawn-at-startup "waybar"
spawn-at-startup "alacritty"
spawn-at-startup-sh "qs -c ~/source/qs/MyAwesomeShell"
prefer-no-csd
@@ -71,6 +72,22 @@ spawn-at-startup "alacritty"
Note that running niri as a systemd session supports xdg-desktop-autostart out of the box, which may be more convenient to use.
Thanks to this, apps that you configured to autostart in GNOME will also "just work" in niri, without any manual `spawn-at-startup` configuration.
### `spawn-at-startup-sh`
<sup>Since: next release</sup>
Add lines like this to run shell commands at niri startup.
The argument is a single string that is passed verbatim to `sh`.
You can use shell variables, pipelines, `~` expansion and everything else as expected.
See detailed description in the docs for the [`spawn-sh` key binding action](./Configuration:-Key-Bindings.md#spawn-sh).
```kdl
// Pass all arguments in the same string.
spawn-at-startup-sh "qs -c ~/source/qs/MyAwesomeShell"
```
### `prefer-no-csd`
This flag will make niri ask the applications to omit their client-side decorations.

View File

@@ -41,6 +41,8 @@ pub struct Config {
pub outputs: Outputs,
#[knuffel(children(name = "spawn-at-startup"))]
pub spawn_at_startup: Vec<SpawnAtStartup>,
#[knuffel(children(name = "spawn-at-startup-sh"))]
pub spawn_at_startup_sh: Vec<SpawnAtStartupSh>,
#[knuffel(child, default)]
pub layout: Layout,
#[knuffel(child, default)]
@@ -606,6 +608,12 @@ pub struct SpawnAtStartup {
pub command: Vec<String>,
}
#[derive(knuffel::Decode, Debug, Clone, PartialEq, Eq)]
pub struct SpawnAtStartupSh {
#[knuffel(argument)]
pub command: String,
}
#[derive(knuffel::Decode, Debug, Clone, Copy, PartialEq)]
pub struct FocusRing {
#[knuffel(child)]
@@ -1719,6 +1727,7 @@ pub enum Action {
DebugToggleOpaqueRegions,
DebugToggleDamage,
Spawn(#[knuffel(arguments)] Vec<String>),
SpawnSh(#[knuffel(argument)] String),
DoScreenTransition(#[knuffel(property(name = "delay-ms"))] Option<u16>),
#[knuffel(skip)]
ConfirmScreenshot {
@@ -1962,6 +1971,7 @@ impl From<niri_ipc::Action> for Action {
niri_ipc::Action::PowerOffMonitors {} => Self::PowerOffMonitors,
niri_ipc::Action::PowerOnMonitors {} => Self::PowerOnMonitors,
niri_ipc::Action::Spawn { command } => Self::Spawn(command),
niri_ipc::Action::SpawnSh { command } => Self::SpawnSh(command),
niri_ipc::Action::DoScreenTransition { delay_ms } => Self::DoScreenTransition(delay_ms),
niri_ipc::Action::Screenshot { show_pointer } => Self::Screenshot(show_pointer),
niri_ipc::Action::ScreenshotScreen {
@@ -3854,7 +3864,7 @@ where
}
match Action::decode_node(child, ctx) {
Ok(action) => {
if !matches!(action, Action::Spawn(_)) {
if !matches!(action, Action::Spawn(_) | Action::SpawnSh(_)) {
if let Some(node) = allow_when_locked_node {
ctx.emit_error(DecodeError::unexpected(
node,
@@ -4453,6 +4463,7 @@ mod tests {
}
spawn-at-startup "alacritty" "-e" "fish"
spawn-at-startup-sh "qs -c ~/source/qs/MyAwesomeShell"
prefer-no-csd
@@ -4549,6 +4560,7 @@ mod tests {
Mod+Shift+1 { focus-workspace "workspace-1"; }
Mod+Shift+E allow-inhibiting=false { quit skip-confirmation=true; }
Mod+WheelScrollDown cooldown-ms=150 { focus-workspace-down; }
Super+Alt+S allow-when-locked=true { spawn-sh "pkill orca || exec orca"; }
}
switch-events {
@@ -4794,6 +4806,11 @@ mod tests {
],
},
],
spawn_at_startup_sh: [
SpawnAtStartupSh {
command: "qs -c ~/source/qs/MyAwesomeShell",
},
],
layout: Layout {
focus_ring: FocusRing {
off: false,
@@ -5681,6 +5698,24 @@ mod tests {
allow_inhibiting: true,
hotkey_overlay_title: None,
},
Bind {
key: Key {
trigger: Keysym(
XK_s,
),
modifiers: Modifiers(
ALT | SUPER,
),
},
action: SpawnSh(
"pkill orca || exec orca",
),
repeat: true,
cooldown: None,
allow_when_locked: true,
allow_inhibiting: true,
hotkey_overlay_title: None,
},
],
),
switch_events: SwitchBinds {

View File

@@ -203,6 +203,12 @@ pub enum Action {
#[cfg_attr(feature = "clap", arg(last = true, required = true))]
command: Vec<String>,
},
/// Spawn a command through the shell.
SpawnSh {
/// Command to run.
#[cfg_attr(feature = "clap", arg(last = true, required = true))]
command: String,
},
/// Do a screen transition.
DoScreenTransition {
/// Delay in milliseconds for the screen to freeze before starting the transition.

View File

@@ -270,6 +270,9 @@ layout {
// This line starts waybar, a commonly used bar for Wayland compositors.
spawn-at-startup "waybar"
// To run a shell command (with variables, pipes, etc.), use spawn-at-startup-sh:
// spawn-at-startup-sh "qs -c ~/source/qs/MyAwesomeShell"
hotkey-overlay {
// Uncomment this line to disable the "Important Hotkeys" pop-up at startup.
// skip-at-startup
@@ -363,10 +366,10 @@ binds {
Mod+D hotkey-overlay-title="Run an Application: fuzzel" { spawn "fuzzel"; }
Super+Alt+L hotkey-overlay-title="Lock the Screen: swaylock" { spawn "swaylock"; }
// You can also use a shell. Do this if you need pipes, multiple commands, etc.
// Note: the entire command goes as a single argument in the end.
// Use spawn-sh to run a shell command. Do this if you need pipes, multiple commands, etc.
// Note: the entire command goes as a single argument. It's passed verbatim to `sh -c`.
// For example, this is a standard bind to toggle the screen reader (orca).
Super+Alt+S hotkey-overlay-title=null { spawn "sh" "-c" "pkill orca || exec orca"; }
Super+Alt+S hotkey-overlay-title=null { spawn-sh "pkill orca || exec orca"; }
// Example volume keys mappings for PipeWire & WirePlumber.
// The allow-when-locked=true property makes them work even when the session is locked.

View File

@@ -44,7 +44,7 @@ use crate::layout::scrolling::ScrollDirection;
use crate::layout::{ActivateWindow, LayoutElement as _};
use crate::niri::{CastTarget, PointerVisibility, State};
use crate::ui::screenshot_ui::ScreenshotUi;
use crate::utils::spawning::spawn;
use crate::utils::spawning::{spawn, spawn_sh};
use crate::utils::{center, get_monotonic_time, ResizeEdge};
pub mod backend_ext;
@@ -595,6 +595,10 @@ impl State {
let (token, _) = self.niri.activation_state.create_external_token(None);
spawn(command, Some(token.clone()));
}
Action::SpawnSh(command) => {
let (token, _) = self.niri.activation_state.create_external_token(None);
spawn_sh(command, Some(token.clone()));
}
Action::DoScreenTransition(delay_ms) => {
self.backend.with_primary_renderer(|renderer| {
self.niri.do_screen_transition(renderer, delay_ms);

View File

@@ -20,8 +20,8 @@ use niri::dbus;
use niri::ipc::client::handle_msg;
use niri::niri::State;
use niri::utils::spawning::{
spawn, store_and_increase_nofile_rlimit, CHILD_DISPLAY, CHILD_ENV, REMOVE_ENV_RUST_BACKTRACE,
REMOVE_ENV_RUST_LIB_BACKTRACE,
spawn, spawn_sh, store_and_increase_nofile_rlimit, CHILD_DISPLAY, CHILD_ENV,
REMOVE_ENV_RUST_BACKTRACE, REMOVE_ENV_RUST_LIB_BACKTRACE,
};
use niri::utils::{cause_panic, version, watcher, xwayland, IS_SYSTEMD_SERVICE};
use niri_config::ConfigPath;
@@ -151,6 +151,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
.unwrap_or_default();
let spawn_at_startup = mem::take(&mut config.spawn_at_startup);
let spawn_at_startup_sh = mem::take(&mut config.spawn_at_startup_sh);
*CHILD_ENV.write().unwrap() = mem::take(&mut config.environment);
store_and_increase_nofile_rlimit();
@@ -237,6 +238,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
for elem in spawn_at_startup {
spawn(elem.command, None);
}
for elem in spawn_at_startup_sh {
spawn_sh(elem.command, None);
}
// Show the config error notification right away if needed.
if config_errored {

View File

@@ -270,7 +270,7 @@ fn render(
// Add the spawn actions.
for bind in binds.iter().filter(|bind| {
matches!(bind.action, Action::Spawn(_))
matches!(bind.action, Action::Spawn(_) | Action::SpawnSh(_))
// Only show binds with Mod or Super to filter out stuff like volume up/down.
&& (bind.key.modifiers.contains(Modifiers::COMPOSITOR)
|| bind.key.modifiers.contains(Modifiers::SUPER))
@@ -447,6 +447,11 @@ fn action_name(action: &Action) -> String {
"Spawn <span face='monospace' bgcolor='#000000'>{}</span>",
args.first().unwrap_or(&String::new())
),
Action::SpawnSh(command) => format!(
"Spawn <span face='monospace' bgcolor='#000000'>{}</span>",
// Fairly crude but should get the job done in most cases.
command.split_ascii_whitespace().next().unwrap_or("")
),
_ => String::from("FIXME: Unknown"),
}
}

View File

@@ -83,6 +83,16 @@ pub fn spawn<T: AsRef<OsStr> + Send + 'static>(command: Vec<T>, token: Option<Xd
}
}
/// Spawns the command through the shell.
///
/// We hardcode `sh -c`, consistent with other compositors:
///
/// - https://github.com/swaywm/sway/blob/b3dcde8d69c3f1304b076968a7a64f54d0c958be/sway/commands/exec_always.c#L64
/// - https://github.com/hyprwm/Hyprland/blob/1ac1ff457ab8ef1ae6a8f2ab17ee7965adfa729f/src/managers/KeybindManager.cpp#L987
pub fn spawn_sh(command: String, token: Option<XdgActivationToken>) {
spawn(vec![String::from("sh"), String::from("-c"), command], token);
}
fn spawn_sync(
command: impl AsRef<OsStr>,
args: impl IntoIterator<Item = impl AsRef<OsStr>>,