diff --git a/.github/workflows/nix-build.yml b/.github/workflows/nix-build.yml index 94c5b2bd..2332e3d6 100644 --- a/.github/workflows/nix-build.yml +++ b/.github/workflows/nix-build.yml @@ -147,6 +147,94 @@ jobs: echo "closure: $(nix path-info --closure-size --human-readable ./result | tail -1)" ls -la result/bin/ + # The native components are resolved at runtime behind existence checks, so + # a wrapper pointing at a path that is not there degrades the app instead of + # failing it -- silently, which is the failure mode this whole workflow was + # written for. The smoke test below covers the compositor addon by + # exercising export; nothing exercises the PipeWire helper, because that + # needs a Wayland portal this runner does not have. Asserting the file + # exists and is executable is the part that can be checked here, and it is + # the part that actually broke when a --set pointed somewhere wrong. + - name: Check the wrapper's native components + run: | + set -euo pipefail + missing=0 + + wrapper_value() { + sed -nE "s/^export $1='(.*)'\$/\1/p" result/bin/openscreen | tail -1 + } + + # -x, not -e, for the ones that get spawned. The app agrees: the helper + # lookup in pipeWireCursorRecordingSession.ts requires X_OK before it + # accepts a candidate, so a present-but-not-executable file would pass a + # mere existence check here and be rejected at runtime. The compositor + # addon is require()'d rather than spawned, so readable is its bar. + for entry in \ + "OPENSCREEN_FFMPEG_PATH:-x" \ + "OPENSCREEN_COMPOSITOR_VIEW_NODE:-r" \ + "OPENSCREEN_LINUX_CURSOR_HELPER_EXE:-x" \ + "OPENSCREEN_WHISPER_SERVER_EXE:-x"; do + var=${entry%:*} + test=${entry##*:} + # Read the value the wrapper exports rather than re-deriving it here, + # so this checks the artefact and not the recipe. + path=$(wrapper_value "$var") + if [ -z "$path" ]; then + echo "::error::$var is not set by the wrapper" + missing=$((missing + 1)) + continue + fi + if [ ! "$test" "$path" ]; then + echo "::error::$var points at $path, which fails $test" + missing=$((missing + 1)) + continue + fi + echo "$var -> $path ($test)" + done + + # ldd was the wrong instrument here, and it passed for the wrong reason: + # it resolves DT_NEEDED entries, and libpipewire is reached by dlopen, so + # it is never a DT_NEEDED and never appears. The check would have gone + # green with the hand-added RPATH missing entirely -- validating + # everything except the one thing this packaging adds. + # + # Read the RPATH out of the binary instead, and require that one of its + # directories actually holds the soname the shim hands to dlopen. + HELPER=$(wrapper_value OPENSCREEN_LINUX_CURSOR_HELPER_EXE) + if [ -n "$HELPER" ]; then + echo "--- helper dlopen contract ---" + # DT_RPATH or DT_RUNPATH: the derivation asks for the former, but read + # whichever is there rather than asserting which, so a silent + # conversion is reported instead of looking like an absent RPATH. + RPATH=$(readelf -d "$HELPER" | sed -nE 's/.*\((RPATH|RUNPATH)\).*\[(.*)\]/\2/p' | tail -1) + echo "rpath: ${RPATH:-}" + found="" + origin=$(dirname "$HELPER") + IFS=: read -ra dirs <<<"$RPATH" + for dir in ${dirs[@]+"${dirs[@]}"}; do + # $ORIGIN is relative to the binary; the ffmpeg entries use it. + dir=${dir//\$ORIGIN/$origin} + if [ -e "$dir/libpipewire-0.3.so.0" ]; then + found="$dir" + break + fi + done + if [ -z "$found" ]; then + echo "::error::no RPATH entry of the helper holds libpipewire-0.3.so.0; its dlopen will fail on any host without an ld.so.cache" + missing=$((missing + 1)) + else + echo "libpipewire-0.3.so.0 resolves from $found" + fi + # Unresolved DT_NEEDED entries are a different fault, and this is + # where they would show. + if ldd "$HELPER" | grep -q "not found"; then + echo "::error::the helper has unresolved DT_NEEDED libraries" + ldd "$HELPER" | grep "not found" + missing=$((missing + 1)) + fi + fi + [ "$missing" -eq 0 ] + # Electron initialises Chromium even for the headless CLI, so it needs a # display server present. xvfb ships on ubuntu-latest. # diff --git a/flake.nix b/flake.nix index a2af9551..be01a58b 100644 --- a/flake.nix +++ b/flake.nix @@ -17,13 +17,27 @@ { # -- Per-system outputs (packages, dev shells) -- - packages = forAllSystems (pkgs: { - compositor-view = pkgs.callPackage ./nix/compositor-view.nix { }; - openscreen = pkgs.callPackage ./nix/package.nix { - compositor-view = pkgs.callPackage ./nix/compositor-view.nix { }; - }; - default = self.packages.${pkgs.stdenv.hostPlatform.system}.openscreen; - }); + packages = forAllSystems ( + pkgs: + let + # Bound once and reused. compositor-view used to be applied twice -- + # once for the exposed attribute, once inline as package.nix's argument + # -- which produces the same store path today but means an override + # applied to the attribute never reaches the app. With a second native + # component the same mistake would have been made twice. + ffmpeg-lgpl = pkgs.callPackage ./nix/ffmpeg-lgpl.nix { }; + compositor-view = pkgs.callPackage ./nix/compositor-view.nix { inherit ffmpeg-lgpl; }; + pipewire-helper = pkgs.callPackage ./nix/pipewire-helper.nix { inherit ffmpeg-lgpl; }; + whisper-stt = pkgs.callPackage ./nix/whisper-stt.nix { }; + in + { + inherit compositor-view pipewire-helper whisper-stt; + openscreen = pkgs.callPackage ./nix/package.nix { + inherit compositor-view pipewire-helper whisper-stt; + }; + default = self.packages.${pkgs.stdenv.hostPlatform.system}.openscreen; + } + ); devShells = forAllSystems ( pkgs: diff --git a/nix/compositor-view.nix b/nix/compositor-view.nix index 6a71a2bf..e939750d 100644 --- a/nix/compositor-view.nix +++ b/nix/compositor-view.nix @@ -8,7 +8,7 @@ { lib, rustPlatform, - ffmpeg, + ffmpeg-lgpl, symlinkJoin, pkg-config, rustfmt, @@ -18,21 +18,9 @@ }: let - # nixpkgs' default ffmpeg has no H.264 encoder this project can use, which the - # app reports precisely: - # - # aucun encodeur video utilisable : libopenh264: absent de ce build ffmpeg - # - # withOpenh264 defaults to withFullDeps, so only ffmpeg-full carries it, while - # withX264 is on by default and is GPL. Both halves of this override matter. - # scripts/fetch-ffmpeg.mjs vendors BtbN's *lgpl* build and asserts the licence - # before using it, so linking GPL x264 into an MIT application is the exact - # thing upstream takes care to avoid -- a nix package that quietly did it would - # be a licensing fault, not a packaging shortcut. - ffmpegLgpl = ffmpeg.override { - withOpenh264 = true; - withGPL = false; - }; + # Was an inline `ffmpeg.override` here; moved to nix/ffmpeg-lgpl.nix once the + # PipeWire helper became a third consumer of the same subtle pair of flags. + ffmpegLgpl = ffmpeg-lgpl; # crates/compositor/build.rs wants a single tree holding both include/ and # lib/, the shape of the vendored ffmpeg the Windows and Linux scripts diff --git a/nix/ffmpeg-lgpl.nix b/nix/ffmpeg-lgpl.nix new file mode 100644 index 00000000..817e34a7 --- /dev/null +++ b/nix/ffmpeg-lgpl.nix @@ -0,0 +1,28 @@ +# The ffmpeg both native components link against. +# +# nixpkgs' default has no H.264 encoder this project can use, which the app +# reports precisely: +# +# aucun encodeur video utilisable : libopenh264: absent de ce build ffmpeg +# +# withOpenh264 defaults to withFullDeps, so only ffmpeg-full carries it, while +# withX264 is on by default and is GPL. Both halves of the override matter. +# scripts/fetch-ffmpeg.mjs vendors BtbN's *lgpl* build and asserts the licence +# before using it, so linking GPL x264 into an MIT application is the exact thing +# upstream takes care to avoid -- a nix package that quietly did it would be a +# licensing fault, not a packaging shortcut. +# +# Its own file because there are now three consumers with different needs, and a +# subtle override copied three times is one bump away from disagreeing with +# itself. compositor-view.nix links it into the napi addon (with every symbol +# renamed, since that one shares an address space with Chromium's libffmpeg); +# pipewire-helper.nix links it normally (a separate process, so no collision); +# package.nix wants only the *binary* and takes the headless variant instead, +# which is a deliberate difference and not drift -- the CLI only decodes to raw +# PCM, so X11 and SDL would be closure weight for nothing. +{ ffmpeg }: + +ffmpeg.override { + withOpenh264 = true; + withGPL = false; +} diff --git a/nix/package.nix b/nix/package.nix index 23eb3968..9e5dd1e2 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -5,6 +5,8 @@ electron, ffmpeg-headless, compositor-view, + pipewire-helper, + whisper-stt, makeWrapper, makeDesktopItem, copyDesktopItems, @@ -103,12 +105,22 @@ buildNpmPackage { # -headless rather than the full build: the only invocation is a decode to # raw PCM (-i/-ac/-ar/-f), so X11 and SDL would be closure weight for # nothing. + # + # OPENSCREEN_LINUX_CURSOR_HELPER_EXE is the first candidate in + # helperCandidates (pipeWireCursorRecordingSession.ts), and the same lookup + # serves linuxNativeCaptureSession, so one variable covers both consumers. + # Every other candidate is relative to APP_ROOT or resourcesPath and assumes + # the electron-builder layout, which this package does not produce; without + # the override the helper is simply never found and Wayland capture and + # cursor sampling degrade silently. mkdir -p "$out/bin" makeWrapper "${electron}/bin/electron" "$out/bin/openscreen" \ --add-flags "$out/lib/openscreen" \ --set ELECTRON_IS_DEV 0 \ --set OPENSCREEN_FFMPEG_PATH "${ffmpegLgpl}/bin/ffmpeg" \ - --set OPENSCREEN_COMPOSITOR_VIEW_NODE "${compositor-view}/lib/compositor_view.node" + --set OPENSCREEN_COMPOSITOR_VIEW_NODE "${compositor-view}/lib/compositor_view.node" \ + --set OPENSCREEN_LINUX_CURSOR_HELPER_EXE "${lib.getExe pipewire-helper}" \ + --set OPENSCREEN_WHISPER_SERVER_EXE "${lib.getExe whisper-stt}" # Install icons to hicolor theme for size in 16 24 32 48 64 128 256 512 1024; do diff --git a/nix/pipewire-helper.nix b/nix/pipewire-helper.nix new file mode 100644 index 00000000..761e408f --- /dev/null +++ b/nix/pipewire-helper.nix @@ -0,0 +1,115 @@ +# The Linux capture sidecar: Wayland screen capture through xdg-desktop-portal +# and PipeWire, plus the cursor sampling the compositor cannot give us. Without +# it pipeWireCursorRecordingSession reports "Linux cursor helper is not +# available" and the app degrades to no cursor data on Wayland. +# +# Its own derivation, mirroring what the crate already is: a standalone package +# with its own [workspace] and its own Cargo.lock, deliberately outside the +# compositor workspace (see the comment at the top of its Cargo.toml). Nothing is +# shared with compositor-view.nix except the ffmpeg, and that now comes from a +# common file. +# +# NOTHING HERE NEEDS THE osff_ SYMBOL PREFIXING. That scheme exists because the +# napi addon is dlopen'd into Electron, where Chromium's own libffmpeg.so already +# holds the global symbol scope. This is a separate process spawned over stdio, +# so the flat-namespace collision cannot happen and ffmpeg links normally -- +# which is also why this derivation is a fraction of its sibling's size. +{ + lib, + rustPlatform, + ffmpeg-lgpl, + symlinkJoin, + pkg-config, + patchelfUnstable, + pipewire, +}: + +let + # build.rs wants a single tree holding both include/ and lib/, the shape of the + # vendored ffmpeg the Linux script downloads. nixpkgs splits ffmpeg across + # outputs, so join them back. Unlike compositor-view.nix, both halves are used + # as-is: no renamed copies, so no staging. + ffmpegTree = symlinkJoin { + name = "ffmpeg-tree-for-pipewire-helper"; + paths = [ + ffmpeg-lgpl.dev + ffmpeg-lgpl.lib + ]; + }; +in +rustPlatform.buildRustPackage { + pname = "openscreen-pipewire-helper"; + version = (lib.importJSON ../package.json).version; + + # gitTracked, not cleanSource, for the reason nix/package.nix spells out and + # nix/compositor-view.nix learned the hard way: cleanSource honours neither + # .gitignore nor git tracking, so a developer's `build/` output would land in + # the store and move the src hash on every local cargo invocation. The vendored + # PipeWire headers under vendor/ ARE tracked and must survive the filter -- + # build.rs asserts on pipewire/pipewire.h and fails the build without them. + src = + let + fs = lib.fileset; + isStorePath = + builtins.storeDir + == builtins.substring 0 (builtins.stringLength builtins.storeDir) (toString ../.); + baseFiles = if isStorePath then fs.fromSource (lib.cleanSource ../.) else fs.gitTracked ../.; + crate = ../electron/native/pipewire-capture; + in + fs.toSource { + root = crate; + fileset = fs.intersection baseFiles crate; + }; + + cargoLock.lockFile = ../electron/native/pipewire-capture/Cargo.lock; + + nativeBuildInputs = [ + # libclang for bindgen, which build.rs uses to read the ffmpeg headers. The + # hook also sets BINDGEN_EXTRA_CLANG_ARGS, which is what makes build.rs's + # freestanding_header_args() return early: that function hunts through + # /usr/lib/gcc for a libclang missing its builtin headers, a Debian problem + # that does not exist here and whose search would find nothing anyway. + rustPlatform.bindgenHook + pkg-config + # --add-rpath arrived in 0.14 and --force-rpath predates it, but nixpkgs' + # stable patchelf is old enough that compositor-view.nix already had to reach + # for the unstable one; keep the two derivations on the same tool. + patchelfUnstable + ]; + + buildInputs = [ ffmpeg-lgpl ]; + + env.FFMPEG_DIR = "${ffmpegTree}"; + + # There is no PipeWire build dependency by design -- csrc/pw_shim.c is compiled + # against the vendored headers and resolves every libpipewire entry point with + # dlsym, so a C compiler is the whole requirement. See the comment at the top of + # that file. + # + # The corollary is that nothing links libpipewire, so nothing puts it on the + # binary's RPATH, and `dlopen("libpipewire-0.3.so.0")` then fails on any host + # without an ld.so.cache -- which is every NixOS host. Same shape as the Vulkan + # loader in compositor-view.nix: an soname reached by dlopen is invisible to the + # linker and has to be added deliberately. + # + # --force-rpath because build.rs passes -Wl,--disable-new-dtags on purpose: it + # wants DT_RPATH rather than DT_RUNPATH, so that the entries apply to the + # transitive ffmpeg libraries too. patchelf defaults to writing DT_RUNPATH, + # which would silently undo that choice. + postInstall = '' + patchelf --force-rpath --add-rpath "${lib.makeLibraryPath [ pipewire ]}" \ + "$out/bin/openscreen-pipewire-helper" + ''; + + # The suite covers the accumulator on the TypeScript side; the crate itself has + # no tests, and cargo test here would only rebuild it. + doCheck = false; + + meta = { + description = "PipeWire/portal capture helper for OpenScreen"; + homepage = "https://github.com/getopenscreen/openscreen"; + license = lib.licenses.mit; + platforms = lib.platforms.linux; + mainProgram = "openscreen-pipewire-helper"; + }; +} diff --git a/nix/whisper-stt.nix b/nix/whisper-stt.nix new file mode 100644 index 00000000..831222be --- /dev/null +++ b/nix/whisper-stt.nix @@ -0,0 +1,155 @@ +# The on-device speech-to-text server that backs captions. Without it +# resolveWhisperServer finds nothing, `openscreen captions` fails and the AI +# edition's transcription pump never starts. +# +# THE MODEL IS NOT PACKAGED, AND SHOULD NOT BE. modelManager.ts downloads a GGML +# file from HuggingFace into userData on first use, checksums it, and replaces a +# stale copy. That is a runtime concern with a cache the user owns; baking a +# multi-gigabyte blob into the store would be wrong even if the sandbox allowed +# the download, which it does not. +# +# WHAT MAKES THIS ONE AWKWARD. The CMakeLists pulls whisper.cpp, cpp-httplib and +# nlohmann/json with FetchContent at configure time, over the network, which a nix +# build does not have. Rather than patch the CMakeLists -- the pins are +# deliberate and documented there, and a nix-only fork of them would drift from +# what every other platform builds -- the three trees are fetched here and handed +# to FetchContent through FETCHCONTENT_SOURCE_DIR_, which is exactly the +# override CMake provides for this. FETCHCONTENT_FULLY_DISCONNECTED then makes a +# missed one fail loudly instead of silently reaching for the network. +{ + lib, + stdenv, + cmake, + fetchurl, + shaderc, + vulkan-headers, + vulkan-loader, +}: + +let + # fetchurl on a pinned tag, not fetchFromGitHub, and the difference is worth + # stating because it is a trade rather than a preference. fetchFromGitHub hashes + # the unpacked tree, which is immune to GitHub re-compressing an archive; it + # also cannot be computed or checked without nix. These hashes are of the + # tarball itself, so any reviewer can verify one with curl and sha256sum, on any + # platform -- which is the only way this file could be produced or reviewed from + # a machine with no nix on it. If GitHub ever re-compresses a tag, the build + # fails closed with a hash mismatch and the fix is one line. + whisperSrc = fetchurl { + url = "https://github.com/ggml-org/whisper.cpp/archive/refs/tags/v1.9.1.tar.gz"; + sha256 = "147267177eef7b22ec3d2476dd514d1b12e160e176230b740e3d1bd600118447"; + }; + httplibSrc = fetchurl { + url = "https://github.com/yhirose/cpp-httplib/archive/refs/tags/v0.18.1.tar.gz"; + sha256 = "405abd8170f2a446fc8612ac635d0db5947c0d2e156e32603403a4496255ff00"; + }; + jsonSrc = fetchurl { + url = "https://github.com/nlohmann/json/archive/refs/tags/v3.11.3.tar.gz"; + sha256 = "0d8ef5af7f9794e3263480193c491549b2ba6cc74bb018906202ada498a79406"; + }; +in +stdenv.mkDerivation { + pname = "openscreen-whisper-stt"; + version = (lib.importJSON ../package.json).version; + + # gitTracked for the same reason as the other two native derivations: a local + # build/ tree would otherwise land in the store and move the src hash on every + # cmake invocation. + src = + let + fs = lib.fileset; + isStorePath = + builtins.storeDir + == builtins.substring 0 (builtins.stringLength builtins.storeDir) (toString ../.); + baseFiles = if isStorePath then fs.fromSource (lib.cleanSource ../.) else fs.gitTracked ../.; + helper = ../electron/native/whisper-stt; + in + fs.toSource { + root = helper; + fileset = fs.intersection baseFiles helper; + }; + + nativeBuildInputs = [ + cmake + # glslc, for whisper.cpp's vulkan-shaders-gen. Only reachable with + # OSC_ENABLE_VULKAN=ON below, and the sub-project fails at configure time + # without it rather than degrading. + shaderc + ]; + + buildInputs = [ + vulkan-headers + vulkan-loader + ]; + + # OSC_ENABLE_VULKAN=ON is what scripts/build-whisper-stt.sh selects for + # linux-x64 and linux-arm64, and matching it is the point: the alternative is a + # CPU-only binary, which works but is the same class of silent reduction this + # packaging exists to remove. ggml links libvulkan normally here -- unlike the + # compositor addon and the PipeWire helper, nothing is dlopen'd by soname, so + # the linker records it and no RPATH surgery is needed. + # + # OSC_NATIVE_CPU is left off, deliberately and per the CMakeLists' own warning: + # ON would compile with -march=native for whichever machine ran the build, and + # a nix package is precisely a thing built once and run elsewhere. + cmakeFlags = [ + "-DCMAKE_BUILD_TYPE=Release" + "-DOSC_ENABLE_VULKAN=ON" + "-DFETCHCONTENT_FULLY_DISCONNECTED=ON" + ]; + + # The tarballs are unpacked before cmake runs and the flags are rewritten to + # point at them. Done here rather than in cmakeFlags above because the paths are + # only known once the archives are extracted, and $NIX_BUILD_TOP is not + # available at evaluation time. + preConfigure = '' + deps="$NIX_BUILD_TOP/fetchcontent" + mkdir -p "$deps" + tar -xzf ${whisperSrc} -C "$deps" + tar -xzf ${httplibSrc} -C "$deps" + tar -xzf ${jsonSrc} -C "$deps" + + cmakeFlagsArray+=( + "-DFETCHCONTENT_SOURCE_DIR_WHISPER=$deps/whisper.cpp-1.9.1" + "-DFETCHCONTENT_SOURCE_DIR_HTTPLIB=$deps/cpp-httplib-0.18.1" + "-DFETCHCONTENT_SOURCE_DIR_JSON=$deps/json-3.11.3" + ) + + for dir in "$deps"/whisper.cpp-1.9.1 "$deps"/cpp-httplib-0.18.1 "$deps"/json-3.11.3; do + test -d "$dir" || { + echo "expected unpacked source at $dir; the tarball layout changed" >&2 + exit 1 + } + done + ''; + + # Everything in one directory, binary and shared objects together. + # + # Not a nix convention, and deliberate: the CMakeLists sets + # CMAKE_INSTALL_RPATH to '$ORIGIN:$ORIGIN/bin' for Linux, so the binary looks + # for libwhisper.so and the ggml objects beside itself. That is also exactly how + # scripts/stage-whisper-stt.sh lays them out in electron/native/bin/linux-x64/. + # Splitting them into $out/bin and $out/lib would mean overriding an RPATH the + # upstream file chose on purpose, for the sake of a directory name. + installPhase = '' + runHook preInstall + mkdir -p "$out/bin" + cp whisper-stt-server "$out/bin/" + find . -name '*.so' -o -name '*.so.*' | while read -r so; do + cp "$so" "$out/bin/" + done + test -x "$out/bin/whisper-stt-server" || { + echo "whisper-stt-server was not produced" >&2 + exit 1 + } + runHook postInstall + ''; + + meta = { + description = "On-device speech-to-text server for OpenScreen captions"; + homepage = "https://github.com/getopenscreen/openscreen"; + license = lib.licenses.mit; + platforms = lib.platforms.linux; + mainProgram = "whisper-stt-server"; + }; +}