diff --git a/README.md b/README.md index cb3bbee77..6badd4388 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..67fcde3fe 100644 --- a/include/ipc.h +++ b/include/ipc.h @@ -28,25 +28,26 @@ enum ipc_command_type { IPC_GET_TRAILS = 121, IPC_GET_SPACES = 122, IPC_GET_BINDINGS = 123, + IPC_MINT_ACTIVATION_TOKEN = 124, // 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..60a7788b8 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 *activation_token; }; bool criteria_is_empty(struct criteria *criteria); diff --git a/include/sway/desktop/animation.h b/include/sway/desktop/animation.h index a11e94720..b80ef7f60 100644 --- a/include/sway/desktop/animation.h +++ b/include/sway/desktop/animation.h @@ -135,6 +135,12 @@ bool animation_animating(); // Are we in the middle of an animation for output? bool animation_animating_output(struct wlr_output *output); +// Manual stepping control for testing +void animation_set_manual_stepping(bool enabled); +void animation_step(uint32_t ms); +uint32_t animation_get_duration(); +uint32_t animation_get_elapsed_time(); + // Get the current parameters for the active animation void animation_get_values(double *t, double *x, double *y); 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/server.h b/include/sway/server.h index bfed65cd0..886edb08e 100644 --- a/include/sway/server.h +++ b/include/sway/server.h @@ -156,6 +156,7 @@ struct sway_server { bool delay_transaction; struct wl_event_source *delayed_modeset; + bool exiting; }; extern struct sway_server server; diff --git a/include/sway/tree/view.h b/include/sway/tree/view.h index a542d985e..1083036df 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 *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/meson.build b/meson.build index 9fc938855..cc0402b91 100644 --- a/meson.build +++ b/meson.build @@ -68,6 +68,7 @@ wayland_cursor = dependency('wayland-cursor') wayland_protos = dependency('wayland-protocols', version: '>=1.47', default_options: ['tests=false']) xkbcommon = dependency('xkbcommon', version: '>=1.5.0') cairo = dependency('cairo') +fontconfig = dependency('fontconfig') pango = dependency('pango') pangocairo = dependency('pangocairo') gdk_pixbuf = dependency('gdk-pixbuf-2.0', required: get_option('gdk-pixbuf')) 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/pytest.ini b/pytest.ini new file mode 100644 index 000000000..82cbd5bd3 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +addopts = -n 4 diff --git a/scroll.lua b/scroll.lua index d9de7e90b..f9737d6c5 100644 --- a/scroll.lua +++ b/scroll.lua @@ -643,4 +643,52 @@ function scroll.add_callback(event, cb_func, cb_data) end --- @return integer function scroll.remove_callback(id) end +--- +--- Enables or disables manual stepping for animations. +--- When enabled, animations do not progress automatically with real time. +--- Instead, they stay at the current frame until scroll.animation_step is called. +--- +--- @param enabled boolean +--- +function scroll.animation_set_manual_stepping(enabled) end + +--- +--- Steps the current animation forward by the given amount of milliseconds. +--- Only has an effect if manual stepping is enabled. +--- +--- @param ms integer +--- +function scroll.animation_step(ms) end + +--- +--- Returns true if there is an active animation running. +--- +--- @return boolean +--- +function scroll.animating() end + +--- +--- Returns true if there are pending transactions that haven't been applied yet. +--- +--- @return boolean +--- +function scroll.pending_transactions() end + +--- +--- Returns the total duration of the current animation in milliseconds. +--- Returns 0 if there is no active animation. +--- +--- @return integer +--- +function scroll.animation_get_duration() end + +--- +--- Returns the elapsed time of the current animation in milliseconds. +--- Accounts for manual stepping if enabled. +--- Returns 0 if there is no active animation. +--- +--- @return integer +--- +function scroll.animation_get_elapsed_time() end + return scroll 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..ab02d70ef 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->activation_token) { + char *temp_token = criteria->activation_token; + criteria->activation_token = NULL; + bool has_others = !criteria_is_empty(criteria); + criteria->activation_token = temp_token; + + if (has_others) { + criteria_destroy(criteria); + return cmd_results_new(CMD_INVALID, + "activation_token criteria cannot be combined with other criteria"); + } + + struct launcher_ctx *ctx = launcher_ctx_find_token(criteria->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->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/commands/include.c b/sway/commands/include.c index e0d0c0640..94c42d123 100644 --- a/sway/commands/include.c +++ b/sway/commands/include.c @@ -9,6 +9,7 @@ struct cmd_results *cmd_include(int argc, char **argv) { char *files = join_args(argv, argc); // We don't care if the included config(s) fails to load. load_include_configs(files, config, &config->swaynag_config_errors); + free(files); return cmd_results_new(CMD_SUCCESS, NULL); } diff --git a/sway/config.c b/sway/config.c index f111ff7a6..2647821d3 100644 --- a/sway/config.c +++ b/sway/config.c @@ -193,6 +193,7 @@ void free_config(struct sway_config *config) { free(config->floating_scroll_left_cmd); free(config->floating_scroll_right_cmd); free(config->font); + pango_font_description_free(config->font_description); free(config->swaybg_command); free(config->swaynag_command); free((char *)config->current_config_path); diff --git a/sway/criteria.c b/sway/criteria.c index 7446e9745..e70c87dca 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->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->activation_token); free(criteria->raw); free(criteria); } @@ -355,6 +344,13 @@ bool criteria_matches_view(struct criteria *criteria, } } + if (criteria->activation_token) { + const char *token = view->activation_token; + if (!token || strcmp(token, criteria->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_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, "activation_token") == 0) { + return T_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_ACTIVATION_TOKEN: + criteria->activation_token = strdup(value); + break; case T_INVALID: break; } @@ -965,6 +967,7 @@ 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->activation_token = criteria->activation_token ? strdup(criteria->activation_token) : NULL; return dup; } diff --git a/sway/desktop/animation.c b/sway/desktop/animation.c index 90c5a5cb1..d431492be 100644 --- a/sway/desktop/animation.c +++ b/sway/desktop/animation.c @@ -167,6 +167,9 @@ struct sway_animation { struct sway_animation_callbacks default_callbacks; struct sway_animation_config config; + + bool manual_stepping; + uint32_t manual_time_ms; }; static struct sway_animation *animation = NULL; @@ -619,7 +622,11 @@ void animation_begin() { if (path) { animation_reset_path(path); animation->animating = true; - clock_gettime(CLOCK_MONOTONIC, &animation->start); + if (animation->manual_stepping) { + animation->manual_time_ms = 0; + } else { + clock_gettime(CLOCK_MONOTONIC, &animation->start); + } if (animation->current.callbacks.callback_begin) { animation->current.callbacks.callback_begin(animation->current.callbacks.callback_begin_data); } @@ -650,7 +657,12 @@ static bool animation_set_time(struct timespec *time) { if (!curve) { goto last; } - uint32_t diff = difftime_ms(&animation->start, time); + uint32_t diff; + if (animation->manual_stepping) { + diff = animation->manual_time_ms; + } else { + diff = difftime_ms(&animation->start, time); + } uint32_t duration = curve->duration_ms; animation->time = (double) diff / duration; if (animation->time <= 1.0) { @@ -661,7 +673,11 @@ static bool animation_set_time(struct timespec *time) { path->idx = path->curves->length - 1; goto last; } else { - addtime_ms(&animation->start, duration); + if (animation->manual_stepping) { + animation->manual_time_ms -= duration; + } else { + addtime_ms(&animation->start, duration); + } } } return false; @@ -883,3 +899,75 @@ void destroy_animation_curve(struct sway_animation_curve *curve) { } free(curve); } + +void animation_set_manual_stepping(bool enabled) { + if (animation) { + animation->manual_stepping = enabled; + if (enabled) { + animation->manual_time_ms = 0; + } + } +} + +void animation_step(uint32_t ms) { + if (animation && animation->manual_stepping && animation->animating) { + animation->manual_time_ms += ms; + for (int i = 0; i < root->outputs->length; ++i) { + struct sway_output *output = root->outputs->items[i]; + if (animation_animating_output(output->wlr_output)) { + animation_animate(output->wlr_output); + } + } + } +} + +uint32_t animation_get_duration() { + if (!animation || !animation->animating) { + return 0; + } + struct sway_animation_path *path = get_path(); + if (!path) { + return 0; + } + uint32_t duration = 0; + for (int i = 0; i < path->curves->length; ++i) { + struct sway_animation_curve *curve = path->curves->items[i]; + if (curve) { + duration += curve->duration_ms; + } + } + return duration; +} + +uint32_t animation_get_elapsed_time() { + if (!animation || !animation->animating) { + return 0; + } + struct sway_animation_path *path = get_path(); + if (!path) { + return 0; + } + if (animation->manual_stepping) { + uint32_t elapsed = 0; + for (int i = 0; i < path->idx; ++i) { + struct sway_animation_curve *curve = path->curves->items[i]; + if (curve) { + elapsed += curve->duration_ms; + } + } + elapsed += animation->manual_time_ms; + return elapsed; + } else { + struct timespec now; + clock_gettime(CLOCK_MONOTONIC, &now); + uint32_t elapsed = 0; + for (int i = 0; i < path->idx; ++i) { + struct sway_animation_curve *curve = path->curves->items[i]; + if (curve) { + elapsed += curve->duration_ms; + } + } + elapsed += difftime_ms(&animation->start, &now); + return elapsed; + } +} 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/input/keyboard.c b/sway/input/keyboard.c index 4691f01de..6e18f5d2d 100644 --- a/sway/input/keyboard.c +++ b/sway/input/keyboard.c @@ -870,8 +870,12 @@ static void sway_keyboard_group_remove(struct sway_keyboard *keyboard) { // To prevent use-after-free conditions when handling key events, defer // freeing the wlr_keyboard_group until idle - wl_event_loop_add_idle(server.wl_event_loop, - destroy_empty_wlr_keyboard_group, wlr_group); + if (server.exiting) { + wlr_keyboard_group_destroy(wlr_group); + } else { + wl_event_loop_add_idle( + server.wl_event_loop, destroy_empty_wlr_keyboard_group, wlr_group); + } } } diff --git a/sway/input/seat.c b/sway/input/seat.c index 518a108f2..7004d6d84 100644 --- a/sway/input/seat.c +++ b/sway/input/seat.c @@ -74,6 +74,8 @@ void seat_destroy(struct sway_seat *seat) { static void handle_seat_destroy(struct wl_listener *listener, void *data) { struct sway_seat *seat = wl_container_of(listener, seat, destroy); + seatop_end(seat); + if (seat == config->handler_context.seat) { config->handler_context.seat = input_manager_get_default_seat(); } diff --git a/sway/ipc-json.c b/sway/ipc-json.c index f7883cfbf..cbfe2da16 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 *activation_token = c->view->activation_token; + json_object_object_add(object, "activation_token", + activation_token ? json_object_new_string(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 9d7cf462f..466ba3a24 100644 --- a/sway/lua.c +++ b/sway/lua.c @@ -11,6 +11,7 @@ #include "sway/output.h" #include "sway/desktop/animation.h" #include "sway/ipc-server.h" +#include "sway/server.h" #if 0 static void print_table(lua_State *L, int index); @@ -1613,7 +1614,53 @@ static int scroll_remove_callback(lua_State *L) { return 0; } +static int scroll_animation_set_manual_stepping(lua_State *L) { + int argc = lua_gettop(L); + if (argc < 1) { + return 0; + } + bool enabled = lua_toboolean(L, 1); + animation_set_manual_stepping(enabled); + return 0; +} + +static int scroll_animation_step(lua_State *L) { + int argc = lua_gettop(L); + if (argc < 1) { + return 0; + } + int ms = luaL_checkinteger(L, 1); + if (ms < 0) { + return 0; + } + animation_step(ms); + return 0; +} + +static int scroll_animating(lua_State *L) { + lua_pushboolean(L, animation_animating()); + return 1; +} + +static int scroll_pending_transactions(lua_State *L) { + bool pending = server.queued_transaction != NULL || server.pending_transaction != NULL || + server.dirty_nodes->length > 0; + lua_pushboolean(L, pending); + return 1; +} + +static int scroll_animation_get_duration(lua_State *L) { + lua_pushinteger(L, animation_get_duration()); + return 1; +} + +static int scroll_animation_get_elapsed_time(lua_State *L) { + lua_pushinteger(L, animation_get_elapsed_time()); + return 1; +} + // Module functions +/* clang-format off */ static luaL_Reg const scroll_lib[] = { { "log", scroll_log }, { "state_set_value", scroll_state_set_value }, @@ -1682,8 +1729,15 @@ static luaL_Reg const scroll_lib[] = { { "scratchpad_hide", scroll_scratchpad_hide }, { "add_callback", scroll_add_callback }, { "remove_callback", scroll_remove_callback }, + { "animation_set_manual_stepping", scroll_animation_set_manual_stepping }, + { "animation_step", scroll_animation_step }, + { "animating", scroll_animating }, + { "pending_transactions", scroll_pending_transactions }, + { "animation_get_duration", scroll_animation_get_duration }, + { "animation_get_elapsed_time", scroll_animation_get_elapsed_time }, { NULL, NULL } }; +/* clang-format on */ // Module Loader int luaopen_scroll(lua_State *L) { diff --git a/sway/main.c b/sway/main.c index 6bc0fd0f2..320097f52 100644 --- a/sway/main.c +++ b/sway/main.c @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -16,6 +17,9 @@ #include #include #include +#ifdef __SANITIZE_ADDRESS__ +#include +#endif #include #include #include "sway/config.h" @@ -34,6 +38,8 @@ static bool terminate_request = false; static int exit_value = 0; static struct rlimit original_nofile_rlimit = {0}; +static struct wl_event_source *sigterm_source = NULL; +static struct wl_event_source *sigint_source = NULL; struct sway_server server = {0}; struct sway_debug debug = {0}; @@ -181,8 +187,8 @@ void handler(int sig) { #endif static void init_signals(void) { - wl_event_loop_add_signal(server.wl_event_loop, SIGTERM, term_signal, NULL); - wl_event_loop_add_signal(server.wl_event_loop, SIGINT, term_signal, NULL); + sigterm_source = wl_event_loop_add_signal(server.wl_event_loop, SIGTERM, term_signal, NULL); + sigint_source = wl_event_loop_add_signal(server.wl_event_loop, SIGINT, term_signal, NULL); struct sigaction sa_ign = { .sa_handler = SIG_IGN }; // avoid need to reap children @@ -429,7 +435,22 @@ int main(int argc, char **argv) { shutdown: sway_log(SWAY_INFO, "Shutting down scroll"); + if (sigterm_source) { + wl_event_source_remove(sigterm_source); + } + if (sigint_source) { + wl_event_source_remove(sigint_source); + } + server_fini(&server); + +#ifdef __SANITIZE_ADDRESS__ + pango_cairo_font_map_set_default(NULL); + cairo_debug_reset_static_data(); + FcFini(); + __lsan_do_leak_check(); +#endif + root_destroy(root); root = NULL; animation_destroy(); @@ -442,7 +463,5 @@ int main(int argc, char **argv) { wl_client_destroy(nag_gpu.client); } - pango_cairo_font_map_set_default(NULL); - return exit_value; } diff --git a/sway/meson.build b/sway/meson.build index 37f49f541..bd3900324 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', @@ -250,6 +251,7 @@ sway_sources = files( sway_deps = [ cairo, drm, + fontconfig, jsonc, libevdev, libinput, diff --git a/sway/scroll-ipc.7.scd b/sway/scroll-ipc.7.scd index 5047f8065..46b4eed7a 100644 --- a/sway/scroll-ipc.7.scd +++ b/sway/scroll-ipc.7.scd @@ -96,6 +96,9 @@ supported. *For all replies, any properties not listed are subject to removal.* |- 123 : GET_BINDINGS : Get a list with the bindings for the current binding mode +|- 124 +: MINT_ACTIVATION_TOKEN +: Mint a new XDG activation token ## 0. RUN_COMMAND @@ -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_ +|- 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, + "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", + "activation_token": null, "visible": true, "shell": "xdg_shell", "inhibit_idle": false, @@ -1816,6 +1825,35 @@ The array contains objects with the following properties: } ``` +## 124. 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" +} +``` + # EVENTS Events are a way for clients to get notified of changes to scroll. A client can @@ -2084,6 +2122,7 @@ The following change types are currently available: "type": "con", "pid": 19787, "app_id": null, + "activation_token": null, "window_properties": { "class": "URxvt", "instance": "urxvt", diff --git a/sway/scroll.5.scd b/sway/scroll.5.scd index 1d5f04e4d..c563083a6 100644 --- a/sway/scroll.5.scd +++ b/sway/scroll.5.scd @@ -1536,6 +1536,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. @@ -1889,6 +1895,15 @@ The following attributes may be matched with: *all* Matches all windows. +*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 @@ -2390,6 +2405,31 @@ scroll.command(nil, "set_size v 0.33333333; move left nomode") Removes a callback set earlier using *add_callback*. _id_ is the unique identifier returned by *add_callback*. +*animation_set_manual_stepping(enabled)* + Enables or disables manual stepping for animations. When enabled, + animations do not progress automatically with real time. Instead, they + stay at the current frame until *animation_step* is called. + +*animation_step(ms)* + Steps the current animation forward by the given amount of _ms_ + (milliseconds). Only has an effect if manual stepping is enabled. + +*animating()* + Returns _true_ if there is an active animation running. + +*pending_transactions()* + Returns _true_ if there are pending transactions that haven't been applied + yet. + +*animation_get_duration()* + Returns the total duration of the current animation in milliseconds, or + 0 if there is no active animation. + +*animation_get_elapsed_time()* + Returns the elapsed time of the current animation in milliseconds. + Accounts for manual stepping if enabled. Returns 0 if there is no active + animation. + Examples: Calling this script from the configuration file, you will get focus on every diff --git a/sway/server.c b/sway/server.c index 3e69e0236..09c724eeb 100644 --- a/sway/server.c +++ b/sway/server.c @@ -719,6 +719,7 @@ bool server_init(struct sway_server *server) { } void server_fini(struct sway_server *server) { + server->exiting = true; // remove listeners wl_list_remove(&server->renderer_lost.link); wl_list_remove(&server->new_output.link); diff --git a/sway/tree/view.c b/sway/tree/view.c index c0b6d1d17..c08b96952 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->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->activation_token); + view->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/swaybar/main.c b/swaybar/main.c index 5fe8596f1..638adca77 100644 --- a/swaybar/main.c +++ b/swaybar/main.c @@ -3,6 +3,12 @@ #include #include #include +#ifdef __SANITIZE_ADDRESS__ +#include +#endif +#include +#include +#include #include "swaybar/bar.h" #include "ipc-client.h" #include "log.h" @@ -94,5 +100,11 @@ int main(int argc, char **argv) { swaybar.running = true; bar_run(&swaybar); bar_teardown(&swaybar); +#ifdef __SANITIZE_ADDRESS__ + pango_cairo_font_map_set_default(NULL); + cairo_debug_reset_static_data(); + FcFini(); + __lsan_do_leak_check(); +#endif return 0; } diff --git a/swaybar/meson.build b/swaybar/meson.build index 3f6176409..6b814a48f 100644 --- a/swaybar/meson.build +++ b/swaybar/meson.build @@ -8,6 +8,7 @@ tray_files = have_tray ? [ swaybar_deps = [ cairo, + fontconfig, gdk_pixbuf, jsonc, math, diff --git a/swaybar/status_line.c b/swaybar/status_line.c index e542e606b..5af74234d 100644 --- a/swaybar/status_line.c +++ b/swaybar/status_line.c @@ -65,50 +65,52 @@ bool status_handle_readable(struct status_line *status) { // the header must be sent completely the first time round char *newline = strchr(status->buffer, '\n'); - json_object *header, *version; - if (newline != NULL - && (header = json_tokener_parse(status->buffer)) - && json_object_object_get_ex(header, "version", &version) - && json_object_get_int(version) == 1) { - sway_log(SWAY_DEBUG, "Using i3bar protocol."); - status->protocol = PROTOCOL_I3BAR; - - json_object *click_events; - if (json_object_object_get_ex(header, "click_events", &click_events) - && json_object_get_boolean(click_events)) { - sway_log(SWAY_DEBUG, "Enabling click events."); - status->click_events = true; - if (write(status->write_fd, "[\n", 2) != 2) { - status_error(status, "[failed to write to status command]"); - json_object_put(header); - return true; + json_object *header = NULL, *version; + if (newline != NULL && (header = json_tokener_parse(status->buffer))) { + if (json_object_object_get_ex(header, "version", &version) && + json_object_get_int(version) == 1) { + sway_log(SWAY_DEBUG, "Using i3bar protocol."); + status->protocol = PROTOCOL_I3BAR; + + json_object *click_events; + if (json_object_object_get_ex(header, "click_events", &click_events) && + json_object_get_boolean(click_events)) { + sway_log(SWAY_DEBUG, "Enabling click events."); + status->click_events = true; + if (write(status->write_fd, "[\n", 2) != 2) { + status_error(status, "[failed to write to status command]"); + json_object_put(header); + return true; + } } - } - json_object *float_event_coords; - if (json_object_object_get_ex(header, "float_event_coords", &float_event_coords) - && json_object_get_boolean(float_event_coords)) { - sway_log(SWAY_DEBUG, "Enabling floating-point coordinates."); - status->float_event_coords = true; - } + json_object *float_event_coords; + if (json_object_object_get_ex(header, "float_event_coords", &float_event_coords) && + json_object_get_boolean(float_event_coords)) { + sway_log(SWAY_DEBUG, "Enabling floating-point coordinates."); + status->float_event_coords = true; + } - json_object *signal; - if (json_object_object_get_ex(header, "stop_signal", &signal)) { - status->stop_signal = json_object_get_int(signal); - sway_log(SWAY_DEBUG, "Setting stop signal to %d", status->stop_signal); - } - if (json_object_object_get_ex(header, "cont_signal", &signal)) { - status->cont_signal = json_object_get_int(signal); - sway_log(SWAY_DEBUG, "Setting cont signal to %d", status->cont_signal); - } + json_object *signal; + if (json_object_object_get_ex(header, "stop_signal", &signal)) { + status->stop_signal = json_object_get_int(signal); + sway_log(SWAY_DEBUG, "Setting stop signal to %d", status->stop_signal); + } + if (json_object_object_get_ex(header, "cont_signal", &signal)) { + status->cont_signal = json_object_get_int(signal); + sway_log(SWAY_DEBUG, "Setting cont signal to %d", status->cont_signal); + } - json_object_put(header); + json_object_put(header); - wl_list_init(&status->blocks); - status->tokener = json_tokener_new(); - status->buffer_index = strlen(newline + 1); - memmove(status->buffer, newline + 1, status->buffer_index + 1); - return i3bar_handle_readable(status); + wl_list_init(&status->blocks); + status->tokener = json_tokener_new(); + status->buffer_index = strlen(newline + 1); + memmove(status->buffer, newline + 1, status->buffer_index + 1); + return i3bar_handle_readable(status); + } else { + json_object_put(header); + } } sway_log(SWAY_DEBUG, "Using text protocol."); 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/conftest.py b/tests/conftest.py index 4531b51cd..770274767 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,52 +10,109 @@ def pytest_addoption(parser: pytest.Parser) -> None: parser.addoption("--scroll", help="the scroll binary to test", default=None) +def _build_scroll() -> str: + # Auto-build using Meson/Ninja + print("\nBuilding scroll with Meson/Ninja...") + build_dir = os.path.abspath("./build") + if not os.path.exists(build_dir): + res = subprocess.run( + [ + "meson", + "setup", + "build", + "-Dwerror=false", + "-Db_sanitize=address", + "-Dbuildtype=debugoptimized", + ], + capture_output=True, + text=True, + ) + if res.returncode != 0: + pytest.exit( + f"Failed to setup build:\nStdout: {res.stdout}\nStderr: {res.stderr}" + ) + else: + # Ensure ASan is enabled + res = subprocess.run( + [ + "meson", + "configure", + "build", + "-Db_sanitize=address", + "-Dbuildtype=debugoptimized", + ], + capture_output=True, + text=True, + ) + if res.returncode != 0: + pytest.exit( + "Failed to configure build with ASan:\nStdout:" + f" {res.stdout}\nStderr: {res.stderr}" + ) + + # Run ninja to compile (incremental build) + res = subprocess.run(["ninja", "-C", "build"], capture_output=True, text=True) + if res.returncode != 0: + pytest.exit( + f"Failed to build scroll:\nStdout: {res.stdout}\nStderr: {res.stderr}" + ) + + return os.path.join(build_dir, "sway", "scroll") + + @pytest.fixture(scope="session") def scroll_compositor_binary(request: pytest.FixtureRequest) -> str: binary_path: str = request.config.getoption("scroll") if not binary_path: - # Auto-build using Meson/Ninja - print("\nBuilding scroll with Meson/Ninja...") - build_dir = os.path.abspath("./build") - if not os.path.exists(build_dir): - res = subprocess.run( - ["meson", "setup", "build", "-Dwerror=false", "-Db_sanitize=address"], - capture_output=True, - text=True, - ) - if res.returncode != 0: - pytest.exit( - f"Failed to setup build:\nStdout: {res.stdout}\nStderr: {res.stderr}" - ) + # Check if we are running under xdist + try: + worker_id = request.getfixturevalue("worker_id") + except Exception: + worker_id = "master" + + if worker_id == "master": + binary_path = _build_scroll() else: - # Ensure ASan is enabled - res = subprocess.run( - ["meson", "configure", "build", "-Db_sanitize=address"], - capture_output=True, - text=True, - ) - if res.returncode != 0: - pytest.exit( - f"Failed to configure build with ASan:\nStdout: {res.stdout}\nStderr: {res.stderr}" - ) + tmp_path_factory = request.getfixturevalue("tmp_path_factory") + shared_dir = tmp_path_factory.getbasetemp().parent + lock_path = shared_dir / "scroll_build.lock" + status_path = shared_dir / "scroll_build.status" - # Run ninja to compile (incremental build) - res = subprocess.run(["ninja", "-C", "build"], capture_output=True, text=True) - if res.returncode != 0: - pytest.exit( - f"Failed to build scroll:\nStdout: {res.stdout}\nStderr: {res.stderr}" - ) + import fcntl - binary_path = os.path.join(build_dir, "sway", "scroll") + # Open with 'a' to avoid truncating while another process might have it locked + with open(lock_path, "a") as lock_file: + fcntl.flock(lock_file, fcntl.LOCK_EX) + try: + if status_path.exists(): + binary_path = status_path.read_text().strip() + else: + binary_path = _build_scroll() + status_path.write_text(binary_path) + finally: + fcntl.flock(lock_file, fcntl.LOCK_UN) else: binary_path = os.path.abspath(binary_path) assert os.path.exists(binary_path), f"Binary not found at {binary_path}" + + # Set up PATH to include build directories so that the compositor can find + # our newly built scrollbar, swaymsg, swaynag, etc. + build_dir = Path(binary_path).parent.parent + old_path = os.environ.get("PATH", "") + build_paths = [ + str(build_dir / "sway"), + str(build_dir / "swaymsg"), + str(build_dir / "swaybar"), + str(build_dir / "swaynag"), + ] + os.environ["PATH"] = ":".join(build_paths) + ":" + old_path + return binary_path @pytest.fixture(scope="session") -def scroll_compositor( +def _scroll_compositor_session( scroll_compositor_binary: str, tmp_path_factory: pytest.TempPathFactory ) -> Generator[ScrollInstance, None, None]: temp_dir: Path = tmp_path_factory.mktemp("scroll") @@ -63,6 +120,14 @@ def scroll_compositor( yield inst +@pytest.fixture(scope="function") +def scroll_compositor( + _scroll_compositor_session: ScrollInstance, +) -> Generator[ScrollInstance, None, None]: + yield _scroll_compositor_session + _scroll_compositor_session.reset() + + @pytest.fixture(scope="function") def fresh_compositor( scroll_compositor_binary: str, tmp_path: Path diff --git a/tests/lsan.supp b/tests/lsan.supp index e01775a38..f9c243689 100644 --- a/tests/lsan.supp +++ b/tests/lsan.supp @@ -1,2 +1,8 @@ -# Leaks in EGL/DRM initialization due to missing eglTerminate in wlroots (or system libraries) +# Leaks in EGL/DRM initialization due to missing eglTerminate in wlroots leak:egl_init_display + +# Suppress tray-related DBus leaks in swaybar +leak:create_watcher +leak:init_host +leak:sni_match_signal_async +leak:sni_get_property_async diff --git a/tests/scrollipc.py b/tests/scrollipc.py index 84204c716..eeadebd29 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 = 124 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..c6baf8395 --- /dev/null +++ b/tests/test_activation_token.py @@ -0,0 +1,189 @@ +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 activation_token + cmd_line2: str = f'for_window [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 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("activation_token") == token, ( + f"Expected token {token}, got {node.get('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 activation_token with class + cmd_line = ( + 'for_window [activation_token="test-token" class="Kitty"] floating enable' + ) + res = scroll_compositor.cmd(cmd_line) + assert not res[0]["success"] + assert ( + "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 activation_token + cmd_line = f'for_window [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 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("activation_token") == token, ( + f"Expected token {token}, got {node.get('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 + res = subprocess.run( + ["scrollmsg", "-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_animation_offset.py b/tests/test_animation_offset.py index 7cd551080..a149bb34c 100644 --- a/tests/test_animation_offset.py +++ b/tests/test_animation_offset.py @@ -1,4 +1,3 @@ -import time from typing import Generator from pathlib import Path import pytest @@ -29,13 +28,13 @@ def test_animation_offset_unintended_move( try: with wayland_client(inst, "client1"): wait_for_client_map(inst, "client1") - time.sleep(0.5) + inst.wait_for_idle() with wayland_client(inst, "client2"): wait_for_client_map(inst, "client2") - time.sleep(0.5) + inst.wait_for_idle() with wayland_client(inst, "client3"): wait_for_client_map(inst, "client3") - time.sleep(0.5) + inst.wait_for_idle() tree = inst.get_tree() @@ -77,11 +76,13 @@ def find_views(node, result): print("Focusing rightmost...") res = inst.cmd(f"[con_id={v3['id']}] focus") assert res[0]["success"] - time.sleep(1.0) # wait for focus scroll to settle + inst.wait_for_idle() + + # Enable manual stepping for the swap animation + inst.set_manual_stepping(True) # Swap leftmost and middle by moving leftmost right, using its ID as context - print("Swapping leftmost and middle in background...") - # We use execute_lua to run scroll.command with v1['id'] context + print("Swapping leftmost and middle...") res_lua = inst.execute_lua( f"return scroll.command({v1['id']}, 'move right')" ) @@ -94,15 +95,19 @@ def find_views(node, result): query_id = parent_id if parent_id is not None else v3["id"] print(f"Querying container {query_id} (parent of {v3['id']})") - # Query position of client3 during animation + # Query position of client3 during animation by manually stepping positions = [] - start_time = time.time() - while time.time() - start_time < 2.0: + # Total animation duration is 5000ms. We step 2000ms total to match the original test's 2.0s limit. + # 40 steps of 50ms = 2000ms. + for _ in range(40): + inst.animation_step(50) geom = inst.execute_lua( f"return scroll.container_get_animated_geometry({query_id})" ) positions.append(geom) - time.sleep(0.05) + + # Restore normal stepping + inst.set_manual_stepping(False) print(f"Positions of client3: {positions}") diff --git a/tests/test_clients.py b/tests/test_clients.py index b29a7c690..aa8d47060 100644 --- a/tests/test_clients.py +++ b/tests/test_clients.py @@ -1,9 +1,9 @@ import os -import subprocess -import time from pathlib import Path -import pytest +import subprocess from conftest import ScrollInstance +import pytest +from test_utils import wait_for_client_map def test_wayland_client(scroll_compositor: ScrollInstance) -> None: @@ -23,31 +23,12 @@ def test_wayland_client(scroll_compositor: ScrollInstance) -> None: [str(client_path), title, app_id], env=env ) - view_info: dict | None = None - tries: int = 0 - while tries < 50: - view_info = scroll_compositor.execute_lua(""" - local view = scroll.focused_view() - if view then - return { - id = view, - title = scroll.view_get_title(view), - app_id = scroll.view_get_app_id(view) - } - end - """) - if ( - view_info - and view_info.get("title") == title - and view_info.get("app_id") == app_id - ): - break - - time.sleep(0.1) - tries += 1 - - assert tries < 50, "Timed out waiting for client to map or verify" - assert view_info is not None + view_id = wait_for_client_map(scroll_compositor, title) + app_id_actual = scroll_compositor.execute_lua( + f"return scroll.view_get_app_id({view_id})" + ) + assert app_id_actual == app_id + view_info = {"id": view_id} scroll_compositor.execute_lua(f"scroll.view_close({view_info['id']})") @@ -68,13 +49,7 @@ def test_x11_client(scroll_compositor: ScrollInstance) -> None: xauthority: str | None = scroll_compositor.getenv("XAUTHORITY") # Wait for Xwayland to be ready - xwayland_ready_tries: int = 0 - while xwayland_ready_tries < 50: - if "Xserver is ready" in scroll_compositor.read_log(): - break - time.sleep(0.1) - xwayland_ready_tries += 1 - assert xwayland_ready_tries < 50, "Timed out waiting for Xwayland to be ready" + scroll_compositor.wait_for_log_pattern("Xserver is ready", from_start=True) client_path: Path = Path("./build/tests/x11-test-client").resolve() if not client_path.exists(): @@ -93,36 +68,16 @@ def test_x11_client(scroll_compositor: ScrollInstance) -> None: [str(client_path), title, instance, class_name], env=env ) - view_info: dict | None = None - tries: int = 0 - while tries < 50: - view_info = scroll_compositor.execute_lua(""" - local view = scroll.focused_view() - if view then - return { - id = view, - title = scroll.view_get_title(view), - class = scroll.view_get_class(view), - shell = scroll.view_get_shell(view) - } - end - """) - if ( - view_info - and view_info.get("title") == title - and view_info.get("class") == class_name - ): - assert view_info.get("shell") == "xwayland" - break - - time.sleep(0.1) - tries += 1 - - if tries >= 50: - Path("build/test_x11_compositor.log").write_text(scroll_compositor.read_log()) - print("Wrote compositor log to build/test_x11_compositor.log") - assert tries < 50, "Timed out waiting for X11 client to map or verify" - assert view_info is not None + view_id = wait_for_client_map(scroll_compositor, title) + class_actual = scroll_compositor.execute_lua( + f"return scroll.view_get_class({view_id})" + ) + shell_actual = scroll_compositor.execute_lua( + f"return scroll.view_get_shell({view_id})" + ) + assert class_actual == class_name + assert shell_actual == "xwayland" + view_info = {"id": view_id} scroll_compositor.execute_lua(f"scroll.view_close({view_info['id']})") diff --git a/tests/test_focused_inactive_null_crash.py b/tests/test_focused_inactive_null_crash.py index c38f5bdb8..73c4c2cc4 100644 --- a/tests/test_focused_inactive_null_crash.py +++ b/tests/test_focused_inactive_null_crash.py @@ -1,4 +1,3 @@ -import time import json from conftest import ScrollInstance from test_utils import wayland_client, wait_for_client_map @@ -35,7 +34,7 @@ def test_focused_inactive_null_crash(fresh_compositor: ScrollInstance) -> None: # 5. Move w1 to workspace 2 (this detaches it, making col's focused_inactive_child NULL) fresh_compositor.cmd(f"[con_id={w1_id}] move container to workspace 2") - time.sleep(0.1) + fresh_compositor.wait_for_idle() # 6. Focus the parent column print(f"col_id: {col_id}") @@ -68,7 +67,6 @@ def test_focused_inactive_null_crash(fresh_compositor: ScrollInstance) -> None: pass ret = fresh_compositor.proc.wait(timeout=5) print(f"Compositor exit code: {ret}") - assert ret == 0, f"Compositor crashed or exited with error code {ret}" - print("Compositor Log:") print(fresh_compositor.read_log()) + assert ret == 0, f"Compositor crashed or exited with error code {ret}" diff --git a/tests/test_focused_inactive_uaf.py b/tests/test_focused_inactive_uaf.py index 1a19e5fc6..8ca5ebb42 100644 --- a/tests/test_focused_inactive_uaf.py +++ b/tests/test_focused_inactive_uaf.py @@ -1,36 +1,35 @@ -import time from conftest import ScrollInstance from test_utils import wayland_client, wait_for_client_map -def test_focused_inactive_uaf(fresh_compositor: ScrollInstance) -> None: +def test_focused_inactive_uaf(scroll_compositor: ScrollInstance) -> None: # 1. Set mode to vertical to stack windows - fresh_compositor.cmd("set_mode v") + scroll_compositor.cmd("set_mode v") # 2. Create first view - with wayland_client(fresh_compositor, "client1") as client1: - wait_for_client_map(fresh_compositor, "client1") - w1_id = fresh_compositor.execute_lua("return scroll.focused_container()") + with wayland_client(scroll_compositor, "client1") as client1: + wait_for_client_map(scroll_compositor, "client1") + w1_id = scroll_compositor.execute_lua("return scroll.focused_container()") print(f"w1_id: {w1_id}") # 3. Create second view - with wayland_client(fresh_compositor, "client2"): - wait_for_client_map(fresh_compositor, "client2") - w2_id = fresh_compositor.execute_lua("return scroll.focused_container()") + with wayland_client(scroll_compositor, "client2"): + wait_for_client_map(scroll_compositor, "client2") + w2_id = scroll_compositor.execute_lua("return scroll.focused_container()") print(f"w2_id: {w2_id}") # Verify they are siblings (same parent) - w1_parent = fresh_compositor.execute_lua( + w1_parent = scroll_compositor.execute_lua( f"return scroll.container_get_parent({w1_id})" ) - w2_parent = fresh_compositor.execute_lua( + w2_parent = scroll_compositor.execute_lua( f"return scroll.container_get_parent({w2_id})" ) print(f"w1_parent: {w1_parent}, w2_parent: {w2_parent}") assert w1_parent == w2_parent, "w1 and w2 should be siblings" # Get Col1 ID (parent ID) - col1_id = fresh_compositor.execute_lua(""" + col1_id = scroll_compositor.execute_lua(""" local outputs = scroll.root_get_outputs() local workspaces = scroll.output_get_workspaces(outputs[1]) local ws1 @@ -47,29 +46,28 @@ def test_focused_inactive_uaf(fresh_compositor: ScrollInstance) -> None: assert col1_id == w1_parent, "Col1 ID should match parent ID" # 4. Focus w1 to make it the focused_inactive_child of the parent - fresh_compositor.cmd(f"[con_id={w1_id}] focus") - focused = fresh_compositor.execute_lua("return scroll.focused_container()") + scroll_compositor.cmd(f"[con_id={w1_id}] focus") + focused = scroll_compositor.execute_lua("return scroll.focused_container()") assert focused == w1_id, ( f"w1 ({w1_id}) should be focused, but got {focused}" ) # 5. Switch to workspace 2 - fresh_compositor.cmd("workspace 2") + scroll_compositor.cmd("workspace 2") # 6. Kill w1 (which is on workspace 1) - fresh_compositor.cmd(f"[con_id={w1_id}] kill") + scroll_compositor.cmd(f"[con_id={w1_id}] kill") - # Wait for client1 to exit (destruction complete) client1.wait(timeout=5) - time.sleep(0.1) + scroll_compositor.wait_for_idle() # 7. Switch back to workspace 1. - fresh_compositor.cmd("workspace 1") + scroll_compositor.cmd("workspace 1") # 8. Run move command targeted at Col1 (which has dangling focused_inactive_child) # This should trigger UAF in container_get_active_view! - fresh_compositor.cmd(f"[con_id={col1_id}] move left nomode") + scroll_compositor.cmd(f"[con_id={col1_id}] move left nomode") # If we survive, let's verify w2 is still there - focused = fresh_compositor.execute_lua("return scroll.focused_container()") + focused = scroll_compositor.execute_lua("return scroll.focused_container()") print(f"Focused after move: {focused}") 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})") diff --git a/tests/test_geometry.py b/tests/test_geometry.py index 6ce923245..70a8ef45c 100644 --- a/tests/test_geometry.py +++ b/tests/test_geometry.py @@ -1,4 +1,3 @@ -import time from typing import Generator from pathlib import Path import pytest @@ -29,7 +28,7 @@ def test_static_geometry(scroll_compositor: ScrollInstance) -> None: assert con_id is not None # Wait for any map animation to settle - time.sleep(2.0) + inst.wait_for_idle() geom = inst.execute_lua(f"return scroll.container_get_geometry({con_id})") actual_geom = inst.execute_lua( @@ -90,9 +89,7 @@ def test_animating_geometry(animating_compositor: ScrollInstance) -> None: with wayland_client(inst, "client2"): wait_for_client_map(inst, "client2") - - # Wait for initial map animations to settle - time.sleep(0.5) + inst.wait_for_idle() geom_before = inst.execute_lua( f"return scroll.container_get_geometry({c1})" @@ -104,6 +101,9 @@ def test_animating_geometry(animating_compositor: ScrollInstance) -> None: print("Before move:", geom_before) + # Enable manual stepping + inst.set_manual_stepping(True) + # Trigger move inst.execute_lua(f"scroll.command({c1}, 'move right')") @@ -119,24 +119,23 @@ def test_animating_geometry(animating_compositor: ScrollInstance) -> None: print("Immediately after trigger (actual):", actual_geom_after_trigger) # The target geometry (geom) should have jumped to the final position. - # The actual geometry should still be close to the initial position. + # The actual geometry should still be exactly the initial position. assert geom_after_trigger != geom_before - assert actual_geom_after_trigger["x"] == pytest.approx( - actual_geom_before["x"], abs=10.0 - ) + assert actual_geom_after_trigger == actual_geom_before - # Monitor animation + # Monitor animation using manual steps actual_xs = [] target_xs = [] - start_time = time.time() - while time.time() - start_time < 2.5: # Animation is 2s + + # Step 10 times, 200ms each (total 2000ms = 2s) + for _ in range(10): + inst.animation_step(200) g = inst.execute_lua(f"return scroll.container_get_geometry({c1})") ag = inst.execute_lua( f"return scroll.container_get_animated_geometry({c1})" ) target_xs.append(g["x"]) actual_xs.append(ag["x"]) - time.sleep(0.1) print("Target Xs:", target_xs) print("Actual Xs:", actual_xs) @@ -146,14 +145,16 @@ def test_animating_geometry(animating_compositor: ScrollInstance) -> None: for tx in target_xs: assert tx == final_x - # Actual Xs should start near before_x and end at final_x - assert actual_xs[0] < final_x # Assuming it moved right + # Actual Xs should start near before_x (after first step) and end at final_x + assert actual_xs[0] < final_x assert actual_xs[-1] == pytest.approx(final_x, abs=1.0) - # Verify it is monotonically increasing (if it moved right) + # Verify it is monotonically increasing (since it moved right) for i in range(1, len(actual_xs)): assert actual_xs[i] >= actual_xs[i - 1] - 0.1 + inst.set_manual_stepping(False) + def test_invalid_geometry(scroll_compositor: ScrollInstance) -> None: inst = scroll_compositor diff --git a/tests/test_leak_two_clients.py b/tests/test_leak_two_clients.py new file mode 100644 index 000000000..8e1a7579f --- /dev/null +++ b/tests/test_leak_two_clients.py @@ -0,0 +1,43 @@ +from pathlib import Path +import subprocess +import time +from test_utils import run_compositor, wait_for_client_map, wayland_client + + +def test_leak_two_clients(scroll_compositor_binary: str, tmp_path: Path) -> None: + config_path = Path(__file__).parent.parent / "config.in" + config_content = config_path.read_text() + + fresh_compositor = None + try: + with run_compositor(scroll_compositor_binary, tmp_path, config_content) as fc: + fresh_compositor = fc + # Start two clients and keep them running + with wayland_client(fresh_compositor, "client1"): + wait_for_client_map(fresh_compositor, "client1") + + with wayland_client(fresh_compositor, "client2"): + wait_for_client_map(fresh_compositor, "client2") + + # Let them run a bit + time.sleep(0.5) + + # Terminate compositor while clients are still running + fresh_compositor.proc.terminate() + try: + ret = fresh_compositor.proc.wait(timeout=5) + except subprocess.TimeoutExpired: + fresh_compositor.proc.kill() + ret = fresh_compositor.proc.wait() + + log_content = fresh_compositor.read_log() + print(f"Compositor log:\n{log_content}") + + assert ret == 0, f"Compositor exited with code {ret}" + except Exception as e: + if fresh_compositor: + try: + print(f"Compositor log on failure:\n{fresh_compositor.read_log()}") + except Exception as le: + print(f"Failed to read compositor log: {le}") + raise e diff --git a/tests/test_lua_api.py b/tests/test_lua_api.py index 53e9e2f37..22360cf59 100644 --- a/tests/test_lua_api.py +++ b/tests/test_lua_api.py @@ -1,4 +1,8 @@ +from pathlib import Path +from typing import Generator from conftest import ScrollInstance +import pytest +from test_utils import run_compositor, wait_for_client_map, wayland_client def test_lua_comprehensive_api(scroll_compositor: ScrollInstance) -> None: @@ -91,3 +95,69 @@ def test_lua_comprehensive_api(scroll_compositor: ScrollInstance) -> None: assert len(invalid_output_ws) == 0 assert scroll_compositor.proc.poll() is None + + +@pytest.fixture(scope="function") +def animating_compositor( + scroll_compositor_binary: str, tmp_path: Path +) -> Generator[ScrollInstance, None, None]: + config: str = ( + "workspace 1\n" + "xwayland force\n" + "animations enabled yes\n" + "animations window_move yes 1000 var 3 [ 0.25 0.1 0.25 1.0 ]\n" # 1s (1000ms) duration + ) + with run_compositor(scroll_compositor_binary, tmp_path, config) as inst: + yield inst + + +def test_lua_animation_api(animating_compositor: ScrollInstance) -> None: + inst = animating_compositor + + # Initially not animating + assert inst.execute_lua("return scroll.animating()") is False + assert inst.execute_lua("return scroll.animation_get_duration()") == 0 + assert inst.execute_lua("return scroll.animation_get_elapsed_time()") == 0 + + with wayland_client(inst, "client1"): + v1 = wait_for_client_map(inst, "client1") + inst.wait_for_idle() + + with wayland_client(inst, "client2"): + wait_for_client_map(inst, "client2") + inst.wait_for_idle() + + # Enable manual stepping + inst.set_manual_stepping(True) + + # Trigger window move (starts animation) + res = inst.execute_lua(f"return scroll.command({v1}, 'move right')") + assert res == [0] + + # Now animating + assert inst.execute_lua("return scroll.animating()") is True + # Duration should be 1000ms (as configured) + assert inst.execute_lua("return scroll.animation_get_duration()") == 1000 + # Elapsed time should start at 0 + assert inst.execute_lua("return scroll.animation_get_elapsed_time()") == 0 + + # Step 200ms + inst.animation_step(200) + assert inst.execute_lua("return scroll.animating()") is True + assert inst.execute_lua("return scroll.animation_get_duration()") == 1000 + assert inst.execute_lua("return scroll.animation_get_elapsed_time()") == 200 + + # Step another 500ms (total 700ms) + inst.animation_step(500) + assert inst.execute_lua("return scroll.animating()") is True + assert inst.execute_lua("return scroll.animation_get_duration()") == 1000 + assert inst.execute_lua("return scroll.animation_get_elapsed_time()") == 700 + + # Step past the end (another 400ms, total 1100ms > 1000ms) + inst.animation_step(400) + # Animation should have ended + assert inst.execute_lua("return scroll.animating()") is False + assert inst.execute_lua("return scroll.animation_get_duration()") == 0 + assert inst.execute_lua("return scroll.animation_get_elapsed_time()") == 0 + + inst.set_manual_stepping(False) diff --git a/tests/test_lua_path.py b/tests/test_lua_path.py index 2fb6461b8..6d7a671d1 100644 --- a/tests/test_lua_path.py +++ b/tests/test_lua_path.py @@ -1,4 +1,3 @@ -import time from pathlib import Path from conftest import ScrollInstance from test_utils import run_compositor @@ -36,8 +35,7 @@ def test_lua_relative_path_config_load( config = "workspace 1\nxwayland force\nlua test_relative.lua\n" with run_compositor(binary_path, tmp_path, config) as inst: - time.sleep(1.0) - assert "RELATIVE_LOAD_SUCCESS" in inst.read_log() + inst.wait_for_log_pattern("RELATIVE_LOAD_SUCCESS", from_start=True) def test_lua_relative_path_subdir_config_load( @@ -51,8 +49,7 @@ def test_lua_relative_path_subdir_config_load( config = "workspace 1\nxwayland force\nlua scripts/test_relative2.lua\n" with run_compositor(binary_path, tmp_path, config) as inst: - time.sleep(1.0) - assert "RELATIVE_SUBDIR_LOAD_SUCCESS" in inst.read_log() + inst.wait_for_log_pattern("RELATIVE_SUBDIR_LOAD_SUCCESS", from_start=True) def test_lua_relative_glob_config_load( @@ -66,8 +63,7 @@ def test_lua_relative_glob_config_load( config = "workspace 1\nxwayland force\nlua scripts/test_glob*.lua\n" with run_compositor(binary_path, tmp_path, config) as inst: - time.sleep(1.0) - assert "RELATIVE_GLOB_LOAD_SUCCESS" in inst.read_log() + inst.wait_for_log_pattern("RELATIVE_GLOB_LOAD_SUCCESS", from_start=True) def test_lua_relative_glob_multiple_config_load( @@ -81,5 +77,4 @@ def test_lua_relative_glob_multiple_config_load( config = "workspace 1\nxwayland force\nlua scripts/test_glob*.lua\n" with run_compositor(binary_path, tmp_path, config) as inst: - time.sleep(1.0) - assert "Path expanded to multiple files" in inst.read_log() + inst.wait_for_log_pattern("Path expanded to multiple files", from_start=True) diff --git a/tests/test_move_cleanup_crash.py b/tests/test_move_cleanup_crash.py index e7d9949ed..edca7f015 100644 --- a/tests/test_move_cleanup_crash.py +++ b/tests/test_move_cleanup_crash.py @@ -1,42 +1,35 @@ -import time from conftest import ScrollInstance from test_utils import wayland_client, wait_for_client_map -def test_move_cleanup_uaf_crash(fresh_compositor: ScrollInstance) -> None: +def test_move_cleanup_uaf_crash(scroll_compositor: ScrollInstance) -> None: # 1. Open Window 1 on Workspace 1 - with wayland_client(fresh_compositor, "Window 1"): - wait_for_client_map(fresh_compositor, "Window 1") + with wayland_client(scroll_compositor, "Window 1"): + wait_for_client_map(scroll_compositor, "Window 1") # Make Window 1 floating - res = fresh_compositor.cmd("floating enable") + res = scroll_compositor.cmd("floating enable") assert res and res[0]["success"], f"floating enable failed: {res}" # 2. Switch to Workspace 3 (so Workspace 1 becomes inactive) - res = fresh_compositor.cmd("workspace 3") + res = scroll_compositor.cmd("workspace 3") assert res and res[0]["success"], f"workspace 3 failed: {res}" - time.sleep(0.5) + scroll_compositor.wait_for_idle() # 3. Use criteria to move Window 1 from Workspace 1 to Workspace 2. # This should make Workspace 1 empty and inactive, so it gets destroyed. # Then the move command cleanup code should UAF on Workspace 1. try: - res = fresh_compositor.cmd( + res = scroll_compositor.cmd( '[title="Window 1"] move container to workspace 2' ) assert res and res[0]["success"], f"move failed: {res}" except Exception as e: - print(f"Compositor log:\n{fresh_compositor.read_log()}") + print(f"Compositor log:\n{scroll_compositor.read_log()}") raise e # Check if compositor process is still alive - log_content = fresh_compositor.read_log() + log_content = scroll_compositor.read_log() print(f"Compositor log:\n{log_content}") - if ( - fresh_compositor.proc.poll() is not None - or "node_table not initialized" in log_content - ): - pass - - assert fresh_compositor.proc.poll() is None, "Compositor crashed" + assert scroll_compositor.proc.poll() is None, "Compositor crashed" diff --git a/tests/test_normal_exit.py b/tests/test_normal_exit.py index 0a51e8e7b..78c379072 100644 --- a/tests/test_normal_exit.py +++ b/tests/test_normal_exit.py @@ -1,5 +1,8 @@ +from pathlib import Path +import subprocess import time from conftest import ScrollInstance +from test_utils import run_compositor def test_normal_exit_no_errors(fresh_compositor: ScrollInstance) -> None: @@ -15,14 +18,10 @@ def test_normal_exit_no_errors(fresh_compositor: ScrollInstance) -> None: raise e # Wait for compositor to exit - tries = 0 - poll = None - while tries < 50: - poll = fresh_compositor.proc.poll() - if poll is not None: - break - time.sleep(0.1) - tries += 1 + try: + poll = fresh_compositor.proc.wait(timeout=5) + except subprocess.TimeoutExpired: + poll = None assert poll is not None, "Compositor did not exit" @@ -32,3 +31,44 @@ def test_normal_exit_no_errors(fresh_compositor: ScrollInstance) -> None: assert poll == 0, f"Compositor exited with non-zero code: {poll}" assert "node_table not initialized" not in log_content + + +def test_normal_exit_with_bar(scroll_compositor_binary: str, tmp_path: Path) -> None: + config_content = """ +workspace 1 +xwayland force +animations enabled no +bar { + scrollbar_command scrollbar +} +""" + fresh_compositor = None + try: + with run_compositor(scroll_compositor_binary, tmp_path, config_content) as fc: + fresh_compositor = fc + + # Let it run a bit to ensure swaybar starts + time.sleep(0.5) + + # Send exit command + try: + res = fresh_compositor.cmd("exit") + assert res and res[0]["success"], f"Exit command failed: {res}" + except EOFError: + pass + + ret = fresh_compositor.proc.wait(timeout=5) + assert ret == 0, f"Compositor exited with code {ret}" + + log_content = fresh_compositor.read_log() + print(f"Compositor log:\n{log_content}") + assert "ERROR: LeakSanitizer" not in log_content, ( + "Leak detected in compositor or helper process" + ) + except Exception as e: + if fresh_compositor: + try: + print(f"Compositor log on failure:\n{fresh_compositor.read_log()}") + except Exception as le: + print(f"Failed to read compositor log: {le}") + raise e diff --git a/tests/test_shutdown_events.py b/tests/test_shutdown_events.py new file mode 100644 index 000000000..9fb57bc5d --- /dev/null +++ b/tests/test_shutdown_events.py @@ -0,0 +1,113 @@ +import json +from pathlib import Path +import socket +import struct +from test_utils import run_compositor, wait_for_client_map, wayland_client + + +def test_shutdown_events_verification( + scroll_compositor_binary: str, tmp_path: Path +) -> None: + config_path: Path = Path(__file__).parent.parent / "config.in" + config_content: str = config_path.read_text() + + with run_compositor(scroll_compositor_binary, tmp_path, config_content) as fc: + # Connect a second socket for events subscription + event_socket: socket.socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + event_socket.connect(fc.ipc.socket_path) + + # Helper functions for the custom subscription socket + def send_msg(msg_type: int, payload: str) -> None: + payload_bytes: bytes = payload.encode("utf-8") + length: int = len(payload_bytes) + header: bytes = struct.pack("<6sII", b"i3-ipc", length, msg_type) + event_socket.sendall(header + payload_bytes) + + def recv_msg() -> tuple[int, str]: + header_data: bytes = b"" + while len(header_data) < 14: + chunk: bytes = event_socket.recv(14 - len(header_data)) + if not chunk: + raise EOFError("Socket closed") + header_data += chunk + magic: bytes + length: int + msg_type: int + magic, length, msg_type = struct.unpack("<6sII", header_data) + payload_data: bytes = b"" + while len(payload_data) < length: + chunk = event_socket.recv(length - len(payload_data)) + if not chunk: + raise EOFError("Socket closed") + payload_data += chunk + return msg_type, payload_data.decode("utf-8") + + # Subscribe to workspace, window, and shutdown events + # 2 is IPC_SUBSCRIBE + send_msg(2, json.dumps(["workspace", "window", "shutdown"])) + msg_type, payload = recv_msg() + assert msg_type == 2 + assert json.loads(payload)["success"] is True + + # Start two clients to have some active views/workspaces + with wayland_client(fc, "client1"): + wait_for_client_map(fc, "client1") + with wayland_client(fc, "client2"): + wait_for_client_map(fc, "client2") + + # Drain all pending events on subscription socket before terminating + event_socket.setblocking(False) + try: + while True: + header_data: bytes = event_socket.recv(14) + if len(header_data) == 14: + magic, length, msg_type = struct.unpack( + "<6sII", header_data + ) + payload_data = b"" + event_socket.setblocking(True) + while len(payload_data) < length: + chunk = event_socket.recv(length - len(payload_data)) + if not chunk: + break + payload_data += chunk + event_socket.setblocking(False) + except BlockingIOError: + pass + + event_socket.setblocking(True) + + # Now terminate the compositor by sending the exit command + try: + fc.cmd("exit") + except (EOFError, BrokenPipeError, ConnectionResetError): + # The socket might close immediately during exit processing, which is fine + pass + + # Read all events sent during shutdown until socket EOF + shutdown_events: list[tuple[int, dict]] = [] + try: + while True: + msg_type, payload = recv_msg() + shutdown_events.append((msg_type, json.loads(payload))) + except (EOFError, BrokenPipeError, ConnectionResetError): + pass # EOF or connection error is expected when compositor exits + + print(f"Events received during shutdown: {shutdown_events}") + + shutdown_msg_type: int = (1 << 31) | 6 + # Verify that if any events are received, they are only shutdown events and nothing unexpected + unexpected_events: list[tuple[int, dict]] = [] + for mtype, p in shutdown_events: + if mtype == shutdown_msg_type: + assert p["change"] == "exit" + else: + unexpected_events.append((mtype, p)) + + try: + assert not unexpected_events, ( + f"Received unexpected events: {unexpected_events}" + ) + except AssertionError as ae: + Path("scroll-test-failure.log").write_text(fc.read_log()) + raise ae diff --git a/tests/test_shutdown_lua_callbacks.py b/tests/test_shutdown_lua_callbacks.py new file mode 100644 index 000000000..7f13375d6 --- /dev/null +++ b/tests/test_shutdown_lua_callbacks.py @@ -0,0 +1,84 @@ +from pathlib import Path +import re +from test_utils import run_compositor, wait_for_client_map, wayland_client + + +def test_shutdown_lua_callbacks_verification( + scroll_compositor_binary: str, tmp_path: Path +) -> None: + config_path: Path = Path(__file__).parent.parent / "config.in" + config_content: str = config_path.read_text() + + with run_compositor(scroll_compositor_binary, tmp_path, config_content) as fc: + # Register callbacks for all events, logging them to the scroll debug log + res = fc.execute_lua(""" + scroll.add_callback("view_map", function(view, data) + scroll.log("LUA_CALLBACK: view_map " .. tostring(view)) + end, nil) + scroll.add_callback("view_unmap", function(view, data) + scroll.log("LUA_CALLBACK: view_unmap " .. tostring(view)) + end, nil) + scroll.add_callback("view_focus", function(view, data) + scroll.log("LUA_CALLBACK: view_focus " .. tostring(view)) + end, nil) + scroll.add_callback("workspace_create", function(ws, data) + scroll.log("LUA_CALLBACK: workspace_create " .. tostring(ws)) + end, nil) + scroll.add_callback("workspace_focus", function(ws, data) + scroll.log("LUA_CALLBACK: workspace_focus " .. tostring(ws)) + end, nil) + """) + print(f"execute_lua result: {res}") + + # Start two clients to populate windows and workspaces + with wayland_client(fc, "client1"): + wait_for_client_map(fc, "client1") + with wayland_client(fc, "client2"): + wait_for_client_map(fc, "client2") + + # Record the log length before exit command + log_before_exit: str = fc.read_log() + log_len_before_exit: int = len(log_before_exit) + + # Now terminate the compositor via the exit command + try: + fc.cmd("exit") + except (EOFError, BrokenPipeError, ConnectionResetError): + pass + + # Wait for compositor to exit + fc.proc.wait(timeout=5) + + # Read all new log lines generated during shutdown + full_log: str = fc.read_log() + shutdown_log: str = full_log[log_len_before_exit:] + + # Extract all callback events triggered during shutdown + callback_pattern = re.compile(r"LUA_CALLBACK: (\w+)") + triggered_callbacks: list[str] = callback_pattern.findall(shutdown_log) + + print(f"Triggered callbacks during shutdown: {triggered_callbacks}") + + # During shutdown, we unmap the existing views, so 'view_unmap' callbacks are expected. + # However, we should NOT see any new focus events ('view_focus', 'workspace_focus') + # or creation events ('workspace_create') being invoked. + unexpected_callbacks: list[str] = [ + cb + for cb in triggered_callbacks + if cb + in ( + "view_map", + "view_focus", + "workspace_create", + "workspace_focus", + ) + ] + + try: + assert not unexpected_callbacks, ( + "Unexpected Lua callbacks invoked during shutdown:" + f" {unexpected_callbacks}" + ) + except AssertionError as ae: + Path("scroll-lua-callbacks-failure.log").write_text(full_log) + raise ae diff --git a/tests/test_space_aba.py b/tests/test_space_aba.py index c01e83e30..270edc76e 100644 --- a/tests/test_space_aba.py +++ b/tests/test_space_aba.py @@ -1,41 +1,43 @@ -import time from conftest import ScrollInstance from test_utils import wayland_client, wait_for_client_map -def test_space_aba(fresh_compositor: ScrollInstance) -> None: - # 1. Create client1 on WS 1 - with wayland_client(fresh_compositor, "client1"): - wait_for_client_map(fresh_compositor, "client1") - - # Save space "sp1" - fresh_compositor.cmd("space_save sp1") - - # client1 is closed now. - # Wait for it to be fully destroyed. - time.sleep(0.2) - - # 2. Create client2 on WS 1. - # Hopefully it reuses client1's view struct address. - with wayland_client(fresh_compositor, "client2"): - wait_for_client_map(fresh_compositor, "client2") - - # Switch to WS 2 - fresh_compositor.cmd("workspace 2") - - # Load space "sp1" on WS 2. - # If ABA bug occurs, it might find client2 (matching old client1 address) - # and move it to WS 2. - fresh_compositor.cmd("space_load sp1 load") - - # Check if client2 is visible on WS 2. - # If it was moved to WS 2, it should be focused because space_load focuses restored containers. - focused_title = fresh_compositor.execute_lua(""" - local view = scroll.focused_view() - return view and scroll.view_get_title(view) - """) - print(f"Focused title after space_load: {focused_title}") - - assert focused_title != "client2", ( - "ABA bug: client2 was incorrectly moved to WS 2!" - ) +def test_space_aba(scroll_compositor: ScrollInstance) -> None: + inst = scroll_compositor + try: + # 1. Create client1 on WS 1 + with wayland_client(inst, "client1"): + wait_for_client_map(inst, "client1") + + # Save space "sp1" + inst.cmd("space_save sp1") + + # client1 is closed now. + inst.wait_for_idle() + + # 2. Create client2 on WS 1. + # Hopefully it reuses client1's view struct address. + with wayland_client(inst, "client2"): + wait_for_client_map(inst, "client2") + + # Switch to WS 2 + inst.cmd("workspace 2") + + # Load space "sp1" on WS 2. + # If ABA bug occurs, it might find client2 (matching old client1 address) + # and move it to WS 2. + inst.cmd("space_load sp1 load") + + # Check if client2 is visible on WS 2. + # If it was moved to WS 2, it should be focused because space_load focuses restored containers. + focused_title = inst.execute_lua(""" + local view = scroll.focused_view() + return view and scroll.view_get_title(view) + """) + print(f"Focused title after space_load: {focused_title}") + + assert focused_title != "client2", ( + "ABA bug: client2 was incorrectly moved to WS 2!" + ) + finally: + inst.cmd("space delete sp1") diff --git a/tests/test_space_crash.py b/tests/test_space_crash.py index 6e35559e6..b68f0e87b 100644 --- a/tests/test_space_crash.py +++ b/tests/test_space_crash.py @@ -2,82 +2,72 @@ from test_utils import wayland_client, wait_for_client_map -def test_space_restore_uaf_crash(fresh_compositor: ScrollInstance) -> None: - # 1. Open Window 1 on Workspace 1 - with wayland_client(fresh_compositor, "Window 1"): - wait_for_client_map(fresh_compositor, "Window 1") - - # Make Window 1 floating - res = fresh_compositor.cmd("floating enable") - assert res and res[0]["success"], f"floating enable failed: {res}" - - # Save layout "space1" on Workspace 1 - res = fresh_compositor.cmd("space save space1") - assert res and res[0]["success"], f"space save failed: {res}" - - # 2. Switch to Workspace 2 - res = fresh_compositor.cmd("workspace 2") - assert res and res[0]["success"], f"workspace 2 failed: {res}" - - # Move Window 1 to Workspace 2 - # (It should still be floating? Yes, moving floating window to workspace works) - # Actually, we can just move it. - # Wait, if we are on Workspace 2, we can't easily move it here unless we focus it. - # But we switched to Workspace 2, so focus is on Workspace 2 (empty). - # We should go back to Workspace 1, move it to Workspace 2, then go to Workspace 2. - res = fresh_compositor.cmd("workspace 1") - assert res and res[0]["success"], f"workspace 1 failed: {res}" - - res = fresh_compositor.cmd("move container to workspace 2") - assert res and res[0]["success"], f"move to ws 2 failed: {res}" - - res = fresh_compositor.cmd("workspace 2") - assert res and res[0]["success"], f"workspace 2 failed: {res}" - - # Now Window 1 is floating on Workspace 2. - # We must make it tiled on Workspace 2. - res = fresh_compositor.cmd("floating disable") - assert res and res[0]["success"], f"floating disable failed: {res}" - - # Set mode to vertical to stack next window - res = fresh_compositor.cmd("set_mode v") - assert res and res[0]["success"], f"set_mode v failed: {res}" - - # Open Window 2 on Workspace 2 - with wayland_client(fresh_compositor, "Window 2"): - wait_for_client_map(fresh_compositor, "Window 2") - - # Now we have on Workspace 2: Split (V) -> [Window 1, Window 2] - - # Move Window 2 to Workspace 3 (so Window 1 is only child of V-split) - res = fresh_compositor.cmd("move container to workspace 3") - assert res and res[0]["success"], f"move to ws 3 failed: {res}" - - # Now Window 1 is the only child of V-split on Workspace 2. - # Workspace 2 has no other windows. - - # 3. Go to Workspace 1 - res = fresh_compositor.cmd("workspace 1") +def test_space_restore_uaf_crash(scroll_compositor: ScrollInstance) -> None: + inst = scroll_compositor + try: + # 1. Open Window 1 on Workspace 1 + with wayland_client(inst, "Window 1"): + wait_for_client_map(inst, "Window 1") + + # Make Window 1 floating + res = inst.cmd("floating enable") + assert res and res[0]["success"], f"floating enable failed: {res}" + + # Save layout "space1" on Workspace 1 + res = inst.cmd("space save space1") + assert res and res[0]["success"], f"space save failed: {res}" + + # 2. Switch to Workspace 2 + res = inst.cmd("workspace 2") + assert res and res[0]["success"], f"workspace 2 failed: {res}" + + # Move Window 1 to Workspace 2 + res = inst.cmd("workspace 1") assert res and res[0]["success"], f"workspace 1 failed: {res}" - # 4. Restore layout "space1" - # This should try to restore Window 1 as floating on Workspace 1. - # It will detach Window 1 from Workspace 2, reaping V-split and destroying Workspace 2. - # Then it will call arrange_container with dangling Workspace 2 pointer. - try: - res = fresh_compositor.cmd("space restore space1") - assert res and res[0]["success"], f"space restore failed: {res}" - except Exception as e: - print(f"Compositor log:\n{fresh_compositor.read_log()}") - raise e - - # Check if compositor process is still alive - log_content = fresh_compositor.read_log() - print(f"Compositor log:\n{log_content}") - if ( - fresh_compositor.proc.poll() is not None - or "node_table not initialized" in log_content - ): - pass - - assert fresh_compositor.proc.poll() is None, "Compositor crashed" + res = inst.cmd("move container to workspace 2") + assert res and res[0]["success"], f"move to ws 2 failed: {res}" + + res = inst.cmd("workspace 2") + assert res and res[0]["success"], f"workspace 2 failed: {res}" + + # Now Window 1 is floating on Workspace 2. + # We must make it tiled on Workspace 2. + res = inst.cmd("floating disable") + assert res and res[0]["success"], f"floating disable failed: {res}" + + # Set mode to vertical to stack next window + res = inst.cmd("set_mode v") + assert res and res[0]["success"], f"set_mode v failed: {res}" + + # Open Window 2 on Workspace 2 + with wayland_client(inst, "Window 2"): + wait_for_client_map(inst, "Window 2") + + # Now we have on Workspace 2: Split (V) -> [Window 1, Window 2] + + # Move Window 2 to Workspace 3 (so Window 1 is only child of V-split) + res = inst.cmd("move container to workspace 3") + assert res and res[0]["success"], f"move to ws 3 failed: {res}" + + # Now Window 1 is the only child of V-split on Workspace 2. + # Workspace 2 has no other windows. + + # 3. Go to Workspace 1 + res = inst.cmd("workspace 1") + assert res and res[0]["success"], f"workspace 1 failed: {res}" + + # 4. Restore layout "space1" + try: + res = inst.cmd("space restore space1") + assert res and res[0]["success"], f"space restore failed: {res}" + except Exception as e: + print(f"Compositor log:\n{inst.read_log()}") + raise e + + # Check if compositor process is still alive + log_content = inst.read_log() + print(f"Compositor log:\n{log_content}") + assert inst.proc.poll() is None, "Compositor crashed" + finally: + inst.cmd("space delete space1") diff --git a/tests/test_utils.py b/tests/test_utils.py index 27df53ed9..bcbc46383 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -152,6 +152,80 @@ def execute_lua(self, code: str) -> Any: def getenv(self, var: str) -> str | None: return self.execute_lua(f'return os.getenv("{var}")') + def wait_for_idle(self, timeout: float = 5.0) -> None: + start = time.time() + while time.time() - start < timeout: + pending = self.execute_lua( + "return scroll.pending_transactions() or scroll.animating()" + ) + if not pending: + return + time.sleep(0.005) + raise TimeoutError("Timeout waiting for compositor to become idle") + + def wait_for_transactions(self, timeout: float = 5.0) -> None: + start = time.time() + while time.time() - start < timeout: + if not self.execute_lua("return scroll.pending_transactions()"): + return + time.sleep(0.005) + raise TimeoutError("Timeout waiting for transactions") + + def set_manual_stepping(self, enabled: bool) -> None: + self.execute_lua( + f"scroll.animation_set_manual_stepping({str(enabled).lower()})" + ) + + def reset(self) -> None: + # 1. Kill all views to clean up leftover windows + try: + self.cmd("kill all") + except Exception: + pass + + # 2. Clean up extra outputs + try: + tree = self.get_tree() + outputs: list[str] = [] + for child in tree.get("nodes", []): + if child.get("type") == "output" and child.get("name") != "__i3": + outputs.append(child["name"]) + + if "HEADLESS-1" in outputs: + for out in outputs: + if out != "HEADLESS-1" and out.startswith("HEADLESS-"): + self.cmd(f"output {out} unplug") + self.wait_for_idle() + except Exception: + pass + + # 3. Reload config to reset defaults + try: + self.cmd("reload") + self.wait_for_idle() + except Exception: + pass + + # 4. Reset workspaces (recreate workspace 1) + try: + self.cmd("workspace __temp") + self.cmd("workspace 1") + self.wait_for_idle() + except Exception: + pass + + # 5. Reset manual stepping (animations will be reset by config reload) + try: + self.execute_lua( + "if scroll and scroll.animation_set_manual_stepping then " + "scroll.animation_set_manual_stepping(false) end" + ) + except Exception: + pass + + def animation_step(self, ms: int) -> None: + self.execute_lua(f"scroll.animation_step({ms})") + @contextmanager def assert_logs_match( self, pattern: str, timeout: float = 5.0 @@ -171,6 +245,24 @@ def assert_logs_match( ) time.sleep(0.1) + def wait_for_log_pattern( + self, pattern: str, timeout: float = 5.0, from_start: bool = False + ) -> None: + compiled_pattern = re.compile(pattern) + start_time = time.time() + initial_log_len = 0 if from_start else len(self.read_log()) + while True: + current_log: str = self.read_log() + log_to_search = current_log if from_start else current_log[initial_log_len:] + if compiled_pattern.search(log_to_search): + return + if time.time() - start_time > timeout: + raise AssertionError( + f"Pattern '{pattern}' not found in log output within" + f" {timeout}s.\nLog searched was:\n{log_to_search}" + ) + time.sleep(0.1) + @contextmanager def run_compositor( @@ -181,7 +273,7 @@ def run_compositor( config_path: Path = temp_dir / "config" if config_content is None: - config_content = "workspace 1\nxwayland force\n" + config_content = "workspace 1\nxwayland force\nanimations enabled no\n" config_path.write_text(config_content) env = os.environ.copy() @@ -269,7 +361,7 @@ def wayland_client( def wait_for_client_map(compositor: ScrollInstance, title: str) -> int: tries: int = 0 - while tries < 50: + while tries < 200: view_id = compositor.execute_lua(f""" local view = scroll.focused_view() if view and scroll.view_get_title(view) == "{title}" then diff --git a/tests/test_workspace_split_uaf.py b/tests/test_workspace_split_uaf.py index cb0345171..0bfa6cc51 100644 --- a/tests/test_workspace_split_uaf.py +++ b/tests/test_workspace_split_uaf.py @@ -1,59 +1,55 @@ -import time from conftest import ScrollInstance from test_utils import wayland_client, wait_for_client_map -def test_workspace_split_uaf_crash(fresh_compositor: ScrollInstance) -> None: +def test_workspace_split_uaf_crash(scroll_compositor: ScrollInstance) -> None: try: # 1. Open Window 1 on Workspace 1 (active on HEADLESS-1) - with wayland_client(fresh_compositor, "Window 1"): - wait_for_client_map(fresh_compositor, "Window 1") + with wayland_client(scroll_compositor, "Window 1"): + wait_for_client_map(scroll_compositor, "Window 1") # 2. Split workspace 1. This creates workspace 2 as sibling. # Workspace 1 has Window 1, Workspace 2 is empty. - res = fresh_compositor.cmd("workspace split") + res = scroll_compositor.cmd("workspace split") assert res and res[0]["success"], f"workspace split failed: {res}" # 3. Create a second output HEADLESS-2. # It should get a default workspace (probably 3). - res = fresh_compositor.cmd("create_output") + res = scroll_compositor.cmd("create_output") assert res and res[0]["success"], f"create_output failed: {res}" - - time.sleep(0.5) + scroll_compositor.wait_for_idle() # 4. Unplug HEADLESS-1. # Workspaces 1 and 2 should be evacuated. # Workspace 1 (non-empty) is moved to HEADLESS-2. # Workspace 2 (empty) is destroyed. - res = fresh_compositor.cmd("output HEADLESS-1 unplug") + res = scroll_compositor.cmd("output HEADLESS-1 unplug") assert res and res[0]["success"], f"unplug failed: {res}" - - time.sleep(0.5) + scroll_compositor.wait_for_idle() # 5. Focus Workspace 3 on HEADLESS-2 (so Workspace 1 becomes inactive) - res = fresh_compositor.cmd("workspace 3") + res = scroll_compositor.cmd("workspace 3") assert res and res[0]["success"], f"workspace 3 failed: {res}" - - time.sleep(0.5) + scroll_compositor.wait_for_idle() # 6. Move Window 1 from Workspace 1 to Workspace 3. # Since Window 1 is moved out of Workspace 1, and Workspace 1 is inactive, # it should trigger workspace_consider_destroy(Workspace 1). # Workspace 1 is empty, and it is split (sibling was Workspace 2). # It will try to access Workspace 2 (which is destroyed) -> UAF. - res = fresh_compositor.cmd( + res = scroll_compositor.cmd( '[title="Window 1"] move container to workspace 3' ) assert res and res[0]["success"], f"move container failed: {res}" # Check if compositor process is still alive - log_content = fresh_compositor.read_log() + log_content = scroll_compositor.read_log() print(f"Compositor log:\n{log_content}") - assert fresh_compositor.proc.poll() is None, "Compositor crashed" + assert scroll_compositor.proc.poll() is None, "Compositor crashed" except Exception as e: print(f"Test failed with exception: {e}") try: - print(f"Compositor log:\n{fresh_compositor.read_log()}") + print(f"Compositor log:\n{scroll_compositor.read_log()}") except Exception as log_err: print(f"Failed to read compositor log: {log_err}") raise e