diff --git a/.github/workflows/release-canary.yml b/.github/workflows/release-canary.yml index b029bc7e6..b0d5d82fb 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,13 @@ 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 + # 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) + 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 + 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 33bb254d3..7788d3799 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 @@ -120,7 +130,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 +144,25 @@ 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: | + packaging-workflow/scripts/build-linux-appimage.sh target/release/orx app-dist + # 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 from the runner + - 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: Remove the system WebKit run: sudo apt-get remove -y libwebkit2gtk-4.1-0 libjavascriptcoregtk-4.1-0 - - name: Smoke-test the AppImage - run: scripts/smoke-test-linux-appimage.sh app-dist/OpenResearch-${{ matrix.arch }}.AppImage + - 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 8a1af94a8..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 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** 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/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..8b1ea29fb 100755 --- a/scripts/build-linux-appimage.sh +++ b/scripts/build-linux-appimage.sh @@ -62,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 @@ -85,6 +86,8 @@ DEPLOY_GTK_VERSION=3 linuxdeploy \ # 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. 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/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..38a5318b8 100755 --- a/scripts/smoke-test-linux-appimage.sh +++ b/scripts/smoke-test-linux-appimage.sh @@ -1,16 +1,15 @@ #!/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. Run it where WebKitGTK is 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" export ORX_DATA_DIR="$SANDBOX/data" XDG_CONFIG_HOME="$SANDBOX/config" XDG_DATA_HOME="$SANDBOX/share" @@ -27,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 @@ -36,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 } @@ -48,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." @@ -68,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 @@ -94,19 +137,42 @@ 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" +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[@]}" \ + "$MEDIA_PROBE" "$ROOT/scripts/fixtures/appimage-media.html" +) >"$SANDBOX/media.log" 2>&1; then + cat "$SANDBOX/media.log" + fail "The selected 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 - 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." + helpers=$((helpers + 1)) + 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 -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());