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
24 changes: 24 additions & 0 deletions docs/linux-native-packages.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Native Linux desktop packages

The builders consume our own CI's generated x86_64 Node bundle. That input
must be trusted and must not change while packaging runs. Symlink validation
rejects accidental links outside the bundle, then flattens internal links.
It is not a race-safe boundary for an attacker editing the tree concurrently.

Staged directories are 755 and files 644, with only the app launcher, bundled
Node and /usr/bin wrapper executable (755). The desktop entry opens the app
without a terminal; the tray opens the existing loopback WebUI.

Build commands, after `bash scripts/build-posix.sh linux`:

```
bash scripts/build-linux-native.sh deb dist/linux/nowplaying 0.2.1+dev out
bash scripts/build-linux-native.sh rpm dist/linux/nowplaying 0.2.1+dev out
bash scripts/build-linux-native.sh arch dist/linux/nowplaying 0.2.1+dev out
```

Run each builder on its target distro with the corresponding tooling. Arch's
makepkg requires an unprivileged builder. The scripts build files only; they
do not install, publish, enable autostart or cut stable releases. Versions use
letters, digits, dot, plus and dash; RPM/Arch translate dash to underscore.
This slice rejects non-x86_64 hosts and bundles rather than mislabeling them.
18 changes: 7 additions & 11 deletions scripts/build-deb.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
# usage: build-deb.sh <bundle-dir> <version> <amd64|arm64> <out-dir>
# The Maintainer field comes from NOWPLAYING_DEB_MAINTAINER.
set -euo pipefail
[[ "$(uname -s)" = Linux ]] || { echo "Linux only: native Linux packaging" >&2; exit 2; }

bundle="${1:?usage: build-deb.sh <bundle-dir> <version> <amd64|arm64> <out-dir>}"
version="${2:?missing version}"
Expand All @@ -27,14 +28,9 @@ esac

stage="$(mktemp -d)"
trap 'rm -rf "$stage"' EXIT
mkdir -p "$stage/opt" "$stage/usr/bin" "$stage/DEBIAN" "$out"
chmod 755 "$stage" "$stage/opt" "$stage/usr" "$stage/usr/bin"
cp -a "$bundle" "$stage/opt/nowplaying"
cat > "$stage/usr/bin/nowplaying" <<'WRAP'
#!/bin/sh
exec /opt/nowplaying/nowplaying "$@"
WRAP
chmod 755 "$stage/usr/bin/nowplaying"
mkdir -p "$stage/DEBIAN" "$out"
chmod 755 "$stage"
bash "$(dirname "$0")/stage-linux-package.sh" "$bundle" "$stage"

size_kb=$(du -sk "$stage/opt" "$stage/usr" | awk '{ sum += $1 } END { print sum }')
cat > "$stage/DEBIAN/control" <<CONTROL
Expand All @@ -43,16 +39,16 @@ Version: $version
Architecture: $arch
Maintainer: $maintainer
Installed-Size: $size_kb
Depends: libc6, libstdc++6, libgcc-s1
Depends: libc6, libstdc++6, libgcc-s1, python3-gi, gir1.2-gtk-3.0, gir1.2-ayatanaappindicator3-0.1, libsecret-tools, gnome-keyring, xdg-utils
Section: sound
Priority: optional
Homepage: https://github.com/rowkavdev/nowplaying
Description: Show what you are playing on Plex, Jellyfin, Emby or Navidrome
Turns playback from your media server into a README card or Discord Rich
Presence. Ships its own Node runtime, so nothing else needs to be installed.
Presence. Ships its own Node runtime; desktop dependencies are installed by apt.
CONTROL

# Root-owned files regardless of who builds, and no stray build-time modes.
file="$out/nowplaying_${version}_${arch}.deb"
fakeroot sh -c "chown -R 0:0 '$stage' && dpkg-deb --root-owner-group -Zxz --build '$stage' '$file' >/dev/null"
fakeroot dpkg-deb --root-owner-group -Zxz --build "$stage" "$file" >/dev/null
echo "$file"
75 changes: 75 additions & 0 deletions scripts/build-linux-native.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
# Build only. Installing/releasing is an explicit next step. Source app version
# is normalised for package managers; a dev package must use a dev version.
set -euo pipefail
[[ "$(uname -s)" = Linux ]] || { echo "Linux only: native Linux packaging" >&2; exit 2; }
kind="${1:?deb|rpm|arch required}"
case "$kind" in
deb|rpm|arch) ;;
*) echo 'Unknown package format' >&2; exit 2 ;;
esac
bundle="$(cd "${2:?bundle directory required}" && pwd)"
version="${3:?package version required}"
out="${4:?output directory required}"
[[ "$version" =~ ^[0-9][0-9A-Za-z.+-]*$ ]] || { echo 'Invalid package version' >&2; exit 2; }
# This desktop slice ships only x86_64, matching the current dev artifact.
[[ "$(uname -m)" = x86_64 ]] || { echo 'Native desktop packages currently require x86_64' >&2; exit 2; }
[[ "$("$bundle/runtime/node" -p 'process.arch')" = x64 ]] || { echo 'Bundle architecture must be x64' >&2; exit 2; }
mkdir -p "$out"; out="$(cd "$out" && pwd)"
root="$(cd "$(dirname "$0")/.." && pwd)"
case "$kind" in
deb) exec bash "$root/scripts/build-deb.sh" "$bundle" "$version" amd64 "$out" ;;
rpm|arch) ;;
esac
stage="$(mktemp -d)"
trap 'rm -rf "$stage"' EXIT
bash "$root/scripts/stage-linux-package.sh" "$bundle" "$stage/payload"
if [[ "$kind" = rpm ]]; then
mkdir -p "$stage/rpm/"{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS}
tar -czf "$stage/rpm/SOURCES/payload.tar.gz" -C "$stage/payload" .
cat > "$stage/rpm/SPECS/nowplaying.spec" <<SPEC
Name: nowplaying
Version: ${version//-/_}
Release: 1
Summary: Media presence and README cards
License: AGPL-3.0-only
URL: https://github.com/rowkavdev/nowplaying
Source0: payload.tar.gz
BuildArch: x86_64
AutoReqProv: no
Requires: glibc, libstdc++, libgcc, python3-gobject, gtk3, libayatana-appindicator-gtk3, libsecret, gnome-keyring, xdg-utils
%description
NowPlaying desktop app with a local web UI and system tray.
%prep
%setup -q -c -T
%build
%install
mkdir -p %{buildroot}
tar -xzf %{SOURCE0} -C %{buildroot}
%files
/opt/nowplaying
/usr/bin/nowplaying
/usr/share/applications/nowplaying.desktop
/usr/share/icons/hicolor/512x512/apps/nowplaying.png
SPEC
rpmbuild --define "_topdir $stage/rpm" -bb "$stage/rpm/SPECS/nowplaying.spec"
find "$stage/rpm/RPMS" -name '*.rpm' -exec cp {} "$out/" \;
else
# makepkg cannot run as root. Caller must be an unprivileged builder.
cat > "$stage/PKGBUILD" <<PKG
pkgname=nowplaying
pkgver=${version//-/_}
pkgrel=1
pkgdesc='Media presence and README cards'
arch=('x86_64')
url='https://github.com/rowkavdev/nowplaying'
license=('AGPL-3.0-only')
depends=('glibc' 'gcc-libs' 'python-gobject' 'gtk3' 'libayatana-appindicator' 'libsecret' 'gnome-keyring' 'xdg-utils')
options=('!strip' '!debug')
package() {
cp -a "\$startdir/payload/." "\$pkgdir/"
}
PKG
(cd "$stage" && makepkg --nodeps --noconfirm)
find "$stage" -maxdepth 1 -name '*.pkg.tar.zst' -exec cp {} "$out/" \;
fi
40 changes: 40 additions & 0 deletions scripts/stage-linux-package.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# Shared filesystem layout for .deb, .rpm and Arch packages.
# Input is trusted, immutable build output from our own CI. Link validation
# rejects accidental escapes; it is not race-safe against concurrent edits.
set -euo pipefail
[[ "$(uname -s)" = Linux ]] || { echo "Linux only: native Linux packaging" >&2; exit 2; }
bundle="${1:?bundle directory required}"
stage="${2:?staging directory required}"
[[ -x "$bundle/nowplaying" && -x "$bundle/runtime/node" ]] || { echo 'Not a built bundle' >&2; exit 2; }
[[ -f "$bundle/app/assets/brand/png/icon-512.png" ]] || { echo 'Bundle branding missing' >&2; exit 2; }
mkdir -p "$stage/opt" "$stage/usr/bin" "$stage/usr/share/applications" "$stage/usr/share/icons/hicolor/512x512/apps"
# npm dependencies may contain symlinks. Resolve each before copying, and
# reject absolute/relative links that escape the built bundle. Then flatten
# links in the staged copy so permission normalization cannot touch outside.
bundle="$(cd "$bundle" && pwd)"
while IFS= read -r -d '' link; do
target="$(realpath -e "$link")" || { echo 'Broken bundle symlink' >&2; exit 2; }
[[ "$target" = "$bundle/"* ]] || { echo 'Bundle symlink escapes package' >&2; exit 2; }
done < <(find "$bundle" -type l -print0)
cp -aL "$bundle" "$stage/opt/nowplaying"
cat > "$stage/usr/bin/nowplaying" <<'WRAP'
#!/bin/sh
exec /opt/nowplaying/nowplaying "$@"
WRAP
chmod 755 "$stage/usr/bin/nowplaying"
cat > "$stage/usr/share/applications/nowplaying.desktop" <<'DESKTOP'
[Desktop Entry]
Type=Application
Version=1.0
Name=NowPlaying
Comment=Media presence and README cards
Exec=/usr/bin/nowplaying start
Icon=nowplaying
Terminal=false
Categories=AudioVideo;Audio;
DESKTOP
cp "$bundle/app/assets/brand/png/icon-512.png" "$stage/usr/share/icons/hicolor/512x512/apps/nowplaying.png"
find "$stage/opt" "$stage/usr" -type d -exec chmod 755 {} +
find "$stage/opt" "$stage/usr" -type f -exec chmod 644 {} +
chmod 755 "$stage/usr/bin/nowplaying" "$stage/opt/nowplaying/nowplaying" "$stage/opt/nowplaying/runtime/node"
26 changes: 22 additions & 4 deletions test/build-deb.test.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import test from "node:test";
import assert from "node:assert/strict";
import { execFileSync, spawnSync } from "node:child_process";
import { chmodSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { copyFileSync, chmodSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

const script = new URL("../scripts/build-deb.sh", import.meta.url).pathname;
const haveDpkg = spawnSync("dpkg-deb", ["--version"]).status === 0 && spawnSync("fakeroot", ["--version"]).status === 0;
const haveDpkg = process.platform === "linux" && spawnSync("dpkg-deb", ["--version"]).status === 0 && spawnSync("fakeroot", ["--version"]).status === 0;

function fakeBundle() {
const dir = mkdtempSync(join(tmpdir(), "np-deb-"));
Expand All @@ -16,10 +16,12 @@
writeFileSync(join(bundle, "nowplaying"), "#!/bin/sh\nexit 0\n"); chmodSync(join(bundle, "nowplaying"), 0o755);
writeFileSync(join(bundle, "runtime", "node"), "#!/bin/sh\nexit 0\n"); chmodSync(join(bundle, "runtime", "node"), 0o755);
writeFileSync(join(bundle, "app", "package.json"), "{}");
mkdirSync(join(bundle, "app/assets/brand/png"), { recursive: true });
copyFileSync(new URL("../assets/brand/png/icon-512.png", import.meta.url), join(bundle, "app/assets/brand/png/icon-512.png"));
return { dir, bundle };
}

test("rejects a bad version or architecture before building", () => {
test("rejects a bad version or architecture before building", { skip: process.platform !== "linux" }, () => {
const { dir, bundle } = fakeBundle();
const bad = [[bundle, "0.2.0\nSection: evil", "amd64", dir, /invalid version/], [bundle, "v0.2", "amd64", dir, /invalid version/], [bundle, "0.2.0", "i386", dir, /unsupported architecture/]];
for (const [bundleDir, version, arch, out, message] of bad) {
Expand All @@ -38,11 +40,27 @@
assert.match(info, /^Package: nowplaying$/m);
assert.match(info, /^Version: 0\.2\.0$/m);
assert.match(info, /^Architecture: arm64$/m);
assert.match(info, /^Depends: libc6, libstdc\+\+6, libgcc-s1$/m);
assert.match(info, /^Depends: libc6, libstdc\+\+6, libgcc-s1, python3-gi, .*libsecret-tools.*xdg-utils$/m);
assert.doesNotMatch(info, /evil/);
assert.match(listingDesktop(file), /Exec=\/usr\/bin\/nowplaying start/);
const listing = execFileSync("dpkg-deb", ["-c", file], { encoding: "utf8" });
assert.match(listing, /\.\/opt\/nowplaying\/runtime\/node$/m);
assert.match(listing, /^-rwxr-xr-x root\/root .*\.\/usr\/bin\/nowplaying$/m);
assert.doesNotMatch(listing, /^\S+ (?!root\/root)\S+/m);
assert.match(listing, /^drwxr-xr-x root\/root .* \.\/$/m);
});

function listingDesktop(file) {
const dir = mkdtempSync(join(tmpdir(), "np-deb-extract-"));
execFileSync("dpkg-deb", ["-x", file, dir]);
return execFileSync("cat", [join(dir, "usr/share/applications/nowplaying.desktop")], { encoding: "utf8" });
}

test("output paths containing shell syntax are literal argv, not commands", { skip: !haveDpkg }, () => {
const { dir, bundle } = fakeBundle();
const out = join(dir, "out'; touch INJECTED; #");
const result = execFileSync("bash", [script, bundle, "0.2.1+dev", "amd64", out], { encoding: "utf8" }).trim();
assert.equal(result, join(out, "nowplaying_0.2.1+dev_amd64.deb"));
assert.equal(spawnSync("test", ["-e", join(dir, "INJECTED")]).status, 1);
assert.match(execFileSync("dpkg-deb", ["-f", result], { encoding: "utf8" }), /Package: nowplaying/);
});
75 changes: 75 additions & 0 deletions test/linux-native.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import test from "node:test";
import assert from "node:assert/strict";
import { execFileSync, spawnSync } from "node:child_process";
import { mkdtempSync, mkdirSync, writeFileSync, chmodSync, readFileSync, statSync, symlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

const linuxOnly = { skip: process.platform !== "linux" };
const root = new URL("../", import.meta.url).pathname;
test("common staging contains a non-terminal desktop entry and branding", linuxOnly, () => {
const dir = mkdtempSync(join(tmpdir(), "np-native-"));
const bundle = join(dir, "bundle");
mkdirSync(join(bundle, "runtime"), { recursive: true });
mkdirSync(join(bundle, "app/assets/brand/png"), { recursive: true });
for (const file of ["nowplaying", "runtime/node"]) {
writeFileSync(join(bundle, file), "#!/bin/sh\nexit 0\n");
chmodSync(join(bundle, file), 0o777);
}
writeFileSync(join(bundle, "app/assets/brand/png/icon-512.png"), "test");
chmodSync(join(bundle, "app/assets/brand/png/icon-512.png"), 0o777);
chmodSync(join(bundle, "app/assets/brand/png"), 0o777);
const stage = join(dir, "stage");
execFileSync("bash", [join(root, "scripts/stage-linux-package.sh"), bundle, stage]);
const entry = readFileSync(join(stage, "usr/share/applications/nowplaying.desktop"), "utf8");
assert.equal(statSync(join(stage, "opt/nowplaying/runtime/node")).mode & 0o777, 0o755);
assert.equal(statSync(join(stage, "opt/nowplaying/app/assets/brand/png/icon-512.png")).mode & 0o777, 0o644);
assert.equal(statSync(join(stage, "opt/nowplaying/app/assets/brand/png")).mode & 0o777, 0o755);
assert.match(entry, /^Terminal=false$/m);
assert.match(entry, /^Exec=\/usr\/bin\/nowplaying start$/m);
assert.match(entry, /^Icon=nowplaying$/m);
assert.match(readFileSync(join(stage, "usr/bin/nowplaying"), "utf8"), /exec \/opt\/nowplaying\/nowplaying "\$@"/);
});

test("native builder rejects invalid package version and unknown format", linuxOnly, () => {
const bundle = mkdtempSync(join(tmpdir(), "np-package-bad-"));
const out = join(bundle, "out");
mkdirSync(join(bundle, "runtime"));
writeFileSync(join(bundle, "runtime/node"), "#!/bin/sh\ntouch FORMAT_PROBE_RAN\nprintf x64\n");
chmodSync(join(bundle, "runtime/node"), 0o755);
for (const [kind, version] of [["deb", "0.2\nRequires: injected"], ["other", "0.2.1"]]) {
const result = spawnSync("bash", [join(root, "scripts/build-linux-native.sh"), kind, bundle, version, out], { encoding: "utf8" });
assert.equal(result.status, 2);
if (kind === "other") assert.match(result.stderr, /Unknown package format/);
}
});

test("native staging accepts internal links but rejects paths escaping the bundle", linuxOnly, () => {
const dir = mkdtempSync(join(tmpdir(), "np-native-links-"));
const bundle = join(dir, "bundle");
mkdirSync(join(bundle, "runtime"), { recursive: true });
mkdirSync(join(bundle, "app/assets/brand/png"), { recursive: true });
for (const file of ["nowplaying", "runtime/node"]) { writeFileSync(join(bundle, file), "#!/bin/sh\nexit 0\n"); chmodSync(join(bundle, file), 0o755); }
writeFileSync(join(bundle, "app/assets/brand/png/icon-512.png"), "test");
symlinkSync("icon-512.png", join(bundle, "app/assets/brand/png/alias.png"));
execFileSync("bash", [join(root, "scripts/stage-linux-package.sh"), bundle, join(dir, "good")]);
writeFileSync(join(dir, "outside"), "outside");
symlinkSync(join(dir, "outside"), join(bundle, "escape"));
const result = spawnSync("bash", [join(root, "scripts/stage-linux-package.sh"), bundle, join(dir, "bad")], { encoding: "utf8" });
assert.equal(result.status, 2);
assert.match(result.stderr, /escapes/);
});

test("native builder rejects unsupported versions and bundled arm64 before packaging", { skip: process.platform !== "linux" }, () => {
const dir = mkdtempSync(join(tmpdir(), "np-native-arch-"));
mkdirSync(join(dir, "runtime"));
writeFileSync(join(dir, "runtime/node"), "#!/bin/sh\nprintf arm64\n");
chmodSync(join(dir, "runtime/node"), 0o755);
for (const format of ["deb", "rpm", "arch"]) {
for (const version of ["0.2~dev", "0.2_dev", "0.2.1+dev"]) {
const result = spawnSync("bash", [join(root, "scripts/build-linux-native.sh"), format, dir, version, join(dir, "out")], { encoding: "utf8" });
assert.equal(result.status, 2);
assert.match(result.stderr, /Invalid package version|Bundle architecture|require x86_64/);
}
}
});
Loading