Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions .github/workflows/nix-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:-<none>}"
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.
#
Expand Down
28 changes: 21 additions & 7 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
20 changes: 4 additions & 16 deletions nix/compositor-view.nix
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
{
lib,
rustPlatform,
ffmpeg,
ffmpeg-lgpl,
symlinkJoin,
pkg-config,
rustfmt,
Expand All @@ -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
Expand Down
28 changes: 28 additions & 0 deletions nix/ffmpeg-lgpl.nix
Original file line number Diff line number Diff line change
@@ -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;
}
14 changes: 13 additions & 1 deletion nix/package.nix
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
electron,
ffmpeg-headless,
compositor-view,
pipewire-helper,
whisper-stt,
makeWrapper,
makeDesktopItem,
copyDesktopItems,
Expand Down Expand Up @@ -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
Expand Down
115 changes: 115 additions & 0 deletions nix/pipewire-helper.nix
Original file line number Diff line number Diff line change
@@ -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";
};
}
Loading
Loading