feat: add native fnOS package

This commit is contained in:
2026-08-02 14:43:37 +08:00
parent e62dfd2de1
commit e8772a39b5
24 changed files with 842 additions and 4 deletions
+156
View File
@@ -0,0 +1,156 @@
#!/bin/bash
set -euo pipefail
ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
VERSION=1.0.0
OUTPUT="${1:-$ROOT_DIR/artifacts/fnos/liverecorder-${VERSION}-x86_64.fpk}"
WORKSPACE_CACHE=$(CDPATH= cd -- "$ROOT_DIR/.." && pwd)
DOTNET_BIN="${DOTNET:-$WORKSPACE_CACHE/.dotnet8/dotnet}"
NUGET_FEED="${LIVERECORDER_NUGET_FEED:-$WORKSPACE_CACHE/.nuget-feed}"
NUGET_PACKAGES="${NUGET_PACKAGES:-$WORKSPACE_CACHE/.nuget-packages}"
DOTNET_CLI_HOME="${DOTNET_CLI_HOME:-$WORKSPACE_CACHE/.dotnet-cli-home}"
BUILD_TMP_ROOT="${LIVERECORDER_BUILD_TMPDIR:-$WORKSPACE_CACHE/.fnos-build-tmp}"
SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH:-$(git -C "$ROOT_DIR" show -s --format=%ct HEAD)}"
mkdir -p "$BUILD_TMP_ROOT" "$(dirname -- "$OUTPUT")"
WORK_DIR=$(mktemp -d "${BUILD_TMP_ROOT%/}/liverecorder-fnos-build.XXXXXX")
trap 'rm -rf -- "$WORK_DIR"' EXIT
for command_name in npm apt-get dpkg-deb tar md5sum sha256sum node; do
command -v "$command_name" >/dev/null 2>&1 || {
printf 'required build command is missing: %s\n' "$command_name" >&2
exit 1
}
done
test -x "$DOTNET_BIN" || { printf 'missing .NET SDK: %s\n' "$DOTNET_BIN" >&2; exit 1; }
test -d "$NUGET_FEED" || { printf 'missing offline NuGet feed: %s\n' "$NUGET_FEED" >&2; exit 1; }
printf 'Building frontend...\n'
npm run build --prefix "$ROOT_DIR/frontend"
printf 'Publishing self-contained .NET application...\n'
export NUGET_PACKAGES DOTNET_CLI_HOME
"$DOTNET_BIN" restore "$ROOT_DIR/src/LiveRecorder.WebApi/LiveRecorder.WebApi.csproj" \
-r linux-x64 \
--source "$NUGET_FEED" \
--source "https://api.nuget.org/v3/index.json" \
--disable-parallel
"$DOTNET_BIN" publish "$ROOT_DIR/src/LiveRecorder.WebApi/LiveRecorder.WebApi.csproj" \
-c Release \
-r linux-x64 \
--self-contained true \
--no-restore \
-p:DebugType=None \
-p:DebugSymbols=false \
-p:PublishSingleFile=false \
-p:PublishReadyToRun=false \
-o "$WORK_DIR/payload/server" \
/maxcpucount:1
rm -f "$WORK_DIR/payload/server/"*.pdb
mkdir -p "$WORK_DIR/payload/server/wwwroot"
cp -a "$ROOT_DIR/frontend/dist/." "$WORK_DIR/payload/server/wwwroot/"
printf 'Downloading pinned Debian Bookworm native runtime packages...\n'
APT_ROOT="$WORK_DIR/apt"
mkdir -p \
"$APT_ROOT/etc/apt" \
"$APT_ROOT/var/lib/apt/lists/partial" \
"$APT_ROOT/var/lib/dpkg" \
"$APT_ROOT/var/cache/apt/archives/partial"
cp "$ROOT_DIR/scripts/fnos-bookworm.sources.list" "$APT_ROOT/etc/apt/sources.list"
touch "$APT_ROOT/var/lib/dpkg/status"
APT_OPTIONS=(
-o "Dir::Etc::sourcelist=$APT_ROOT/etc/apt/sources.list"
-o "Dir::Etc::sourceparts=-"
-o "Dir::State::status=$APT_ROOT/var/lib/dpkg/status"
-o "Dir::State::lists=$APT_ROOT/var/lib/apt/lists"
-o "Dir::Cache::archives=$APT_ROOT/var/cache/apt/archives"
-o "Debug::NoLocking=1"
-o "APT::Architecture=amd64"
-o "Acquire::Languages=none"
)
apt-get "${APT_OPTIONS[@]}" update
apt-get "${APT_OPTIONS[@]}" \
--download-only \
--no-install-recommends \
--yes \
install \
postgresql-15 \
postgresql-client-15 \
nodejs \
ffmpeg \
curl \
ca-certificates
RUNTIME_ROOT="$WORK_DIR/payload/runtime"
mkdir -p "$RUNTIME_ROOT"
shopt -s nullglob
packages=("$APT_ROOT"/var/cache/apt/archives/*.deb)
test "${#packages[@]}" -gt 0 || { printf 'APT did not download runtime packages\n' >&2; exit 1; }
for package_file in "${packages[@]}"; do
case "$(basename -- "$package_file")" in
libc6_*|libc-bin_*)
# Native programs must use the fnOS glibc/loader as one matched
# pair. Bundling Debian's libc while an executable still starts
# through the host loader can crash before main() on newer fnOS
# releases. All other runtime libraries remain private to the app.
continue
;;
esac
dpkg-deb -x "$package_file" "$RUNTIME_ROOT"
done
shopt -u nullglob
for forbidden_glibc_file in \
"$RUNTIME_ROOT/lib/x86_64-linux-gnu/libc.so.6" \
"$RUNTIME_ROOT/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2"; do
test ! -e "$forbidden_glibc_file" || {
printf 'host glibc must not be shadowed: %s\n' "$forbidden_glibc_file" >&2
exit 1
}
done
rm -rf \
"$RUNTIME_ROOT/usr/share/doc" \
"$RUNTIME_ROOT/usr/share/man" \
"$RUNTIME_ROOT/usr/share/lintian" \
"$RUNTIME_ROOT/usr/share/locale"
if [ ! -e "$RUNTIME_ROOT/usr/bin/node" ] && [ -x "$RUNTIME_ROOT/usr/bin/nodejs" ]; then
ln -s nodejs "$RUNTIME_ROOT/usr/bin/node"
fi
for required_file in \
"$RUNTIME_ROOT/usr/lib/postgresql/15/bin/postgres" \
"$RUNTIME_ROOT/usr/lib/postgresql/15/bin/initdb" \
"$RUNTIME_ROOT/usr/bin/node" \
"$RUNTIME_ROOT/usr/bin/ffmpeg" \
"$RUNTIME_ROOT/usr/bin/curl"; do
test -x "$required_file" || { printf 'native runtime file is missing: %s\n' "$required_file" >&2; exit 1; }
done
mkdir -p "$WORK_DIR/payload/ui" "$WORK_DIR/package"
cp "$ROOT_DIR/fnos/ui/config" "$WORK_DIR/payload/ui/config"
node "$ROOT_DIR/scripts/generate-fnos-icons.mjs" "$WORK_DIR/package" "$WORK_DIR/payload/ui/images"
printf 'Packing fnOS payload...\n'
tar --sort=name --mtime="@$SOURCE_DATE_EPOCH" --owner=0 --group=0 --numeric-owner \
-czf "$WORK_DIR/package/app.tgz" \
-C "$WORK_DIR/payload" \
server runtime ui
cp -a "$ROOT_DIR/fnos/cmd" "$ROOT_DIR/fnos/config" "$ROOT_DIR/fnos/wizard" "$WORK_DIR/package/"
chmod 0755 "$WORK_DIR/package/cmd/"*
checksum=$(md5sum "$WORK_DIR/package/app.tgz" | cut -d' ' -f1)
sed "s/@CHECKSUM@/$checksum/" "$ROOT_DIR/fnos/manifest" >"$WORK_DIR/package/manifest"
tar --sort=name --mtime="@$SOURCE_DATE_EPOCH" --owner=0 --group=0 --numeric-owner \
-czf "$OUTPUT" \
-C "$WORK_DIR/package" \
app.tgz cmd config wizard ICON.PNG ICON_256.PNG manifest
(
cd "$(dirname -- "$OUTPUT")"
sha256sum "$(basename -- "$OUTPUT")" >"$(basename -- "$OUTPUT").sha256"
)
"$ROOT_DIR/scripts/verify-fnos-package.sh" "$OUTPUT"
printf 'Built %s\n' "$OUTPUT"
+3
View File
@@ -0,0 +1,3 @@
deb https://mirrors.tuna.tsinghua.edu.cn/debian bookworm main
deb https://mirrors.tuna.tsinghua.edu.cn/debian bookworm-updates main
deb https://mirrors.tuna.tsinghua.edu.cn/debian-security bookworm-security main
+131
View File
@@ -0,0 +1,131 @@
import { deflateSync } from "node:zlib";
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
const [packageRoot, uiImageRoot] = process.argv.slice(2);
if (!packageRoot || !uiImageRoot) {
throw new Error("usage: node generate-fnos-icons.mjs package-root ui-image-root");
}
const crcTable = new Uint32Array(256);
for (let index = 0; index < 256; index++) {
let value = index;
for (let bit = 0; bit < 8; bit++) {
value = (value & 1) !== 0 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
}
crcTable[index] = value >>> 0;
}
function crc32(buffer) {
let value = 0xffffffff;
for (const byte of buffer) {
value = crcTable[(value ^ byte) & 0xff] ^ (value >>> 8);
}
return (value ^ 0xffffffff) >>> 0;
}
function chunk(type, data) {
const typeBuffer = Buffer.from(type, "ascii");
const length = Buffer.alloc(4);
length.writeUInt32BE(data.length);
const checksum = Buffer.alloc(4);
checksum.writeUInt32BE(crc32(Buffer.concat([typeBuffer, data])));
return Buffer.concat([length, typeBuffer, data, checksum]);
}
function roundedRectangleDistance(x, y, left, top, right, bottom, radius) {
const centerX = (left + right) / 2;
const centerY = (top + bottom) / 2;
const halfWidth = (right - left) / 2 - radius;
const halfHeight = (bottom - top) / 2 - radius;
const dx = Math.max(Math.abs(x - centerX) - halfWidth, 0);
const dy = Math.max(Math.abs(y - centerY) - halfHeight, 0);
return Math.hypot(dx, dy) - radius;
}
function render(size) {
const pixels = Buffer.alloc(size * size * 4);
const samples = 3;
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const rgba = [0, 0, 0, 0];
for (let sy = 0; sy < samples; sy++) {
for (let sx = 0; sx < samples; sx++) {
const px = x + (sx + 0.5) / samples;
const py = y + (sy + 0.5) / samples;
const scale = size / 256;
let color = [0, 0, 0, 0];
const background = roundedRectangleDistance(px, py, 12 * scale, 12 * scale, 244 * scale, 244 * scale, 50 * scale);
if (background <= 0) {
const mix = Math.min(1, Math.max(0, (px + py) / (512 * scale)));
color = [Math.round(27 + 33 * mix), Math.round(94 + 66 * mix), Math.round(180 + 49 * mix), 255];
}
const body = roundedRectangleDistance(px, py, 51 * scale, 75 * scale, 190 * scale, 185 * scale, 22 * scale);
if (body <= 0) {
color = [245, 249, 255, 255];
}
const lensDistance = Math.hypot(px - 120 * scale, py - 130 * scale);
if (lensDistance <= 36 * scale) {
color = lensDistance <= 23 * scale ? [42, 109, 196, 255] : [128, 185, 239, 255];
}
const viewfinder = roundedRectangleDistance(px, py, 73 * scale, 56 * scale, 122 * scale, 86 * scale, 9 * scale);
if (viewfinder <= 0) {
color = [232, 241, 253, 255];
}
if (px >= 190 * scale && px <= 222 * scale && py >= 101 * scale && py <= 159 * scale) {
const edge = Math.abs(py - 130 * scale) / (29 * scale);
const leftEdge = 190 * scale + edge * 16 * scale;
if (px >= leftEdge) {
color = [237, 244, 253, 255];
}
}
const statusDistance = Math.hypot(px - 165 * scale, py - 98 * scale);
if (statusDistance <= 9 * scale) {
color = [244, 85, 93, 255];
}
for (let channel = 0; channel < 4; channel++) {
rgba[channel] += color[channel];
}
}
}
const offset = (y * size + x) * 4;
for (let channel = 0; channel < 4; channel++) {
pixels[offset + channel] = Math.round(rgba[channel] / (samples * samples));
}
}
}
const scanlines = Buffer.alloc((size * 4 + 1) * size);
for (let row = 0; row < size; row++) {
const target = row * (size * 4 + 1);
scanlines[target] = 0;
pixels.copy(scanlines, target + 1, row * size * 4, (row + 1) * size * 4);
}
const header = Buffer.alloc(13);
header.writeUInt32BE(size, 0);
header.writeUInt32BE(size, 4);
header[8] = 8;
header[9] = 6;
return Buffer.concat([
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
chunk("IHDR", header),
chunk("IDAT", deflateSync(scanlines, { level: 9 })),
chunk("IEND", Buffer.alloc(0))
]);
}
mkdirSync(packageRoot, { recursive: true });
mkdirSync(uiImageRoot, { recursive: true });
const icon64 = render(64);
const icon256 = render(256);
writeFileSync(join(packageRoot, "ICON.PNG"), icon64);
writeFileSync(join(packageRoot, "ICON_256.PNG"), icon256);
writeFileSync(join(uiImageRoot, "icon_64.png"), icon64);
writeFileSync(join(uiImageRoot, "icon_256.png"), icon256);
+75
View File
@@ -0,0 +1,75 @@
#!/bin/bash
set -euo pipefail
PACKAGE=${1:?usage: smoke-fnos-package.sh package.fpk [temporary-directory]}
SMOKE_TMP_ROOT="${2:-${LIVERECORDER_SMOKE_TMPDIR:-${TMPDIR:-/tmp}}}"
mkdir -p "$SMOKE_TMP_ROOT"
SMOKE_TMP_ROOT=$(CDPATH= cd -- "$SMOKE_TMP_ROOT" && pwd)
WORK_DIR=$(mktemp -d "${SMOKE_TMP_ROOT%/}/liverecorder-fnos-smoke.XXXXXX")
PORT=${LIVERECORDER_SMOKE_PORT:-19180}
cleanup() {
status=$?
if [ -x "$WORK_DIR/cmd/main" ]; then
TRIM_APPDEST="$WORK_DIR" \
TRIM_PKGVAR="$WORK_DIR/var" \
TRIM_APPDEST_VOL="$WORK_DIR/volume" \
TRIM_SERVICE_PORT="$PORT" \
"$WORK_DIR/cmd/main" stop >/dev/null 2>&1 || true
fi
if [ "$status" -ne 0 ]; then
printf '%s\n' 'fnOS smoke test failed; application logs follow:' >&2
for log_file in "$WORK_DIR/var/log/postgresql.log" "$WORK_DIR/var/log/liverecorder.log"; do
if [ -f "$log_file" ]; then
printf '%s\n' "--- $log_file ---" >&2
tail -n 120 "$log_file" >&2 || true
fi
done
fi
rm -rf -- "$WORK_DIR"
return "$status"
}
trap cleanup EXIT HUP INT TERM
tar -xzf "$PACKAGE" -C "$WORK_DIR"
tar -xzf "$WORK_DIR/app.tgz" -C "$WORK_DIR"
mkdir -p "$WORK_DIR/volume"
export TRIM_APPDEST="$WORK_DIR"
export TRIM_PKGVAR="$WORK_DIR/var"
export TRIM_APPDEST_VOL="$WORK_DIR/volume"
export TRIM_SERVICE_PORT="$PORT"
SMOKE_PASSWORD='LiveRecorder-Smoke-2026!'
wizard_admin_password="$SMOKE_PASSWORD" \
wizard_admin_password_confirm="$SMOKE_PASSWORD" \
"$WORK_DIR/cmd/install_callback"
"$WORK_DIR/cmd/main" start
"$WORK_DIR/cmd/main" status
BASE_URL="http://127.0.0.1:$PORT"
curl -fsS "$BASE_URL/" >"$WORK_DIR/index.html"
grep -q '<div id="app"></div>' "$WORK_DIR/index.html"
curl -fsS "$BASE_URL/health/ready" | grep -q '"status":"ready"'
login_response=$(curl -fsS \
-H 'Content-Type: application/json' \
--data "{\"username\":\"admin\",\"password\":\"$SMOKE_PASSWORD\"}" \
"$BASE_URL/api/auth/login")
token=$(printf '%s' "$login_response" | sed -n 's/.*"token":"\([^"]*\)".*/\1/p')
test -n "$token"
settings_response=$(curl -fsS -H "Authorization: Bearer $token" "$BASE_URL/api/settings")
expected_record_root="$WORK_DIR/volume/@appshare/liverecorder/records"
printf '%s' "$settings_response" | grep -Fq "\"outputRoot\":\"$expected_record_root\""
runtime_libs="$WORK_DIR/runtime/lib/x86_64-linux-gnu:$WORK_DIR/runtime/usr/lib/x86_64-linux-gnu:$WORK_DIR/runtime/usr/lib/x86_64-linux-gnu/pulseaudio:$WORK_DIR/runtime/usr/lib/x86_64-linux-gnu/blas:$WORK_DIR/runtime/usr/lib/x86_64-linux-gnu/lapack:$WORK_DIR/runtime/usr/lib/postgresql/15/lib"
LD_LIBRARY_PATH="$runtime_libs" "$WORK_DIR/runtime/usr/bin/node" --version >/dev/null
LD_LIBRARY_PATH="$runtime_libs" "$WORK_DIR/runtime/usr/bin/ffmpeg" -version >/dev/null 2>&1
LD_LIBRARY_PATH="$runtime_libs" "$WORK_DIR/runtime/usr/bin/curl" -fsS "$BASE_URL/health/ready" >/dev/null
"$WORK_DIR/cmd/main" stop
"$WORK_DIR/cmd/main" start
curl -fsS "$BASE_URL/health/ready" | grep -q '"status":"ready"'
printf 'fnOS native smoke test passed: frontend, API, PostgreSQL, Node.js and FFmpeg are ready\n'
+47
View File
@@ -0,0 +1,47 @@
#!/bin/bash
set -euo pipefail
PACKAGE=${1:?usage: verify-fnos-package.sh package.fpk}
VERIFY_TMP_ROOT="${LIVERECORDER_VERIFY_TMPDIR:-${TMPDIR:-/tmp}}"
mkdir -p "$VERIFY_TMP_ROOT"
WORK_DIR=$(mktemp -d "${VERIFY_TMP_ROOT%/}/liverecorder-fnos-verify.XXXXXX")
trap 'rm -rf -- "$WORK_DIR"' EXIT
tar -xzf "$PACKAGE" -C "$WORK_DIR"
grep -q '^appname=liverecorder$' "$WORK_DIR/manifest"
grep -q '^version=1.0.0$' "$WORK_DIR/manifest"
grep -q '^platform=x86$' "$WORK_DIR/manifest"
test -x "$WORK_DIR/cmd/main"
test -x "$WORK_DIR/cmd/install_callback"
test -s "$WORK_DIR/wizard/install"
test -s "$WORK_DIR/wizard/upgrade"
test -s "$WORK_DIR/ICON.PNG"
test -s "$WORK_DIR/ICON_256.PNG"
expected=$(sed -n 's/^checksum=//p' "$WORK_DIR/manifest")
actual=$(md5sum "$WORK_DIR/app.tgz" | cut -d' ' -f1)
test -n "$expected"
test "$expected" = "$actual"
tar -tzf "$WORK_DIR/app.tgz" >"$WORK_DIR/app-files.txt"
grep -q '^server/LiveRecorder.WebApi$' "$WORK_DIR/app-files.txt"
grep -q '^server/wwwroot/index.html$' "$WORK_DIR/app-files.txt"
grep -q '^runtime/usr/lib/postgresql/15/bin/postgres$' "$WORK_DIR/app-files.txt"
grep -q '^runtime/usr/lib/postgresql/15/bin/initdb$' "$WORK_DIR/app-files.txt"
grep -q '^runtime/usr/bin/ffmpeg$' "$WORK_DIR/app-files.txt"
grep -q '^runtime/usr/bin/node$' "$WORK_DIR/app-files.txt"
grep -q '^runtime/usr/bin/curl$' "$WORK_DIR/app-files.txt"
grep -q '^ui/config$' "$WORK_DIR/app-files.txt"
grep -q '^ui/images/icon_64.png$' "$WORK_DIR/app-files.txt"
if grep -Eq '^runtime/(usr/)?lib/x86_64-linux-gnu/(libc\.so\.6|ld-linux-x86-64\.so\.2)$' "$WORK_DIR/app-files.txt"; then
printf 'the FPK must use the fnOS glibc and matching system loader\n' >&2
exit 1
fi
if grep -Eq '^(data|records|postgres|log|var)/' "$WORK_DIR/app-files.txt"; then
printf 'persistent data must not be included in app.tgz\n' >&2
exit 1
fi
printf 'fnOS package verified: %s\n' "$PACKAGE"