From a00cca5cc39efcc269b0373ea5521c9be9323da2 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Fri, 21 Aug 2026 22:54:04 +0200 Subject: [PATCH 1/3] feat(nix): package the PipeWire capture helper The third native component, and the last one #419 listed as deliberately left out. Without it pipeWireCursorRecordingSession reports "Linux cursor helper is not available" and the app degrades to no cursor data on Wayland -- the same quiet reduction the compositor addon produced before it was packaged, and the one the smoke test exists to catch. The crate is far easier to package than its sibling, and for a reason worth stating: it is a separate process spawned over stdio, not a napi addon dlopen'd into Electron, so Chromium's libffmpeg.so is not in its address space and the osff_ symbol prefixing that dominates compositor-view.nix has no counterpart here. ffmpeg links normally. PipeWire itself is not a build input at all -- the C shim resolves every entry point with dlsym against vendored headers, which is what lets the crate build on a machine with no libpipewire-0.3-dev. That last part has a corollary the Vulkan loader already taught us: an soname reached only by dlopen is invisible to the linker, so nothing puts libpipewire on the binary's RPATH and dlopen fails on any host without an ld.so.cache. Added by hand, with --force-rpath because build.rs passes --disable-new-dtags on purpose and patchelf would otherwise convert DT_RPATH to DT_RUNPATH and undo it. Three consumers now share one subtle ffmpeg override, so it moves to nix/ffmpeg-lgpl.nix rather than being copied a third time. package.nix keeps the headless variant for the CLI binary, which is a deliberate difference and is written down as such. flake.nix binds each component once instead of applying callPackage twice -- harmless today, since identical arguments give an identical store path, but it meant an override applied to the exposed attribute never reached the app, and with a second component the mistake would have been made twice. CI gains an assertion, because nothing exercises this helper: it needs a Wayland portal the runner does not have. What can be checked is that the wrapper's three --set paths exist and that the helper's libraries resolve, read out of the built wrapper rather than re-derived, so it tests the artefact and not the recipe. A wrapper pointing somewhere wrong is exactly how these components fail. Co-Authored-By: Claude --- .github/workflows/nix-build.yml | 42 ++++++++++++ flake.nix | 27 ++++++-- nix/compositor-view.nix | 20 ++---- nix/ffmpeg-lgpl.nix | 28 ++++++++ nix/package.nix | 12 +++- nix/pipewire-helper.nix | 115 ++++++++++++++++++++++++++++++++ 6 files changed, 220 insertions(+), 24 deletions(-) create mode 100644 nix/ffmpeg-lgpl.nix create mode 100644 nix/pipewire-helper.nix diff --git a/.github/workflows/nix-build.yml b/.github/workflows/nix-build.yml index 94c5b2bd..23ff2715 100644 --- a/.github/workflows/nix-build.yml +++ b/.github/workflows/nix-build.yml @@ -147,6 +147,48 @@ 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 + for var in OPENSCREEN_FFMPEG_PATH OPENSCREEN_COMPOSITOR_VIEW_NODE OPENSCREEN_LINUX_CURSOR_HELPER_EXE; do + # The wrapper is a shell script; read the value it exports rather than + # re-deriving it here, so this checks the artefact and not the recipe. + path=$(sed -nE "s/^export $var='(.*)'\$/\1/p" result/bin/openscreen | tail -1) + if [ -z "$path" ]; then + echo "::error::$var is not set by the wrapper" + missing=$((missing + 1)) + continue + fi + if [ ! -e "$path" ]; then + echo "::error::$var points at $path, which does not exist" + missing=$((missing + 1)) + continue + fi + echo "$var -> $path" + done + # The helper dlopens libpipewire by soname, which no linker records, so + # the derivation adds it to the RPATH by hand. ldd is what tells us the + # hand-added entry actually resolves on this host. + HELPER=$(sed -nE "s/^export OPENSCREEN_LINUX_CURSOR_HELPER_EXE='(.*)'\$/\1/p" result/bin/openscreen | tail -1) + if [ -n "$HELPER" ]; then + echo "--- helper linkage ---" + ldd "$HELPER" || true + if ldd "$HELPER" | grep -q "not found"; then + echo "::error::the PipeWire helper has unresolved shared libraries" + 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..90e1e260 100644 --- a/flake.nix +++ b/flake.nix @@ -17,13 +17,26 @@ { # -- 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; }; + in + { + inherit compositor-view pipewire-helper; + openscreen = pkgs.callPackage ./nix/package.nix { + inherit compositor-view pipewire-helper; + }; + 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..001ca182 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -5,6 +5,7 @@ electron, ffmpeg-headless, compositor-view, + pipewire-helper, makeWrapper, makeDesktopItem, copyDesktopItems, @@ -103,12 +104,21 @@ 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}" # 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"; + }; +} From 9d77f9ae10bc406960145201f7920a53cff2c3df Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Fri, 21 Aug 2026 23:20:37 +0200 Subject: [PATCH 2/3] ci(nix): check the helper's dlopen contract, not its DT_NEEDED list The review caught that the assertion validated everything except the thing this PR adds. ldd resolves DT_NEEDED entries; libpipewire is reached by dlopen and is therefore never a DT_NEEDED and never appears in that output. The check would have gone green with the hand-added RPATH missing entirely -- which is the whole of the packaging work, and the one failure mode it was written to catch. Read the RPATH out of the binary instead and require that one of its directories actually holds libpipewire-0.3.so.0, expanding $ORIGIN against the binary's own location since the ffmpeg entries use it. Whichever of DT_RPATH or DT_RUNPATH is present is read rather than asserted, so a silent conversion by patchelf reports itself instead of looking like an absent RPATH. ldd stays, demoted to what it is good for: unresolved DT_NEEDED entries are a different fault and this is where they would show. Also -x rather than -e for the two paths that get spawned. The app already sets that bar -- helperCandidates requires X_OK before accepting a candidate -- so a present-but-not-executable file passed here and was rejected at runtime. The compositor addon is require()'d, not spawned, so -r is its bar. Co-Authored-By: Claude --- .github/workflows/nix-build.yml | 73 ++++++++++++++++++++++++++------- 1 file changed, 59 insertions(+), 14 deletions(-) diff --git a/.github/workflows/nix-build.yml b/.github/workflows/nix-build.yml index 23ff2715..f5375967 100644 --- a/.github/workflows/nix-build.yml +++ b/.github/workflows/nix-build.yml @@ -159,31 +159,76 @@ jobs: run: | set -euo pipefail missing=0 - for var in OPENSCREEN_FFMPEG_PATH OPENSCREEN_COMPOSITOR_VIEW_NODE OPENSCREEN_LINUX_CURSOR_HELPER_EXE; do - # The wrapper is a shell script; read the value it exports rather than - # re-deriving it here, so this checks the artefact and not the recipe. - path=$(sed -nE "s/^export $var='(.*)'\$/\1/p" result/bin/openscreen | tail -1) + + 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"; 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 [ ! -e "$path" ]; then - echo "::error::$var points at $path, which does not exist" + if [ ! "$test" "$path" ]; then + echo "::error::$var points at $path, which fails $test" missing=$((missing + 1)) continue fi - echo "$var -> $path" + echo "$var -> $path ($test)" done - # The helper dlopens libpipewire by soname, which no linker records, so - # the derivation adds it to the RPATH by hand. ldd is what tells us the - # hand-added entry actually resolves on this host. - HELPER=$(sed -nE "s/^export OPENSCREEN_LINUX_CURSOR_HELPER_EXE='(.*)'\$/\1/p" result/bin/openscreen | tail -1) + + # 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 linkage ---" - ldd "$HELPER" || true + 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 PipeWire helper has unresolved shared libraries" + echo "::error::the helper has unresolved DT_NEEDED libraries" + ldd "$HELPER" | grep "not found" missing=$((missing + 1)) fi fi From b765aef74a101a0b54e99029bf7ec0b5e6ef8078 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Fri, 21 Aug 2026 23:00:16 +0200 Subject: [PATCH 3/3] feat(nix): package the whisper-stt server The last native component. Without it resolveWhisperServer finds nothing, `openscreen captions` fails and the AI edition's transcription pump never starts. The model is deliberately not packaged. modelManager.ts downloads a GGML file from HuggingFace into userData on first use, checksums it and replaces a stale copy; that is a runtime cache the user owns, and baking a multi-gigabyte blob into the store would be wrong even if the sandbox allowed the download. The awkward part is that the CMakeLists pulls whisper.cpp, cpp-httplib and nlohmann/json with FetchContent at configure time, over a network the build does not have. Patching the CMakeLists was the wrong answer: those pins are deliberate and documented there -- one of them exists because a build once picked up OpenSSL from the host and shipped a binary that died in the Windows loader -- and a nix-only fork of them would drift from what every other platform builds. CMake already provides the override for this, so the three trees are fetched here and handed over through FETCHCONTENT_SOURCE_DIR_, with FETCHCONTENT_FULLY_DISCONNECTED so a missed one fails loudly rather than reaching for the network. fetchurl on pinned tags rather than fetchFromGitHub, which is a trade and not a preference. fetchFromGitHub hashes the unpacked tree and is immune to GitHub re-compressing an archive, but its hash cannot be computed or checked without nix, and there is no nix on the machine this was written on. A tarball hash can be verified by anyone with curl and sha256sum. If a tag is ever re-compressed the build fails closed and the fix is one line. Vulkan is on, matching what scripts/build-whisper-stt.sh selects for Linux. The alternative is a CPU-only binary, which works and is the same class of silent reduction this packaging exists to remove. Nothing is dlopen'd by soname here -- ggml links libvulkan normally -- so unlike the other two derivations no RPATH surgery is needed. OSC_NATIVE_CPU stays off per the CMakeLists' own warning: it compiles for whichever machine ran the build, and a nix package is precisely a thing built once and run elsewhere. Co-Authored-By: Claude --- .github/workflows/nix-build.yml | 3 +- flake.nix | 5 +- nix/package.nix | 4 +- nix/whisper-stt.nix | 155 ++++++++++++++++++++++++++++++++ 4 files changed, 163 insertions(+), 4 deletions(-) create mode 100644 nix/whisper-stt.nix diff --git a/.github/workflows/nix-build.yml b/.github/workflows/nix-build.yml index f5375967..2332e3d6 100644 --- a/.github/workflows/nix-build.yml +++ b/.github/workflows/nix-build.yml @@ -172,7 +172,8 @@ jobs: for entry in \ "OPENSCREEN_FFMPEG_PATH:-x" \ "OPENSCREEN_COMPOSITOR_VIEW_NODE:-r" \ - "OPENSCREEN_LINUX_CURSOR_HELPER_EXE:-x"; do + "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, diff --git a/flake.nix b/flake.nix index 90e1e260..be01a58b 100644 --- a/flake.nix +++ b/flake.nix @@ -28,11 +28,12 @@ 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; + inherit compositor-view pipewire-helper whisper-stt; openscreen = pkgs.callPackage ./nix/package.nix { - inherit compositor-view pipewire-helper; + inherit compositor-view pipewire-helper whisper-stt; }; default = self.packages.${pkgs.stdenv.hostPlatform.system}.openscreen; } diff --git a/nix/package.nix b/nix/package.nix index 001ca182..9e5dd1e2 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -6,6 +6,7 @@ ffmpeg-headless, compositor-view, pipewire-helper, + whisper-stt, makeWrapper, makeDesktopItem, copyDesktopItems, @@ -118,7 +119,8 @@ buildNpmPackage { --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_LINUX_CURSOR_HELPER_EXE "${lib.getExe pipewire-helper}" + --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/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"; + }; +}