From c477e11f57418b62923c1b2be0427cc66c186a93 Mon Sep 17 00:00:00 2001 From: Jeremy Maitin-Shepard Date: Thu, 25 Jun 2026 21:40:53 -0700 Subject: [PATCH] Implement for_exec_window and IPC token minting - Implement `for_exec_window ` 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`. --- README.md | 2 +- completions/bash/scrollmsg | 1 + completions/fish/scrollmsg.fish | 1 + completions/zsh/_scrollmsg | 1 + include/ipc.h | 27 ++--- include/sway/commands.h | 3 +- include/sway/criteria.h | 1 + include/sway/desktop/launcher.h | 3 + include/sway/tree/view.h | 2 + protocols/meson.build | 1 + sway/commands.c | 21 ++-- sway/commands/exec.c | 2 +- sway/commands/exec_always.c | 8 +- sway/commands/for_exec_window.c | 30 +++++ sway/commands/for_window.c | 29 +++++ sway/criteria.c | 44 ++++---- sway/desktop/launcher.c | 18 +++ sway/ipc-json.c | 4 + sway/ipc-server.c | 21 ++++ sway/lua.c | 2 +- sway/meson.build | 1 + sway/scroll-ipc.7.scd | 40 +++++++ sway/scroll.5.scd | 17 ++- sway/tree/view.c | 36 ++++++ sway/xdg_activation_v1.c | 2 + swaymsg/main.c | 2 + swaymsg/scrollmsg.1.scd | 3 + tests/clients/wayland-client.c | 11 ++ tests/clients/x11-client.c | 13 +++ tests/scrollipc.py | 10 ++ tests/test_activation_token.py | 190 ++++++++++++++++++++++++++++++++ tests/test_for_exec_window.py | 110 ++++++++++++++++++ 32 files changed, 604 insertions(+), 52 deletions(-) create mode 100644 sway/commands/for_exec_window.c create mode 100644 tests/test_activation_token.py create mode 100644 tests/test_for_exec_window.py diff --git a/README.md b/README.md index 5446fa988..f53f926ea 100644 --- a/README.md +++ b/README.md @@ -1957,7 +1957,7 @@ and the [FAQ](https://github.com/emersion/xdg-desktop-portal-wlr/wiki/FAQ). desktop bar. See `include/ipc.h` for `IPC_GET_SCROLLER`, `IPC_EVENT_SCROLLER`, `IPC_EVENT_LUA`, -`IPG_GET_TRAILS` and `IPC_EVENT_TRAILS`. +`IPC_GET_TRAILS`, `IPC_EVENT_TRAILS` and `IPC_MINT_ACTIVATION_TOKEN`. You can get data for mode/mode modifiers, overview and scale mode as well as trails and whether a view has an active trailmark. diff --git a/completions/bash/scrollmsg b/completions/bash/scrollmsg index 77b75d90f..cc54ee454 100644 --- a/completions/bash/scrollmsg +++ b/completions/bash/scrollmsg @@ -20,6 +20,7 @@ _scrollmsg() 'get_config' 'send_tick' 'subscribe' + 'mint_activation_token' ) short=( diff --git a/completions/fish/scrollmsg.fish b/completions/fish/scrollmsg.fish index 0535e7d02..9581ec9d7 100644 --- a/completions/fish/scrollmsg.fish +++ b/completions/fish/scrollmsg.fish @@ -24,3 +24,4 @@ complete -c scrollmsg -s t -l type -fra 'get_config' --description "Gets a JSON- complete -c scrollmsg -s t -l type -fra 'get_seats' --description "Gets a JSON-encoded list of all seats, its properties and all assigned devices." complete -c scrollmsg -s t -l type -fra 'send_tick' --description "Sends a tick event to all subscribed clients." complete -c scrollmsg -s t -l type -fra 'subscribe' --description "Subscribe to a list of event types." +complete -c scrollmsg -s t -l type -fra 'mint_activation_token' --description "Mint a new XDG activation token." diff --git a/completions/zsh/_scrollmsg b/completions/zsh/_scrollmsg index c6e70852e..c86a1bb90 100644 --- a/completions/zsh/_scrollmsg +++ b/completions/zsh/_scrollmsg @@ -28,6 +28,7 @@ types=( 'get_config' 'send_tick' 'subscribe' +'mint_activation_token' ) _arguments -s \ diff --git a/include/ipc.h b/include/ipc.h index 6805be8a8..43419e7cf 100644 --- a/include/ipc.h +++ b/include/ipc.h @@ -22,6 +22,7 @@ enum ipc_command_type { // sway-specific command types IPC_GET_INPUTS = 100, IPC_GET_SEATS = 101, + IPC_MINT_ACTIVATION_TOKEN = 102, // scroll-specific command types IPC_GET_SCROLLER = 120, @@ -30,23 +31,23 @@ enum ipc_command_type { IPC_GET_BINDINGS = 123, // Events sent from sway to clients. Events have the highest bits set. - IPC_EVENT_WORKSPACE = ((1<<31) | 0), - IPC_EVENT_OUTPUT = ((1<<31) | 1), - IPC_EVENT_MODE = ((1<<31) | 2), - IPC_EVENT_WINDOW = ((1<<31) | 3), - IPC_EVENT_BARCONFIG_UPDATE = ((1<<31) | 4), - IPC_EVENT_BINDING = ((1<<31) | 5), - IPC_EVENT_SHUTDOWN = ((1<<31) | 6), - IPC_EVENT_TICK = ((1<<31) | 7), + IPC_EVENT_WORKSPACE = ((1 << 31) | 0), + IPC_EVENT_OUTPUT = ((1 << 31) | 1), + IPC_EVENT_MODE = ((1 << 31) | 2), + IPC_EVENT_WINDOW = ((1 << 31) | 3), + IPC_EVENT_BARCONFIG_UPDATE = ((1 << 31) | 4), + IPC_EVENT_BINDING = ((1 << 31) | 5), + IPC_EVENT_SHUTDOWN = ((1 << 31) | 6), + IPC_EVENT_TICK = ((1 << 31) | 7), // sway-specific event types - IPC_EVENT_BAR_STATE_UPDATE = ((1<<31) | 20), - IPC_EVENT_INPUT = ((1<<31) | 21), + IPC_EVENT_BAR_STATE_UPDATE = ((1 << 31) | 20), + IPC_EVENT_INPUT = ((1 << 31) | 21), // scroll-specific event types - IPC_EVENT_LUA = ((1<<31) | 29), - IPC_EVENT_SCROLLER = ((1<<31) | 30), - IPC_EVENT_TRAILS = ((1<<31) | 31), + IPC_EVENT_LUA = ((1 << 31) | 29), + IPC_EVENT_SCROLLER = ((1 << 31) | 30), + IPC_EVENT_TRAILS = ((1 << 31) | 31), }; #endif diff --git a/include/sway/commands.h b/include/sway/commands.h index 097c7a219..3bc1de19f 100644 --- a/include/sway/commands.h +++ b/include/sway/commands.h @@ -93,7 +93,7 @@ char *cmd_results_to_json(list_t *res_list); * Handlers shared by exec and exec_always. */ sway_cmd cmd_exec_validate; -sway_cmd cmd_exec_process; +struct cmd_results *cmd_exec_process(int argc, char **argv, const char *cmdlist); sway_cmd cmd_align; sway_cmd cmd_align_reset_auto; @@ -149,6 +149,7 @@ sway_cmd cmd_focus_follows_mouse; sway_cmd cmd_focus_on_window_activation; sway_cmd cmd_focus_wrapping; sway_cmd cmd_font; +sway_cmd cmd_for_exec_window; sway_cmd cmd_for_window; sway_cmd cmd_force_display_urgency_hint; sway_cmd cmd_force_focus_wrapping; diff --git a/include/sway/criteria.h b/include/sway/criteria.h index 376a25b99..483b54ab9 100644 --- a/include/sway/criteria.h +++ b/include/sway/criteria.h @@ -57,6 +57,7 @@ struct criteria { struct pattern *sandbox_app_id; struct pattern *sandbox_instance_id; struct pattern *tag; + char *initial_activation_token; }; bool criteria_is_empty(struct criteria *criteria); diff --git a/include/sway/desktop/launcher.h b/include/sway/desktop/launcher.h index 412068a90..217dcc060 100644 --- a/include/sway/desktop/launcher.h +++ b/include/sway/desktop/launcher.h @@ -20,10 +20,13 @@ struct launcher_ctx { struct wl_listener node_destroy; struct wl_list link; // sway_server::pending_launcher_ctxs + char *cmdlist; }; struct launcher_ctx *launcher_ctx_find_pid(pid_t pid); +struct launcher_ctx *launcher_ctx_find_token(const char *token_name); + struct sway_workspace *launcher_ctx_get_workspace(struct launcher_ctx *ctx); void launcher_ctx_consume(struct launcher_ctx *ctx); diff --git a/include/sway/tree/view.h b/include/sway/tree/view.h index a542d985e..872de63f5 100644 --- a/include/sway/tree/view.h +++ b/include/sway/tree/view.h @@ -88,6 +88,8 @@ struct sway_view { pid_t pid; struct launcher_ctx *ctx; + char *exec_cmdlist; + char *initial_activation_token; // The size the view would want to be if it weren't tiled. // Used when changing a view from tiled to floating. diff --git a/protocols/meson.build b/protocols/meson.build index a1cb35344..65877e1ec 100644 --- a/protocols/meson.build +++ b/protocols/meson.build @@ -11,6 +11,7 @@ protocols = [ wl_protocol_dir / 'stable/xdg-shell/xdg-shell.xml', wl_protocol_dir / 'staging/cursor-shape/cursor-shape-v1.xml', wl_protocol_dir / 'unstable/xdg-output/xdg-output-unstable-v1.xml', + wl_protocol_dir / 'staging/xdg-activation/xdg-activation-v1.xml', 'wlr-layer-shell-unstable-v1.xml', 'wlr-output-power-management-unstable-v1.xml', ] diff --git a/sway/commands.c b/sway/commands.c index eee9b7c58..7b559f800 100644 --- a/sway/commands.c +++ b/sway/commands.c @@ -77,6 +77,7 @@ static const struct cmd_handler handlers[] = { { "focus_on_window_activation", cmd_focus_on_window_activation }, { "focus_wrapping", cmd_focus_wrapping }, { "font", cmd_font }, + { "for_exec_window", cmd_for_exec_window }, { "for_window", cmd_for_window }, { "force_display_urgency_hint", cmd_force_display_urgency_hint }, { "force_focus_wrapping", cmd_force_focus_wrapping }, @@ -329,9 +330,8 @@ list_t *execute_command(char *_exec, struct sway_seat *seat, //TODO better handling of argv int argc; char **argv = split_args(cmd, &argc); - if (strcmp(argv[0], "exec") != 0 && - strcmp(argv[0], "exec_always") != 0 && - strcmp(argv[0], "mode") != 0) { + if (strcmp(argv[0], "exec") != 0 && strcmp(argv[0], "exec_always") != 0 && + strcmp(argv[0], "for_exec_window") != 0 && strcmp(argv[0], "mode") != 0) { for (int i = 1; i < argc; ++i) { if (*argv[i] == '\"' || *argv[i] == '\'') { strip_quotes(argv[i]); @@ -476,15 +476,12 @@ struct cmd_results *config_command(char *exec, char **new_block) { // Strip quotes and unescape the string for (int i = handler->handle == cmd_set ? 2 : 1; i < argc; ++i) { - if (handler->handle != cmd_exec && handler->handle != cmd_exec_always - && handler->handle != cmd_mode - && handler->handle != cmd_bindsym - && handler->handle != cmd_bindcode - && handler->handle != cmd_bindswitch - && handler->handle != cmd_bindgesture - && handler->handle != cmd_set - && handler->handle != cmd_for_window - && (*argv[i] == '\"' || *argv[i] == '\'')) { + if (handler->handle != cmd_exec && handler->handle != cmd_exec_always && + handler->handle != cmd_mode && handler->handle != cmd_bindsym && + handler->handle != cmd_bindcode && handler->handle != cmd_bindswitch && + handler->handle != cmd_bindgesture && handler->handle != cmd_set && + handler->handle != cmd_for_window && handler->handle != cmd_for_exec_window && + (*argv[i] == '\"' || *argv[i] == '\'')) { strip_quotes(argv[i]); } unescape_string(argv[i]); diff --git a/sway/commands/exec.c b/sway/commands/exec.c index 2c6f3d2d5..4cd3be607 100644 --- a/sway/commands/exec.c +++ b/sway/commands/exec.c @@ -15,5 +15,5 @@ struct cmd_results *cmd_exec(int argc, char **argv) { free(args); return cmd_results_new(CMD_SUCCESS, NULL); } - return cmd_exec_process(argc, argv); + return cmd_exec_process(argc, argv, NULL); } diff --git a/sway/commands/exec_always.c b/sway/commands/exec_always.c index a966696c5..ef0893c9a 100644 --- a/sway/commands/exec_always.c +++ b/sway/commands/exec_always.c @@ -25,7 +25,7 @@ struct cmd_results *cmd_exec_validate(int argc, char **argv) { return error; } -struct cmd_results *cmd_exec_process(int argc, char **argv) { +struct cmd_results *cmd_exec_process(int argc, char **argv, const char *cmdlist) { struct cmd_results *error = NULL; char *cmd = NULL; bool no_startup_id = false; @@ -47,6 +47,10 @@ struct cmd_results *cmd_exec_process(int argc, char **argv) { sway_log(SWAY_DEBUG, "Executing %s", cmd); struct launcher_ctx *ctx = launcher_ctx_create_internal(); + if (ctx && cmdlist) { + ctx->cmdlist = strdup(cmdlist); + sway_log(SWAY_DEBUG, "Recorded cmdlist '%s' to ctx %p", ctx->cmdlist, ctx); + } // Fork process pid_t child = fork(); @@ -85,5 +89,5 @@ struct cmd_results *cmd_exec_always(int argc, char **argv) { if ((error = cmd_exec_validate(argc, argv))) { return error; } - return cmd_exec_process(argc, argv); + return cmd_exec_process(argc, argv, NULL); } diff --git a/sway/commands/for_exec_window.c b/sway/commands/for_exec_window.c new file mode 100644 index 000000000..273d7dc07 --- /dev/null +++ b/sway/commands/for_exec_window.c @@ -0,0 +1,30 @@ +#include +#include "sway/commands.h" +#include "sway/config.h" +#include "log.h" +#include "stringop.h" + +struct cmd_results *cmd_for_exec_window(int argc, char **argv) { + struct cmd_results *error = NULL; + if ((error = checkarg(argc, "for_exec_window", EXPECTED_AT_LEAST, 2))) { + return error; + } + if (!config->active || config->validating) { + return cmd_results_new(CMD_DEFER, NULL); + } + if (config->reloading) { + char *args = join_args(argv, argc); + sway_log(SWAY_DEBUG, "Ignoring 'for_exec_window %s' due to reload", args); + free(args); + return cmd_results_new(CMD_SUCCESS, NULL); + } + + char *cmdlist = strdup(argv[0]); + strip_quotes(cmdlist); + strip_whitespace(cmdlist); + sway_log(SWAY_DEBUG, "for_exec_window: cmdlist='%s'", cmdlist); + + struct cmd_results *res = cmd_exec_process(argc - 1, argv + 1, cmdlist); + free(cmdlist); + return res; +} diff --git a/sway/commands/for_window.c b/sway/commands/for_window.c index 905e67767..928deaba6 100644 --- a/sway/commands/for_window.c +++ b/sway/commands/for_window.c @@ -4,6 +4,7 @@ #include "list.h" #include "log.h" #include "stringop.h" +#include "sway/desktop/launcher.h" struct cmd_results *cmd_for_window(int argc, char **argv) { struct cmd_results *error = NULL; @@ -22,6 +23,34 @@ struct cmd_results *cmd_for_window(int argc, char **argv) { criteria->type = CT_COMMAND; criteria->cmdlist = join_args(argv + 1, argc - 1); + if (criteria->initial_activation_token) { + char *temp_token = criteria->initial_activation_token; + criteria->initial_activation_token = NULL; + bool has_others = !criteria_is_empty(criteria); + criteria->initial_activation_token = temp_token; + + if (has_others) { + criteria_destroy(criteria); + return cmd_results_new(CMD_INVALID, + "initial_activation_token criteria cannot be combined with other criteria"); + } + + struct launcher_ctx *ctx = launcher_ctx_find_token(criteria->initial_activation_token); + if (ctx) { + free(ctx->cmdlist); + ctx->cmdlist = strdup(criteria->cmdlist); + sway_log(SWAY_DEBUG, "Bound commands '%s' to activation token '%s'", ctx->cmdlist, + criteria->initial_activation_token); + } else { + criteria_destroy(criteria); + return cmd_results_new( + CMD_INVALID, "Activation token '%s' not found or expired", temp_token); + } + + criteria_destroy(criteria); + return cmd_results_new(CMD_SUCCESS, NULL); + } + // Check if it already exists if (criteria_already_exists(criteria)) { sway_log(SWAY_DEBUG, "for_window already exists: '%s' -> '%s'", diff --git a/sway/criteria.c b/sway/criteria.c index 7446e9745..bcce0c5f9 100644 --- a/sway/criteria.c +++ b/sway/criteria.c @@ -18,28 +18,16 @@ #include "config.h" bool criteria_is_empty(struct criteria *criteria) { - return !criteria->title - && !criteria->shell - && !criteria->all - && !criteria->app_id - && !criteria->con_mark - && !criteria->con_id + return !criteria->title && !criteria->shell && !criteria->all && !criteria->app_id && + !criteria->con_mark && !criteria->con_id #if WLR_HAS_XWAYLAND - && !criteria->class - && !criteria->id - && !criteria->instance - && !criteria->window_role - && criteria->window_type == ATOM_LAST + && !criteria->class && !criteria->id && !criteria->instance && !criteria->window_role && + criteria->window_type == ATOM_LAST #endif - && !criteria->floating - && !criteria->tiling - && !criteria->urgent - && !criteria->workspace - && !criteria->pid - && !criteria->sandbox_engine - && !criteria->sandbox_app_id - && !criteria->sandbox_instance_id - && !criteria->tag; + && !criteria->floating && !criteria->tiling && !criteria->urgent && + !criteria->workspace && !criteria->pid && !criteria->sandbox_engine && + !criteria->sandbox_app_id && !criteria->sandbox_instance_id && !criteria->tag && + !criteria->initial_activation_token; } // The error pointer is used for parsing functions, and saves having to pass it @@ -123,6 +111,7 @@ void criteria_destroy(struct criteria *criteria) { pattern_destroy(criteria->tag); free(criteria->target); free(criteria->cmdlist); + free(criteria->initial_activation_token); free(criteria->raw); free(criteria); } @@ -355,6 +344,13 @@ bool criteria_matches_view(struct criteria *criteria, } } + if (criteria->initial_activation_token) { + const char *token = view->initial_activation_token; + if (!token || strcmp(token, criteria->initial_activation_token) != 0) { + return false; + } + } + if (!criteria_matches_container(criteria, view->container)) { return false; } @@ -598,6 +594,7 @@ enum criteria_token { T_SANDBOX_APP_ID, T_SANDBOX_INSTANCE_ID, T_TAG, + T_INITIAL_ACTIVATION_TOKEN, T_INVALID, }; @@ -645,6 +642,8 @@ static enum criteria_token token_from_name(char *name) { return T_SANDBOX_INSTANCE_ID; } else if (strcmp(name, "tag") == 0) { return T_TAG; + } else if (strcmp(name, "initial_activation_token") == 0) { + return T_INITIAL_ACTIVATION_TOKEN; } return T_INVALID; } @@ -759,6 +758,9 @@ static bool parse_token(struct criteria *criteria, char *name, char *value) { case T_TAG: pattern_create(&criteria->tag, value); break; + case T_INITIAL_ACTIVATION_TOKEN: + criteria->initial_activation_token = strdup(value); + break; case T_INVALID: break; } @@ -965,6 +967,8 @@ struct criteria *criteria_duplicate(struct criteria *criteria) { dup->sandbox_app_id = pattern_duplicate(criteria->sandbox_app_id); dup->sandbox_instance_id = pattern_duplicate(criteria->sandbox_instance_id); dup->tag = pattern_duplicate(criteria->tag); + dup->initial_activation_token = + criteria->initial_activation_token ? strdup(criteria->initial_activation_token) : NULL; return dup; } diff --git a/sway/desktop/launcher.c b/sway/desktop/launcher.c index 2da40d2ab..fa0de2d72 100644 --- a/sway/desktop/launcher.c +++ b/sway/desktop/launcher.c @@ -73,6 +73,7 @@ void launcher_ctx_destroy(struct launcher_ctx *ctx) { wl_list_remove(&ctx->link); wlr_xdg_activation_token_v1_destroy(ctx->token); free(ctx->fallback_name); + free(ctx->cmdlist); free(ctx); } @@ -101,6 +102,23 @@ struct launcher_ctx *launcher_ctx_find_pid(pid_t pid) { return ctx; } +struct launcher_ctx *launcher_ctx_find_token(const char *token_name) { + if (wl_list_empty(&server.pending_launcher_ctxs)) { + return NULL; + } + + struct launcher_ctx *ctx = NULL; + wl_list_for_each(ctx, &server.pending_launcher_ctxs, link) { + if (ctx->token) { + const char *name = wlr_xdg_activation_token_v1_get_name(ctx->token); + if (name && strcmp(name, token_name) == 0) { + return ctx; + } + } + } + return NULL; +} + struct sway_workspace *launcher_ctx_get_workspace( struct launcher_ctx *ctx) { struct sway_workspace *ws = NULL; diff --git a/sway/ipc-json.c b/sway/ipc-json.c index f7883cfbf..274af3ced 100644 --- a/sway/ipc-json.c +++ b/sway/ipc-json.c @@ -569,6 +569,10 @@ static void ipc_json_describe_view(struct sway_container *c, json_object *object json_object_object_add(object, "app_id", app_id ? json_object_new_string(app_id) : NULL); + const char *initial_activation_token = c->view->initial_activation_token; + json_object_object_add(object, "initial_activation_token", + initial_activation_token ? json_object_new_string(initial_activation_token) : NULL); + json_object_object_add(object, "foreign_toplevel_identifier", c->view->ext_foreign_toplevel ? json_object_new_string(c->view->ext_foreign_toplevel->identifier) : NULL); diff --git a/sway/ipc-server.c b/sway/ipc-server.c index 9ed91573c..b2411f572 100644 --- a/sway/ipc-server.c +++ b/sway/ipc-server.c @@ -17,6 +17,7 @@ #include "sway/commands.h" #include "sway/config.h" #include "sway/desktop/transaction.h" +#include "sway/desktop/launcher.h" #include "sway/ipc-json.h" #include "sway/ipc-server.h" #include "sway/output.h" @@ -980,6 +981,26 @@ void ipc_client_handle_command(struct ipc_client *client, uint32_t payload_lengt goto exit_cleanup; } + case IPC_MINT_ACTIVATION_TOKEN: { + struct launcher_ctx *ctx = launcher_ctx_create_internal(); + if (!ctx) { + const char *error = + "{ \"success\": false, \"error\": \"Failed to create activation context\" }"; + ipc_send_reply(client, payload_type, error, (uint32_t)strlen(error)); + goto exit_cleanup; + } + + const char *token = launcher_ctx_get_token_name(ctx); + json_object *reply = json_object_new_object(); + json_object_object_add(reply, "success", json_object_new_boolean(true)); + json_object_object_add(reply, "token", json_object_new_string(token)); + + const char *json_string = json_object_to_json_string(reply); + ipc_send_reply(client, payload_type, json_string, (uint32_t)strlen(json_string)); + json_object_put(reply); + goto exit_cleanup; + } + default: sway_log(SWAY_INFO, "Unknown IPC command type %x", payload_type); goto exit_cleanup; diff --git a/sway/lua.c b/sway/lua.c index de5544096..dfe33decd 100644 --- a/sway/lua.c +++ b/sway/lua.c @@ -373,7 +373,7 @@ static int scroll_exec_process(lua_State *L) { } else { lua_pushnil(L); } - lua_setfield(L, -2, "activation_token"); + lua_setfield(L, -2, "initial_activation_token"); } else { lua_pushnil(L); } diff --git a/sway/meson.build b/sway/meson.build index 37f49f541..e2e0bd1bf 100644 --- a/sway/meson.build +++ b/sway/meson.build @@ -71,6 +71,7 @@ sway_sources = files( 'commands/focus_on_window_activation.c', 'commands/focus_wrapping.c', 'commands/font.c', + 'commands/for_exec_window.c', 'commands/for_window.c', 'commands/force_display_urgency_hint.c', 'commands/force_focus_wrapping.c', diff --git a/sway/scroll-ipc.7.scd b/sway/scroll-ipc.7.scd index 5047f8065..a534ec398 100644 --- a/sway/scroll-ipc.7.scd +++ b/sway/scroll-ipc.7.scd @@ -84,6 +84,9 @@ supported. *For all replies, any properties not listed are subject to removal.* |- 101 : GET_SEATS : Get the list of seats +|- 102 +: MINT_ACTIVATION_TOKEN +: Mint a new XDG activation token |- 120 : GET_SCROLLER : Get the list of scroller properties @@ -401,6 +404,10 @@ node and will have the following properties: : string : (Only windows) For an xdg-shell window, the name of the application, if set. Otherwise, _null_ +|- initial_activation_token +: string +: (Only windows) The activation token of the window, if it was launched with + one. Otherwise, _null_ |- pid : integer : (Only windows) The PID of the application that owns the window @@ -733,6 +740,7 @@ node and will have the following properties: "fullscreen_mode": 0, "pid": 23959, "app_id": null, + "initial_activation_token": null, "visible": true, "shell": "xwayland", "inhibit_idle": true, @@ -793,6 +801,7 @@ node and will have the following properties: "fullscreen_mode": 0, "pid": 25370, "app_id": "termite", + "initial_activation_token": null, "visible": true, "shell": "xdg_shell", "inhibit_idle": false, @@ -1480,6 +1489,35 @@ one seat. Each object has the following properties: ] ``` +## 102. MINT_ACTIVATION_TOKEN + +*MESSAGE*++ +Request the compositor to mint a new XDG activation token. + +*REPLY*++ +An object indicating success, and the generated token string. + +[- *PROPERTY* +:- *DATA TYPE* +:- *DESCRIPTION* +|- success +: boolean +:[ Whether the request succeeded +|- token +: string +: The generated activation token (only on success) +|- error +: string +: A human readable error message (only on failure) + +*Example Reply:* +``` +{ + "success": true, + "token": "scroll-3cbaea5a9d821213fdf109503023023" +} +``` + ## 120. GET_SCROLLER *MESSAGE*++ @@ -1816,6 +1854,7 @@ The array contains objects with the following properties: } ``` + # EVENTS Events are a way for clients to get notified of changes to scroll. A client can @@ -2084,6 +2123,7 @@ The following change types are currently available: "type": "con", "pid": 19787, "app_id": null, + "initial_activation_token": null, "window_properties": { "class": "URxvt", "instance": "urxvt", diff --git a/sway/scroll.5.scd b/sway/scroll.5.scd index 7ffe6f8d6..bd63306d8 100644 --- a/sway/scroll.5.scd +++ b/sway/scroll.5.scd @@ -1535,6 +1535,12 @@ The default colors are: should be greater than titlebar_border_thickness. If _vertical_ value is not specified it is set to the _horizontal_ value. +*for_exec_window* "" + Execute _exec command_ and whenever the window created by that process + appears, run the list of _commands_ (specified as a quoted string) on it. + The window is matched based on the xdg activation token, or process ID + (PID) as a fallback. + *for_window* Whenever a window that matches _criteria_ appears, run list of commands. See *CRITERIA* for more details. @@ -1888,6 +1894,15 @@ The following attributes may be matched with: *all* Matches all windows. +*initial_activation_token* + Compare value against the activation token. Only exact matches are + supported. Can only be used with *for_window* and cannot be combined + with other criteria. It is used to match a window that was launched + with the corresponding activation token (either Wayland XDG activation + or X11 startup ID). Specifying this criteria makes the rule + automatically expire when the token expires (either because a window + maps and consumes it, or it times out, typically after 30 seconds). + *app_id* Compare value against the app id. Can be a regular expression. If value is \_\_focused\_\_, then the app id must be the same as that of the currently @@ -2081,7 +2096,7 @@ scroll.command(nil, "set_size v 0.33333333; move left nomode") *exec_process(command)* Execute a _command_ in the shell. If successful, tt will return a table - with members _pid_ (the PID of the running _command_), and _activation_token_ + with members _pid_ (the PID of the running _command_), and _initial_activation_token_ (the activation token generated when running the command). ``` diff --git a/sway/tree/view.c b/sway/tree/view.c index 81f4df35a..f6e1f9126 100644 --- a/sway/tree/view.c +++ b/sway/tree/view.c @@ -136,6 +136,8 @@ void view_destroy(struct sway_view *view) { list_free(view->executed_criteria); view_assign_ctx(view, NULL); + free(view->exec_cmdlist); + free(view->initial_activation_token); wlr_scene_node_destroy(&view->image_capture_scene->tree.node); wlr_scene_node_destroy(&view->scene_tree->node); if (view->impl->destroy) { @@ -642,6 +644,27 @@ void view_execute_criteria(struct sway_view *view) { list_free(criterias); } +static void view_execute_launch_commands(struct sway_view *view) { + sway_log(SWAY_DEBUG, "view_execute_launch_commands: view=%p, exec_cmdlist=%s", view, + view->exec_cmdlist); + if (view->exec_cmdlist) { + sway_log(SWAY_DEBUG, "Executing for_exec_window commands for view %p: %s", view, + view->exec_cmdlist); + list_t *res_list = execute_command(view->exec_cmdlist, NULL, view->container); + while (res_list->length) { + struct cmd_results *res = res_list->items[0]; + if (res->status != CMD_SUCCESS) { + sway_log(SWAY_ERROR, "for_exec_window command failed: %s", res->error); + } + free_cmd_results(res); + list_del(res_list, 0); + } + list_free(res_list); + free(view->exec_cmdlist); + view->exec_cmdlist = NULL; + } +} + static void view_populate_pid(struct sway_view *view) { pid_t pid; switch (view->type) { @@ -670,9 +693,21 @@ void view_assign_ctx(struct sway_view *view, struct launcher_ctx *ctx) { if (ctx == NULL) { return; } + free(view->exec_cmdlist); + view->exec_cmdlist = NULL; + + const char *token_name = launcher_ctx_get_token_name(ctx); + if (token_name) { + free(view->initial_activation_token); + view->initial_activation_token = strdup(token_name); + } + launcher_ctx_consume(ctx); view->ctx = ctx; + if (ctx->cmdlist) { + view->exec_cmdlist = strdup(ctx->cmdlist); + } } static struct sway_workspace *select_workspace(struct sway_view *view) { @@ -1048,6 +1083,7 @@ void view_map(struct sway_view *view, struct wlr_surface *wlr_surface, } view_execute_criteria(view); + view_execute_launch_commands(view); // Lua callbacks lua_execute_view_map_cbs(view); diff --git a/sway/xdg_activation_v1.c b/sway/xdg_activation_v1.c index 6ca99fea8..c81c40f71 100644 --- a/sway/xdg_activation_v1.c +++ b/sway/xdg_activation_v1.c @@ -3,6 +3,7 @@ #include "sway/desktop/launcher.h" #include "sway/tree/view.h" #include "sway/tree/workspace.h" +#include "log.h" void xdg_activation_v1_handle_request_activate(struct wl_listener *listener, void *data) { @@ -32,6 +33,7 @@ void xdg_activation_v1_handle_request_activate(struct wl_listener *listener, return; } else { ctx->activated = true; + sway_log(SWAY_DEBUG, "Matched view %p via XDG activation token", view); view_assign_ctx(view, ctx); } return; diff --git a/swaymsg/main.c b/swaymsg/main.c index cbe0634c5..fdabf1f5e 100644 --- a/swaymsg/main.c +++ b/swaymsg/main.c @@ -716,6 +716,8 @@ int main(int argc, char **argv) { type = IPC_GET_SPACES; } else if (strcasecmp(cmdtype, "get_bindings") == 0) { type = IPC_GET_BINDINGS; + } else if (strcasecmp(cmdtype, "mint_activation_token") == 0) { + type = IPC_MINT_ACTIVATION_TOKEN; } else { if (quiet) { exit(EXIT_FAILURE); diff --git a/swaymsg/scrollmsg.1.scd b/swaymsg/scrollmsg.1.scd index 659244231..789b4488b 100644 --- a/swaymsg/scrollmsg.1.scd +++ b/swaymsg/scrollmsg.1.scd @@ -109,6 +109,9 @@ _scrollmsg_ [options...] [message] *send\_tick* Sends a tick event to all subscribed clients. +*mint\_activation\_token* + Mint a new XDG activation token. + *subscribe* Subscribe to a list of event types. The argument for this type should be provided in the form of a valid JSON array. If any of the types are invalid diff --git a/tests/clients/wayland-client.c b/tests/clients/wayland-client.c index 9aad66e2e..b9cd827fb 100644 --- a/tests/clients/wayland-client.c +++ b/tests/clients/wayland-client.c @@ -7,6 +7,7 @@ #include #include #include "xdg-shell-client-protocol.h" +#include "xdg-activation-v1-client-protocol.h" struct client_state { struct wl_display *display; @@ -14,6 +15,7 @@ struct client_state { struct wl_compositor *compositor; struct wl_shm *shm; struct xdg_wm_base *xdg_wm_base; + struct xdg_activation_v1 *xdg_activation; struct wl_surface *surface; struct xdg_surface *xdg_surface; struct xdg_toplevel *xdg_toplevel; @@ -42,6 +44,8 @@ static void registry_global(void *data, struct wl_registry *registry, uint32_t n } else if (strcmp(interface, xdg_wm_base_interface.name) == 0) { state->xdg_wm_base = wl_registry_bind(registry, name, &xdg_wm_base_interface, 1); xdg_wm_base_add_listener(state->xdg_wm_base, &xdg_wm_base_listener, state); + } else if (strcmp(interface, xdg_activation_v1_interface.name) == 0) { + state->xdg_activation = wl_registry_bind(registry, name, &xdg_activation_v1_interface, 1); } } static void registry_global_remove(void *data, struct wl_registry *registry, uint32_t name) {} @@ -129,6 +133,11 @@ int main(int argc, char **argv) { wl_surface_commit(state.surface); + char *token = getenv("XDG_ACTIVATION_TOKEN"); + if (token && state.xdg_activation) { + xdg_activation_v1_activate(state.xdg_activation, token, state.surface); + } + while (wl_display_dispatch(state.display) != -1) { // Loop } @@ -141,6 +150,8 @@ int main(int argc, char **argv) { if (state.compositor) wl_compositor_destroy(state.compositor); if (state.shm) wl_shm_destroy(state.shm); if (state.xdg_wm_base) xdg_wm_base_destroy(state.xdg_wm_base); + if (state.xdg_activation) + xdg_activation_v1_destroy(state.xdg_activation); if (state.registry) wl_registry_destroy(state.registry); if (state.display) wl_display_disconnect(state.display); diff --git a/tests/clients/x11-client.c b/tests/clients/x11-client.c index 6357f3d88..04597d990 100644 --- a/tests/clients/x11-client.c +++ b/tests/clients/x11-client.c @@ -48,6 +48,19 @@ int main(int argc, char **argv) { class_len, class_str); free(class_str); + char *startup_id = getenv("DESKTOP_STARTUP_ID"); + if (startup_id) { + xcb_intern_atom_cookie_t cookie = + xcb_intern_atom(conn, 0, strlen("_NET_STARTUP_ID"), "_NET_STARTUP_ID"); + xcb_intern_atom_reply_t *reply = xcb_intern_atom_reply(conn, cookie, NULL); + if (reply) { + xcb_atom_t startup_id_atom = reply->atom; + free(reply); + xcb_change_property(conn, XCB_PROP_MODE_REPLACE, win, startup_id_atom, XCB_ATOM_STRING, + 8, strlen(startup_id), startup_id); + } + } + xcb_map_window(conn, win); xcb_flush(conn); diff --git a/tests/scrollipc.py b/tests/scrollipc.py index 84204c716..f78b1d608 100644 --- a/tests/scrollipc.py +++ b/tests/scrollipc.py @@ -11,6 +11,7 @@ IPC_GET_WORKSPACES: int = 1 IPC_SUBSCRIBE: int = 2 IPC_GET_VERSION: int = 7 +IPC_MINT_ACTIVATION_TOKEN: int = 102 class ScrollIPC: @@ -68,3 +69,12 @@ def get_tree(self) -> dict: if reply_type != 4: raise ValueError(f"Unexpected reply type: {reply_type}") return json.loads(reply_payload) + + def mint_activation_token(self) -> dict: + self._send(IPC_MINT_ACTIVATION_TOKEN, "") + reply_type, reply_payload = self._recv() + if reply_type != IPC_MINT_ACTIVATION_TOKEN: + raise ValueError(f"Unexpected reply type: {reply_type}") + result = json.loads(reply_payload) + assert isinstance(result, dict) + return result diff --git a/tests/test_activation_token.py b/tests/test_activation_token.py new file mode 100644 index 000000000..a6d1fad82 --- /dev/null +++ b/tests/test_activation_token.py @@ -0,0 +1,190 @@ +import os +import subprocess +import time +from pathlib import Path +from typing import Any, Dict, Optional +import pytest +from conftest import ScrollInstance +from test_utils import wait_for_client_map + + +def find_node(node: Dict[str, Any], title: str) -> Optional[Dict[str, Any]]: + if node.get("name") == title: + return node + for child in node.get("nodes", []): + res = find_node(child, title) + if res: + return res + for child in node.get("floating_nodes", []): + res = find_node(child, title) + if res: + return res + return None + + +def test_activation_token(scroll_compositor: ScrollInstance, tmp_path: Path) -> None: + client_path: Path = Path("./build/tests/wayland-test-client").resolve() + assert client_path.exists(), f"Client not found at {client_path}" + + token_file: Path = tmp_path / "token.txt" + title: str = "Activation Token Test" + app_id: str = "act_token_test" + + # Launch sh which writes token to file and exits immediately + cmd_line: str = f"exec sh -c 'echo $XDG_ACTIVATION_TOKEN > {token_file}'" + res: list = scroll_compositor.cmd(cmd_line) + assert res[0]["success"], f"exec failed: {res}" + + # Wait for token file to be written + start_time: float = time.time() + while not token_file.exists(): + if time.time() - start_time > 5: + pytest.fail("Timeout waiting for token file") + time.sleep(0.1) + + token: str = token_file.read_text().strip() + assert token, "Token is empty" + + # Register for_window rule with initial_activation_token + cmd_line2: str = f'for_window [initial_activation_token="{token}"] floating enable' + res2: list = scroll_compositor.cmd(cmd_line2) + assert res2[0]["success"], f"for_window failed: {res2}" + + # Launch client directly from python with token in env + wayland_display: Optional[str] = scroll_compositor.getenv("WAYLAND_DISPLAY") + assert wayland_display is not None + + env: Dict[str, str] = os.environ.copy() + env["WAYLAND_DISPLAY"] = wayland_display + env["XDG_ACTIVATION_TOKEN"] = token + + proc: Optional[subprocess.Popen] = None + try: + proc = subprocess.Popen([str(client_path), title, app_id], env=env) + + # Wait for client to map + view_id: int = wait_for_client_map(scroll_compositor, title) + + # Verify it is floating + is_floating: bool = scroll_compositor.execute_lua(f""" + local view = {view_id} + local container = scroll.view_get_container(view) + return scroll.container_get_floating(container) + """) + assert is_floating is True + + # Verify initial_activation_token is exposed in IPC (get_tree) + tree: dict = scroll_compositor.get_tree() + + node: Optional[Dict[str, Any]] = find_node(tree, title) + assert node is not None, f"Node '{title}' not found in tree" + assert node.get("initial_activation_token") == token, ( + f"Expected token {token}, got {node.get('initial_activation_token')}" + ) + + # Clean up + scroll_compositor.execute_lua(f"scroll.view_close({view_id})") + finally: + if proc: + if proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=2) + except subprocess.TimeoutExpired: + proc.kill() + + +def test_activation_token_invalid_combination( + scroll_compositor: ScrollInstance, +) -> None: + # Try to combine initial_activation_token with class + cmd_line = 'for_window [initial_activation_token="test-token" class="Kitty"] floating enable' + res = scroll_compositor.cmd(cmd_line) + assert not res[0]["success"] + assert ( + "initial_activation_token criteria cannot be combined with other criteria" + in res[0]["error"] + ) + + +def test_ipc_mint_activation_token( + scroll_compositor: ScrollInstance, +) -> None: + client_path: Path = Path("./build/tests/wayland-test-client").resolve() + assert client_path.exists(), f"Client not found at {client_path}" + + title: str = "IPC Activation Token Test" + app_id: str = "ipc_act_token_test" + + # Mint token via IPC + res = scroll_compositor.ipc.mint_activation_token() + assert res["success"] + token = res["token"] + assert token + + # Register for_window rule with initial_activation_token + cmd_line = f'for_window [initial_activation_token="{token}"] floating enable' + res2 = scroll_compositor.cmd(cmd_line) + assert res2[0]["success"], f"for_window failed: {res2}" + + # Launch client directly from python with token in env + wayland_display = scroll_compositor.getenv("WAYLAND_DISPLAY") + assert wayland_display is not None + + env = os.environ.copy() + env["WAYLAND_DISPLAY"] = wayland_display + env["XDG_ACTIVATION_TOKEN"] = token + + proc = None + try: + proc = subprocess.Popen([str(client_path), title, app_id], env=env) + + # Wait for client to map + view_id = wait_for_client_map(scroll_compositor, title) + + # Verify it is floating + is_floating = scroll_compositor.execute_lua(f""" + local view = {view_id} + local container = scroll.view_get_container(view) + return scroll.container_get_floating(container) + """) + assert is_floating is True + + # Verify initial_activation_token is exposed in IPC (get_tree) + tree = scroll_compositor.get_tree() + + node = find_node(tree, title) + assert node is not None, f"Node '{title}' not found in tree" + assert node.get("initial_activation_token") == token, ( + f"Expected token {token}, got {node.get('initial_activation_token')}" + ) + + # Clean up + scroll_compositor.execute_lua(f"scroll.view_close({view_id})") + finally: + if proc: + if proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=2) + except subprocess.TimeoutExpired: + proc.kill() + + +def test_scrollmsg_mint_activation_token(scroll_compositor: ScrollInstance) -> None: + socket_path = scroll_compositor.ipc.socket_path + scrollmsg_bin = "./build/swaymsg/scrollmsg" + if not Path(scrollmsg_bin).exists(): + scrollmsg_bin = "scrollmsg" + res = subprocess.run( + [scrollmsg_bin, "-s", socket_path, "-t", "mint_activation_token"], + capture_output=True, + text=True, + ) + assert res.returncode == 0 + import json + + data = json.loads(res.stdout) + assert data["success"] is True + assert "token" in data + assert data["token"] diff --git a/tests/test_for_exec_window.py b/tests/test_for_exec_window.py new file mode 100644 index 000000000..75f53c131 --- /dev/null +++ b/tests/test_for_exec_window.py @@ -0,0 +1,110 @@ +import pytest +import time +from pathlib import Path +from conftest import ScrollInstance +from test_utils import wait_for_client_map + + +def test_for_exec_window_pid_matching(scroll_compositor: ScrollInstance) -> None: + client_path: Path = Path("./build/tests/wayland-test-client").resolve() + assert client_path.exists(), f"Client not found at {client_path}" + + title: str = "PID Matching Test" + app_id: str = "pid_match_app_id" + + # We use 'env -u XDG_ACTIVATION_TOKEN' to force fallback to PID matching + # by preventing the client from using the token. + cmd_line = f"for_exec_window \"floating enable\" env -u XDG_ACTIVATION_TOKEN {client_path} '{title}' '{app_id}'" + res = scroll_compositor.cmd(cmd_line) + assert res[0]["success"], f"for_exec_window command failed: {res}" + + view_id = wait_for_client_map(scroll_compositor, title) + + is_floating = scroll_compositor.execute_lua(f""" + local view = {view_id} + local container = scroll.view_get_container(view) + return scroll.container_get_floating(container) + """) + + try: + assert is_floating is True + except AssertionError: + print("Compositor log:") + print(scroll_compositor.read_log()) + raise + + scroll_compositor.execute_lua(f"scroll.view_close({view_id})") + + +def test_for_exec_window_xdg_activation(scroll_compositor: ScrollInstance) -> None: + client_path: Path = Path("./build/tests/wayland-test-client").resolve() + assert client_path.exists(), f"Client not found at {client_path}" + + title: str = "XDG Activation Test" + app_id: str = "xdg_act_app_id" + + # We use 'sh -c "... &"' to double-fork and break PID matching. + # The client will use XDG activation token from env. + cmd_line = f'for_exec_window "floating enable" sh -c \'{client_path} "{title}" "{app_id}" &\'' + res = scroll_compositor.cmd(cmd_line) + assert res[0]["success"], f"for_exec_window command failed: {res}" + + view_id = wait_for_client_map(scroll_compositor, title) + + is_floating = scroll_compositor.execute_lua(f""" + local view = {view_id} + local container = scroll.view_get_container(view) + return scroll.container_get_floating(container) + """) + + try: + assert is_floating is True + except AssertionError: + print("Compositor log:") + print(scroll_compositor.read_log()) + raise + + scroll_compositor.execute_lua(f"scroll.view_close({view_id})") + + +def test_for_exec_window_x11_startup_id(scroll_compositor: ScrollInstance) -> None: + display: str | None = scroll_compositor.getenv("DISPLAY") + if not display: + pytest.skip("Xwayland is not enabled (no DISPLAY env var in compositor)") + + # Wait for Xwayland to be ready + start_time = time.time() + while "Xserver is ready" not in scroll_compositor.read_log(): + if time.time() - start_time > 5: + pytest.fail("Timeout waiting for Xwayland to be ready") + time.sleep(0.1) + + client_path: Path = Path("./build/tests/x11-test-client").resolve() + assert client_path.exists(), f"Client not found at {client_path}" + + title: str = "X11 Startup ID Test" + instance: str = "x11_startup_id_instance" + class_name: str = "X11StartupIdClass" + + # We use 'sh -c "... &"' to double-fork and break PID matching. + # The client will use DESKTOP_STARTUP_ID from env. + cmd_line = f'for_exec_window "floating enable" sh -c \'{client_path} "{title}" "{instance}" "{class_name}" &\'' + res = scroll_compositor.cmd(cmd_line) + assert res[0]["success"], f"for_exec_window command failed: {res}" + + try: + view_id = wait_for_client_map(scroll_compositor, title) + + is_floating = scroll_compositor.execute_lua(f""" + local view = {view_id} + local container = scroll.view_get_container(view) + return scroll.container_get_floating(container) + """) + + assert is_floating is True + except Exception: + print("Compositor log:") + print(scroll_compositor.read_log()) + raise + + scroll_compositor.execute_lua(f"scroll.view_close({view_id})")