qutebrowser / qutebrowser/qutebrowser
Add session hooks to support window manager integration (save/restore window positions)
Nobody has claimed this yet.
- Dominant language
- Python
- Stars
- 11.7k
- Forks
- 1.1k
- PR merge metrics
- No merged PRs in 30d
Description
Problem
When using qutebrowser with tiling window managers like Sway (Wayland) or i3 (X11), session restore does not preserve window positions, workspaces, or outputs. After restoring a session (via auto_save.session, :wq, or crash recovery), all windows typically end up on the same workspace rather than being placed back where they were.
This is a known limitation — as noted in #4829, qutebrowser doesn't know anything about workspaces. And rightly so: on Wayland, clients cannot position their own windows (this is a protocol-level restriction), so only the compositor can handle placement.
Proposed solution
Add two configuration options that allow an external script to hook into the session lifecycle:
c.session.after_save_command = 'qb-sway-session save {session_path}'
c.session.after_load_command = 'qb-sway-session restore {session_path}'
session.after_save_command: Executed synchronously (subprocess.run, timeout 10 s) after any session save — including autosave,:wq, and named:session-save. The placeholder{session_path}is replaced with the path to the session YAML file. Synchronous execution is required so that temporary title markers (__qb_save_<win_id>__) are still visible to the compositor when the external script queries the window tree, and to prevent windows from disappearing (e.g. during:wqshutdown) before the script can read them.session.after_load_command: Executed asynchronously after a session is loaded and all windows are created.
The :session-load command also accepts a --no-hooks flag that disables both the title marker injection and the after_load_command execution. This is useful when loading a session saved from a single window (:session-save --only-active-window) where the user just wants the tabs back without the window being repositioned.
Supporting changes in qutebrowser (all WM-agnostic)
-
Add
win_id,window_titleandrestore_idto session YAML data: In_save_all(), include each window's internalwin_id, current Qt title, and a sequential integerrestore_id. Thewin_idis used for save-side correlation (via temporary title markers), whilerestore_idprovides a stable identifier for cross-session correlation. These extra keys are ignored by_load_window()(backward compatible). -
Temporary title markers on restore: When
session.after_load_commandis configured and arestore_idis present in the session data,_load_window()sets the window title to a temporary marker (__qb_restore_<N>__) before callingwindow.show(). This gives the external script a deterministic way to identify which WM window corresponds to which session window — even before the real page titles are loaded. -
Suppress normal title updates briefly: A flag on
TabbedBrowsertemporarily prevents_update_window_title()from overwriting the marker. The flag is automatically cleared after a timeout (~3-5 seconds), after which normal title behavior resumes. This avoids a race condition whereQTimer.singleShot(0, _update_window_title)could overwrite the marker before the external script has a chance to read it.
The external script (WM-specific, not in qutebrowser core)
A companion script (e.g., qb-sway-session in misc/userscripts/) handles the WM-specific logic:
- Save: Reads the session YAML (read-only) and queries the WM (e.g.,
swaymsg -t get_tree). Correlation is done via temporary title markers (__qb_save_<win_id>__) that qutebrowser sets on each window right before running the command, giving exact 1:1 matching even with duplicate page titles. Saves a companion YAML sidecar file (e.g.,default.sway.yml) with position/workspace/layout data. The session file is never modified. - Restore: Polls the WM tree until windows with
__qb_restore_<N>__markers appear, then repositions them using WM commands (e.g.,swaymsg '[con_id=X] move to workspace Y'), including full layout tree reconstruction for tiled windows.
This architecture is WM-agnostic by design: supporting a different WM only requires writing a new external script — no changes to qutebrowser core. For example:
qb-sway-sessionfor Sway (using swaymsg)qb-hyprland-sessionfor Hyprland (using hyprctl)- etc.
Additional minor change
Expose QUTE_WIN_ID as an environment variable for userscripts (one line in browser/commands.py). This is useful independently of the session hooks.
Relationship to #35
This proposal is a focused subset of the autocmds concept discussed in #35. Rather than implementing a full event system, it adds two specific hooks for the session lifecycle. If #35 is implemented in the future, these hooks could be refactored to use the generic autocmd system (e.g., autocmd session-saved :spawn ...). In the meantime, this provides immediate value without requiring the design of a complete event framework.
Related issues
- #35 — autocmds (expose signals and call userscripts on them)
- #4829 — Browser window is always started on first workspace
- #572 — Per-window sessions (tangentially related)
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start by tracing session save and load through _save_all() and _load_window(), then inspect TabbedBrowser title updates and browser/commands.py for userscript environment variables. Review misc/userscripts/ for the external-script boundary. Done means the hooks, markers, session metadata, --no-hooks behavior, and QUTE_WIN_ID work across the listed save and restore paths without WM-specific logic in core.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- desktop
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100