Repository: alebastr/sway-systemd Branch: main Commit: f8b36c53927e Files: 19 Total size: 41.8 KB Directory structure: gitextract_u67umdq4/ ├── LICENSE ├── README.md ├── config.d/ │ ├── 10-systemd-cgroups.conf.in │ ├── 10-systemd-session.conf.in │ ├── 95-system-keyboard-config.conf.in │ └── 95-xdg-desktop-autostart.conf.in ├── meson.build ├── meson_options.txt ├── rpkg.conf ├── setup.cfg ├── src/ │ ├── assign-cgroups.py │ ├── locale1-xkb-config │ ├── session.sh │ └── wait-sni-ready ├── srpm/ │ ├── rpkg.macros │ └── sway-systemd.spec.rpkg └── units/ ├── sway-session-shutdown.target ├── sway-session.target └── sway-xdg-autostart.target ================================================ FILE CONTENTS ================================================ ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2021 Aleksei Bavshin Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Systemd integration for Sway ## Goals and requirements The goal of this project is to provide a minimal set of configuration files and scripts required for running [Sway] in a systemd environment. This includes several areas of integration: - Propagate required variables to the systemd user session environment. - Define sway-session.target for starting user services. - Place GUI applications into systemd scopes for systemd-oomd compatibility. ## Non-goals - Running the compositor itself as a user service. [sway-services] already exists and does exactly that. - Managing Sway environment. It's hard, opinionated and depends on the way user starts Sway, so I don't have a solution that works for everyone and is acceptable for default configuration. See also [#6]. The common solutions are `~/.profile` (if your display manager supports that) or a wrapper script that sets the variables before starting Sway. - Supporting multiple concurrent Sway sessions for the same user. It's uncommon and doing so would cause problems for which there are no easy solutions: As a part of the integration, we set `WAYLAND_DISPLAY` and `DISPLAY` for a systemd user session. The variables are only accurate per-session, while the systemd user sessions are per-user. So if the user starts a second Sway instance on the same machine, the new instance would overwrite the variables, potentially causing some services to break for the first session. ## Components ### Session targets Systemd forbids starting the `graphical-session.target` directly and encourages use of an environment-specific target units. Thus, the package here defines [`sway-session.target`] that binds to the `graphical-session.target` and starts user services enabled for a graphical session. `sway-session.target` should be started when the compositor is ready and the user session environment is set, and stopped before the compositor exits. An user service may depend on or reference `sway-session.target` only if it is specific for Sway. Otherwise, it's recommended to use `graphical-session.target`. A special `sway-session-shutdown.target` can be used to stop the `graphical-session.target` and the `sway-session.target` with all the contained services.\ `systemctl start sway-session-shutdown.target` will apply the `Conflicts=` statements in the unit file and ensure that everything is exited, something that `systemctl stop sway-session.target` is unable to guarantee. ### Session script The [`session.sh`](./src/session.sh) script is responsible for importing variables into systemd and dbus activation environments and starting session target. It also stays in the background until the compositor exits, stops the session target and unsets variables for systemd user session (this can be disabled by passing `--no-cleanup`). The script itself does not set any variables except `XDG_CURRENT_DESKTOP`/ `XDG_SESSION_TYPE`; it simply passes the values received from Sway. The list of variables and the name of the session target are currently hardcoded and could be changed by editing the script. For a better description see [comments in the code](./src/session.sh). ### Cgroups assignment script The [`assign-cgroups.py`](./src/assign-cgroups.py) script subscribes to a new window i3 ipc event and automatically creates a transient scope unit (with path `app.slice/app-${app_id}.slice/app-${app_id}-${pid}.scope`) for each GUI application launched in the same cgroup as the compositor. Existing child processes of the application are assigned to the same scope. The script is necessary to overcome a limitation of `systemd-oomd`: it only tracks resource usage by cgroups and kills the whole group when a single application misbehaves and exceeds resource usage limits. By placing individual apps into isolated cgroups we are decreasing the chance that the oomd killer would target the group with the compositor and accidentally terminate the session. It can also be used to impose resource usage limits on a specific application, because transient units are still loading override configs. For example, by creating `$XDG_CONFIG_HOME/systemd/user/app-firefox.slice.d/override.conf` with content ```ini [Slice] MemoryHigh=2G ``` you can tell systemd that all the Firefox processes combined are not allowed to exceed 2 Gb of memory. See [`systemd.resource-control(5)`] for other available resource control options. ### Keyboard layout configuration The [`locale1-xkb-config`] script reads the system-wide input configuration from [`org.freedesktop.locale1`] systemd interface, translates it into a Sway configuration and applies to all devices with type:keyboard. The main motivation for this component was an ability to apply system-wide keyboard mappings configured in the OS installer to a greetd or SDDM greeter running with Sway as a display server. The component is not enabled by default. Use `-Dautoload-configs=locale1,...` to install the configuration file to Sway's default config drop-in directory or check [`95-system-keyboard-config.conf`] for necessary configuration. ### XDG Desktop autostart target The `sway-xdg-autostart.target` wraps systemd bultin [`xdg-desktop-autostart.target`] to allow delayed start from a script. The `xdg-desktop-autostart.target` contains units generated by [`systemd-xdg-autostart-generator(8)`] from XDG desktop files in autostart directories. The recommended way to start it is a `Wants=xdg-desktop-autostart.target` in a Desktop Environment session target (`sway-session.target` in our case), but there are some issues with that approach. Most notably, there's a race between the autostarted applications and the panel with StatusNotifierHost implementation. SNI specification is very clear on that point; if the `StatusNotifierWatcher` is unavailable or `IsStatusNotifierHostRegistered` is not set, the application should fallback to XEmbed tray. There are even known implementations that follow this requirement (Qt...) and will fail to create a status icon if started before the panel. The component is not enabled by default. Use `-Dautoload-configs=autostart,...` to install the configuration file to Sway's default config drop-in directory or check [`95-xdg-desktop-autostart.conf`] for necessary configuration. ## Installation Packaging status ### Dependencies Session script calls these commands: `swaymsg`, `systemctl`, `dbus-update-activation-environment`. Cgroups script uses following python packages: [`dbus-fast`](https://pypi.org/project/dbus-fast/), [`i3ipc`](https://pypi.org/project/i3ipc/), [`psutil`](https://pypi.org/project/psutil/), [`tenacity`](https://pypi.org/project/tenacity/), [`python-xlib`](https://pypi.org/project/python-xlib/) ### Installing with meson ``` meson setup --sysconfdir=/etc [-Dautoload-configs=...,...] build sudo meson install -C build ``` The command will install configuration files from [`config.d`](./config.d/) to the `/etc/sway/config.d/` directory included from the default Sway config. The `autoload-config` option allows you to specify the configuration files that are loaded by default, with the rest being installed to `$PREFIX/share/sway-systemd`. If you are using a custom Sway configuration file and already removed the `include /etc/sway/config.d/*` line, you will need to edit your config and include the installed files. > [!NOTE] > It's not advised to enable everything system-wide, as behavior of certain > integration components can be unexpected and confusing for the users. > E.g. `locale1` can overwrite the keyboard options set in Sway config ([#21]), > and `autostart` can conflict with existing autostart configuration. ### Installing manually/using directly from git checkout 1. Clone repository. 2. Copy `units/*.target` to the systemd user unit directory (`/usr/lib/systemd/user/`, `$XDG_CONFIG_HOME/systemd/user/` or `~/.config/systemd/user` are common locations). 3. Run `systemctl --user daemon-reload` to make systemd rescan the service files. 4. Add `exec /path/to/cloned/repo/src/session.sh` to your Sway config for environment and session configuration. 5. Add `exec /path/to/cloned/repo/src/assign-cgroups.py` to your Sway config to enable cgroup assignment script. 6. Restart your Sway session or run `swaymsg` with the commands above. Simple config reload is insufficient as it does not execute `exec` commands. [Sway]: https://swaywm.org [sway-services]: https://github.com/xdbob/sway-services/ [`systemd.resource-control(5)`]: https://www.freedesktop.org/software/systemd/man/systemd.resource-control.html [`org.freedesktop.locale1`]: https://www.freedesktop.org/software/systemd/man/org.freedesktop.locale1.html [`xdg-desktop-autostart.target`]: https://www.freedesktop.org/software/systemd/man/systemd.special.html#xdg-desktop-autostart.target [`systemd-xdg-autostart-generator(8)`]: https://www.freedesktop.org/software/systemd/man/systemd-xdg-autostart-generator.html [`95-system-keyboard-config.conf`]: ./config.d/95-system-keyboard-config.conf.in [`95-xdg-desktop-autostart.conf`]: ./config.d/95-xdg-desktop-autostart.conf.in [`locale1-xkb-config`]: ./src/locale1-xkb-config [`sway-session.target`]: ./units/sway-session.target [#6]: https://github.com/alebastr/sway-systemd/issues/6 [#21]: https://github.com/alebastr/sway-systemd/issues/21 ================================================ FILE: config.d/10-systemd-cgroups.conf.in ================================================ # Automatically assign a dedicated systemd scope to the GUI applications # launched in the same cgroup as the compositor. This could be helpful for # implementing cgroup-based resource management and would be necessary when # `systemd-oomd` is in use. # # Limitations: The script is using i3ipc window:new event to detect application # launches and would fail to detect background apps or special surfaces. # Therefore it's recommended to supplement the script with use of systemd user # services for such background apps. # exec @execdir@/assign-cgroups.py ================================================ FILE: config.d/10-systemd-session.conf.in ================================================ # Address several issues with DBus activation and systemd user sessions # # 1. DBus-activated and systemd services do not share the environment with user # login session. In order to make the applications that have GUI or interact # with the compositor work as a systemd user service, certain variables must # be propagated to the systemd and dbus. # Possible (but not exhaustive) list of variables: # - DISPLAY - for X11 applications that are started as user session services # - WAYLAND_DISPLAY - similarly, this is needed for wayland-native services # - I3SOCK/SWAYSOCK - allow services to talk with sway using i3 IPC protocol # # 2. `xdg-desktop-portal` requires XDG_CURRENT_DESKTOP to be set in order to # select the right implementation for screenshot and screencast portals. # With all the numerous ways to start sway, it's not possible to rely on the # right value of the XDG_CURRENT_DESKTOP variable within the login session, # therefore the script will ensure that it is always set to `sway`. # # 3. GUI applications started as a systemd service (or via xdg-autostart-generator) # may rely on the XDG_SESSION_TYPE variable to select the backend. # Ensure that it is always set to `wayland`. # # 4. The common way to autostart a systemd service along with the desktop # environment is to add it to a `graphical-session.target`. However, systemd # forbids starting the graphical session target directly and encourages use # of an environment-specific target units. Therefore, the integration # package here provides and uses `sway-session.target` which would bind to # the `graphical-session.target`. # # 5. Stop the target and unset the variables when the compositor exits. # exec @execdir@/session.sh ================================================ FILE: config.d/95-system-keyboard-config.conf.in ================================================ # Apply system-wide XKB configuration stored in systemd-localed. # # The configuration can be viewed with `localectl` and modified # with `localectl set-x11-keymap`. # # Normal mode will pick up the configuration changes immediately # and oneshot mode will require a Sway config reload. # exec @execdir@/locale1-xkb-config exec_always @execdir@/locale1-xkb-config --oneshot ================================================ FILE: config.d/95-xdg-desktop-autostart.conf.in ================================================ # Wait until a StatusNotifierItem tray implementation is available and # process XDG autostart entries. # # This horror has to exist because # # - SNI spec mandates that if `IsStatusNotifierHostRegistered` is not set, # the client should fall back to the Freedesktop System Tray specification # (XEmbed). # - There are actual implementations that take this seriously and implement # a fallback *even if* StatusNotifierWatcher is already DBus-activated. # - https://github.com/systemd/systemd/issues/3750 # exec @execdir@/wait-sni-ready && \ systemctl --user start sway-xdg-autostart.target ================================================ FILE: meson.build ================================================ project('sway-systemd', [], meson_version: '>= 0.51', license: 'MIT', ) enabled = get_option('autoload-configs') configs = { 'config.d/10-systemd-session.conf.in': true, 'config.d/10-systemd-cgroups.conf.in': enabled.contains('all') or enabled.contains('cgroups'), 'config.d/95-system-keyboard-config.conf.in': enabled.contains('all') or enabled.contains('locale1'), 'config.d/95-xdg-desktop-autostart.conf.in': enabled.contains('all') or enabled.contains('autostart'), } scripts = [ 'src/session.sh', 'src/assign-cgroups.py', 'src/wait-sni-ready', 'src/locale1-xkb-config', ] unit_files = [ 'units/sway-session.target', 'units/sway-session-shutdown.target', 'units/sway-xdg-autostart.target', ] systemd = dependency('systemd') conf_dir = get_option('sysconfdir') / 'sway' / 'config.d' data_dir = get_option('datadir') / meson.project_name() # must be absolute path for configuration_data exec_dir = get_option('prefix') / get_option('libexecdir') / meson.project_name() install_data( scripts, install_dir: exec_dir, install_mode: 'rwxr-xr-x', ) install_data( unit_files, install_dir: systemd.get_variable(pkgconfig: 'systemduserunitdir'), ) conf_data = configuration_data() conf_data.set('execdir', exec_dir) foreach config, enabled : configs configure_file( configuration: conf_data, input: config, output: '@BASENAME@', install_dir: enabled ? conf_dir : data_dir, ) endforeach ================================================ FILE: meson_options.txt ================================================ option('autoload-configs', type: 'array', choices: ['all', 'autostart', 'cgroups', 'locale1'], value: [], description: 'Install configuration for selected components into a system-wide Sway include directory (/etc/sway/config.d)') ================================================ FILE: rpkg.conf ================================================ [rpkg] user_macros = "${git_props:root}/srpm/rpkg.macros" ================================================ FILE: setup.cfg ================================================ [flake8] max-line-length = 88 extend-ignore = E203, W292, W503 [mypy] # Too many libraries without type hints :'( ignore_missing_imports = True [pycodestyle] max-line-length = 88 ignore = E203, W292, W503 [pylint] max-line-length = 88 [pylint.messages_control] # silence C0103 (invalid-name) for script module name good-names = assign-cgroups, locale1-xkb-config, wait-sni-ready ================================================ FILE: src/assign-cgroups.py ================================================ #!/usr/bin/python3 """ Automatically assign a dedicated systemd scope to the GUI applications launched in the same cgroup as the compositor. This could be helpful for implementing cgroup-based resource management and would be necessary when `systemd-oomd` is in use. Limitations: The script is using i3ipc window:new event to detect application launches and would fail to detect background apps or special surfaces. Therefore it's recommended to supplement the script with use of systemd user services for such background apps. Dependencies: dbus-fast, i3ipc, psutil, tenacity, python-xlib """ import argparse import asyncio import logging import re import socket import struct import sys from functools import lru_cache from typing import Optional from dbus_fast import Variant from dbus_fast.aio import MessageBus from dbus_fast.errors import DBusError from i3ipc import Event from i3ipc.aio import Con, Connection from psutil import Process from tenacity import retry, retry_if_exception_type, stop_after_attempt if sys.version_info[:2] >= (3, 9): from collections.abc import Callable else: from typing import Callable LOG = logging.getLogger("assign-cgroups") SD_BUS_NAME = "org.freedesktop.systemd1" SD_OBJECT_PATH = "/org/freedesktop/systemd1" SD_SLICE_FORMAT = "app-{app_id}.slice" SD_UNIT_FORMAT = "app-{app_id}-{unique}.scope" # Ids of known launcher applications that are not special surfaces. When the app is # started using one of those, it should be moved to a new cgroup. # Launcher should only be listed here if it creates cgroup of its own. LAUNCHER_APPS = ["nwgbar", "nwgdmenu", "nwggrid", "onagre"] SD_UNIT_ESCAPE_RE = re.compile(r"[^\w:.\\]", re.ASCII) def escape_app_id(app_id: str) -> str: """Escape app_id for systemd APIs. The "unit prefix" must consist of one or more valid characters (ASCII letters, digits, ":", "-", "_", ".", and "\"). The total length of the unit name including the suffix must not exceed 256 characters. [systemd.unit(5)] We also want to escape "-" to avoid creating extra slices. """ def repl(match): return "".join([f"\\x{x:02x}" for x in match.group().encode()]) return SD_UNIT_ESCAPE_RE.sub(repl, app_id) LAUNCHER_APP_CGROUPS = [ SD_SLICE_FORMAT.format(app_id=escape_app_id(app)) for app in LAUNCHER_APPS ] def get_cgroup(pid: int) -> Optional[str]: """ Get cgroup identifier for the process specified by pid. Assumes cgroups v2 unified hierarchy. """ try: with open(f"/proc/{pid}/cgroup", "r") as file: cgroup = file.read() return cgroup.strip().split(":")[-1] except OSError: LOG.exception("Error geting cgroup info") return None def get_pid_by_socket(sockpath: str) -> int: """ getsockopt (..., SO_PEERCRED, ...) returns the following structure struct ucred { pid_t pid; /* s32: PID of sending process. */ uid_t uid; /* u32: UID of sending process. */ gid_t gid; /* u32: GID of sending process. */ }; See also: socket(7), unix(7) """ with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock: sock.connect(sockpath) ucred = sock.getsockopt( socket.SOL_SOCKET, socket.SO_PEERCRED, struct.calcsize("iII") ) pid, _, _ = struct.unpack("iII", ucred) return pid def create_x11_pid_getter() -> Callable[[int], int]: """Create fallback X11 PID getter. Sway 1.6.1/wlroots 0.14 can use XRes to get the PID for Xwayland apps from the server and won't ever reach that. The fallback is preserved for compatibility with i3 and earlier versions of Sway. """ # pylint: disable=import-outside-toplevel # Defer Xlib import until we really need it. from Xlib import X from Xlib.display import Display try: # requires python-xlib >= 0.30 from Xlib.ext import res as XRes except ImportError: XRes = None display = Display() def get_net_wm_pid(wid: int) -> int: """Get PID from _NET_WM_PID property of X11 window""" window = display.create_resource_object("window", wid) net_wm_pid = display.get_atom("_NET_WM_PID") pid = window.get_full_property(net_wm_pid, X.AnyPropertyType) if pid is None: raise RuntimeError("Failed to get PID from _NET_WM_PID") return int(pid.value.tolist()[0]) def get_xres_client_id(wid: int) -> int: """Get PID from X server via X-Resource extension""" res = display.res_query_client_ids( [{"client": wid, "mask": XRes.LocalClientPIDMask}] ) for cid in res.ids: if cid.spec.client > 0 and cid.spec.mask == XRes.LocalClientPIDMask: for value in cid.value: return value raise RuntimeError("Failed to get PID via X-Resource extension") if XRes is None or display.query_extension(XRes.extname) is None: LOG.warning( "X-Resource extension is not supported. " "Process identification for X11 applications will be less reliable." ) return get_net_wm_pid ver = display.res_query_version() LOG.info( "X-Resource version %d.%d", ver.server_major, ver.server_minor, ) if (ver.server_major, ver.server_minor) < (1, 2): return get_net_wm_pid return get_xres_client_id class CGroupHandler: """Main logic: handle i3/sway IPC events and start systemd transient units.""" def __init__(self, bus: MessageBus, conn: Connection): self._bus = bus self._conn = conn @property @lru_cache(maxsize=1) def get_x11_window_pid(self) -> Optional[Callable[[int], int]]: """On-demand initialization of X11 PID getter""" try: return create_x11_pid_getter() # pylint: disable=broad-except except Exception as exc: LOG.warning("Failed to create X11 PID getter: %s", exc) return None async def connect(self): """asynchronous initialization code""" # pylint: disable=attribute-defined-outside-init introspection = await self._bus.introspect(SD_BUS_NAME, SD_OBJECT_PATH) self._sd_proxy = self._bus.get_proxy_object( SD_BUS_NAME, SD_OBJECT_PATH, introspection ) self._sd_manager = self._sd_proxy.get_interface(f"{SD_BUS_NAME}.Manager") self._compositor_pid = get_pid_by_socket(self._conn.socket_path) self._compositor_cgroup = get_cgroup(self._compositor_pid) assert self._compositor_cgroup is not None LOG.info("compositor:%s %s", self._compositor_pid, self._compositor_cgroup) self._conn.on(Event.WINDOW_NEW, self._on_new_window) return self def get_pid(self, con: Con) -> Optional[int]: """Get PID from IPC response (sway), X-Resource or _NET_WM_PID (i3)""" if isinstance(con.pid, int) and con.pid > 0: return con.pid if con.window is not None and self.get_x11_window_pid is not None: return self.get_x11_window_pid(con.window) return None def cgroup_change_needed(self, cgroup: Optional[str]) -> bool: """Check criteria for assigning current app into an isolated cgroup""" if cgroup is None: return False for launcher in LAUNCHER_APP_CGROUPS: if launcher in cgroup: return True return cgroup == self._compositor_cgroup @retry( reraise=True, retry=retry_if_exception_type(DBusError), stop=stop_after_attempt(3), ) async def assign_scope(self, app_id: str, proc: Process): """ Assign process (and all unassigned children) to the app-{app_id}.slice/app{app_id}-{pid}.scope cgroup """ app_id = escape_app_id(app_id) sd_slice = SD_SLICE_FORMAT.format(app_id=app_id) sd_unit = SD_UNIT_FORMAT.format(app_id=app_id, unique=proc.pid) # Collect child processes as systemd assigns a scope only to explicitly # specified PIDs. # There's a risk of race as the child processes may exit by the time dbus call # reaches systemd, hence the @retry decorator is applied to the method. pids = [proc.pid] + [ x.pid for x in proc.children(recursive=True) if self.cgroup_change_needed(get_cgroup(x.pid)) ] await self._sd_manager.call_start_transient_unit( sd_unit, "fail", [["PIDs", Variant("au", pids)], ["Slice", Variant("s", sd_slice)]], [], ) LOG.debug( "window %s successfully assigned to cgroup %s/%s", app_id, sd_slice, sd_unit ) async def _on_new_window(self, _: Connection, event: Event): """window:new IPC event handler""" con = event.container app_id = con.app_id if con.app_id else con.window_class try: pid = self.get_pid(con) if pid is None: LOG.warning("Failed to get pid for %s", app_id) return proc = Process(pid) cgroup = get_cgroup(proc.pid) # some X11 apps don't set WM_CLASS. fallback to process name if app_id is None: app_id = proc.name() LOG.debug("window %s(%s) cgroup %s", app_id, proc.pid, cgroup) if self.cgroup_change_needed(cgroup): await self.assign_scope(app_id, proc) # pylint: disable=broad-except except Exception as exc: LOG.error("Failed to modify cgroup for %s: %s", app_id, exc) async def main(): """Async entrypoint""" try: bus = await MessageBus().connect() conn = await Connection(auto_reconnect=False).connect() await CGroupHandler(bus, conn).connect() await conn.main() except DBusError as exc: LOG.error("DBus connection error: %s", exc) except (ConnectionError, EOFError) as exc: LOG.error("Sway IPC connection error: %s", exc) if __name__ == "__main__": parser = argparse.ArgumentParser( description="Assign CGroups to apps in compositors with i3 IPC protocol support" ) parser.add_argument( "-l", "--loglevel", choices=["critical", "error", "warning", "info", "debug"], default="info", dest="loglevel", help="set logging level", ) args = parser.parse_args() logging.basicConfig(level=args.loglevel.upper()) asyncio.run(main()) ================================================ FILE: src/locale1-xkb-config ================================================ #!/usr/bin/python3 """ Sync Sway input configuration with org.freedesktop.locale1. Usage: Configure keyboard mappings with `localectl set-x11-keymap`. Add `exec /path/to/script` to your Sway config. See also: https://www.freedesktop.org/software/systemd/man/org.freedesktop.locale1.html Dependencies: dbus-fast, i3ipc """ import argparse import asyncio import logging from typing import Any, Dict from dbus_fast import BusType, DBusError, Variant from dbus_fast.aio import MessageBus from i3ipc.aio import Connection DEFAULT_DEVICE = 'type:keyboard' LOG = logging.getLogger("sway.locale1") LOCALE1_BUS_NAME = "org.freedesktop.locale1" LOCALE1_OBJECT_PATH = "/org/freedesktop/locale1" LOCALE1_INTERFACE = "org.freedesktop.locale1" PROPERTIES_INTERFACE = "org.freedesktop.DBus.Properties" PROPERTIES = { 'X11Layout': 'layout', 'X11Model': 'model', 'X11Variant': 'variant', 'X11Options': 'options' } class Locale1Client: """Handle org.freedesktop.locale1 updates and pass XKB configuration to Sway""" layout: str = '' model: str = '' variant: str = '' options: str = '' def __init__(self, bus: MessageBus, conn: Connection, device: str = DEFAULT_DEVICE): self._bus = bus self._conn = conn self._proxy = None self._device = device async def connect(self): """asynchronous initialization code""" introspection = await self._bus.introspect(LOCALE1_BUS_NAME, LOCALE1_OBJECT_PATH) self._proxy = self._bus.get_proxy_object(LOCALE1_BUS_NAME, LOCALE1_OBJECT_PATH, introspection) self._proxy.get_interface(PROPERTIES_INTERFACE).on_properties_changed( self.on_properties_changed) locale1 = self._proxy.get_interface(LOCALE1_INTERFACE) self.layout = await locale1.get_x11_layout() self.model = await locale1.get_x11_model() self.variant = await locale1.get_x11_variant() self.options = await locale1.get_x11_options() await self.update() async def on_properties_changed(self, interface: str, changed: Dict[str, Any], _invalidated=None): """Handle updates from localed""" if interface != LOCALE1_INTERFACE: return apply = False for name, value in changed.items(): if name not in PROPERTIES: continue if isinstance(value, Variant): value = value.value self.__dict__[PROPERTIES[name]] = value apply = True if apply: await self.update() async def update(self): """Pass the updated xkb configuration to Sway""" LOG.info("xkb(%s): layout '%s' model '%s', variant '%s' options '%s'", self._device, self.layout, self.model, self.variant, self.options) cmd = [f"input {self._device} xkb_variant ''"] cmd.extend([ f"input {self._device} xkb_{name} '{self.__dict__[name]}'" for name in PROPERTIES.values() ]) replies = await self._conn.command(', '.join(cmd)) for cmd, reply in zip(cmd, replies): if reply.error is not None: LOG.error("command '%s' failed: %s", cmd, reply.error) async def main(args: argparse.Namespace): """Async entrypoint""" try: bus = await MessageBus(bus_type=BusType.SYSTEM).connect() conn = await Connection(auto_reconnect=False).connect() await Locale1Client(bus, conn, device=args.device).connect() if not args.oneshot: await conn.main() except DBusError as exc: LOG.error("DBus connection error: %s", exc) except (ConnectionError, EOFError) as exc: LOG.error("Sway IPC connection error: %s", exc) if __name__ == "__main__": parser = argparse.ArgumentParser( description="Sync Sway input configuration with org.freedesktop.locale1" ) parser.add_argument( "-l", "--loglevel", choices=["critical", "error", "warning", "info", "debug"], default="info", dest="loglevel", help="set logging level", ) parser.add_argument( "--device", default=DEFAULT_DEVICE, metavar='identifier', nargs='?', type=str, help="control settings for a specific device " "(see man sway-input; default: %(default)s)", ) parser.add_argument("--oneshot", action='store_true', help="apply current settings and exit immediately") args = parser.parse_args() logging.basicConfig(level=args.loglevel.upper()) asyncio.run(main(args)) ================================================ FILE: src/session.sh ================================================ #!/bin/sh # # Address several issues with DBus activation and systemd user sessions # # 1. DBus-activated and systemd services do not share the environment with user # login session. In order to make the applications that have GUI or interact # with the compositor work as a systemd user service, certain variables must # be propagated to the systemd and dbus. # Possible (but not exhaustive) list of variables: # - DISPLAY - for X11 applications that are started as user session services # - WAYLAND_DISPLAY - similarly, this is needed for wayland-native services # - I3SOCK/SWAYSOCK - allow services to talk with sway using i3 IPC protocol # # 2. `xdg-desktop-portal` requires XDG_CURRENT_DESKTOP to be set in order to # select the right implementation for screenshot and screencast portals. # With all the numerous ways to start sway, it's not possible to rely on the # right value of the XDG_CURRENT_DESKTOP variable within the login session, # therefore the script will ensure that it is always set to `sway`. # # 3. GUI applications started as a systemd service (or via xdg-autostart-generator) # may rely on the XDG_SESSION_TYPE variable to select the backend. # Ensure that it is always set to `wayland`. # # 4. The common way to autostart a systemd service along with the desktop # environment is to add it to a `graphical-session.target`. However, systemd # forbids starting the graphical session target directly and encourages use # of an environment-specific target units. Therefore, the integration # package here provides and uses `sway-session.target` which would bind to # the `graphical-session.target`. # # 5. Stop the target and unset the variables when the compositor exits. # # References: # - https://github.com/swaywm/sway/wiki#gtk-applications-take-20-seconds-to-start # - https://github.com/emersion/xdg-desktop-portal-wlr/wiki/systemd-user-services,-pam,-and-environment-variables # - https://www.freedesktop.org/software/systemd/man/systemd.special.html#graphical-session.target # - https://systemd.io/DESKTOP_ENVIRONMENTS/ # export XDG_CURRENT_DESKTOP=sway export XDG_SESSION_DESKTOP="${XDG_SESSION_DESKTOP:-sway}" export XDG_SESSION_TYPE=wayland VARIABLES="DESKTOP_SESSION XDG_CURRENT_DESKTOP XDG_SESSION_DESKTOP XDG_SESSION_TYPE" VARIABLES="${VARIABLES} DISPLAY I3SOCK SWAYSOCK WAYLAND_DISPLAY" VARIABLES="${VARIABLES} XCURSOR_THEME XCURSOR_SIZE" SESSION_TARGET="sway-session.target" SESSION_SHUTDOWN_TARGET="sway-session-shutdown.target" WITH_CLEANUP=1 print_usage() { cat <&2 print_usage exit 1 ;; *) break ;; esac shift done # Check if another Sway session is already active. # # Ignores all other kinds of parallel or nested sessions # (Sway on Gnome/KDE/X11/etc.), as the only way to detect these is to check # for (WAYLAND_)?DISPLAY and that is know to be broken on Arch. if systemctl --user -q is-active "$SESSION_TARGET"; then echo "Another session found; refusing to overwrite the variables" exit 1 fi # DBus activation environment is independent from systemd. While most of # dbus-activated services are already using `SystemdService` directive, some # still don't and thus we should set the dbus environment with a separate # command. if hash dbus-update-activation-environment 2>/dev/null; then # shellcheck disable=SC2086 dbus-update-activation-environment --systemd ${VARIABLES:- --all} fi # reset failed state of all user units systemctl --user reset-failed # shellcheck disable=SC2086 systemctl --user import-environment $VARIABLES systemctl --user start "$SESSION_TARGET" # Optionally, wait until the compositor exits and cleanup variables and services. if [ -z "$WITH_CLEANUP" ] || [ -z "$SWAYSOCK" ] || ! hash swaymsg 2>/dev/null then exit 0; fi # declare cleanup handler and run it on script termination via kill or Ctrl-C session_cleanup () { # stop the session target and unset the variables systemctl --user start --job-mode=replace-irreversibly "$SESSION_SHUTDOWN_TARGET" if [ -n "$VARIABLES" ]; then # shellcheck disable=SC2086 systemctl --user unset-environment $VARIABLES fi } trap session_cleanup INT TERM # wait until the compositor exits swaymsg -t subscribe '["shutdown"]' # run cleanup handler on normal exit session_cleanup ================================================ FILE: src/wait-sni-ready ================================================ #!/usr/bin/python3 """ A simple script for waiting until an org.kde.StatusNotifierItem host implementation is available and ready. Dependencies: dbus-fast, tenacity """ import asyncio import logging import os from dbus_fast.aio import MessageBus from tenacity import retry, stop_after_delay, wait_fixed LOG = logging.getLogger("wait-sni-host") TIMEOUT = int(os.environ.get("SNI_WAIT_TIMEOUT", default=25)) @retry(reraise=True, stop=stop_after_delay(TIMEOUT), wait=wait_fixed(0.5)) async def get_service(bus, name, object_path, interface_name): """Wait until the service appears on the bus""" introspection = await bus.introspect(name, object_path) proxy = bus.get_proxy_object(name, object_path, introspection) return proxy.get_interface(interface_name) async def wait_sni_host(bus: MessageBus): """Wait until a StatusNotifierWatcher service is available and has a StatusNotifierHost instance""" future = asyncio.get_event_loop().create_future() async def on_host_registered(): value = await sni_watcher.get_is_status_notifier_host_registered() LOG.debug("StatusNotifierHostRegistered: %s", value) if value: future.set_result(value) sni_watcher = await get_service(bus, "org.kde.StatusNotifierWatcher", "/StatusNotifierWatcher", "org.kde.StatusNotifierWatcher") sni_watcher.on_status_notifier_host_registered(on_host_registered) # fetch initial value await on_host_registered() return await asyncio.wait_for(future, timeout=TIMEOUT) async def main(): """asyncio entrypoint""" bus = await MessageBus().connect() try: await wait_sni_host(bus) LOG.info("Successfully waited for org.kde.StatusNotifierHost") # pylint: disable=broad-except except Exception as err: LOG.error("Failed to wait for org.kde.StatusNotifierHost: %s", str(err)) if __name__ == "__main__": logging.basicConfig(level=logging.INFO) asyncio.run(main()) ================================================ FILE: srpm/rpkg.macros ================================================ #!/bin/bash # vim ft:sh function git_tag { git describe --tags --abbrev=0 2>/dev/null | head -n 1 } function git_commit_count { local tag=$1 if [ -n "$tag" ]; then git rev-list "$tag"..HEAD --count 2>/dev/null || printf 0 else git rev-list HEAD --count 2>/dev/null || printf 0 fi } function git_version { tag="$(git_tag)" tag_version="$(echo "$tag" | sed -E -n "s/^v?([^-]+)/\1/p")" if [ -z "$tag_version" ]; then tag_version=0 fi commit_count="$(git_commit_count "$tag")" if [ "$commit_count" -eq 0 ]; then output "$tag_version" else shortcommit="$(git rev-parse --short HEAD)" output "$tag_version^${commit_count}.git${shortcommit}" fi } function git_release { output "1" } function git_dir_release { git_release "$@" } ================================================ FILE: srpm/sway-systemd.spec.rpkg ================================================ # vim: ft=spec %global srcname {{{ git_name }}} Name: {{{ git_name append="-git" }}} Version: {{{ git_version }}} Release: {{{ git_release }}}%{?dist} Summary: Systemd integration for Sway session License: MIT URL: https://github.com/alebastr/sway-systemd Source0: {{{ git_pack path=$(git rev-parse --show-toplevel) }}} BuildArch: noarch BuildRequires: meson BuildRequires: pkgconfig(systemd) BuildRequires: systemd-rpm-macros Conflicts: %{srcname} Requires: python3dist(dbus-fast) Requires: python3dist(i3ipc) Requires: python3dist(psutil) Requires: python3dist(python-xlib) Requires: python3dist(tenacity) Requires: sway Requires: systemd Recommends: /usr/bin/dbus-update-activation-environment %description %{summary}. The goal of this project is to provide a minimal set of configuration files and scripts required for running Sway in a systemd environment. This includes several areas of integration: - Propagate required variables to the systemd user session environment. - Define sway-session.target for starting user services. - Place GUI applications into a systemd scopes for systemd-oomd compatibility. %prep {{{ git_setup_macro path=$(git rev-parse --show-toplevel) }}} %build %meson \ -Dautoload-configs='cgroups' %meson_build %install %meson_install %files %license LICENSE %doc README.md %config(noreplace) %{_sysconfdir}/sway/config.d/10-systemd-session.conf %config(noreplace) %{_sysconfdir}/sway/config.d/10-systemd-cgroups.conf %{_datadir}/%{srcname}/*.conf %dir %{_libexecdir}/%{srcname} %{_libexecdir}/%{srcname}/assign-cgroups.py %{_libexecdir}/%{srcname}/locale1-xkb-config %{_libexecdir}/%{srcname}/session.sh %{_libexecdir}/%{srcname}/wait-sni-ready %{_userunitdir}/sway-session.target %{_userunitdir}/sway-session-shutdown.target %{_userunitdir}/sway-xdg-autostart.target %changelog {{{ git_changelog }}} ================================================ FILE: units/sway-session-shutdown.target ================================================ [Unit] Description=Shutdown running Sway session DefaultDependencies=no StopWhenUnneeded=true Conflicts=graphical-session.target graphical-session-pre.target After=graphical-session.target graphical-session-pre.target Conflicts=sway-session.target After=sway-session.target ================================================ FILE: units/sway-session.target ================================================ [Unit] Description=Sway session Documentation=man:systemd.special(7) BindsTo=graphical-session.target Wants=graphical-session-pre.target After=graphical-session-pre.target ================================================ FILE: units/sway-xdg-autostart.target ================================================ # Systemd provides xdg-desktop-autostart.target as a way to process XDG autostart # desktop files. The target sets RefuseManualStart though, and thus cannot be # used from scripts. # [Unit] Description=XDG autostart for Sway session Documentation=man:systemd.special(7) man:systemd-xdg-autostart-generator(8) Documentation=https://github.com/alebastr/sway-systemd BindsTo=xdg-desktop-autostart.target PartOf=sway-session.target After=sway-session.target