diff --git a/.github/workflows/linux-startup-tests.yml b/.github/workflows/linux-startup-tests.yml new file mode 100644 index 00000000..0cb00ced --- /dev/null +++ b/.github/workflows/linux-startup-tests.yml @@ -0,0 +1,25 @@ +name: Linux startup regression tests + +on: + pull_request: + push: + branches: [main, 'fix/**'] + +permissions: + contents: read + +jobs: + linux-startup: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: actions/setup-node@v4 + with: + node-version: '22' + - name: Test GTK module filtering + run: | + rustc --edition=2021 --test src-tauri/src/linux_gtk.rs -o "$RUNNER_TEMP/linux-gtk-tests" + "$RUNNER_TEMP/linux-gtk-tests" + - name: Test the Linux entry point before Tauri initialization + run: node --experimental-strip-types --test tests/linuxGtkStartup.test.ts diff --git a/src-tauri/src/linux_gtk.rs b/src-tauri/src/linux_gtk.rs new file mode 100644 index 00000000..0858428e --- /dev/null +++ b/src-tauri/src/linux_gtk.rs @@ -0,0 +1,127 @@ +use std::ffi::{OsStr, OsString}; +use std::os::unix::ffi::{OsStrExt, OsStringExt}; + +pub fn without_appmenu_module(value: &OsStr) -> Option { + let modules: Vec<_> = value.as_bytes().split(|byte| *byte == b':').collect(); + let filtered: Vec<_> = modules + .iter() + .copied() + .filter(|module| { + let name = module + .trim_ascii() + .rsplit(|byte| *byte == b'/') + .next() + .unwrap_or_default(); + !matches!( + name, + b"appmenu-gtk-module" + | b"appmenu-gtk-module.so" + | b"libappmenu-gtk-module" + | b"libappmenu-gtk-module.so" + ) + }) + .collect(); + + (filtered.len() != modules.len()).then(|| OsString::from_vec(filtered.join(&b':'))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn preserves_accessibility_modules_from_reported_environment() { + assert_eq!( + without_appmenu_module(OsStr::new("gail:atk-bridge:appmenu-gtk-module")), + Some(OsString::from("gail:atk-bridge")) + ); + } + + #[test] + fn removes_module_at_any_position() { + for value in [ + "appmenu-gtk-module:gail:atk-bridge", + "gail:appmenu-gtk-module:atk-bridge", + "gail:atk-bridge:appmenu-gtk-module", + ] { + assert_eq!( + without_appmenu_module(OsStr::new(value)), + Some(OsString::from("gail:atk-bridge")) + ); + } + } + + #[test] + fn recognizes_library_names_and_paths() { + for module in [ + "appmenu-gtk-module", + "appmenu-gtk-module.so", + "libappmenu-gtk-module", + "libappmenu-gtk-module.so", + "/usr/lib/x86_64-linux-gnu/gtk-3.0/modules/libappmenu-gtk-module.so", + "./modules/libappmenu-gtk-module.so", + " \tappmenu-gtk-module \t", + ] { + let value = format!("gail:{module}:atk-bridge"); + assert_eq!( + without_appmenu_module(OsStr::new(&value)), + Some(OsString::from("gail:atk-bridge")), + "{module}" + ); + } + } + + #[test] + fn removes_all_occurrences_including_module_only_lists() { + assert_eq!( + without_appmenu_module(OsStr::new("appmenu-gtk-module:libappmenu-gtk-module.so")), + Some(OsString::new()) + ); + assert_eq!( + without_appmenu_module(OsStr::new("appmenu-gtk-module:gail:appmenu-gtk-module")), + Some(OsString::from("gail")) + ); + } + + #[test] + fn leaves_unrelated_and_empty_values_unchanged() { + for value in [ + "", + "gail:atk-bridge", + ":gail::atk-bridge:", + "my-appmenu-gtk-module:appmenu-gtk-module-extra", + "libappmenu-gtk-module.so.backup", + "/opt/appmenu-gtk-module/other.so", + ] { + assert_eq!(without_appmenu_module(OsStr::new(value)), None, "{value}"); + } + } + + #[test] + fn preserves_remaining_entries_byte_for_byte() { + assert_eq!( + without_appmenu_module(OsStr::new(":gail::appmenu-gtk-module: atk-bridge :")), + Some(OsString::from(":gail:: atk-bridge :")) + ); + } + + #[test] + fn preserves_non_utf8_module_paths() { + assert_eq!( + without_appmenu_module(OsStr::from_bytes( + b"/opt/\xff/other.so:appmenu-gtk-module:atk-bridge" + )), + Some(OsString::from_vec(b"/opt/\xff/other.so:atk-bridge".to_vec())) + ); + assert_eq!( + without_appmenu_module(OsStr::from_bytes(b"/opt/\xff/other.so")), + None + ); + } + + #[test] + fn filtering_is_idempotent() { + let filtered = without_appmenu_module(OsStr::new("gail:appmenu-gtk-module")).unwrap(); + assert_eq!(without_appmenu_module(&filtered), None); + } +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index ac44cfa3..1c959637 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,6 +1,19 @@ // Prevents additional console window on Windows in release, DO NOT REMOVE!! #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +#[cfg(target_os = "linux")] +mod linux_gtk; + fn main() { + #[cfg(target_os = "linux")] + for variable in ["GTK_MODULES", "GTK3_MODULES"] { + if let Some(filtered) = std::env::var_os(variable) + .as_deref() + .and_then(linux_gtk::without_appmenu_module) + { + unsafe { std::env::set_var(variable, filtered) }; + } + } + codex_switcher_lib::run() } diff --git a/tests/linuxGtkStartup.test.ts b/tests/linuxGtkStartup.test.ts new file mode 100644 index 00000000..d3d370a4 --- /dev/null +++ b/tests/linuxGtkStartup.test.ts @@ -0,0 +1,103 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +const probeSource = ` +pub fn run() { + for name in ["GTK_MODULES", "GTK3_MODULES", "GDK_BACKEND"] { + match std::env::var_os(name) { + Some(value) => println!("{}={}", name, value.to_string_lossy()), + None => println!("{}=", name), + } + } +} +`; + +test("Linux entry point filters modules before starting Tauri", { + skip: process.platform !== "linux", +}, async (t) => { + const directory = mkdtempSync(join(tmpdir(), "codex-switcher-startup-")); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const probe = join(directory, "probe.rs"); + const library = join(directory, "libcodex_switcher_lib.rlib"); + const executable = join(directory, "startup"); + const main = fileURLToPath(new URL("../src-tauri/src/main.rs", import.meta.url)); + writeFileSync(probe, probeSource); + + for (const args of [ + ["--edition=2021", "--crate-type=rlib", "--crate-name=codex_switcher_lib", probe, "-o", library], + ["--edition=2021", main, "--extern", `codex_switcher_lib=${library}`, "-o", executable], + ]) { + const result = spawnSync(process.env.RUSTC ?? "rustc", args, { encoding: "utf8" }); + assert.ifError(result.error); + assert.equal(result.status, 0, result.stderr); + } + + const cases: { + name: string; + modules?: string; + gtk3?: string; + expectedModules?: string; + expectedGtk3?: string; + }[] = [ + { name: "leaves unset variables unset" }, + { name: "preserves explicitly empty values", modules: "", gtk3: "", expectedModules: "", expectedGtk3: "" }, + { + name: "handles the reported Ubuntu environment", + modules: "gail:atk-bridge:appmenu-gtk-module", + expectedModules: "gail:atk-bridge", + }, + { + name: "filters GTK3_MODULES independently", + gtk3: "appmenu-gtk-module:canberra-gtk-module", + expectedGtk3: "canberra-gtk-module", + }, + { + name: "filters both variables before the library starts", + modules: "appmenu-gtk-module:gail", + gtk3: "atk-bridge:libappmenu-gtk-module.so", + expectedModules: "gail", + expectedGtk3: "atk-bridge", + }, + { + name: "removes duplicate and module-only entries", + modules: "appmenu-gtk-module:appmenu-gtk-module", + expectedModules: "", + }, + { + name: "accepts an absolute library path", + modules: "gail:/usr/lib/x86_64-linux-gnu/gtk-3.0/modules/libappmenu-gtk-module.so", + expectedModules: "gail", + }, + { + name: "preserves unrelated module names and separators", + modules: ":gail::my-appmenu-gtk-module:atk-bridge:", + gtk3: "canberra-gtk-module", + expectedModules: ":gail::my-appmenu-gtk-module:atk-bridge:", + expectedGtk3: "canberra-gtk-module", + }, + ]; + + for (const { name, modules, gtk3, expectedModules, expectedGtk3 } of cases) { + await t.test(name, () => { + const env: Record = { ...process.env, GDK_BACKEND: "wayland" }; + delete env.GTK_MODULES; + delete env.GTK3_MODULES; + if (modules !== undefined) env.GTK_MODULES = modules; + if (gtk3 !== undefined) env.GTK3_MODULES = gtk3; + const result = spawnSync(executable, [], { env, encoding: "utf8" }); + assert.ifError(result.error); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, [ + `GTK_MODULES=${expectedModules ?? ""}`, + `GTK3_MODULES=${expectedGtk3 ?? ""}`, + "GDK_BACKEND=wayland", + "", + ].join("\n")); + }); + } +});