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