Skip to content

Add for_exec_window command and activation_token criteria#339

Draft
jbms wants to merge 1 commit into
dawsers:masterfrom
jbms:for-exec-window
Draft

Add for_exec_window command and activation_token criteria#339
jbms wants to merge 1 commit into
dawsers:masterfrom
jbms:for-exec-window

Conversation

@jbms

@jbms jbms commented Jun 26, 2026

Copy link
Copy Markdown
Contributor
  • for_exec_window allows executing a program and applying a list of sway commands to the window created by that program.
    Syntax: for_exec_window "<sway commands>" <exec command>
    Matches via XDG activation token, PID (fallback), or X11 startup ID (Xwayland).

  • Expose activation_token in window's JSON representation (IPC). Added activation_token to sway_view and serialized it in get_tree.

  • Support for_window [activation_token=...] criteria. This is a one-shot criteria that binds commands to the corresponding launcher_ctx immediately, and cannot be combined with other criteria.

Changes:

  • include/sway/desktop/launcher.h & sway/desktop/launcher.c:
    • Add cmdlist to launcher_ctx.
    • Implement launcher_ctx_find_token to look up ctx by activation token.
    • Fix crash in view_assign_ctx by obtaining token name before consuming.
  • include/sway/tree/view.h & sway/tree/view.c:
    • Add exec_cmdlist and activation_token to sway_view.
    • Populate them in view_assign_ctx.
  • include/sway/criteria.h & sway/criteria.c:
    • Add activation_token to criteria struct and implement parsing/matching/destruction.
  • sway/commands/for_exec_window.c: Implement for_exec_window command.
  • sway/commands/for_window.c: Intercept activation_token criteria and bind to launcher_ctx.
  • sway/commands/exec_always.c & exec.c: Refactor cmd_exec_process to support passing cmdlist.
  • sway/ipc-json.c: Expose activation_token in view JSON.
  • sway/scroll.5.scd: Document new command and criteria, noting that activation_token rules automatically expire.
  • sway/scroll-ipc.7.scd: Document activation_token property in window JSON representation.
  • protocols/meson.build: Add xdg-activation-v1 protocol.
  • tests/clients/wayland-client.c: Implement XDG activation support in test client.
  • tests/clients/x11-client.c: Implement _NET_STARTUP_ID support.
  • tests/test_for_exec_window.py: Add tests for for_exec_window (wait for Xserver using socket check).
  • tests/test_activation_token.py: Add tests for activation_token criteria (avoiding sleep by launching client from python) and IPC exposure, and verification of invalid criteria combinations.

@jbms

jbms commented Jun 26, 2026

Copy link
Copy Markdown
Contributor Author

This is related to this old feature request for sway: swaywm/sway#4045

@jbms jbms force-pushed the for-exec-window branch 2 times, most recently from 1555569 to 819f013 Compare June 26, 2026 07:08
@dawsers

dawsers commented Jun 26, 2026

Copy link
Copy Markdown
Owner

I prefer to use Lua for these things instead of cluttering the code more. The Lua environment is richer and more powerful than some parsed config rule.

In general for_window and workspace rules should use Lua long term. I even removed some rudimentary workspace rule I implemented in the beginning when I added the Lua API.

The view_map callback should work for this.

local scroll = require("scroll")

local function on_create(view, data)
  if scroll.view_get_app_id(view) == "kitty" then
    local container = scroll.view_get_container(view)
    if container then
      scroll.container_set_focus(container)
      scroll.command(scroll.view_get_container(view), "set_size h 0.2")
      scroll.command(scroll.view_get_container(view), "align left")
    end
  end
end

scroll.add_callback("view_map", on_create, nil)

If you think something is missing from the Lua API to implement this feature, maybe it would be better to add it there. Currently, there are functions to retrieve information about the view and application running in it, maybe something else is missing there. Running commands is already supported.

@jbms

jbms commented Jun 26, 2026

Copy link
Copy Markdown
Contributor Author

The key thing this adds is the ability to match based on an xdg activation token, which was not previously exposed either via IPC or LUA, in order to say: launch a new window, and perform certain actions on just that exact window associated with my launch action. I can certainly add a LUA API for getting the activation token of a window (which this PR also exposes via get_tree now) and for minting a new activation token (which this PR also exposes as a new IPC request), but I think the added for_window[activation_token="..."] and for_exec_window commands are quite ergonomic. Maybe sway would also upstream them.

@dawsers

dawsers commented Jun 27, 2026

Copy link
Copy Markdown
Owner

You can already do what for_exec_window does. For example:

for_exec_window.lua "exec firefox" "floating enable"

local args, state = ...

local scroll = require("scroll")

local id_cmd = nil
local id_app = nil

local function on_view_map(view, data)
  if id_app then
    scroll.remove_callback(id_app)
  end
  if #args > 1 then
    local container = scroll.view_get_container(view)
    scroll.command(container, args[2])
  end
end

local function on_command_end(cmd_data, data)
  if id_cmd then
    scroll.remove_callback(id_cmd)
  end
  id_app = scroll.add_callback("view_map", on_view_map, nil)
end

if #args > 0 then
  id_cmd = scroll.add_callback("command_end", on_command_end, nil)
  scroll.command(nil, args[1])
end

and with the command_end callback and lua_command_data_create() you can do many more things.

@jbms

jbms commented Jun 27, 2026

Copy link
Copy Markdown
Contributor Author

The problem with this approach of just matching the next window to map is that if there are multiple windows opening concurrently you might match the wrong one. For my specific use case of the session manager I want to be able to restore multiple windows at the same time in some cases (e.g. after restart) and also be robust to the user opening windows manually at the same time. I also can't rely on app_id because there may be multiple windows with the same app_id, e.g. multiple terminals or multiple browser windows.

There are other heuristics that can be used for matching, like title, but activation token provides the most robust and easiest way by far.

@dawsers

dawsers commented Jun 27, 2026

Copy link
Copy Markdown
Owner

Sure, what I meant is we could add Lua functions to retrieve the view's launch context activation token, or whatever is necessary. There is already one to get the view's parent PID too.

My main idea when I added Lua was to get rid of IPC as much as possible, because it is usually abused, slow, and needs a lot of parsing of the content tree, which is fragile and prone to errors and questions by users.

@dawsers

dawsers commented Jun 27, 2026

Copy link
Copy Markdown
Owner

In 5bd4975 I added the Lua API function exec_process(command). Now you can do:

test_for_exec_window.lua "kitty" "floating enable": this will create a kitty window and make it float.

local args, state = ...

local scroll = require("scroll")

local id_app = nil
local context = nil

local function on_view_map(view, data)
  if context and scroll.view_get_pid(view) == context.pid then
    if id_app then
      scroll.remove_callback(id_app)
    end
    if #args > 1 then
      local container = scroll.view_get_container(view)
      scroll.command(container, args[2])
    end
  end
end

if #args > 0 then
  context = scroll.exec_process(args[1])
  if context then
    id_app = scroll.add_callback("view_map", on_view_map, nil)
  end
end

test_for_exec_child_window.lua "kitty" "floating enable": this will float any window created from the new kitty instance.

local args, state = ...

local scroll = require("scroll")

local id_map = nil
local id_unmap = nil
local context = nil

local function on_view_map(view, data)
  if context and #args > 1 then
    local parent = scroll.view_get_parent_view(view)
    if parent and scroll.view_get_pid(parent) == context.pid then
      local container = scroll.view_get_container(view)
      scroll.command(container, args[2])
    end
  end
end

local function on_view_unmap(view, data)
  if context and scroll.view_get_pid(view) == context.pid then
    if id_map then
      scroll.remove_callback(id_map)
    end
    if id_unmap then
      scroll.remove_callback(id_unmap)
    end
  end
end

if #args > 0 then
  context = scroll.exec_process(args[1])
  if context then
    id_map = scroll.add_callback("view_map", on_view_map, nil)
    id_unmap = scroll.add_callback("view_unmap", on_view_unmap, nil)
  end
end

Do you miss anything else? I think the activation token has its own problems, like timeouts, regeneration for different purposes etc, the PID should be more robust and always present until the application closes.

@jbms

jbms commented Jun 28, 2026

Copy link
Copy Markdown
Contributor Author

For my use case of the session manager, I already could match by pid using the existing capabilities but pid matching is problematic for programs where you have a shared background process that can be used to launch multiple windows, e.g. Firefox, chromium, footclient. Many of those programs support xdg activation protocol.

Also because the session manager is a separate program, not implemented as a lua extension, and intended to support multiple compositors, ipc is generally more convenient than lua although I'm happy to use lua if needed to access certain capabilities.

For most other customizations, though, I agree lua is better.

@dawsers

dawsers commented Jun 28, 2026

Copy link
Copy Markdown
Owner

But what I see from the code is you basically are using the initial activation token as some sort of initialzation id token, you store it within the view, and after that point, it stops being an activation token, "ignoring" any rules that apply to the original token. (which will be destroyed not much later).

A real activation token will change during the lifetime of the application, for example the application will generate new ones to give focus away, and that will change the XDG_ACTIVATION_TOKEN environment variable. That is why I don't like the protocol, and especially that there is no standard, robust way to communicate, and most applications rely on the environment variable method. For focus changes, it is not pretty, but it more or less works, but I am not sure it works to identify the origin of future applications that will be launched maybe much later, with other events happening that may have changed the activation token. If I understand correctly, for_window will try to find a launcher ctx with a specific token, but that may have changed or been deleted.

But if we consider this is just a way to generate a unique parent ID created at launch, I could add that part of the code and provide a Lua API function to retrieve it, something like: scroll.view_get_initialization_token(view), or scroll.view_get_parent_id(view), and then change the name of what scroll.exec_process() returns to be an initialization_token instead of activation_token too, so that way parent and children could be matched without using the PID. It seems the use is more that of a unique ID than of an activation token.

What would be the minimum you need for your project, and how do you expect to implement it using those new capabilities?

@jbms

jbms commented Jun 28, 2026

Copy link
Copy Markdown
Contributor Author

You are right that the name "activation_token" may be a bit misleading --- "initial_activation_token" might be better.

The use case is as follows:

The session manager creates its own windows to represent each suspended window (and shows a screenshot). To restore a window, it needs to tell the application to create a new window with the same "state" as the suspended window, ideally have it initially map on a hidden workspace to avoid flicker, and then once it opens and is ready swap it with the screenshot window.

Using the initial activation token, the session manager can:

  1. run some command like firefox --new-window, passing in a freshly minted activation token (obtained via the new scroll ipc).
  2. Use the scroll ipc to add a for_window[initial_activation_token=...] rule to force the new window onto the scratchpad workspace.
  3. Once the session manager detects that the window has mapped and is ready (based on the initial_activation_token), swaps it with the screenshot window and closes the screenshot window.

I know that activation tokens were really only intended to prevent focus stealing but they do provide a mechanism to uniquely associate a launch request with the window that gets mapped, without relying on application-specific and less reliable heuristics (though of course some applications don't support them).

@jbms jbms force-pushed the for-exec-window branch 2 times, most recently from 8b6d449 to 740ba7c Compare June 30, 2026 18:07
@jbms

jbms commented Jun 30, 2026

Copy link
Copy Markdown
Contributor Author

Note: I discovered that firefox and other programs like footclient do correctly support xdg activation protocol but chromium has code intended to support XDG_ACTIVATION_TOKEN but that support is currently buggy and mostly doesn't work.

@jbms jbms force-pushed the for-exec-window branch 2 times, most recently from 28e4a91 to ea15f76 Compare June 30, 2026 23:39
@dawsers

dawsers commented Jul 1, 2026

Copy link
Copy Markdown
Owner

I think we are not understanding each other well. Let me explain what I understand you are trying to achieve:

  1. You want to have an ID (initial_activation_token) any view can access when launched, and depending on its value, "do things".
  2. You don't want this ID to be the PID or parent application PID, because you may want to tweak it at runtime, or do different things on a child than you would do on other children of the same parent.
  3. Optimally, you would want every application to support this, and you found out, not every application supports activation tokens, and they may communicate them in different ways (most use the environment variable XDG_ACTIVATION_TOKEN).

With that in mind, you developed a framework that allows you to:

  1. Launch an application and apply a set of scroll commands only to that application (for_exec_window).
  2. A way to mint an activation token from IPC using a fake launcher context. When you want to use that token, you set the XDG_ACTIVATION_TOKEN environment variable for a process you launch?
  3. Now you can then query it when a new view is mapped. If that view's initial activation token matches the token you generated, that view will get a set of scroll commands apply. (for_window[activation_token=...].

Isn't there a cleaner and more general way to achieve this? I mean, we just want a way to communicate specific new mapped windows to do something, and we want to have support for every application.

  1. for_exec_window can already be achieved using the PID, like in the script I wrote above.
  2. You want a runtime way to set an ID that mapped views can read and act upon.

So in my understanding, we don't need activation tokens for anything. The for_exec_window case is covered by using the PID, and scroll_exec_process() doesn't even need to return the activation token or store it. The unique ID for mapped views can still be passed by environment, like you do in your test, but it could be anything, even a UUID.

So I added the Lua API function scroll.get_env(view, env_variable), which returns the value of env_variable in the environment of the application that created the view. That could be used to communicate with the view, or simply query the activation token from scripts, like this one:

local args, state = ...

local scroll = require("scroll")

local id_app = nil
local context = nil

local function on_view_map(view, data)
  if context and scroll.view_get_pid(view) == context.pid then
    if id_app then
      scroll.remove_callback(id_app)
    end
    if #args > 1 then
      local id = scroll.view_get_env(view, "XDG_ACTIVATION_TOKEN")
      scroll.log("from view_get_env XDG_ACTIVATION_TOKEN="..id)
      scroll.log("from exec_process XDG_ACTIVATION_TOKEN="..context.activation_token)
      local container = scroll.view_get_container(view)
      scroll.command(container, args[2])
    end
  end
end

if #args > 0 then
  context = scroll.exec_process(args[1])
  if context then
    id_app = scroll.add_callback("view_map", on_view_map, nil)
  end
end

I think with this, we don't need to fake activation tokens or store them, but correct me if I am wrong.

@jbms

jbms commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

for_exec_window can't be achieved using PID in general --- only for the simple case where the process only opens a single window.

Consider:

  1. Run firefox (pid1). Normally it will open a single window initially. For this initial run, PID matching works because we have a single window created by pid1.
  2. Run firefox --new-window (pid2). pid2 just sends a message to pid1 and then exits. The window is opened by pid1. Therefore we can't match on pid2, and pid1 (which requires application-specific logic to even determine) doesn't uniquely identify the window. We can't distinguish the new window from a window opened by a concurrent user action or firefox --new-window call.

Lots of applications work the same way as firefox as far as multiple windows, many of them support XDG_ACTIVATION_TOKEN, and even the ones that don't are likely to be receptive to a patch to support it since it is also needed for the new window to gain focus under certain compositors.

@jbms jbms marked this pull request as draft July 1, 2026 21:38
@jbms

jbms commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

I created a corresponding sway PR swaywm/sway#9205

I also updated this to match the IPC number used in the sway PR, but this shouldn't be merged like that until it is also merged in sway. If sway rejects this but you are okay with it, then I'll fix the IPC number.

- Implement `for_exec_window <commands> <command_to_exec>` to run
  commands on the next window opened by the executed process.
- Add `activation_token` criteria to match windows by their XDG
  activation token.
- Implement `IPC_MINT_ACTIVATION_TOKEN` (102) IPC command to allow
  clients to obtain a freshly generated XDG activation token.
- Expose `activation_token` in the window JSON representation
  (get_tree).
- Add documentation for the new IPC command in `scroll-ipc.7.scd`
  and `README.md`.
- Add tests for `for_exec_window` (including PID, XDG activation,
  and X11 startup ID matching) and `IPC_MINT_ACTIVATION_TOKEN`.
@jbms jbms force-pushed the for-exec-window branch from ea15f76 to c477e11 Compare July 1, 2026 21:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants