From 9f7c1061fbb879185d266267a1afda64f687c0c1 Mon Sep 17 00:00:00 2001 From: assembledev <299203145+assembledev@users.noreply.github.com> Date: Sun, 12 Jul 2026 15:49:58 +0300 Subject: [PATCH 1/4] fix(linux): preserve actions in approval notifications --- CHANGELOG.md | 5 + Cargo.lock | 12 + Cargo.toml | 8 +- docs/agents/repository-map.md | 4 + flake.nix | 40 +++ install.sh | 2 + notification-actions-linux/Cargo.toml | 16 + notification-actions-linux/README.md | 23 ++ notification-actions-linux/src/main.rs | 274 ++++++++++++++++++ scripts/lib/notification-actions.sh | 67 +++++ scripts/lib/package-common.sh | 2 + scripts/patch-linux-window-ui.test.js | 1 + .../main-process/notifications/patch.js | 14 + .../impl/main-process/notifications.js | 225 ++++++++++++++ .../impl/main-process/notifications.test.js | 138 +++++++++ tests/scripts_smoke.sh | 27 ++ 16 files changed, 857 insertions(+), 1 deletion(-) create mode 100644 notification-actions-linux/Cargo.toml create mode 100644 notification-actions-linux/README.md create mode 100644 notification-actions-linux/src/main.rs create mode 100644 scripts/lib/notification-actions.sh create mode 100644 scripts/patches/core/all-linux/main-process/notifications/patch.js create mode 100644 scripts/patches/impl/main-process/notifications.js create mode 100644 scripts/patches/impl/main-process/notifications.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ce395a11..f0ebe103a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Fixed +- Approval notifications now preserve the upstream Approve, Approve for + session, and Decline actions on Linux. A small freedesktop notification + bridge forwards the action and close signals that Electron's Linux + notification backend does not expose, with the existing View-only Electron + notification retained as a fail-soft fallback. - Remote mobile cold starts now select one runtime owner deterministically. Explicit systemd user-service configuration takes precedence over the Desktop app-server and standalone fallback, while a versioned Desktop marker diff --git a/Cargo.lock b/Cargo.lock index 9bb99a3b5..6df8dee09 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -516,6 +516,18 @@ dependencies = [ "zbus", ] +[[package]] +name = "codex-notification-actions-linux" +version = "0.1.0" +dependencies = [ + "anyhow", + "futures-util", + "serde", + "serde_json", + "tokio", + "zbus", +] + [[package]] name = "codex-read-aloud-linux" version = "0.1.0-linux-alpha1" diff --git a/Cargo.toml b/Cargo.toml index 6e5362d25..7ef616bb4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,9 @@ [workspace] -members = ["computer-use-linux", "read-aloud-linux", "updater", "record-replay-linux"] +members = [ + "computer-use-linux", + "notification-actions-linux", + "read-aloud-linux", + "updater", + "record-replay-linux", +] resolver = "2" diff --git a/docs/agents/repository-map.md b/docs/agents/repository-map.md index f7302b1af..f3baba3af 100644 --- a/docs/agents/repository-map.md +++ b/docs/agents/repository-map.md @@ -220,6 +220,10 @@ The updater runs unprivileged and only escalates through `pkexec` for ## Computer Use, Browser, Read Aloud, And Record & Replay +- `notification-actions-linux/` + Small Rust D-Bus bridge for freedesktop notification action and close + signals. The main-process core patch uses it only for upstream notifications + that already carry actions and falls back to Electron otherwise. - `computer-use-linux/` Rust crate for Linux Computer Use MCP, Chrome native messaging host, and the COSMIC helper. It covers input, capture, accessibility, terminal, identity, diff --git a/flake.nix b/flake.nix index 92e7c71e7..318b3b70a 100644 --- a/flake.nix +++ b/flake.nix @@ -75,6 +75,17 @@ cp -R ${./computer-use-linux} "$out/computer-use-linux" chmod -R u+w "$out" ''; + notificationActionsBuildSource = pkgs.runCommandLocal "codex-notification-actions-linux-source" { } '' + mkdir -p "$out" + cp ${./Cargo.lock} "$out/Cargo.lock" + cat > "$out/Cargo.toml" <<'EOF' + [workspace] + members = ["notification-actions-linux"] + resolver = "2" + EOF + cp -R ${./notification-actions-linux} "$out/notification-actions-linux" + chmod -R u+w "$out" + ''; nativeModulesBuildSupport = pkgs.runCommandLocal "codex-native-modules-build-support" { } '' mkdir -p "$out/scripts/lib" cp ${./scripts/lib/native-modules.sh} "$out/scripts/lib/native-modules.sh" @@ -161,6 +172,33 @@ ''; }; + codexNotificationActionsBinary = pkgs.rustPlatform.buildRustPackage { + pname = "codex-notification-actions-linux"; + version = "0.1.0"; + src = notificationActionsBuildSource; + + cargoLock = { + lockFile = ./Cargo.lock; + }; + + cargoBuildFlags = [ + "-p" + "codex-notification-actions-linux" + ]; + + doCheck = true; + + installPhase = '' + runHook preInstall + release_dir="target/''${CARGO_BUILD_TARGET:-${pkgs.stdenv.hostPlatform.rust.rustcTarget}}/release" + if [ ! -d "$release_dir" ]; then + release_dir="target/release" + fi + install -Dm0755 "$release_dir/codex-notification-actions-linux" "$out/bin/codex-notification-actions-linux" + runHook postInstall + ''; + }; + codexMcpHelperReaper = pkgs.rustPlatform.buildRustPackage { pname = "codex-mcp-helper-reaper"; version = "0.1.0"; @@ -497,6 +535,7 @@ PY export CODEX_LINUX_COMPUTER_USE_BACKEND_SOURCE="${codexComputerUseBinaries}/bin/codex-computer-use-linux" export CODEX_LINUX_COMPUTER_USE_COSMIC_SOURCE="${codexComputerUseBinaries}/bin/codex-computer-use-cosmic" export CODEX_CHROME_EXTENSION_HOST_SOURCE="${codexComputerUseBinaries}/bin/codex-chrome-extension-host" + export CODEX_NOTIFICATION_ACTIONS_SOURCE="${codexNotificationActionsBinary}/bin/codex-notification-actions-linux" ${pkgs.lib.optionalString (builtins.elem "mcp-helper-reaper" linuxFeatureIds) '' export CODEX_MCP_HELPER_REAPER_SOURCE="${codexMcpHelperReaper}/bin/codex-mcp-helper-reaper" ''} @@ -719,6 +758,7 @@ PY }; checks = { + notification-actions-linux = codexNotificationActionsBinary; nix-linux-features-evaluation = import ./nix/linux-features-test.nix { inherit pkgs self system; }; diff --git a/install.sh b/install.sh index ff7472703..03e8f20b2 100755 --- a/install.sh +++ b/install.sh @@ -36,6 +36,7 @@ LINUX_ICON_SOURCE="${CODEX_LINUX_ICON_SOURCE:-}" . "$SCRIPT_DIR/scripts/lib/asar-patch.sh" . "$SCRIPT_DIR/scripts/lib/webview-install.sh" . "$SCRIPT_DIR/scripts/lib/bundled-plugins.sh" +. "$SCRIPT_DIR/scripts/lib/notification-actions.sh" . "$SCRIPT_DIR/scripts/lib/linux-features.sh" . "$SCRIPT_DIR/scripts/lib/rebuild-report.sh" . "$SCRIPT_DIR/scripts/lib/build-info.sh" @@ -341,6 +342,7 @@ main() { download_electron extract_webview "$app_dir" install_app + stage_linux_notification_actions_bridge install_bundled_plugin_resources "$app_dir" run_linux_feature_stage_hooks "$app_dir" create_start_script diff --git a/notification-actions-linux/Cargo.toml b/notification-actions-linux/Cargo.toml new file mode 100644 index 000000000..219fbef5b --- /dev/null +++ b/notification-actions-linux/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "codex-notification-actions-linux" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "codex-notification-actions-linux" +path = "src/main.rs" + +[dependencies] +anyhow = "1.0.102" +futures-util = "0.3.32" +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.149" +tokio = { version = "1.51.1", features = ["io-std", "io-util", "macros", "rt"] } +zbus = "5.14.0" diff --git a/notification-actions-linux/README.md b/notification-actions-linux/README.md new file mode 100644 index 000000000..50e753870 --- /dev/null +++ b/notification-actions-linux/README.md @@ -0,0 +1,23 @@ +# Linux Notification Actions Bridge + +`codex-notification-actions-linux` bridges the action-bearing notification +payload already produced by Codex Desktop to the freedesktop +`org.freedesktop.Notifications` interface. Electron exposes notification action +buttons on macOS and Windows but not Linux, even when the active Linux +notification server advertises the standard `actions` capability. + +The Electron main-process patch starts one short-lived bridge process per +actionable notification. The bridge accepts one JSON request on stdin, sends JSON events +for show, click, action, and close on stdout, and accepts `close` on stdin. It +does not execute commands or make approval decisions; it returns only the +selected action index to the existing upstream notification callback. + +If the session bus or notification server is unavailable, or the server does +not advertise action support, the bridge reports `unavailable` and the app +uses its existing Electron notification instead. + +Run its tests from the repository root: + +```bash +cargo test -p codex-notification-actions-linux +``` diff --git a/notification-actions-linux/src/main.rs b/notification-actions-linux/src/main.rs new file mode 100644 index 000000000..bb5c15055 --- /dev/null +++ b/notification-actions-linux/src/main.rs @@ -0,0 +1,274 @@ +use std::{collections::HashMap, io::Write}; + +use anyhow::{bail, Context, Result}; +use futures_util::StreamExt; +use serde::{Deserialize, Serialize}; +use tokio::io::{AsyncBufReadExt, BufReader}; +use zbus::{zvariant::OwnedValue, Connection, Proxy}; + +const NOTIFICATIONS_SERVICE: &str = "org.freedesktop.Notifications"; +const NOTIFICATIONS_PATH: &str = "/org/freedesktop/Notifications"; +const NOTIFICATIONS_INTERFACE: &str = "org.freedesktop.Notifications"; +const MAX_TITLE_BYTES: usize = 1024; +const MAX_BODY_BYTES: usize = 8192; +const MAX_ACTIONS: usize = 4; +const MAX_ACTION_TEXT_BYTES: usize = 256; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ShowRequest { + title: String, + body: String, + #[serde(default)] + actions: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(tag = "event", rename_all = "kebab-case")] +enum BridgeEvent<'a> { + Shown { notification_id: u32 }, + Click, + Action { index: usize }, + Closed, + Unavailable { reason: &'a str }, +} + +#[tokio::main(flavor = "current_thread")] +async fn main() { + if let Err(error) = run().await { + eprintln!("{error:#}"); + std::process::exit(1); + } +} + +async fn run() -> Result<()> { + let mut commands = BufReader::new(tokio::io::stdin()).lines(); + let request_line = commands + .next_line() + .await + .context("failed to read notification request")? + .context("notification request was not provided")?; + let request: ShowRequest = + serde_json::from_str(&request_line).context("invalid notification request")?; + validate_request(&request)?; + + let connection = match Connection::session().await { + Ok(connection) => connection, + Err(_) => { + emit(&BridgeEvent::Unavailable { + reason: "session-bus-unavailable", + })?; + return Ok(()); + } + }; + let proxy = match Proxy::new( + &connection, + NOTIFICATIONS_SERVICE, + NOTIFICATIONS_PATH, + NOTIFICATIONS_INTERFACE, + ) + .await + { + Ok(proxy) => proxy, + Err(_) => { + emit(&BridgeEvent::Unavailable { + reason: "notification-service-unavailable", + })?; + return Ok(()); + } + }; + + let capabilities: Vec = match proxy.call("GetCapabilities", &()).await { + Ok(capabilities) => capabilities, + Err(_) => { + emit(&BridgeEvent::Unavailable { + reason: "notification-capabilities-unavailable", + })?; + return Ok(()); + } + }; + if !capabilities + .iter() + .any(|capability| capability == "actions") + { + emit(&BridgeEvent::Unavailable { + reason: "notification-actions-unsupported", + })?; + return Ok(()); + } + + let mut action_stream = proxy + .receive_signal("ActionInvoked") + .await + .context("failed to subscribe to notification actions")?; + let mut closed_stream = proxy + .receive_signal("NotificationClosed") + .await + .context("failed to subscribe to notification closure")?; + let action_pairs = notification_actions(&request.actions); + let hints: HashMap = HashMap::new(); + let notification_id: u32 = proxy + .call( + "Notify", + &( + "ChatGPT", + 0u32, + "codex-desktop", + request.title.as_str(), + request.body.as_str(), + action_pairs, + hints, + 0i32, + ), + ) + .await + .context("failed to show actionable notification")?; + emit(&BridgeEvent::Shown { notification_id })?; + + loop { + tokio::select! { + message = action_stream.next() => { + let message = message.context("notification action stream ended")?; + let (id, action_key): (u32, String) = message + .body() + .deserialize() + .context("failed to decode notification action")?; + if id != notification_id { + continue; + } + if action_key == "default" { + emit(&BridgeEvent::Click)?; + } else if let Some(index) = action_index(&action_key, request.actions.len()) { + emit(&BridgeEvent::Action { index })?; + } else { + continue; + } + close_notification(&proxy, notification_id).await; + emit(&BridgeEvent::Closed)?; + return Ok(()); + } + message = closed_stream.next() => { + let message = message.context("notification close stream ended")?; + let (id, _reason): (u32, u32) = message + .body() + .deserialize() + .context("failed to decode notification closure")?; + if id == notification_id { + emit(&BridgeEvent::Closed)?; + return Ok(()); + } + } + command = commands.next_line() => { + match command.context("failed to read notification bridge command")? { + Some(command) if command == "close" => { + close_notification(&proxy, notification_id).await; + emit(&BridgeEvent::Closed)?; + return Ok(()); + } + Some(command) if command.trim().is_empty() => {} + Some(_) => bail!("unknown notification bridge command"), + None => { + close_notification(&proxy, notification_id).await; + return Ok(()); + } + } + } + } + } +} + +fn validate_request(request: &ShowRequest) -> Result<()> { + if request.title.is_empty() || request.title.len() > MAX_TITLE_BYTES { + bail!("notification title must contain between 1 and {MAX_TITLE_BYTES} bytes"); + } + if request.body.len() > MAX_BODY_BYTES { + bail!("notification body exceeds {MAX_BODY_BYTES} bytes"); + } + if request.actions.is_empty() || request.actions.len() > MAX_ACTIONS { + bail!("notification must contain between 1 and {MAX_ACTIONS} actions"); + } + if request + .actions + .iter() + .any(|action| action.is_empty() || action.len() > MAX_ACTION_TEXT_BYTES) + { + bail!("notification action text must contain between 1 and {MAX_ACTION_TEXT_BYTES} bytes"); + } + Ok(()) +} + +fn notification_actions(actions: &[String]) -> Vec { + let mut pairs = Vec::with_capacity(2 + actions.len() * 2); + pairs.push("default".to_owned()); + pairs.push("View".to_owned()); + for (index, title) in actions.iter().enumerate() { + pairs.push(format!("action-{index}")); + pairs.push(title.clone()); + } + pairs +} + +fn action_index(action_key: &str, action_count: usize) -> Option { + let index = action_key.strip_prefix("action-")?.parse::().ok()?; + (index < action_count).then_some(index) +} + +async fn close_notification(proxy: &Proxy<'_>, notification_id: u32) { + let _: Result<(), _> = proxy.call("CloseNotification", &(notification_id,)).await; +} + +fn emit(event: &BridgeEvent<'_>) -> Result<()> { + let mut stdout = std::io::stdout().lock(); + serde_json::to_writer(&mut stdout, event).context("failed to encode bridge event")?; + stdout + .write_all(b"\n") + .context("failed to write bridge event")?; + stdout.flush().context("failed to flush bridge event") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn request(actions: &[&str]) -> ShowRequest { + ShowRequest { + title: "Approval required".to_owned(), + body: "Run command?".to_owned(), + actions: actions.iter().map(|action| (*action).to_owned()).collect(), + } + } + + #[test] + fn builds_default_and_indexed_action_pairs() { + assert_eq!( + notification_actions(&request(&["Approve", "Decline"]).actions), + ["default", "View", "action-0", "Approve", "action-1", "Decline",] + ); + } + + #[test] + fn accepts_only_in_range_action_keys() { + assert_eq!(action_index("action-0", 2), Some(0)); + assert_eq!(action_index("action-1", 2), Some(1)); + assert_eq!(action_index("action-2", 2), None); + assert_eq!(action_index("default", 2), None); + assert_eq!(action_index("action-nope", 2), None); + } + + #[test] + fn rejects_empty_or_excessive_actions() { + assert!(validate_request(&request(&[])).is_err()); + assert!(validate_request(&request(&["1", "2", "3", "4", "5"])).is_err()); + } + + #[test] + fn rejects_oversized_fields() { + let mut oversized_title = request(&["Approve"]); + oversized_title.title = "x".repeat(MAX_TITLE_BYTES + 1); + assert!(validate_request(&oversized_title).is_err()); + + let mut oversized_action = request(&["Approve"]); + oversized_action.actions[0] = "x".repeat(MAX_ACTION_TEXT_BYTES + 1); + assert!(validate_request(&oversized_action).is_err()); + } +} diff --git a/scripts/lib/notification-actions.sh b/scripts/lib/notification-actions.sh new file mode 100644 index 000000000..293ebde6d --- /dev/null +++ b/scripts/lib/notification-actions.sh @@ -0,0 +1,67 @@ +#!/bin/bash +# Build and stage the native bridge used for freedesktop notification actions. +# Sourced by install.sh. Do not run directly. +# shellcheck shell=bash + +find_notification_actions_cargo() { + if command -v cargo >/dev/null 2>&1; then + command -v cargo + return 0 + fi + if [ -x "$HOME/.cargo/bin/cargo" ]; then + printf '%s\n' "$HOME/.cargo/bin/cargo" + return 0 + fi + return 1 +} + +build_linux_notification_actions_bridge() { + local source_binary="${CODEX_NOTIFICATION_ACTIONS_SOURCE:-}" + local cargo_cmd="" + + if [ -n "$source_binary" ]; then + if [ ! -x "$source_binary" ]; then + warn "Prebuilt Linux notification actions bridge is not executable: $source_binary" + return 1 + fi + printf '%s\n' "$source_binary" + return 0 + fi + + if ! cargo_cmd="$(find_notification_actions_cargo)"; then + warn "cargo not found; Linux notification action buttons will fall back to View" + return 1 + fi + + info "Building Linux notification actions bridge..." + if ! (cd "$SCRIPT_DIR" && "$cargo_cmd" build --release -p codex-notification-actions-linux >&2); then + warn "Failed to build Linux notification actions bridge; action buttons will fall back to View" + return 1 + fi + + source_binary="${CARGO_TARGET_DIR:-$SCRIPT_DIR/target}/release/codex-notification-actions-linux" + case "$source_binary" in + /*) ;; + *) source_binary="$SCRIPT_DIR/$source_binary" ;; + esac + if [ ! -x "$source_binary" ]; then + warn "Linux notification actions bridge is missing after build: $source_binary" + return 1 + fi + printf '%s\n' "$source_binary" +} + +stage_linux_notification_actions_bridge() { + local source_binary="" + local target_dir="$INSTALL_DIR/resources/native" + local target_binary="$target_dir/codex-notification-actions-linux" + + if ! source_binary="$(build_linux_notification_actions_bridge)"; then + rm -f "$target_binary" + return 0 + fi + + mkdir -p "$target_dir" + install -m 0755 "$source_binary" "$target_binary" + info "Linux notification actions bridge staged" +} diff --git a/scripts/lib/package-common.sh b/scripts/lib/package-common.sh index 2b8c3286d..471599efd 100755 --- a/scripts/lib/package-common.sh +++ b/scripts/lib/package-common.sh @@ -808,6 +808,7 @@ stage_update_builder_bundle() { cp "$REPO_DIR/Cargo.toml" "$update_builder_root/Cargo.toml" cp "$REPO_DIR/Cargo.lock" "$update_builder_root/Cargo.lock" cp -r "$REPO_DIR/computer-use-linux" "$update_builder_root/computer-use-linux" + cp -r "$REPO_DIR/notification-actions-linux" "$update_builder_root/notification-actions-linux" cp -r "$REPO_DIR/record-replay-linux" "$update_builder_root/record-replay-linux" cp -r "$REPO_DIR/read-aloud-linux" "$update_builder_root/read-aloud-linux" cp -r "$REPO_DIR/updater" "$update_builder_root/updater" @@ -834,6 +835,7 @@ stage_update_builder_bundle() { cp "$REPO_DIR/scripts/lib/asar-patch.sh" "$update_builder_root/scripts/lib/asar-patch.sh" cp "$REPO_DIR/scripts/lib/webview-install.sh" "$update_builder_root/scripts/lib/webview-install.sh" cp "$REPO_DIR/scripts/lib/bundled-plugins.sh" "$update_builder_root/scripts/lib/bundled-plugins.sh" + cp "$REPO_DIR/scripts/lib/notification-actions.sh" "$update_builder_root/scripts/lib/notification-actions.sh" cp "$REPO_DIR/scripts/lib/patch-browser-client-iab-socket-scope.js" \ "$update_builder_root/scripts/lib/patch-browser-client-iab-socket-scope.js" cp "$REPO_DIR/scripts/lib/linux-features.js" "$update_builder_root/scripts/lib/linux-features.js" diff --git a/scripts/patch-linux-window-ui.test.js b/scripts/patch-linux-window-ui.test.js index 3804bb18d..987d0a97c 100644 --- a/scripts/patch-linux-window-ui.test.js +++ b/scripts/patch-linux-window-ui.test.js @@ -940,6 +940,7 @@ test("default core patch descriptors are grouped and unique", () => { "linux-bundled-plugin-copy-permissions", "linux-browser-use-route-liveness", "linux-chrome-extension-status", + "linux-notification-actions", "linux-local-app-server-feature-enablement-handler", "linux-remote-control-config-preservation", "linux-app-updater-menu", diff --git a/scripts/patches/core/all-linux/main-process/notifications/patch.js b/scripts/patches/core/all-linux/main-process/notifications/patch.js new file mode 100644 index 000000000..14d25a0a5 --- /dev/null +++ b/scripts/patches/core/all-linux/main-process/notifications/patch.js @@ -0,0 +1,14 @@ +"use strict"; + +const { mainBundlePatch } = require("../../../../descriptor.js"); +const { + applyLinuxNotificationActionsPatch, +} = require("../../../../impl/main-process/notifications.js"); + +module.exports = mainBundlePatch({ + id: "linux-notification-actions", + phase: "main-bundle", + order: 205, + ciPolicy: "optional", + apply: applyLinuxNotificationActionsPatch, +}); diff --git a/scripts/patches/impl/main-process/notifications.js b/scripts/patches/impl/main-process/notifications.js new file mode 100644 index 000000000..4cafcf8f7 --- /dev/null +++ b/scripts/patches/impl/main-process/notifications.js @@ -0,0 +1,225 @@ +"use strict"; + +const PATCH_MARKER = "codex-linux-notification-actions-v1"; + +function codexLinuxNotificationActionsNativePath() { + const fs = require("node:fs"); + const path = require("node:path"); + const candidates = []; + if (process.resourcesPath) { + candidates.push(path.join(process.resourcesPath, "native", "codex-notification-actions-linux")); + } + try { + const appPath = require("electron").app?.getAppPath?.(); + if (appPath) { + candidates.push(path.join(appPath, "native", "codex-notification-actions-linux")); + } + } catch {} + for (const candidate of candidates) { + try { + fs.accessSync(candidate, fs.constants.X_OK); + return candidate; + } catch {} + } + return null; +} + +function codexLinuxNotificationActionLines(stream, onLine) { + let buffer = ""; + stream?.on("data", (chunk) => { + buffer += chunk.toString("utf8"); + if (buffer.length > 65536) { + buffer = ""; + return; + } + let newline = buffer.indexOf("\n"); + while (newline !== -1) { + const line = buffer.slice(0, newline).trim(); + buffer = buffer.slice(newline + 1); + if (line !== "") onLine(line); + newline = buffer.indexOf("\n"); + } + }); +} + +function codexLinuxCreateActionNotification(options, fallbackFactory, runtime) { + const bridgePath = runtime?.bridgePath ?? codexLinuxNotificationActionsNativePath(); + if (bridgePath == null) return null; + + const handlers = new Map(); + let child = null; + let fallback = null; + let mode = "idle"; + let shown = false; + let closed = false; + let stderr = ""; + + const callHandler = (event, ...args) => { + try { + handlers.get(event)?.(...args); + } catch (error) { + console.warn("[linux-notification-actions] notification callback failed", error); + } + }; + const emitClose = () => { + if (closed) return; + closed = true; + callHandler("close", undefined); + }; + const startFallback = () => { + if (closed || mode === "fallback") return; + mode = "fallback"; + try { + child?.kill(); + } catch {} + fallback = fallbackFactory(); + for (const [event, handler] of handlers) fallback.on(event, handler); + fallback.show(); + }; + const handleLine = (line) => { + let event; + try { + event = JSON.parse(line); + } catch { + return; + } + if (mode !== "bridge") return; + switch (event?.event) { + case "shown": + shown = true; + return; + case "click": + callHandler("click", undefined); + return; + case "action": + if (Number.isInteger(event.index) && event.index >= 0 && event.index < options.actions.length) { + callHandler("action", undefined, event.index); + } + return; + case "closed": + emitClose(); + return; + case "unavailable": + startFallback(); + return; + } + }; + + return { + show: () => { + if (closed || mode !== "idle") return; + mode = "bridge"; + try { + const spawn = runtime?.spawn ?? require("node:child_process").spawn; + child = spawn(bridgePath, [], { + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + }); + } catch { + startFallback(); + return; + } + codexLinuxNotificationActionLines(child.stdout, handleLine); + child.stdin?.on?.("error", () => { + if (mode !== "bridge" || closed) return; + if (!shown) startFallback(); + else emitClose(); + }); + child.stderr?.on("data", (chunk) => { + stderr = (stderr + chunk.toString("utf8")).slice(-4096); + }); + child.once("error", () => { + if (!shown) startFallback(); + else emitClose(); + }); + child.once("exit", (code) => { + if (mode !== "bridge" || closed) return; + if (!shown) { + if (stderr.trim()) { + console.warn("[linux-notification-actions] bridge unavailable; using Electron notification"); + } + startFallback(); + } else { + if (code !== 0 && stderr.trim()) { + console.warn("[linux-notification-actions] bridge exited unexpectedly"); + } + emitClose(); + } + }); + child.stdin.write( + `${JSON.stringify({ + title: String(options.title ?? ""), + body: String(options.body ?? ""), + actions: options.actions.map((action) => String(action?.text ?? "")), + })}\n`, + ); + }, + on: (event, handler) => { + handlers.set(event, handler); + }, + close: () => { + if (closed) return; + if (mode === "fallback") { + fallback?.close(); + closed = true; + return; + } + closed = true; + try { + child?.stdin?.end("close\n"); + } catch {} + }, + }; +} + +function bridgeSource() { + return [ + `var codexLinuxNotificationActionsPatch=${JSON.stringify(PATCH_MARKER)};`, + codexLinuxNotificationActionsNativePath, + codexLinuxNotificationActionLines, + codexLinuxCreateActionNotification, + ] + .map(String) + .join(""); +} + +function applyLinuxNotificationActionsPatch(source) { + if (source.includes(PATCH_MARKER)) return source; + + const factoryPattern = + /e\.createNotification\?this\.createNotification=e\.createNotification:this\.createNotification=e=>\{let ([A-Za-z_$][\w$]*)=new ([A-Za-z_$][\w$]*)\.Notification\(e\);return\{show:\(\)=>\1\.show\(\),on:\(e,n\)=>\{switch\(e\)\{case`action`:return \1\.on\(`action`,\(e,t\)=>\{n\(e,t\)\}\);case`click`:return \1\.on\(`click`,\(\)=>\{n\(void 0\)\}\);case`close`:return \1\.on\(`close`,\(\)=>\{n\(void 0\)\}\)\}\},close:\(\)=>\1\.close\(\)\}\}/u; + const match = factoryPattern.exec(source); + if (match == null) { + console.warn( + "WARN: Could not find desktop notification factory - skipping Linux notification actions patch", + ); + return source; + } + + const originalFactory = match[0]; + const electronAlias = match[2]; + const fallbackBody = originalFactory.slice(originalFactory.indexOf("e=>") + 3); + const replacement = + "e.createNotification?this.createNotification=e.createNotification:this.createNotification=e=>{" + + `let codexLinuxNotificationFallback=()=>${fallbackBody};` + + "if(process.platform===`linux`&&Array.isArray(e.actions)&&e.actions.length>0){" + + "let t=codexLinuxCreateActionNotification(e,codexLinuxNotificationFallback);if(t!=null)return t}" + + "return codexLinuxNotificationFallback()}"; + const patched = `${bridgeSource()}${source.replace(factoryPattern, replacement)}`; + + if ( + !patched.includes("codexLinuxCreateActionNotification(e,codexLinuxNotificationFallback)") || + !patched.includes(`new ${electronAlias}.Notification(e)`) + ) { + console.warn("WARN: Linux notification actions patch verification failed"); + return source; + } + return patched; +} + +module.exports = { + PATCH_MARKER, + applyLinuxNotificationActionsPatch, + codexLinuxCreateActionNotification, + codexLinuxNotificationActionLines, +}; diff --git a/scripts/patches/impl/main-process/notifications.test.js b/scripts/patches/impl/main-process/notifications.test.js new file mode 100644 index 000000000..5435d3b2b --- /dev/null +++ b/scripts/patches/impl/main-process/notifications.test.js @@ -0,0 +1,138 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert/strict"); +const { EventEmitter } = require("node:events"); +const test = require("node:test"); + +const { + PATCH_MARKER, + applyLinuxNotificationActionsPatch, + codexLinuxCreateActionNotification, + codexLinuxNotificationActionLines, +} = require("./notifications.js"); + +const currentFactory = + "e.createNotification?this.createNotification=e.createNotification:this.createNotification=e=>{let t=new c.Notification(e);return{show:()=>t.show(),on:(e,n)=>{switch(e){case`action`:return t.on(`action`,(e,t)=>{n(e,t)});case`click`:return t.on(`click`,()=>{n(void 0)});case`close`:return t.on(`close`,()=>{n(void 0)})}},close:()=>t.close()}}"; + +test("Linux notification actions patch routes action payloads through the bridge", () => { + const patched = applyLinuxNotificationActionsPatch(`before;${currentFactory};after`); + + assert.match(patched, new RegExp(PATCH_MARKER)); + assert.match( + patched, + /process\.platform===`linux`&&Array\.isArray\(e\.actions\)&&e\.actions\.length>0/, + ); + assert.match(patched, /codexLinuxCreateActionNotification\(e,codexLinuxNotificationFallback\)/); + assert.match(patched, /new c\.Notification\(e\)/); +}); + +test("Linux notification actions patch is idempotent", () => { + const patched = applyLinuxNotificationActionsPatch(currentFactory); + assert.equal(applyLinuxNotificationActionsPatch(patched), patched); +}); + +test("Linux notification actions patch reports current upstream drift", () => { + const warnings = []; + const originalWarn = console.warn; + console.warn = (message) => warnings.push(String(message)); + try { + assert.equal(applyLinuxNotificationActionsPatch("no notification factory"), "no notification factory"); + } finally { + console.warn = originalWarn; + } + assert.deepEqual(warnings, [ + "WARN: Could not find desktop notification factory - skipping Linux notification actions patch", + ]); +}); + +test("notification bridge line parser handles chunked events", () => { + const stream = new EventEmitter(); + const lines = []; + codexLinuxNotificationActionLines(stream, (line) => lines.push(line)); + + stream.emit("data", Buffer.from('{"event":"sho')); + stream.emit("data", Buffer.from('wn"}\n\n{"event":"action","index":1}\n')); + + assert.deepEqual(lines, ['{"event":"shown"}', '{"event":"action","index":1}']); +}); + +function fakeBridgeProcess() { + const child = new EventEmitter(); + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.stdin = { + writes: [], + ended: false, + write(value) { + this.writes.push(value); + }, + end(value) { + if (value != null) this.writes.push(value); + this.ended = true; + }, + }; + child.kill = () => {}; + return child; +} + +test("action notification keeps the bridge command channel open and forwards an action index", () => { + const child = fakeBridgeProcess(); + const actions = []; + let fallbackStarted = false; + const notification = codexLinuxCreateActionNotification( + { + title: "Command approval", + body: "Run command?", + actions: [{ text: "Approve" }, { text: "Decline" }], + }, + () => { + fallbackStarted = true; + return null; + }, + { bridgePath: "/test/bridge", spawn: () => child }, + ); + notification.on("action", (_event, index) => actions.push(index)); + notification.show(); + + assert.equal(child.stdin.ended, false); + assert.equal(child.stdin.writes.length, 1); + assert.deepEqual(JSON.parse(child.stdin.writes[0]), { + title: "Command approval", + body: "Run command?", + actions: ["Approve", "Decline"], + }); + + child.stdout.emit("data", Buffer.from('{"event":"shown","notification_id":42}\n')); + child.stdout.emit("data", Buffer.from('{"event":"action","index":1}\n')); + assert.deepEqual(actions, [1]); + assert.equal(fallbackStarted, false); +}); + +test("action notification falls back before the bridge is shown", () => { + const child = fakeBridgeProcess(); + const fallbackEvents = []; + const fallback = { + on(event) { + fallbackEvents.push(`on:${event}`); + }, + show() { + fallbackEvents.push("show"); + }, + close() {}, + }; + const notification = codexLinuxCreateActionNotification( + { title: "Approval", body: "Required", actions: [{ text: "Approve" }] }, + () => fallback, + { bridgePath: "/test/bridge", spawn: () => child }, + ); + notification.on("click", () => {}); + notification.on("close", () => {}); + notification.show(); + child.stdout.emit( + "data", + Buffer.from('{"event":"unavailable","reason":"notification-actions-unsupported"}\n'), + ); + + assert.deepEqual(fallbackEvents, ["on:click", "on:close", "show"]); +}); diff --git a/tests/scripts_smoke.sh b/tests/scripts_smoke.sh index 7ad2d098c..12179fc01 100755 --- a/tests/scripts_smoke.sh +++ b/tests/scripts_smoke.sh @@ -635,6 +635,7 @@ SCRIPT assert_file_exists "$pkg_root/opt/codex-desktop/update-builder/scripts/lib/build-info.sh" assert_file_exists "$pkg_root/opt/codex-desktop/update-builder/scripts/lib/linux-features.js" assert_file_exists "$pkg_root/opt/codex-desktop/update-builder/scripts/lib/linux-features.sh" + assert_file_exists "$pkg_root/opt/codex-desktop/update-builder/scripts/lib/notification-actions.sh" assert_file_exists "$pkg_root/opt/codex-desktop/update-builder/scripts/lib/linux-target-context.js" assert_file_exists "$pkg_root/opt/codex-desktop/update-builder/scripts/patches/descriptor.js" assert_file_exists "$pkg_root/opt/codex-desktop/update-builder/scripts/patches/engine.js" @@ -655,6 +656,7 @@ SCRIPT assert_file_exists "$pkg_root/opt/codex-desktop/update-builder/Cargo.toml" assert_file_exists "$pkg_root/opt/codex-desktop/update-builder/CHANGELOG.md" assert_file_exists "$pkg_root/opt/codex-desktop/update-builder/computer-use-linux/Cargo.toml" + assert_file_exists "$pkg_root/opt/codex-desktop/update-builder/notification-actions-linux/Cargo.toml" assert_file_not_exists "$pkg_root/opt/codex-desktop/update-builder/global-dictation-linux/Cargo.toml" assert_file_exists "$pkg_root/opt/codex-desktop/update-builder/read-aloud-linux/Cargo.toml" assert_file_exists "$pkg_root/opt/codex-desktop/update-builder/updater/Cargo.toml" @@ -804,6 +806,7 @@ JSON assert_not_contains "$staged_config" "localComment" assert_not_contains "$staged_config" "disabled-feature" assert_contains "$update_builder_manifest" "record-replay-linux/Cargo.toml" + assert_contains "$update_builder_manifest" "notification-actions-linux/Cargo.toml" assert_contains "$update_builder_manifest" "global-dictation-linux/Cargo.toml" assert_contains "$update_builder_manifest" "assets/codex-linux.png" assert_not_contains "$update_builder_manifest" "^node-runtime/" @@ -9038,6 +9041,29 @@ EOF ) } +test_notification_actions_bridge_accepts_prebuilt_binary() { + local workspace="$TMP_DIR/notification-actions-bridge" + local source_binary="$workspace/prebuilt/codex-notification-actions-linux" + local install_dir="$workspace/codex-app" + local target_binary="$install_dir/resources/native/codex-notification-actions-linux" + + mkdir -p "$(dirname "$source_binary")" "$install_dir/resources/native" + cp "$TRUE_BIN" "$source_binary" + chmod 0755 "$source_binary" + + ( + export SCRIPT_DIR="$REPO_DIR" + export INSTALL_DIR="$install_dir" + export CODEX_NOTIFICATION_ACTIONS_SOURCE="$source_binary" + # shellcheck disable=SC1091 + source "$REPO_DIR/scripts/lib/notification-actions.sh" + stage_linux_notification_actions_bridge + ) + + assert_file_exists "$target_binary" + assert_mode "$target_binary" "755" +} + main() { test_common_helper_sourcing test_package_icon_source_resolution @@ -9120,6 +9146,7 @@ main() { test_native_module_rebuild_uses_local_electron_rebuild_toolchain test_native_module_rebuild_accepts_prebuilt_source test_bundled_plugin_builders_accept_prebuilt_binaries + test_notification_actions_bridge_accepts_prebuilt_binary test_bundled_plugin_system_computer_use_preserves_cosmic_helper_name test_browser_use_node_repl_fallback_runtime test_browser_use_file_url_policy_patch_behavior From 3ebda1ec04633b9e593df6a9c46981296f607213 Mon Sep 17 00:00:00 2001 From: assembledev <299203145+assembledev@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:16:02 +0300 Subject: [PATCH 2/4] fix(linux): preserve bridge close events --- .../impl/main-process/notifications.js | 9 ++++--- .../impl/main-process/notifications.test.js | 27 +++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/scripts/patches/impl/main-process/notifications.js b/scripts/patches/impl/main-process/notifications.js index 4cafcf8f7..95a484c41 100644 --- a/scripts/patches/impl/main-process/notifications.js +++ b/scripts/patches/impl/main-process/notifications.js @@ -52,6 +52,7 @@ function codexLinuxCreateActionNotification(options, fallbackFactory, runtime) { let mode = "idle"; let shown = false; let closed = false; + let closeRequested = false; let stderr = ""; const callHandler = (event, ...args) => { @@ -158,16 +159,18 @@ function codexLinuxCreateActionNotification(options, fallbackFactory, runtime) { handlers.set(event, handler); }, close: () => { - if (closed) return; + if (closed || closeRequested) return; if (mode === "fallback") { fallback?.close(); closed = true; return; } - closed = true; + closeRequested = true; try { child?.stdin?.end("close\n"); - } catch {} + } catch { + emitClose(); + } }, }; } diff --git a/scripts/patches/impl/main-process/notifications.test.js b/scripts/patches/impl/main-process/notifications.test.js index 5435d3b2b..6130ded80 100644 --- a/scripts/patches/impl/main-process/notifications.test.js +++ b/scripts/patches/impl/main-process/notifications.test.js @@ -136,3 +136,30 @@ test("action notification falls back before the bridge is shown", () => { assert.deepEqual(fallbackEvents, ["on:click", "on:close", "show"]); }); + +test("programmatic close waits for the bridge close event and forwards it once", () => { + const child = fakeBridgeProcess(); + let closeCount = 0; + const notification = codexLinuxCreateActionNotification( + { title: "Approval", body: "Required", actions: [{ text: "Approve" }] }, + () => null, + { bridgePath: "/test/bridge", spawn: () => child }, + ); + notification.on("close", () => { + closeCount += 1; + }); + notification.show(); + child.stdout.emit("data", Buffer.from('{"event":"shown","notification_id":42}\n')); + + notification.close(); + notification.close(); + + assert.equal(child.stdin.ended, true); + assert.equal(child.stdin.writes.at(-1), "close\n"); + assert.equal(closeCount, 0); + + child.stdout.emit("data", Buffer.from('{"event":"closed"}\n')); + child.stdout.emit("data", Buffer.from('{"event":"closed"}\n')); + + assert.equal(closeCount, 1); +}); From 69063c225ae83fd28071ea3746a039a3cfc744a7 Mon Sep 17 00:00:00 2001 From: Gary Lysenko Date: Sun, 12 Jul 2026 16:53:16 +0300 Subject: [PATCH 3/4] fix(linux): harden notification bridge shutdown --- flake.nix | 5 ++ .../impl/main-process/notifications.js | 14 +++- .../impl/main-process/notifications.test.js | 64 +++++++++++++++++++ 3 files changed, 82 insertions(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index 318b3b70a..26ceee3c2 100644 --- a/flake.nix +++ b/flake.nix @@ -739,6 +739,7 @@ PY cd "$source_dir" export CODEX_INSTALL_DIR="''${CODEX_INSTALL_DIR:-$root_dir/codex-app}" export CODEX_MANAGED_NODE_SOURCE="${pkgs.nodejs}" + export CODEX_NOTIFICATION_ACTIONS_SOURCE="${codexNotificationActionsBinary}/bin/codex-notification-actions-linux" ${pkgs.bash}/bin/bash "$source_dir/install.sh" "$source_dir/Codex.dmg" "$@" install_dir="''${CODEX_INSTALL_DIR:-$root_dir/codex-app}" @@ -759,6 +760,10 @@ PY checks = { notification-actions-linux = codexNotificationActionsBinary; + notification-actions-installer = pkgs.runCommand "codex-notification-actions-installer-check" { } '' + grep -F 'CODEX_NOTIFICATION_ACTIONS_SOURCE=' ${installer}/bin/codex-desktop-installer >/dev/null + touch "$out" + ''; nix-linux-features-evaluation = import ./nix/linux-features-test.nix { inherit pkgs self system; }; diff --git a/scripts/patches/impl/main-process/notifications.js b/scripts/patches/impl/main-process/notifications.js index 95a484c41..38419ceaf 100644 --- a/scripts/patches/impl/main-process/notifications.js +++ b/scripts/patches/impl/main-process/notifications.js @@ -69,6 +69,13 @@ function codexLinuxCreateActionNotification(options, fallbackFactory, runtime) { }; const startFallback = () => { if (closed || mode === "fallback") return; + if (closeRequested) { + try { + child?.kill(); + } catch {} + emitClose(); + return; + } mode = "fallback"; try { child?.kill(); @@ -85,6 +92,7 @@ function codexLinuxCreateActionNotification(options, fallbackFactory, runtime) { return; } if (mode !== "bridge") return; + if (closeRequested && event?.event !== "closed" && event?.event !== "unavailable") return; switch (event?.event) { case "shown": shown = true; @@ -108,7 +116,7 @@ function codexLinuxCreateActionNotification(options, fallbackFactory, runtime) { return { show: () => { - if (closed || mode !== "idle") return; + if (closed || closeRequested || mode !== "idle") return; mode = "bridge"; try { const spawn = runtime?.spawn ?? require("node:child_process").spawn; @@ -166,6 +174,10 @@ function codexLinuxCreateActionNotification(options, fallbackFactory, runtime) { return; } closeRequested = true; + if (mode === "idle") { + emitClose(); + return; + } try { child?.stdin?.end("close\n"); } catch { diff --git a/scripts/patches/impl/main-process/notifications.test.js b/scripts/patches/impl/main-process/notifications.test.js index 6130ded80..88dbbddfb 100644 --- a/scripts/patches/impl/main-process/notifications.test.js +++ b/scripts/patches/impl/main-process/notifications.test.js @@ -163,3 +163,67 @@ test("programmatic close waits for the bridge close event and forwards it once", assert.equal(closeCount, 1); }); + +test("close before show prevents both bridge and fallback notifications", () => { + let spawnCount = 0; + let fallbackCount = 0; + let closeCount = 0; + const notification = codexLinuxCreateActionNotification( + { title: "Approval", body: "Required", actions: [{ text: "Approve" }] }, + () => { + fallbackCount += 1; + return null; + }, + { + bridgePath: "/test/bridge", + spawn: () => { + spawnCount += 1; + return fakeBridgeProcess(); + }, + }, + ); + notification.on("close", () => { + closeCount += 1; + }); + + notification.close(); + notification.show(); + + assert.equal(spawnCount, 0); + assert.equal(fallbackCount, 0); + assert.equal(closeCount, 1); +}); + +test("close while the bridge starts prevents a later fallback", () => { + const child = fakeBridgeProcess(); + let actionCount = 0; + let fallbackCount = 0; + let closeCount = 0; + const notification = codexLinuxCreateActionNotification( + { title: "Approval", body: "Required", actions: [{ text: "Approve" }] }, + () => { + fallbackCount += 1; + return null; + }, + { bridgePath: "/test/bridge", spawn: () => child }, + ); + notification.on("close", () => { + closeCount += 1; + }); + notification.on("action", () => { + actionCount += 1; + }); + notification.show(); + + notification.close(); + child.stdout.emit("data", Buffer.from('{"event":"action","index":0}\n')); + child.stdout.emit( + "data", + Buffer.from('{"event":"unavailable","reason":"notification-actions-unsupported"}\n'), + ); + child.emit("exit", 0); + + assert.equal(fallbackCount, 0); + assert.equal(actionCount, 0); + assert.equal(closeCount, 1); +}); From 1cdba32db895735c9de37d9b2869116d90768e8a Mon Sep 17 00:00:00 2001 From: Gary Lysenko Date: Sun, 12 Jul 2026 17:11:44 +0300 Subject: [PATCH 4/4] fix(linux): drain notification bridge output --- .../impl/main-process/notifications.js | 11 +++--- .../impl/main-process/notifications.test.js | 35 +++++++++++++++++++ 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/scripts/patches/impl/main-process/notifications.js b/scripts/patches/impl/main-process/notifications.js index 38419ceaf..f0140d54d 100644 --- a/scripts/patches/impl/main-process/notifications.js +++ b/scripts/patches/impl/main-process/notifications.js @@ -129,11 +129,10 @@ function codexLinuxCreateActionNotification(options, fallbackFactory, runtime) { return; } codexLinuxNotificationActionLines(child.stdout, handleLine); - child.stdin?.on?.("error", () => { - if (mode !== "bridge" || closed) return; - if (!shown) startFallback(); - else emitClose(); - }); + // An EPIPE can arrive before the child stdout stream is fully drained. + // Keep the listener to avoid an unhandled stream error, but let the + // ChildProcess "close" event make the lifecycle decision after stdio closes. + child.stdin?.on?.("error", () => {}); child.stderr?.on("data", (chunk) => { stderr = (stderr + chunk.toString("utf8")).slice(-4096); }); @@ -141,7 +140,7 @@ function codexLinuxCreateActionNotification(options, fallbackFactory, runtime) { if (!shown) startFallback(); else emitClose(); }); - child.once("exit", (code) => { + child.once("close", (code) => { if (mode !== "bridge" || closed) return; if (!shown) { if (stderr.trim()) { diff --git a/scripts/patches/impl/main-process/notifications.test.js b/scripts/patches/impl/main-process/notifications.test.js index 88dbbddfb..e4cfe757c 100644 --- a/scripts/patches/impl/main-process/notifications.test.js +++ b/scripts/patches/impl/main-process/notifications.test.js @@ -227,3 +227,38 @@ test("close while the bridge starts prevents a later fallback", () => { assert.equal(actionCount, 0); assert.equal(closeCount, 1); }); + +test("process exit waits for buffered stdout before deciding fallback", () => { + const child = fakeBridgeProcess(); + const actions = []; + let closeCount = 0; + let fallbackCount = 0; + const notification = codexLinuxCreateActionNotification( + { title: "Approval", body: "Required", actions: [{ text: "Approve" }] }, + () => { + fallbackCount += 1; + return null; + }, + { bridgePath: "/test/bridge", spawn: () => child }, + ); + notification.on("action", (_event, index) => actions.push(index)); + notification.on("close", () => { + closeCount += 1; + }); + notification.show(); + + child.emit("exit", 0); + assert.equal(fallbackCount, 0); + + child.stdout.emit( + "data", + Buffer.from( + '{"event":"shown","notification_id":42}\n{"event":"action","index":0}\n{"event":"closed"}\n', + ), + ); + child.emit("close", 0); + + assert.deepEqual(actions, [0]); + assert.equal(closeCount, 1); + assert.equal(fallbackCount, 0); +});