From 50e4568d0bed090a72a5f6cb0254a538f70c48a6 Mon Sep 17 00:00:00 2001 From: TempLinux <847964640@qq.com> Date: Sat, 14 Nov 2026 14:00:32 +0800 Subject: [PATCH 1/3] fix: bundle AppImage media plugins WebKit discovers GStreamer plugins at runtime, so ELF dependency deployment omits the media elements needed by HTML previews. Bundle compatible plugins, their codec dependencies and the scanner, and relocate their search paths. Exercise Web Audio initialization and video frame decoding using the bundled WebKit runtime in both stable and Canary package smoke tests. Closes #1032 Assisted-by: Codex --- .github/workflows/release-canary.yml | 8 ++-- .github/workflows/release-linux-app.yml | 13 ++++-- arc/desktop.md | 2 +- scripts/appimage-media-probe.c | 61 +++++++++++++++++++++++++ scripts/build-linux-appimage.sh | 17 ++++++- scripts/fixtures/appimage-media.html | 28 ++++++++++++ scripts/smoke-test-linux-appimage.sh | 26 ++++++++++- 7 files changed, 143 insertions(+), 12 deletions(-) create mode 100644 scripts/appimage-media-probe.c create mode 100644 scripts/fixtures/appimage-media.html diff --git a/.github/workflows/release-canary.yml b/.github/workflows/release-canary.yml index b029bc7e6..e8292dba1 100644 --- a/.github/workflows/release-canary.yml +++ b/.github/workflows/release-canary.yml @@ -276,7 +276,7 @@ jobs: path: ui/dist - run: | sudo apt-get update - sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev librsvg2-dev file patchelf xvfb xdotool + sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev librsvg2-dev file patchelf xvfb xdotool gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-libav - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: shared-key: canary-linux @@ -298,8 +298,10 @@ jobs: test "$(target/release/orx --no-telemetry version --build-channel)" = production test "$(target/release/orx --no-telemetry version --build-id)" = "$ORX_DESKTOP_CANARY_TAG" scripts/build-linux-appimage.sh target/release/orx app-dist - sudo apt-get remove -y libwebkit2gtk-4.1-0 libjavascriptcoregtk-4.1-0 - scripts/smoke-test-linux-appimage.sh app-dist/OpenResearch-${{ matrix.arch }}.AppImage + # shellcheck disable=SC2046 # pkg-config emits compiler arguments. + cc -Wall -Wextra scripts/appimage-media-probe.c -o app-dist/appimage-media-probe $(pkg-config --cflags --libs webkit2gtk-4.1) + sudo apt-get remove -y libwebkit2gtk-4.1-0 libjavascriptcoregtk-4.1-0 gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-libav + scripts/smoke-test-linux-appimage.sh app-dist/OpenResearch-${{ matrix.arch }}.AppImage app-dist/appimage-media-probe - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: canary-linux-${{ matrix.arch }} diff --git a/.github/workflows/release-linux-app.yml b/.github/workflows/release-linux-app.yml index 33bb254d3..6e766b3cf 100644 --- a/.github/workflows/release-linux-app.yml +++ b/.github/workflows/release-linux-app.yml @@ -120,7 +120,7 @@ jobs: - name: Install WebKitGTK and packaging tools run: | sudo apt-get update - sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev librsvg2-dev file patchelf xvfb xdotool + sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev librsvg2-dev file patchelf xvfb xdotool gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-libav # The runner's preinstalled stable toolchain, as release-macos-app.yml uses. - name: Build the production orx @@ -134,14 +134,17 @@ jobs: run: test "$(target/release/orx version --build-channel)" = production - name: Package the AppImage - run: scripts/build-linux-appimage.sh target/release/orx app-dist + run: | + scripts/build-linux-appimage.sh target/release/orx app-dist + # shellcheck disable=SC2046 # pkg-config emits compiler arguments. + cc -Wall -Wextra scripts/appimage-media-probe.c -o app-dist/appimage-media-probe $(pkg-config --cflags --libs webkit2gtk-4.1) # Otherwise the host's WebKit would stand in for a broken bundled one. - - name: Remove WebKitGTK from the runner - run: sudo apt-get remove -y libwebkit2gtk-4.1-0 libjavascriptcoregtk-4.1-0 + - name: Remove WebKitGTK and media plugins from the runner + run: sudo apt-get remove -y libwebkit2gtk-4.1-0 libjavascriptcoregtk-4.1-0 gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-libav - name: Smoke-test the AppImage - run: scripts/smoke-test-linux-appimage.sh app-dist/OpenResearch-${{ matrix.arch }}.AppImage + run: scripts/smoke-test-linux-appimage.sh app-dist/OpenResearch-${{ matrix.arch }}.AppImage app-dist/appimage-media-probe - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: diff --git a/arc/desktop.md b/arc/desktop.md index 8a1af94a8..246efdae2 100644 --- a/arc/desktop.md +++ b/arc/desktop.md @@ -16,7 +16,7 @@ Each platform's package wraps one `orx` binary that is the CLI an agent shells o Anything but `app`, `app ` or `app ` is a CLI invocation. A file becomes a `file:` link, so both travel one path, including to a running app. - **Windows** would flash a window for every console program a GUI process starts, so `windows/launcher/` starts `orx.exe app` in a hidden console shared by orx and everything it runs. **The launcher changes only with a new installer**, so `orx app [link]` is a contract with every installed launcher. `windows/OpenResearch.iss` installs per user, so `orx.exe` can replace itself without elevation. -- **Linux** bundles WebKitGTK in the AppImage, so its library floor is glibc's, set for every user by the Ubuntu runners that build it. AppRun records the session's values before running the GTK hooks once, points GTK at the bundle and starts orx inside the read-only mount; orx restores those values for the host programs it opens (`src/local/shell_env.rs`, in step with AppRun's list). The session chooses Wayland or X11 unless the user sets `GDK_BACKEND`; the bundling plugin's X11 default must not override that choice. Each start points a desktop entry at the AppImage's current path, which also claims `openresearch:` links and `.orx` files. +- **Linux** bundles WebKitGTK and its GStreamer audio/video plugins and scanner in the AppImage, so its library floor is glibc's, set for every user by the Ubuntu runners that build it. WebKit's ELF dependencies do not bring in its media plugins; packaging deploys them explicitly and relocates their compiled paths alongside WebKit's. The package smoke test opens sandboxed HTML with Web Audio and an H.264/AAC video after removing the runner's WebKit and media plugins. AppRun records the session's values before running the GTK hooks once, points GTK at the bundle and starts orx inside the read-only mount; orx restores those values for the host programs it opens (`src/local/shell_env.rs`, in step with AppRun's list). The session chooses Wayland or X11 unless the user sets `GDK_BACKEND`; the bundling plugin's X11 default must not override that choice. Each start points a desktop entry at the AppImage's current path, which also claims `openresearch:` links and `.orx` files. ## App mode diff --git a/scripts/appimage-media-probe.c b/scripts/appimage-media-probe.c new file mode 100644 index 000000000..c3d135626 --- /dev/null +++ b/scripts/appimage-media-probe.c @@ -0,0 +1,61 @@ +#include + +static int result = 1; + +static void received(WebKitUserContentManager *manager, WebKitJavascriptResult *message, gpointer data) +{ + (void)manager; + (void)data; + JSCValue *value = webkit_javascript_result_get_js_value(message); + JSCValue *pass = jsc_value_is_object(value) ? jsc_value_object_get_property(value, "pass") : NULL; + char *text = jsc_value_to_json(value, 0); + g_print("%s\n", text ? text : "Invalid media probe response"); + result = text && pass && jsc_value_is_boolean(pass) && jsc_value_to_boolean(pass) ? 0 : 1; + g_clear_object(&pass); + g_free(text); + gtk_main_quit(); +} + +static void terminated(WebKitWebView *view, WebKitWebProcessTerminationReason reason, gpointer data) +{ + (void)view; + (void)data; + g_printerr("WebKit web process terminated: %d\n", reason); + result = 2; + gtk_main_quit(); +} + +static gboolean timed_out(gpointer data) +{ + (void)data; + g_printerr("WebKit media preview timed out\n"); + gtk_main_quit(); + return G_SOURCE_REMOVE; +} + +int main(int argc, char **argv) +{ + if (argc != 2) return 1; + char *html = NULL; + GError *error = NULL; + if (!g_file_get_contents(argv[1], &html, NULL, &error)) { + g_printerr("%s\n", error->message); + g_error_free(error); + return 1; + } + gtk_init(&argc, &argv); + GtkWidget *window = gtk_window_new(GTK_WINDOW_TOPLEVEL); + GtkWidget *view = webkit_web_view_new(); + WebKitUserContentManager *manager = webkit_web_view_get_user_content_manager(WEBKIT_WEB_VIEW(view)); + webkit_user_content_manager_register_script_message_handler(manager, "mediaProbe"); + g_signal_connect(manager, "script-message-received::mediaProbe", G_CALLBACK(received), NULL); + g_signal_connect(view, "web-process-terminated", G_CALLBACK(terminated), NULL); + gtk_container_add(GTK_CONTAINER(window), view); + gtk_widget_show_all(window); + webkit_web_view_load_html(WEBKIT_WEB_VIEW(view), html, "http://127.0.0.1/"); + g_free(html); + g_timeout_add_seconds(25, timed_out, NULL); + gtk_main(); + gtk_widget_destroy(window); + return result; +} diff --git a/scripts/build-linux-appimage.sh b/scripts/build-linux-appimage.sh index e6bc3078b..ad8771ff1 100755 --- a/scripts/build-linux-appimage.sh +++ b/scripts/build-linux-appimage.sh @@ -2,7 +2,7 @@ # Package an `orx` built with `--features desktop` into OpenResearch-.AppImage, # bundling WebKitGTK so the app runs without it installed. Run on the oldest # glibc to support (CI uses Ubuntu 22.04) with libwebkit2gtk-4.1-dev and -# patchelf installed. +# patchelf and GStreamer's base, good and libav plugins installed. # # scripts/build-linux-appimage.sh set -euo pipefail @@ -13,6 +13,8 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)" ARCH="$(uname -m)" TRIPLET="$ARCH-linux-gnu" WEBKIT_DIR="/usr/lib/$TRIPLET/webkit2gtk-4.1" +GST_PLUGIN_DIR="/usr/lib/$TRIPLET/gstreamer-1.0" +GST_SCANNER="/usr/lib/$TRIPLET/gstreamer1.0/gstreamer-1.0/gst-plugin-scanner" # Pinned, and checked against the digests GitHub publishes for each release asset. LINUXDEPLOY_URL="https://github.com/linuxdeploy/linuxdeploy/releases/download/1-alpha-20251107-1/linuxdeploy-$ARCH.AppImage" @@ -71,6 +73,13 @@ done # are built against a newer GLib than the bundled one. install -Dm755 "/usr/lib/$TRIPLET/gio/modules/libgiognutls.so" \ "$APPDIR/usr/lib/gio/modules/libgiognutls.so" +# WebKit loads these at runtime; its ELF dependencies contain no media plugins. +for plugin in coreelements app typefindfunctions playback audioconvert audioresample audiofx \ + autodetect pulseaudio volume videoconvert videoscale isomp4 libav matroska vpx \ + ogg vorbis opus theora wavparse soup; do + install -Dm755 "$GST_PLUGIN_DIR/libgst$plugin.so" "$APPDIR$GST_PLUGIN_DIR/libgst$plugin.so" +done +install -Dm755 "$GST_SCANNER" "$APPDIR$GST_SCANNER" echo "==> Deploying libraries with linuxdeploy" DEPLOY_GTK_VERSION=3 linuxdeploy \ @@ -79,6 +88,8 @@ DEPLOY_GTK_VERSION=3 linuxdeploy \ --desktop-file "$ROOT/linux/OpenResearch.desktop" \ --icon-file "$ROOT/linux/OpenResearch.png" \ --custom-apprun "$ROOT/linux/AppRun" \ + --deploy-deps-only "$APPDIR$GST_PLUGIN_DIR" \ + --deploy-deps-only "$APPDIR$GST_SCANNER" \ --plugin gtk # linuxdeploy wraps custom entry points with its hooks. AppRun must capture the @@ -93,10 +104,14 @@ patchelf --set-rpath '$ORIGIN/../..' \ "$APPDIR/usr/lib/gio/modules/libgiognutls.so" patchelf --set-rpath '$ORIGIN/../../..' \ "$APPDIR$WEBKIT_DIR/injected-bundle/libwebkit2gtkinjectedbundle.so" +patchelf --set-rpath '$ORIGIN/../..' "$APPDIR$GST_PLUGIN_DIR/"*.so +patchelf --set-rpath '$ORIGIN/../../..' "$APPDIR$GST_SCANNER" # The same-length rewrite Tauri uses: WebKit's absolute /usr paths become ././, # which AppRun resolves inside the image by running from $APPDIR/usr. find "$APPDIR/usr/lib" -name 'libwebkit*' -exec sed -i -e 's|/usr|././|g' '{}' + +# GStreamer's plugin and scanner paths use the same Ubuntu prefix as WebKit's. +find "$APPDIR/usr/lib" -name 'libgstreamer-1.0.so*' -exec sed -i -e 's|/usr|././|g' '{}' + echo "==> Building the AppImage" ARCH="$ARCH" appimagetool --runtime-file "$WORK/runtime" "$APPDIR" "$OUT/OpenResearch-$ARCH.AppImage" diff --git a/scripts/fixtures/appimage-media.html b/scripts/fixtures/appimage-media.html new file mode 100644 index 000000000..2bb3be964 --- /dev/null +++ b/scripts/fixtures/appimage-media.html @@ -0,0 +1,28 @@ +AppImage sandboxed HTML media probe + + diff --git a/scripts/smoke-test-linux-appimage.sh b/scripts/smoke-test-linux-appimage.sh index f5e087e8b..27f4205dc 100755 --- a/scripts/smoke-test-linux-appimage.sh +++ b/scripts/smoke-test-linux-appimage.sh @@ -3,14 +3,18 @@ # loads the dashboard: the server answers, WebKit's network process has connected # to it, the window appears once the page has loaded, the desktop entry points at # this AppImage, and every WebKit helper runs from inside the image on the image's -# libwebkit2gtk and no host GIO modules. Run it where WebKitGTK is not installed, +# libwebkit2gtk and no host GIO modules. A sandboxed HTML preview also creates +# an AudioContext and decodes an H.264/AAC video into a canvas. Run it where +# WebKitGTK and GStreamer plugins are not installed, # or a fallback to the host's copy would pass. Needs xvfb-run, curl, ss, setsid, # and xdotool. # -# scripts/smoke-test-linux-appimage.sh .AppImage> +# scripts/smoke-test-linux-appimage.sh .AppImage> set -euo pipefail APPIMAGE="$(realpath "$1")" +MEDIA_PROBE="$(realpath "$2")" +ROOT="$(cd "$(dirname "$0")/.." && pwd)" SANDBOX="$(mktemp -d)" LOG="$SANDBOX/app.log" export ORX_DATA_DIR="$SANDBOX/data" XDG_CONFIG_HOME="$SANDBOX/config" XDG_DATA_HOME="$SANDBOX/share" @@ -94,10 +98,27 @@ grep -q 'showing the window anyway' "$LOG" && fail "The dashboard never finished grep -qxF "TryExec=$APPIMAGE" "$XDG_DATA_HOME/applications/openresearch.desktop" \ || fail "The app did not install its desktop entry." +mapfile -d '' -t media_env <"/proc/$ORX_PID/environ" +image_exe="$(readlink "/proc/$ORX_PID/exe")" +image_lib="$(dirname "$(dirname "$image_exe")")/lib" +if ! ( + ulimit -c 0 + cd "/proc/$ORX_PID/cwd" + timeout 35 env -i "${media_env[@]}" LD_LIBRARY_PATH="$image_lib" \ + "$MEDIA_PROBE" "$ROOT/scripts/fixtures/appimage-media.html" +) >"$SANDBOX/media.log" 2>&1; then + cat "$SANDBOX/media.log" + fail "The AppImage's WebKit could not render sandboxed HTML audio/video." +fi +cat "$SANDBOX/media.log" + +helpers=0 for pid in $(pgrep -f 'WebKit(Web|Network)Process'); do + grep -zqxF "XDG_RUNTIME_DIR=$XDG_RUNTIME_DIR" "/proc/$pid/environ" 2>/dev/null || continue # WebKit may replace a web process between pgrep and here. exe="$(readlink "/proc/$pid/exe")" || continue maps="$(cat "/proc/$pid/maps" 2>/dev/null)" || continue + helpers=$((helpers + 1)) case "$exe" in *appimage_extracted_*) ;; *) fail "WebKit helper $pid runs $exe, not the AppImage's." ;; @@ -108,5 +129,6 @@ for pid in $(pgrep -f 'WebKit(Web|Network)Process'); do fail "WebKit helper $pid loaded the host's GIO modules." fi done +[ "$helpers" -gt 0 ] || fail "No bundled WebKit helper could be inspected." echo "The AppImage's WebKit loaded the dashboard:" pgrep -af 'WebKit(Web|Network)Process' From fbf3b05f5510c32e97aac91704f554897c3bbdd0 Mon Sep 17 00:00:00 2001 From: TempLinux <847964640@qq.com> Date: Sat, 14 Nov 2026 14:11:56 +0800 Subject: [PATCH 2/3] fix: retain AppImage tools for old releases Keep release source checked out at source-sha, but take Linux packaging scripts and assets from the workflow revision. Old release tags do not contain the new media probe or its fixture, so mixing current workflow commands with those old scripts fails before upload. Assisted-by: Codex --- .github/workflows/release-linux-app.yml | 16 +++++++++++++--- arc/desktop.md | 2 +- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release-linux-app.yml b/.github/workflows/release-linux-app.yml index 6e766b3cf..8b3f8bdba 100644 --- a/.github/workflows/release-linux-app.yml +++ b/.github/workflows/release-linux-app.yml @@ -96,6 +96,16 @@ jobs: persist-credentials: false ref: ${{ needs.check.outputs.source-sha }} + - name: Take AppImage tools from the workflow revision + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + ref: ${{ github.workflow_sha }} + path: packaging-workflow + sparse-checkout: | + linux + scripts + - name: Remove checked-in UI if: needs.check.outputs.release-run-id != '' run: rm -rf ui/dist @@ -135,16 +145,16 @@ jobs: - name: Package the AppImage run: | - scripts/build-linux-appimage.sh target/release/orx app-dist + packaging-workflow/scripts/build-linux-appimage.sh target/release/orx app-dist # shellcheck disable=SC2046 # pkg-config emits compiler arguments. - cc -Wall -Wextra scripts/appimage-media-probe.c -o app-dist/appimage-media-probe $(pkg-config --cflags --libs webkit2gtk-4.1) + cc -Wall -Wextra packaging-workflow/scripts/appimage-media-probe.c -o app-dist/appimage-media-probe $(pkg-config --cflags --libs webkit2gtk-4.1) # Otherwise the host's WebKit would stand in for a broken bundled one. - name: Remove WebKitGTK and media plugins from the runner run: sudo apt-get remove -y libwebkit2gtk-4.1-0 libjavascriptcoregtk-4.1-0 gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-libav - name: Smoke-test the AppImage - run: scripts/smoke-test-linux-appimage.sh app-dist/OpenResearch-${{ matrix.arch }}.AppImage app-dist/appimage-media-probe + run: packaging-workflow/scripts/smoke-test-linux-appimage.sh app-dist/OpenResearch-${{ matrix.arch }}.AppImage app-dist/appimage-media-probe - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: diff --git a/arc/desktop.md b/arc/desktop.md index 246efdae2..82f73daa4 100644 --- a/arc/desktop.md +++ b/arc/desktop.md @@ -16,7 +16,7 @@ Each platform's package wraps one `orx` binary that is the CLI an agent shells o Anything but `app`, `app ` or `app ` is a CLI invocation. A file becomes a `file:` link, so both travel one path, including to a running app. - **Windows** would flash a window for every console program a GUI process starts, so `windows/launcher/` starts `orx.exe app` in a hidden console shared by orx and everything it runs. **The launcher changes only with a new installer**, so `orx app [link]` is a contract with every installed launcher. `windows/OpenResearch.iss` installs per user, so `orx.exe` can replace itself without elevation. -- **Linux** bundles WebKitGTK and its GStreamer audio/video plugins and scanner in the AppImage, so its library floor is glibc's, set for every user by the Ubuntu runners that build it. WebKit's ELF dependencies do not bring in its media plugins; packaging deploys them explicitly and relocates their compiled paths alongside WebKit's. The package smoke test opens sandboxed HTML with Web Audio and an H.264/AAC video after removing the runner's WebKit and media plugins. AppRun records the session's values before running the GTK hooks once, points GTK at the bundle and starts orx inside the read-only mount; orx restores those values for the host programs it opens (`src/local/shell_env.rs`, in step with AppRun's list). The session chooses Wayland or X11 unless the user sets `GDK_BACKEND`; the bundling plugin's X11 default must not override that choice. Each start points a desktop entry at the AppImage's current path, which also claims `openresearch:` links and `.orx` files. +- **Linux** bundles WebKitGTK and its GStreamer audio/video plugins and scanner in the AppImage, so its library floor is glibc's, set for every user by the Ubuntu runners that build it. WebKit's ELF dependencies do not bring in its media plugins; packaging deploys them explicitly and relocates their compiled paths alongside WebKit's. The package smoke test opens sandboxed HTML with Web Audio and an H.264/AAC video after removing the runner's WebKit and media plugins. Stable release builds take the AppImage tools from the workflow revision and the desktop binary from the release revision, so rebuilding an older release uses the current packaging and media checks. AppRun records the session's values before running the GTK hooks once, points GTK at the bundle and starts orx inside the read-only mount; orx restores those values for the host programs it opens (`src/local/shell_env.rs`, in step with AppRun's list). The session chooses Wayland or X11 unless the user sets `GDK_BACKEND`; the bundling plugin's X11 default must not override that choice. Each start points a desktop entry at the AppImage's current path, which also claims `openresearch:` links and `.orx` files. ## App mode From 630ae7facf02d5d0348c2f446fcf940d1d81cfec Mon Sep 17 00:00:00 2001 From: TempLinux <847964640@qq.com> Date: Sat, 14 Nov 2026 21:28:55 +0800 Subject: [PATCH 3/3] fix: select compatible AppImage media runtime Prefer a compatible system WebKit/GStreamer stack and keep the existing bundled WebKit fallback with compatible host media plugins. Check the media elements and desktop imports before launch; show install instructions if neither stack can run instead of restoring a tab that aborts WebKit. Keep codec dependencies off the image. Restore the session's library path for child processes, and cover system, fallback and missing-runtime paths. Assisted-by: Codex --- .github/workflows/release-canary.yml | 7 +- .github/workflows/release-linux-app.yml | 18 +++- arc/desktop.md | 2 +- linux/AppRun | 76 ++++++++++--- linux/README.md | 23 ++++ linux/appimage-runtime.c | 102 ++++++++++++++++++ scripts/build-linux-appimage.sh | 20 +--- scripts/build-linux-appimage.test.mjs | 138 ++++++++++++++++++------ scripts/smoke-test-linux-appimage.sh | 98 ++++++++++++----- src/local/shell_env.rs | 26 ++++- src/process.rs | 10 ++ 11 files changed, 418 insertions(+), 102 deletions(-) create mode 100644 linux/README.md create mode 100644 linux/appimage-runtime.c diff --git a/.github/workflows/release-canary.yml b/.github/workflows/release-canary.yml index e8292dba1..b0d5d82fb 100644 --- a/.github/workflows/release-canary.yml +++ b/.github/workflows/release-canary.yml @@ -300,8 +300,11 @@ jobs: scripts/build-linux-appimage.sh target/release/orx app-dist # shellcheck disable=SC2046 # pkg-config emits compiler arguments. cc -Wall -Wextra scripts/appimage-media-probe.c -o app-dist/appimage-media-probe $(pkg-config --cflags --libs webkit2gtk-4.1) - sudo apt-get remove -y libwebkit2gtk-4.1-0 libjavascriptcoregtk-4.1-0 gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-libav - scripts/smoke-test-linux-appimage.sh app-dist/OpenResearch-${{ matrix.arch }}.AppImage app-dist/appimage-media-probe + scripts/smoke-test-linux-appimage.sh app-dist/OpenResearch-${{ matrix.arch }}.AppImage app-dist/appimage-media-probe system + sudo apt-get remove -y libwebkit2gtk-4.1-0 libjavascriptcoregtk-4.1-0 + scripts/smoke-test-linux-appimage.sh app-dist/OpenResearch-${{ matrix.arch }}.AppImage app-dist/appimage-media-probe bundled + sudo apt-get remove -y gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-libav + scripts/smoke-test-linux-appimage.sh app-dist/OpenResearch-${{ matrix.arch }}.AppImage app-dist/appimage-media-probe missing - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: canary-linux-${{ matrix.arch }} diff --git a/.github/workflows/release-linux-app.yml b/.github/workflows/release-linux-app.yml index 8b3f8bdba..7788d3799 100644 --- a/.github/workflows/release-linux-app.yml +++ b/.github/workflows/release-linux-app.yml @@ -149,12 +149,20 @@ jobs: # shellcheck disable=SC2046 # pkg-config emits compiler arguments. cc -Wall -Wextra packaging-workflow/scripts/appimage-media-probe.c -o app-dist/appimage-media-probe $(pkg-config --cflags --libs webkit2gtk-4.1) - # Otherwise the host's WebKit would stand in for a broken bundled one. - - name: Remove WebKitGTK and media plugins from the runner - run: sudo apt-get remove -y libwebkit2gtk-4.1-0 libjavascriptcoregtk-4.1-0 gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-libav + - name: Smoke-test the system runtime + run: packaging-workflow/scripts/smoke-test-linux-appimage.sh app-dist/OpenResearch-${{ matrix.arch }}.AppImage app-dist/appimage-media-probe system - - name: Smoke-test the AppImage - run: packaging-workflow/scripts/smoke-test-linux-appimage.sh app-dist/OpenResearch-${{ matrix.arch }}.AppImage app-dist/appimage-media-probe + - name: Remove the system WebKit + run: sudo apt-get remove -y libwebkit2gtk-4.1-0 libjavascriptcoregtk-4.1-0 + + - name: Smoke-test the bundled fallback + run: packaging-workflow/scripts/smoke-test-linux-appimage.sh app-dist/OpenResearch-${{ matrix.arch }}.AppImage app-dist/appimage-media-probe bundled + + - name: Remove the system media plugins + run: sudo apt-get remove -y gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-libav + + - name: Check the missing-dependency instructions + run: packaging-workflow/scripts/smoke-test-linux-appimage.sh app-dist/OpenResearch-${{ matrix.arch }}.AppImage app-dist/appimage-media-probe missing - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: diff --git a/arc/desktop.md b/arc/desktop.md index 82f73daa4..168d80447 100644 --- a/arc/desktop.md +++ b/arc/desktop.md @@ -16,7 +16,7 @@ Each platform's package wraps one `orx` binary that is the CLI an agent shells o Anything but `app`, `app ` or `app ` is a CLI invocation. A file becomes a `file:` link, so both travel one path, including to a running app. - **Windows** would flash a window for every console program a GUI process starts, so `windows/launcher/` starts `orx.exe app` in a hidden console shared by orx and everything it runs. **The launcher changes only with a new installer**, so `orx app [link]` is a contract with every installed launcher. `windows/OpenResearch.iss` installs per user, so `orx.exe` can replace itself without elevation. -- **Linux** bundles WebKitGTK and its GStreamer audio/video plugins and scanner in the AppImage, so its library floor is glibc's, set for every user by the Ubuntu runners that build it. WebKit's ELF dependencies do not bring in its media plugins; packaging deploys them explicitly and relocates their compiled paths alongside WebKit's. The package smoke test opens sandboxed HTML with Web Audio and an H.264/AAC video after removing the runner's WebKit and media plugins. Stable release builds take the AppImage tools from the workflow revision and the desktop binary from the release revision, so rebuilding an older release uses the current packaging and media checks. AppRun records the session's values before running the GTK hooks once, points GTK at the bundle and starts orx inside the read-only mount; orx restores those values for the host programs it opens (`src/local/shell_env.rs`, in step with AppRun's list). The session chooses Wayland or X11 unless the user sets `GDK_BACKEND`; the bundling plugin's X11 default must not override that choice. Each start points a desktop entry at the AppImage's current path, which also claims `openresearch:` links and `.orx` files. +- **Linux** keeps a bundled WebKitGTK fallback at the Ubuntu builder's glibc floor. AppRun first checks the system WebKitGTK 4.1/GStreamer stack and verifies that it can load the desktop binary. A compatible system stack runs without the bundled GTK hooks; otherwise AppRun checks the bundled stack against the installed media plugins. If neither works, an installation dialog replaces the native media crash. GStreamer plugins and codecs stay on the host rather than increasing each AppImage's size. The package smoke tests cover system selection, bundled fallback and missing dependencies; media tests create Web Audio and decode a video frame. [Linux runtime dependencies](../linux/README.md) lists the installation commands. Stable release builds take AppImage tools from the workflow revision and the desktop binary from the release revision. AppRun records the session's values before changing library or GTK paths; orx restores those values for host programs (`src/local/shell_env.rs`, in step with AppRun's list). The session chooses Wayland or X11 unless the user sets `GDK_BACKEND`. Each start points a desktop entry at the AppImage's current path, which also claims `openresearch:` links and `.orx` files. ## App mode diff --git a/linux/AppRun b/linux/AppRun index 2c25a885e..41cc31945 100755 --- a/linux/AppRun +++ b/linux/AppRun @@ -9,7 +9,7 @@ export APPDIR="${APPDIR:-"$(dirname "$(realpath "$0")")"}" # The session's values of what this script and the hook below override, as # `=` or `-` for unset, so orx can hand them back to the host programs it # opens (shell_env::host_gui_env; keep the two lists in step). -for var in GDK_BACKEND GDK_PIXBUF_MODULE_FILE GI_TYPELIB_PATH GIO_EXTRA_MODULES \ +for var in LD_LIBRARY_PATH GDK_BACKEND GDK_PIXBUF_MODULE_FILE GI_TYPELIB_PATH GIO_EXTRA_MODULES \ GIO_MODULE_DIR GSETTINGS_SCHEMA_DIR GTK_DATA_PREFIX GTK_EXE_PREFIX GTK_IM_MODULE_FILE GTK_PATH \ GTK_THEME XDG_DATA_DIRS; do if [ -n "${!var+set}" ]; then @@ -19,26 +19,68 @@ for var in GDK_BACKEND GDK_PIXBUF_MODULE_FILE GI_TYPELIB_PATH GIO_EXTRA_MODULES fi done -# linuxdeploy-plugin-gtk's hook points GTK at the bundled modules and schemas. -for hook in "$APPDIR"/apprun-hooks/*.sh; do - if [ -e "$hook" ]; then - # shellcheck disable=SC1090 - . "$hook" +cd "$APPDIR/usr" +runtime_log="$(mktemp)" +trap 'rm -f "$runtime_log"' EXIT +runtime="$APPDIR/usr/bin/orx-runtime" +check_runtime() { + (ulimit -c 0; timeout 15 "$runtime" check) +} +system_lib= +if system_lib="$(check_runtime 2>"$runtime_log")"; then + case "$system_lib/" in + "$APPDIR/"*) system_lib= ;; + *) + if ! LD_LIBRARY_PATH="$system_lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" check_runtime >>"$runtime_log" 2>&1 || \ + ! LD_LIBRARY_PATH="$system_lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" \ + LD_BIND_NOW=1 ORX_TELEMETRY_ENV=off timeout 10 "$APPDIR/usr/bin/orx" version >/dev/null 2>>"$runtime_log"; then + system_lib= + fi + ;; + esac +fi +if [ -n "$system_lib" ]; then + export LD_LIBRARY_PATH="$system_lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" + export ORX_APPIMAGE_RUNTIME=system +else + # linuxdeploy-plugin-gtk's hook points GTK at the bundled modules and schemas. + for hook in "$APPDIR"/apprun-hooks/*.sh; do + if [ -e "$hook" ]; then + # shellcheck disable=SC1090 + . "$hook" + fi + done + # Let GTK choose the session's backend unless the user selected one; the plugin + # forces X11 even in a Wayland session. + case "$ORX_HOST_GDK_BACKEND" in + =*) export GDK_BACKEND="${ORX_HOST_GDK_BACKEND#=}" ;; + -) unset GDK_BACKEND ;; + esac + # Only the bundled GIO modules (TLS): the host's need a newer GLib than the image's. + export GIO_MODULE_DIR="$APPDIR/usr/lib/gio/modules" + unset GIO_EXTRA_MODULES + export ORX_APPIMAGE_RUNTIME=bundled + if ! LD_LIBRARY_PATH="$APPDIR/usr/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" check_runtime >>"$runtime_log" 2>&1 || \ + ! LD_BIND_NOW=1 ORX_TELEMETRY_ENV=off timeout 10 "$APPDIR/usr/bin/orx" version >/dev/null 2>>"$runtime_log"; then + cat "$runtime_log" >&2 + rm -f "$runtime_log" + trap - EXIT + exec env LD_LIBRARY_PATH="$APPDIR/usr/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" "$runtime" error "OpenResearch could not find compatible WebKitGTK / GStreamer media libraries. + +Install the system libraries, then reopen OpenResearch: + +Ubuntu / Debian: +sudo apt install libwebkit2gtk-4.1-0 gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-libav + +Arch Linux: +sudo pacman -S --needed webkit2gtk-4.1 gst-plugins-base-libs gst-plugins-good gst-libav" fi -done -# Let GTK choose the session's backend unless the user selected one; the plugin -# forces X11 even in a Wayland session. -case "$ORX_HOST_GDK_BACKEND" in - =*) export GDK_BACKEND="${ORX_HOST_GDK_BACKEND#=}" ;; - -) unset GDK_BACKEND ;; -esac -# Only the bundled GIO modules (TLS): the host's need a newer GLib than the image's. -export GIO_MODULE_DIR="$APPDIR/usr/lib/gio/modules" -unset GIO_EXTRA_MODULES +fi +rm -f "$runtime_log" +trap - EXIT # scripts/build-linux-appimage.sh rewrote libwebkit2gtk's /usr paths to ././, # so WebKit finds its bundled helper processes relative to this directory. -cd "$APPDIR/usr" link=() # Any position: AppImage integrators keep the bundled entry's `app` before %u. shopt -s nocasematch diff --git a/linux/README.md b/linux/README.md new file mode 100644 index 000000000..109cc9c93 --- /dev/null +++ b/linux/README.md @@ -0,0 +1,23 @@ +# Linux desktop runtime + +OpenResearch prefers the system's WebKitGTK 4.1 and GStreamer libraries so HTML previews use media plugins built for the same runtime. Installing new system plugins does not make them compatible with the older GStreamer inside an AppImage. + +Install the runtime and common video codecs before opening the AppImage: + +Ubuntu / Debian: + +```sh +sudo apt install libwebkit2gtk-4.1-0 gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-libav +``` + +Arch Linux: + +```sh +sudo pacman -S --needed webkit2gtk-4.1 gst-plugins-base-libs gst-plugins-good gst-libav +``` + +AppRun discovers the library directory, checks the required media elements and verifies the desktop binary's imports before selecting the system stack. It leaves the session's GTK settings in place. No global `LD_LIBRARY_PATH` setting or manual launch command is needed, and host programs launched by OpenResearch receive the session's original library path. + +If the system stack is unavailable, the existing bundled WebKit can still run with compatible system media plugins, as on Ubuntu 22.04. If neither stack has the required media elements, a dialog gives installation commands before the dashboard starts. A video format can still require additional codecs; the libav package above supplies the H.264/AAC support exercised by the package test. + +The fallback keeps its previous bundle size: media plugins and their codec dependencies are not added to the AppImage. Both stable and Canary builds test system selection, the bundled fallback after removing system WebKit, and the dependency dialog after removing media plugins. diff --git a/linux/appimage-runtime.c b/linux/appimage-runtime.c new file mode 100644 index 000000000..38637737a --- /dev/null +++ b/linux/appimage-runtime.c @@ -0,0 +1,102 @@ +#define _GNU_SOURCE +#include +#include +#include +#include +#include + +static void *required_symbol(void *library, const char *name) +{ + void *symbol = dlsym(library, name); + if (!symbol) fprintf(stderr, "Missing runtime function %s: %s\n", name, dlerror()); + return symbol; +} + +static int check_runtime(void) +{ + void *webkit = dlopen("libwebkit2gtk-4.1.so.0", RTLD_NOW | RTLD_LOCAL); + if (!webkit) { + fprintf(stderr, "Cannot load WebKitGTK 4.1: %s\n", dlerror()); + return 1; + } + void *gst = dlopen("libgstreamer-1.0.so.0", RTLD_NOW | RTLD_LOCAL); + if (!gst) { + fprintf(stderr, "Cannot load GStreamer: %s\n", dlerror()); + return 1; + } + int (*init)(int *, char ***, void **) = required_symbol(gst, "gst_init_check"); + void *(*make)(const char *, const char *) = required_symbol(gst, "gst_element_factory_make"); + void (*unref)(void *) = required_symbol(gst, "g_object_unref"); + if (!init || !make || !unref) return 1; + if (!init(NULL, NULL, NULL)) { + fputs("Cannot initialize GStreamer\n", stderr); + return 1; + } + const char *elements[] = { + "appsrc", "appsink", "playbin", "decodebin", "autoaudiosink", + "audioconvert", "audioresample", "volume", "scaletempo", "qtdemux", "videoconvert" + }; + for (size_t i = 0; i < sizeof(elements) / sizeof(elements[0]); i++) { + void *element = make(elements[i], NULL); + if (!element) { + fprintf(stderr, "Missing or incompatible GStreamer element: %s\n", elements[i]); + return 1; + } + unref(element); + } + struct link_map *map = NULL; + if (dlinfo(webkit, RTLD_DI_LINKMAP, &map) != 0 || !map) { + fprintf(stderr, "Cannot locate WebKitGTK: %s\n", dlerror()); + return 1; + } + char *path = realpath(map->l_name, NULL); + if (!path) { + perror("Cannot resolve WebKitGTK path"); + return 1; + } + char *slash = strrchr(path, '/'); + if (!slash) { + free(path); + return 1; + } + *slash = '\0'; + puts(path); + free(path); + return 0; +} + +static int show_error(const char *text) +{ + fputs(text, stderr); + fputc('\n', stderr); + void *gtk = dlopen("libgtk-3.so.0", RTLD_NOW | RTLD_LOCAL); + if (!gtk) { + fprintf(stderr, "Cannot display dependency instructions: %s\n", dlerror()); + return 1; + } + int (*init)(int *, char ***) = required_symbol(gtk, "gtk_init_check"); + void *(*dialog)(void *, int, int, int, const char *, ...) = required_symbol(gtk, "gtk_message_dialog_new"); + void (*title)(void *, const char *) = required_symbol(gtk, "gtk_window_set_title"); + int (*run)(void *) = required_symbol(gtk, "gtk_dialog_run"); + void (*destroy)(void *) = required_symbol(gtk, "gtk_widget_destroy"); + if (!init || !dialog || !title || !run || !destroy) return 1; + if (!init(NULL, NULL)) { + fputs("Cannot display dependency instructions: no GTK display\n", stderr); + return 1; + } + // GTK 3 ABI values; this helper must start even when GTK is not installed. + enum { DIALOG_MODAL = 1, MESSAGE_ERROR = 3, BUTTONS_CLOSE = 2 }; + void *window = dialog(NULL, DIALOG_MODAL, MESSAGE_ERROR, BUTTONS_CLOSE, "%s", text); + title(window, "OpenResearch needs multimedia libraries"); + run(window); + destroy(window); + return 1; +} + +int main(int argc, char **argv) +{ + if (argc == 2 && strcmp(argv[1], "check") == 0) return check_runtime(); + if (argc == 3 && strcmp(argv[1], "error") == 0) return show_error(argv[2]); + fputs("Usage: orx-runtime check | error \n", stderr); + return 1; +} diff --git a/scripts/build-linux-appimage.sh b/scripts/build-linux-appimage.sh index ad8771ff1..8b1ea29fb 100755 --- a/scripts/build-linux-appimage.sh +++ b/scripts/build-linux-appimage.sh @@ -2,7 +2,7 @@ # Package an `orx` built with `--features desktop` into OpenResearch-.AppImage, # bundling WebKitGTK so the app runs without it installed. Run on the oldest # glibc to support (CI uses Ubuntu 22.04) with libwebkit2gtk-4.1-dev and -# patchelf and GStreamer's base, good and libav plugins installed. +# patchelf installed. # # scripts/build-linux-appimage.sh set -euo pipefail @@ -13,8 +13,6 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)" ARCH="$(uname -m)" TRIPLET="$ARCH-linux-gnu" WEBKIT_DIR="/usr/lib/$TRIPLET/webkit2gtk-4.1" -GST_PLUGIN_DIR="/usr/lib/$TRIPLET/gstreamer-1.0" -GST_SCANNER="/usr/lib/$TRIPLET/gstreamer1.0/gstreamer-1.0/gst-plugin-scanner" # Pinned, and checked against the digests GitHub publishes for each release asset. LINUXDEPLOY_URL="https://github.com/linuxdeploy/linuxdeploy/releases/download/1-alpha-20251107-1/linuxdeploy-$ARCH.AppImage" @@ -64,6 +62,7 @@ export APPIMAGE_EXTRACT_AND_RUN=1 echo "==> Staging the AppDir" install -Dm755 "$ORX" "$APPDIR/usr/bin/orx" +cc -Wall -Wextra -Werror "$ROOT/linux/appimage-runtime.c" -ldl -o "$APPDIR/usr/bin/orx-runtime" # WebKit starts these helpers from a path compiled into libwebkit2gtk, which the # sed below makes relative to AppRun's working directory, $APPDIR/usr. for helper in WebKitNetworkProcess WebKitWebProcess injected-bundle/libwebkit2gtkinjectedbundle.so; do @@ -73,13 +72,6 @@ done # are built against a newer GLib than the bundled one. install -Dm755 "/usr/lib/$TRIPLET/gio/modules/libgiognutls.so" \ "$APPDIR/usr/lib/gio/modules/libgiognutls.so" -# WebKit loads these at runtime; its ELF dependencies contain no media plugins. -for plugin in coreelements app typefindfunctions playback audioconvert audioresample audiofx \ - autodetect pulseaudio volume videoconvert videoscale isomp4 libav matroska vpx \ - ogg vorbis opus theora wavparse soup; do - install -Dm755 "$GST_PLUGIN_DIR/libgst$plugin.so" "$APPDIR$GST_PLUGIN_DIR/libgst$plugin.so" -done -install -Dm755 "$GST_SCANNER" "$APPDIR$GST_SCANNER" echo "==> Deploying libraries with linuxdeploy" DEPLOY_GTK_VERSION=3 linuxdeploy \ @@ -88,14 +80,14 @@ DEPLOY_GTK_VERSION=3 linuxdeploy \ --desktop-file "$ROOT/linux/OpenResearch.desktop" \ --icon-file "$ROOT/linux/OpenResearch.png" \ --custom-apprun "$ROOT/linux/AppRun" \ - --deploy-deps-only "$APPDIR$GST_PLUGIN_DIR" \ - --deploy-deps-only "$APPDIR$GST_SCANNER" \ --plugin gtk # linuxdeploy wraps custom entry points with its hooks. AppRun must capture the # host environment before running those hooks itself, exactly once. install -m755 "$ROOT/linux/AppRun" "$APPDIR/AppRun" rm -f "$APPDIR/AppRun.wrapped" +# The runtime checker must discover the host before considering the bundled GTK. +patchelf --remove-rpath "$APPDIR/usr/bin/orx-runtime" # linuxdeploy leaves files below usr/lib looking beside themselves, so they # would load the host's libraries (or none); point them at the bundled usr/lib. @@ -104,14 +96,10 @@ patchelf --set-rpath '$ORIGIN/../..' \ "$APPDIR/usr/lib/gio/modules/libgiognutls.so" patchelf --set-rpath '$ORIGIN/../../..' \ "$APPDIR$WEBKIT_DIR/injected-bundle/libwebkit2gtkinjectedbundle.so" -patchelf --set-rpath '$ORIGIN/../..' "$APPDIR$GST_PLUGIN_DIR/"*.so -patchelf --set-rpath '$ORIGIN/../../..' "$APPDIR$GST_SCANNER" # The same-length rewrite Tauri uses: WebKit's absolute /usr paths become ././, # which AppRun resolves inside the image by running from $APPDIR/usr. find "$APPDIR/usr/lib" -name 'libwebkit*' -exec sed -i -e 's|/usr|././|g' '{}' + -# GStreamer's plugin and scanner paths use the same Ubuntu prefix as WebKit's. -find "$APPDIR/usr/lib" -name 'libgstreamer-1.0.so*' -exec sed -i -e 's|/usr|././|g' '{}' + echo "==> Building the AppImage" ARCH="$ARCH" appimagetool --runtime-file "$WORK/runtime" "$APPDIR" "$OUT/OpenResearch-$ARCH.AppImage" diff --git a/scripts/build-linux-appimage.test.mjs b/scripts/build-linux-appimage.test.mjs index 1a29d1f93..a6b89e4b5 100644 --- a/scripts/build-linux-appimage.test.mjs +++ b/scripts/build-linux-appimage.test.mjs @@ -1,52 +1,130 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { execFileSync } from 'node:child_process' -import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { execFileSync, spawnSync } from 'node:child_process' +import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { fileURLToPath } from 'node:url' -for (const backend of [undefined, '', 'x11', 'wayland', 'wayland,x11']) { - test(`AppRun preserves the session backend (${JSON.stringify(backend)})`, { skip: process.platform !== 'linux' }, () => { - const root = mkdtempSync(join(tmpdir(), 'orx-apprun ')) - try { - mkdirSync(join(root, 'usr/bin'), { recursive: true }) - mkdirSync(join(root, 'apprun-hooks')) - copyFileSync(new URL('../linux/AppRun', import.meta.url), join(root, 'AppRun')) - // The external GTK plugin changes these values even on a Wayland session. - writeFileSync(join(root, 'apprun-hooks/gtk.sh'), ` +const hasCompiler = spawnSync('cc', ['--version']).status === 0 +if (process.platform === 'linux' && process.env.CI && !hasCompiler) throw new Error('AppImage launcher tests need cc') +const skip = process.platform !== 'linux' || !hasCompiler + +function fixture({ hostWorks = false, bundleWorks = true } = {}) { + const sandbox = mkdtempSync(join(tmpdir(), 'orx-apprun ')) + const root = join(sandbox, 'AppDir') + const host = join(sandbox, 'host') + const bundle = join(root, 'usr/lib') + for (const dir of [host, bundle, join(root, 'usr/bin'), join(root, 'apprun-hooks')]) mkdirSync(dir, { recursive: true }) + copyFileSync(new URL('../linux/AppRun', import.meta.url), join(root, 'AppRun')) + execFileSync('cc', [fileURLToPath(new URL('../linux/appimage-runtime.c', import.meta.url)), '-ldl', '-o', join(root, 'usr/bin/orx-runtime')]) + // Stand-ins for the external WebKit/GStreamer ABI, not the runtime checker. + for (const [dir, works] of [[host, hostWorks], [bundle, bundleWorks]]) { + const source = join(dir, 'sdk.c') + writeFileSync(source, ` +#include +int gst_init_check(void *a, void *b, void *c) { (void)a; (void)b; (void)c; return 1; } +void *gst_element_factory_make(const char *name, const char *instance) { + (void)name; (void)instance; return ${works ? 'malloc(1)' : 'NULL'}; +} +void g_object_unref(void *object) { free(object); } +`) + execFileSync('cc', ['-shared', '-fPIC', source, '-o', join(dir, 'libgstreamer-1.0.so.0')]) + execFileSync('cc', ['-shared', '-fPIC', source, '-o', join(dir, 'libwebkit2gtk-4.1.so.0')]) + writeFileSync(join(dir, 'libgtk-3.so.0'), '') + } + writeFileSync(join(root, 'apprun-hooks/gtk.sh'), ` printf hook >> "$HOOK_CALLS_FILE" export GDK_BACKEND=x11 GTK_THEME=bundled export XDG_DATA_DIRS="$APPDIR/usr/share:$XDG_DATA_DIRS" export GIO_EXTRA_MODULES="$APPDIR/usr/lib/gio/modules" `) - // Record the launcher's process boundary without starting the desktop app. - writeFileSync(join(root, 'usr/bin/orx'), `#!${process.execPath} + // Observe the launcher's CLI/process boundary without starting a server. + writeFileSync(join(root, 'usr/bin/orx'), `#!${process.execPath} +if (process.argv[2] === 'version') process.exit(process.env.REJECT_NATIVE_VERSION === '1' && process.env.ORX_APPIMAGE_RUNTIME !== 'bundled' ? 1 : 0) console.log(JSON.stringify({ args: process.argv.slice(2), cwd: process.cwd(), env: process.env })) `, { mode: 0o755 }) - const env = { - ...process.env, - APPDIR: root, - HOOK_CALLS_FILE: join(root, 'hooks-called'), - GTK_THEME: 'host theme:dark', - XDG_DATA_DIRS: '/host/share:/usr/share', - GIO_EXTRA_MODULES: '/host/gio', - } - delete env.GDK_BACKEND - if (backend !== undefined) env.GDK_BACKEND = backend - const result = JSON.parse(execFileSync('bash', [join(root, 'AppRun'), 'app', 'orx', 'openresearch:project/test'], { env, encoding: 'utf8' })) - assert.equal(readFileSync(env.HOOK_CALLS_FILE, 'utf8'), 'hook') + const env = { + ...process.env, APPDIR: root, HOOK_CALLS_FILE: join(root, 'hooks-called'), + LD_LIBRARY_PATH: host, GTK_THEME: 'host theme:dark', + XDG_DATA_DIRS: '/host/share:/usr/share', GIO_EXTRA_MODULES: '/host/gio', + } + delete env.ORX_APPIMAGE_RUNTIME + delete env.GDK_BACKEND + delete env.LD_PRELOAD + delete env.DISPLAY + delete env.WAYLAND_DISPLAY + return { sandbox, root, host, env, run: () => JSON.parse(execFileSync('bash', [join(root, 'AppRun'), 'app', 'orx', 'openresearch:project/test'], { env, encoding: 'utf8' })) } +} + +for (const backend of [undefined, '', 'x11', 'wayland', 'wayland,x11']) { + test(`AppRun preserves the session backend (${JSON.stringify(backend)})`, { skip }, () => { + const app = fixture() + try { + if (backend !== undefined) app.env.GDK_BACKEND = backend + const result = app.run() + assert.equal(readFileSync(app.env.HOOK_CALLS_FILE, 'utf8'), 'hook') assert.equal(result.env.GDK_BACKEND, backend) assert.equal(result.env.ORX_HOST_GDK_BACKEND, backend === undefined ? '-' : `=${backend}`) + assert.equal(result.env.ORX_HOST_LD_LIBRARY_PATH, `=${app.host}`) assert.equal(result.env.ORX_HOST_GTK_THEME, '=host theme:dark') assert.equal(result.env.ORX_HOST_XDG_DATA_DIRS, '=/host/share:/usr/share') assert.equal(result.env.ORX_HOST_GIO_EXTRA_MODULES, '=/host/gio') + assert.equal(result.env.ORX_APPIMAGE_RUNTIME, 'bundled') + assert.equal(result.env.LD_LIBRARY_PATH, app.host) assert.equal(result.env.GTK_THEME, 'bundled') assert.equal(result.env.GIO_EXTRA_MODULES, undefined) - assert.equal(result.env.GIO_MODULE_DIR, join(root, 'usr/lib/gio/modules')) - assert.equal(result.cwd, join(root, 'usr')) + assert.equal(result.env.GIO_MODULE_DIR, join(app.root, 'usr/lib/gio/modules')) + assert.equal(result.cwd, join(app.root, 'usr')) assert.deepEqual(result.args, ['app', 'openresearch:project/test']) - } finally { - rmSync(root, { recursive: true, force: true }) - } + } finally { rmSync(app.sandbox, { recursive: true, force: true }) } }) } + +test('AppRun uses the compatible host runtime without applying the bundled GTK hook', { skip }, () => { + const app = fixture({ hostWorks: true }) + try { + app.env.GDK_BACKEND = 'wayland' + const result = app.run() + assert.equal(result.env.ORX_APPIMAGE_RUNTIME, 'system') + assert.equal(result.env.LD_LIBRARY_PATH.split(':')[0], app.host) + assert.equal(result.env.ORX_HOST_LD_LIBRARY_PATH, `=${app.host}`) + assert.equal(result.env.GDK_BACKEND, 'wayland') + assert.equal(result.env.GTK_THEME, 'host theme:dark') + assert.equal(result.env.GIO_EXTRA_MODULES, '/host/gio') + assert.equal(existsSync(app.env.HOOK_CALLS_FILE), false) + assert.deepEqual(result.args, ['app', 'openresearch:project/test']) + } finally { rmSync(app.sandbox, { recursive: true, force: true }) } +}) + +test('AppRun rejects a host runtime that cannot load the desktop binary', { skip }, () => { + const app = fixture({ hostWorks: true }) + try { + app.env.REJECT_NATIVE_VERSION = '1' + assert.equal(app.run().env.ORX_APPIMAGE_RUNTIME, 'bundled') + assert.equal(readFileSync(app.env.HOOK_CALLS_FILE, 'utf8'), 'hook') + } finally { rmSync(app.sandbox, { recursive: true, force: true }) } +}) + +test('AppRun checks media elements again after choosing the system library directory', { skip }, () => { + const app = fixture() + try { + const alternate = join(app.sandbox, 'alternate') + mkdirSync(alternate) + copyFileSync(join(app.root, 'usr/lib/libgstreamer-1.0.so.0'), join(alternate, 'libgstreamer-1.0.so.0')) + app.env.LD_LIBRARY_PATH = `${alternate}:${app.host}` + assert.equal(app.run().env.ORX_APPIMAGE_RUNTIME, 'bundled') + } finally { rmSync(app.sandbox, { recursive: true, force: true }) } +}) + +test('AppRun reports missing media dependencies instead of starting a broken desktop', { skip }, () => { + const app = fixture({ bundleWorks: false }) + try { + const result = spawnSync('bash', [join(app.root, 'AppRun')], { env: app.env, encoding: 'utf8' }) + assert.notEqual(result.status, 0) + assert.equal(result.stdout, '') + assert.match(result.stderr, /Missing or incompatible GStreamer element: appsrc/) + assert.match(result.stderr, /sudo apt install libwebkit2gtk-4\.1-0/) + assert.match(result.stderr, /sudo pacman -S --needed webkit2gtk-4\.1/) + } finally { rmSync(app.sandbox, { recursive: true, force: true }) } +}) diff --git a/scripts/smoke-test-linux-appimage.sh b/scripts/smoke-test-linux-appimage.sh index 27f4205dc..38a5318b8 100755 --- a/scripts/smoke-test-linux-appimage.sh +++ b/scripts/smoke-test-linux-appimage.sh @@ -1,19 +1,14 @@ #!/usr/bin/env bash -# Launch a built AppImage under a virtual display and prove its bundled WebKit -# loads the dashboard: the server answers, WebKit's network process has connected -# to it, the window appears once the page has loaded, the desktop entry points at -# this AppImage, and every WebKit helper runs from inside the image on the image's -# libwebkit2gtk and no host GIO modules. A sandboxed HTML preview also creates -# an AudioContext and decodes an H.264/AAC video into a canvas. Run it where -# WebKitGTK and GStreamer plugins are not installed, -# or a fallback to the host's copy would pass. Needs xvfb-run, curl, ss, setsid, -# and xdotool. +# Test automatic system-WebKit selection, the bundled-WebKit fallback, or the +# missing-media dependency dialog. The probe uses the selected desktop runtime. # -# scripts/smoke-test-linux-appimage.sh .AppImage> +# scripts/smoke-test-linux-appimage.sh [system|bundled|missing] set -euo pipefail APPIMAGE="$(realpath "$1")" MEDIA_PROBE="$(realpath "$2")" +MODE="${3:-system}" +case "$MODE" in system|bundled|missing) ;; *) echo "Unknown runtime test mode: $MODE" >&2; exit 1 ;; esac ROOT="$(cd "$(dirname "$0")/.." && pwd)" SANDBOX="$(mktemp -d)" LOG="$SANDBOX/app.log" @@ -31,7 +26,7 @@ export TMPDIR="$SANDBOX" # The hook changes the theme and data directories and forces X11. AppRun must # save their original values before sourcing it, and leave backend choice to GTK. -unset GDK_BACKEND +unset GDK_BACKEND LD_LIBRARY_PATH export GTK_THEME=Adwaita:dark APPIMAGE_GTK_THEME=Adwaita:light export XDG_DATA_DIRS=/usr/local/share:/usr/share @@ -40,6 +35,12 @@ setsid xvfb-run -a "$APPIMAGE" >"$LOG" 2>&1 & APP=$! cleanup() { kill -- -"$APP" 2>/dev/null || true + for _ in $(seq 1 30); do + kill -0 -- -"$APP" 2>/dev/null || break + sleep 0.1 + done + kill -KILL -- -"$APP" 2>/dev/null || true + wait "$APP" 2>/dev/null || true # orx may still be shutting down and writing into it. rm -rf "$SANDBOX" || true } @@ -52,6 +53,36 @@ fail() { exit 1 } +if [ "$MODE" = missing ]; then + dialog= + for _ in $(seq 1 45); do + for pid in $(pgrep -f 'usr/bin/orx-runtime error' || true); do + grep -zqxF "XDG_RUNTIME_DIR=$XDG_RUNTIME_DIR" "/proc/$pid/environ" 2>/dev/null || continue + X_ENV="$(tr '\0' '\n' <"/proc/$pid/environ" | grep -E '^(DISPLAY|XAUTHORITY)=')" + # shellcheck disable=SC2086 + dialog="$(env $X_ENV xdotool search --onlyvisible --name '^OpenResearch needs multimedia libraries$' || true)" + [ -n "$dialog" ] && break + done + [ -n "$dialog" ] && break + kill -0 "$APP" 2>/dev/null || fail "No dependency dialog appeared." + sleep 1 + done + [ -n "$dialog" ] || fail "No dependency dialog appeared." + grep -q 'sudo apt install libwebkit2gtk-4.1-0' "$LOG" || fail "No install instructions were printed." + # shellcheck disable=SC2086 + env $X_ENV xdotool windowfocus "$dialog" key Escape || true # Xvfb can exit before key release. + for _ in $(seq 1 10); do + kill -0 "$APP" 2>/dev/null || break + sleep 1 + done + kill -0 "$APP" 2>/dev/null && fail "Escape did not close the dependency dialog." + status=0 + wait "$APP" || status=$? + [ "$status" -eq 1 ] || fail "The missing-runtime launch returned $status, expected 1." + echo "Missing media dependencies produce an actionable dialog and a failed launch." + exit 0 +fi + for _ in $(seq 1 120); do curl -fsS -o /dev/null http://127.0.0.1:4792/api/health && break kill -0 "$APP" 2>/dev/null || fail "The app exited before its dashboard came up." @@ -72,13 +103,21 @@ done pgrep -f WebKitWebProcess >/dev/null || fail "No WebKit web process is running." # The app shows its window when the page finishes loading, or says it gave up. -ORX_PID="$(pgrep -o -f 'usr/bin/orx app' || true)" +ORX_PID= +for pid in $(pgrep -f 'usr/bin/orx app' || true); do + if grep -zqxF "XDG_RUNTIME_DIR=$XDG_RUNTIME_DIR" "/proc/$pid/environ" 2>/dev/null; then ORX_PID="$pid"; break; fi +done [ -n "$ORX_PID" ] || fail "orx app is not running." APP_ENV="$(tr '\0' '\n' <"/proc/$ORX_PID/environ")" for entry in 'ORX_HOST_GDK_BACKEND=-' 'ORX_HOST_GTK_THEME==Adwaita:dark' \ - 'ORX_HOST_XDG_DATA_DIRS==/usr/local/share:/usr/share' 'GTK_THEME=Adwaita:light'; do + 'ORX_HOST_XDG_DATA_DIRS==/usr/local/share:/usr/share' 'ORX_HOST_LD_LIBRARY_PATH=-' "ORX_APPIMAGE_RUNTIME=$MODE"; do grep -qxF "$entry" <<<"$APP_ENV" || fail "AppRun did not preserve the host environment: $entry" done +if [ "$MODE" = system ]; then + grep -qxF 'GTK_THEME=Adwaita:dark' <<<"$APP_ENV" || fail "The system runtime changed the host theme." +else + grep -qxF 'GTK_THEME=Adwaita:light' <<<"$APP_ENV" || fail "The bundled GTK hook did not run." +fi if grep -q '^GDK_BACKEND=' <<<"$APP_ENV"; then fail "AppRun forced a display backend instead of letting GTK select it." fi @@ -99,16 +138,18 @@ grep -qxF "TryExec=$APPIMAGE" "$XDG_DATA_HOME/applications/openresearch.desktop" || fail "The app did not install its desktop entry." mapfile -d '' -t media_env <"/proc/$ORX_PID/environ" -image_exe="$(readlink "/proc/$ORX_PID/exe")" -image_lib="$(dirname "$(dirname "$image_exe")")/lib" +if [ "$MODE" = bundled ]; then + image_exe="$(readlink "/proc/$ORX_PID/exe")" + media_env+=("LD_LIBRARY_PATH=$(dirname "$(dirname "$image_exe")")/lib") +fi if ! ( ulimit -c 0 cd "/proc/$ORX_PID/cwd" - timeout 35 env -i "${media_env[@]}" LD_LIBRARY_PATH="$image_lib" \ + timeout 35 env -i "${media_env[@]}" \ "$MEDIA_PROBE" "$ROOT/scripts/fixtures/appimage-media.html" ) >"$SANDBOX/media.log" 2>&1; then cat "$SANDBOX/media.log" - fail "The AppImage's WebKit could not render sandboxed HTML audio/video." + fail "The selected WebKit could not render sandboxed HTML audio/video." fi cat "$SANDBOX/media.log" @@ -119,16 +160,19 @@ for pid in $(pgrep -f 'WebKit(Web|Network)Process'); do exe="$(readlink "/proc/$pid/exe")" || continue maps="$(cat "/proc/$pid/maps" 2>/dev/null)" || continue helpers=$((helpers + 1)) - case "$exe" in - *appimage_extracted_*) ;; - *) fail "WebKit helper $pid runs $exe, not the AppImage's." ;; - esac - grep -q 'appimage_extracted_.*/libwebkit2gtk' <<<"$maps" \ - || fail "WebKit helper $pid did not load the AppImage's libwebkit2gtk." - if grep -q ' /usr/lib/[^ ]*/gio/modules/' <<<"$maps"; then - fail "WebKit helper $pid loaded the host's GIO modules." + if [ "$MODE" = system ]; then + case "$exe" in *appimage_extracted_*) fail "The system runtime started a bundled helper." ;; esac + if grep -Eq 'appimage_extracted_.*/lib(webkit|glib|gst)' <<<"$maps"; then + fail "A system WebKit helper loaded the bundled runtime." + fi + else + case "$exe" in *appimage_extracted_*) ;; *) fail "A bundled helper runs $exe." ;; esac + grep -q 'appimage_extracted_.*/libwebkit2gtk' <<<"$maps" || fail "Bundled WebKit was not loaded." + if grep -q ' /usr/lib/[^ ]*/gio/modules/' <<<"$maps"; then + fail "The bundled helper loaded the host's GIO modules." + fi fi done -[ "$helpers" -gt 0 ] || fail "No bundled WebKit helper could be inspected." -echo "The AppImage's WebKit loaded the dashboard:" +[ "$helpers" -gt 0 ] || fail "No WebKit helper could be inspected." +echo "The $MODE WebKit loaded the dashboard and media:" pgrep -af 'WebKit(Web|Network)Process' diff --git a/src/local/shell_env.rs b/src/local/shell_env.rs index fd7e5d8e0..52e7ac692 100644 --- a/src/local/shell_env.rs +++ b/src/local/shell_env.rs @@ -330,9 +330,10 @@ pub fn export_to(mut set: impl FnMut(&'static str, &OsString)) { } } -/// What the Linux AppImage's AppRun and GTK hook point at the image (keep in step +/// What the Linux AppImage's AppRun overrides for its runtime (keep in step /// with linux/AppRun, which saves the session's values as `ORX_HOST_`). -const APPIMAGE_GTK_VARS: [&str; 12] = [ +const APPIMAGE_RUNTIME_VARS: [&str; 13] = [ + "LD_LIBRARY_PATH", "GDK_BACKEND", "GDK_PIXBUF_MODULE_FILE", "GI_TYPELIB_PATH", @@ -347,10 +348,10 @@ const APPIMAGE_GTK_VARS: [&str; 12] = [ "XDG_DATA_DIRS", ]; -/// The session's own values (`None`: unset) of what AppRun set for the bundled GTK, +/// The session's own values (`None`: unset) of what AppRun set for its runtime, /// for host programs, which fail on the image's schemas. Empty outside the AppImage. pub fn host_gui_env() -> Vec<(&'static str, Option)> { - APPIMAGE_GTK_VARS + APPIMAGE_RUNTIME_VARS .iter() .filter_map(|var| { let saved = std::env::var_os(format!("ORX_HOST_{var}"))?; @@ -410,6 +411,23 @@ pub fn parse_probe(stdout: &str, marker: &str) -> Option command.env(var, value), + None => command.env_remove(var), + }; + } #[cfg(test)] { command.env(crate::config::PARENT_TEST_ROOT, crate::config::test_root());