diff --git a/README.md b/README.md index 39a920d..4a2c8b3 100644 --- a/README.md +++ b/README.md @@ -252,6 +252,19 @@ docker compose up -d - 数据目录 `./data` 与录制目录 `./records` 映射到宿主机 - 支持多架构构建(`linux/amd64`, `linux/arm64`) +### fnOS 原生 FPK + +```bash +./scripts/build-fnos-package.sh +./scripts/smoke-fnos-package.sh artifacts/fnos/liverecorder-1.0.0-x86_64.fpk +``` + +- x86_64 原生自包含包,不依赖 Docker、系统 .NET、PostgreSQL、Node.js 或 FFmpeg +- 安装向导会要求设置 `admin` 管理员密码 +- Web 管理界面默认使用端口 `18080` +- 数据库与日志保存在 fnOS 应用持久化目录 +- 录制文件保存在 fnOS 共享目录 `liverecorder/records` + ## 验证 - `dotnet build LiveRecorder.sln --no-restore -m:1` diff --git a/fnos/cmd/config_callback b/fnos/cmd/config_callback new file mode 100755 index 0000000..06bd986 --- /dev/null +++ b/fnos/cmd/config_callback @@ -0,0 +1,2 @@ +#!/bin/bash +exit 0 diff --git a/fnos/cmd/config_init b/fnos/cmd/config_init new file mode 100755 index 0000000..06bd986 --- /dev/null +++ b/fnos/cmd/config_init @@ -0,0 +1,2 @@ +#!/bin/bash +exit 0 diff --git a/fnos/cmd/install_callback b/fnos/cmd/install_callback new file mode 100755 index 0000000..cc81af6 --- /dev/null +++ b/fnos/cmd/install_callback @@ -0,0 +1,28 @@ +#!/bin/bash +set -eu + +PASSWORD="${wizard_admin_password:-}" +PASSWORD_CONFIRM="${wizard_admin_password_confirm:-}" +unset wizard_admin_password wizard_admin_password_confirm + +fail() { + printf '%s\n' "$1" >&2 + if [ -n "${TRIM_TEMP_LOGFILE:-}" ]; then + printf '%s\n' "$1" >>"$TRIM_TEMP_LOGFILE" 2>/dev/null || true + fi + exit 1 +} + +[ "$PASSWORD" = "$PASSWORD_CONFIRM" ] || fail "管理员密码两次输入不一致。" +[ "${#PASSWORD}" -ge 10 ] || fail "管理员密码至少需要 10 个字符。" +[ "${#PASSWORD}" -le 256 ] || fail "管理员密码不能超过 256 个字符。" +case "$PASSWORD" in + *$'\n'*|*$'\r'*) fail "管理员密码不能包含换行符。" ;; +esac + +mkdir -p "${TRIM_PKGVAR}/run" "${TRIM_PKGVAR}/log" +chmod 0700 "${TRIM_PKGVAR}" "${TRIM_PKGVAR}/run" 2>/dev/null || true +umask 077 +printf '%s\n' "$PASSWORD" >"${TRIM_PKGVAR}/admin-password.seed" +unset PASSWORD PASSWORD_CONFIRM +exit 0 diff --git a/fnos/cmd/install_init b/fnos/cmd/install_init new file mode 100755 index 0000000..06bd986 --- /dev/null +++ b/fnos/cmd/install_init @@ -0,0 +1,2 @@ +#!/bin/bash +exit 0 diff --git a/fnos/cmd/main b/fnos/cmd/main new file mode 100755 index 0000000..2b65491 --- /dev/null +++ b/fnos/cmd/main @@ -0,0 +1,214 @@ +#!/bin/bash +set -u + +PACKAGE_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +APP_ROOT="${TRIM_APPDEST:-$PACKAGE_ROOT}" +DATA_ROOT="${TRIM_PKGVAR:-$PACKAGE_ROOT/var}" +VOLUME_ROOT="${TRIM_APPDEST_VOL:-$DATA_ROOT/volume}" +RECORD_ROOT="${LIVE_RECORDER_RECORD_ROOT:-$VOLUME_ROOT/@appshare/liverecorder/records}" +RUNTIME_ROOT="$APP_ROOT/runtime" +SERVER="$APP_ROOT/server/LiveRecorder.WebApi" +PG_BIN="$RUNTIME_ROOT/usr/lib/postgresql/15/bin" +PG_SHARE="$RUNTIME_ROOT/usr/share/postgresql/15" +PG_DATA="$DATA_ROOT/postgres" +RUN_ROOT="$DATA_ROOT/run" +LOG_ROOT="$DATA_ROOT/log" +APP_PID_FILE="$RUN_ROOT/liverecorder.pid" +APP_LOG="$LOG_ROOT/liverecorder.log" +PG_LOG="$LOG_ROOT/postgresql.log" +ADMIN_PASSWORD_FILE="$DATA_ROOT/admin-password.seed" +PG_PORT="${LIVE_RECORDER_POSTGRES_PORT:-54329}" +SERVICE_PORT="${TRIM_SERVICE_PORT:-18080}" +RUNTIME_LIBRARY_PATH="$RUNTIME_ROOT/lib/x86_64-linux-gnu:$RUNTIME_ROOT/usr/lib/x86_64-linux-gnu:$RUNTIME_ROOT/usr/lib/x86_64-linux-gnu/pulseaudio:$RUNTIME_ROOT/usr/lib/x86_64-linux-gnu/blas:$RUNTIME_ROOT/usr/lib/x86_64-linux-gnu/lapack:$RUNTIME_ROOT/usr/lib/postgresql/15/lib" + +log_message() { + mkdir -p "$LOG_ROOT" + printf '%s - %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$1" >>"$APP_LOG" +} + +app_pid() { + if [ -f "$APP_PID_FILE" ]; then + pid=$(sed -n '1p' "$APP_PID_FILE" | tr -d '[:space:]') + if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then + printf '%s' "$pid" + return 0 + fi + fi + return 1 +} + +run_pg() { + env LD_LIBRARY_PATH="$RUNTIME_LIBRARY_PATH" PATH="$PG_BIN:$RUNTIME_ROOT/usr/bin:/usr/bin:/bin" "$@" +} + +run_native() { + env LD_LIBRARY_PATH="$RUNTIME_LIBRARY_PATH" PATH="$RUNTIME_ROOT/usr/bin:/usr/bin:/bin" "$@" +} + +postgres_running() { + [ -x "$PG_BIN/pg_ctl" ] || return 1 + [ -f "$PG_DATA/PG_VERSION" ] || return 1 + run_pg "$PG_BIN/pg_ctl" -D "$PG_DATA" status >/dev/null 2>&1 +} + +initialize_postgres() { + if [ -f "$PG_DATA/PG_VERSION" ]; then + return 0 + fi + + mkdir -p "$PG_DATA" "$RUN_ROOT" + chmod 0700 "$PG_DATA" "$RUN_ROOT" + if ! run_pg "$PG_BIN/initdb" \ + -D "$PG_DATA" \ + -L "$PG_SHARE" \ + --username=liverecorder \ + --auth-local=trust \ + --auth-host=reject \ + --encoding=UTF8 \ + --no-locale >>"$PG_LOG" 2>&1; then + log_message "PostgreSQL 初始化失败。" + return 1 + fi + + { + printf "listen_addresses = ''\n" + printf "port = %s\n" "$PG_PORT" + printf "unix_socket_directories = '%s'\n" "$RUN_ROOT" + printf "max_connections = 40\n" + printf "shared_buffers = '64MB'\n" + printf "timezone = 'UTC'\n" + printf "log_timezone = 'UTC'\n" + } >>"$PG_DATA/postgresql.conf" + return 0 +} + +start_postgres() { + if postgres_running; then + return 0 + fi + initialize_postgres || return 1 + if ! run_pg "$PG_BIN/pg_ctl" -D "$PG_DATA" -l "$PG_LOG" -w start; then + log_message "PostgreSQL 启动失败。" + return 1 + fi + + database_exists=$(run_pg "$PG_BIN/psql" \ + -h "$RUN_ROOT" -p "$PG_PORT" -U liverecorder -d postgres -Atqc \ + "SELECT 1 FROM pg_database WHERE datname = 'live_recorder'" 2>/dev/null || true) + if [ "$database_exists" != "1" ]; then + if ! run_pg "$PG_BIN/createdb" \ + -h "$RUN_ROOT" -p "$PG_PORT" -U liverecorder live_recorder >>"$PG_LOG" 2>&1; then + log_message "Live Recorder 数据库创建失败。" + return 1 + fi + fi + return 0 +} + +stop_postgres() { + if postgres_running; then + run_pg "$PG_BIN/pg_ctl" -D "$PG_DATA" -m fast -w stop >>"$PG_LOG" 2>&1 || true + fi +} + +launch_app_process() { + ( + export ASPNETCORE_ENVIRONMENT=Production + export ASPNETCORE_URLS="http://0.0.0.0:$SERVICE_PORT" + export ConnectionStrings__DefaultConnection="Host=$RUN_ROOT;Port=$PG_PORT;Database=live_recorder;Username=liverecorder;Timeout=15;Command Timeout=120;Keepalive=30" + export LIVE_RECORDER_DEFAULT_OUTPUT_ROOT="$RECORD_ROOT" + if [ -f "$ADMIN_PASSWORD_FILE" ]; then + LIVE_RECORDER_DEFAULT_ADMIN_PASSWORD=$(sed -n '1p' "$ADMIN_PASSWORD_FILE") + export LIVE_RECORDER_DEFAULT_ADMIN_PASSWORD + fi + export DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 + export DOTNET_BUNDLE_EXTRACT_BASE_DIR="$DATA_ROOT/dotnet-bundle" + export XDG_CACHE_HOME="$DATA_ROOT/cache" + export TMPDIR="$DATA_ROOT/tmp" + export LD_LIBRARY_PATH="$RUNTIME_LIBRARY_PATH" + export PATH="$RUNTIME_ROOT/usr/bin:/usr/bin:/bin" + mkdir -p "$XDG_CACHE_HOME" "$TMPDIR" + cd "$APP_ROOT/server" || exit 1 + exec "$SERVER" + ) >>"$APP_LOG" 2>&1 & + APP_PROCESS_PID=$! + printf '%s\n' "$APP_PROCESS_PID" >"$APP_PID_FILE" +} + +start_app() { + mkdir -p "$DATA_ROOT" "$RUN_ROOT" "$LOG_ROOT" "$RECORD_ROOT" "$DATA_ROOT/dotnet-bundle" + chmod 0700 "$DATA_ROOT" "$RUN_ROOT" "$DATA_ROOT/dotnet-bundle" 2>/dev/null || true + + if [ ! -x "$SERVER" ]; then + log_message "应用程序不存在或不可执行:$SERVER" + return 1 + fi + if [ ! -x "$PG_BIN/postgres" ] || [ ! -x "$RUNTIME_ROOT/usr/bin/ffmpeg" ] || [ ! -x "$RUNTIME_ROOT/usr/bin/node" ]; then + log_message "FPK 原生运行环境不完整。" + return 1 + fi + if pid=$(app_pid); then + log_message "应用已运行,PID $pid。" + return 0 + fi + + rm -f "$APP_PID_FILE" + start_postgres || return 1 + launch_attempt=1 + launch_app_process + pid=$APP_PROCESS_PID + + attempt=0 + while [ "$attempt" -lt 90 ]; do + if run_native "$RUNTIME_ROOT/usr/bin/curl" -fsS "http://127.0.0.1:$SERVICE_PORT/health/ready" >/dev/null 2>&1; then + rm -f "$ADMIN_PASSWORD_FILE" + log_message "应用启动成功,PID $pid,端口 $SERVICE_PORT。" + return 0 + fi + if ! kill -0 "$pid" 2>/dev/null; then + if [ "$launch_attempt" -ge 3 ]; then + break + fi + log_message "应用启动进程提前退出,3 秒后重试($launch_attempt/3)。" + sleep 3 + launch_attempt=$((launch_attempt + 1)) + launch_app_process + pid=$APP_PROCESS_PID + fi + attempt=$((attempt + 1)) + sleep 1 + done + + log_message "应用未能在 90 秒内就绪。" + stop_app + return 1 +} + +stop_app() { + if pid=$(app_pid); then + kill "$pid" 2>/dev/null || true + attempt=0 + while kill -0 "$pid" 2>/dev/null && [ "$attempt" -lt 30 ]; do + sleep 1 + attempt=$((attempt + 1)) + done + if kill -0 "$pid" 2>/dev/null; then + kill -9 "$pid" 2>/dev/null || true + fi + fi + rm -f "$APP_PID_FILE" + stop_postgres +} + +case "${1:-status}" in + start) start_app ;; + stop) stop_app ;; + restart) stop_app; start_app ;; + status) + if app_pid >/dev/null && postgres_running; then + exit 0 + fi + exit 3 + ;; + *) printf 'usage: %s {start|stop|restart|status}\n' "$0" >&2; exit 2 ;; +esac diff --git a/fnos/cmd/uninstall_callback b/fnos/cmd/uninstall_callback new file mode 100755 index 0000000..13b74bb --- /dev/null +++ b/fnos/cmd/uninstall_callback @@ -0,0 +1,3 @@ +#!/bin/bash +# Persistent PostgreSQL data and recordings are preserved by default. +exit 0 diff --git a/fnos/cmd/uninstall_init b/fnos/cmd/uninstall_init new file mode 100755 index 0000000..3853f01 --- /dev/null +++ b/fnos/cmd/uninstall_init @@ -0,0 +1,5 @@ +#!/bin/bash +set -eu + +"$(dirname -- "$0")/main" stop || true +exit 0 diff --git a/fnos/cmd/upgrade_callback b/fnos/cmd/upgrade_callback new file mode 100755 index 0000000..06bd986 --- /dev/null +++ b/fnos/cmd/upgrade_callback @@ -0,0 +1,2 @@ +#!/bin/bash +exit 0 diff --git a/fnos/cmd/upgrade_init b/fnos/cmd/upgrade_init new file mode 100755 index 0000000..b6663bf --- /dev/null +++ b/fnos/cmd/upgrade_init @@ -0,0 +1,6 @@ +#!/bin/bash +set -eu + +# PostgreSQL data, logs and application settings live in TRIM_PKGVAR and are +# intentionally not touched while fnOS replaces the immutable application. +exit 0 diff --git a/fnos/config/privilege b/fnos/config/privilege new file mode 100644 index 0000000..61f5c2e --- /dev/null +++ b/fnos/config/privilege @@ -0,0 +1,6 @@ +{ + "defaults": { + "run-as": "package" + }, + "join-groups": ["video"] +} diff --git a/fnos/config/resource b/fnos/config/resource new file mode 100644 index 0000000..9ac04ca --- /dev/null +++ b/fnos/config/resource @@ -0,0 +1,18 @@ +{ + "data-share": { + "shares": [ + { + "name": "liverecorder", + "permission": { + "rw": ["liverecorder"] + } + }, + { + "name": "liverecorder/records", + "permission": { + "rw": ["liverecorder"] + } + } + ] + } +} diff --git a/fnos/manifest b/fnos/manifest new file mode 100644 index 0000000..66f80e2 --- /dev/null +++ b/fnos/manifest @@ -0,0 +1,13 @@ +appname=liverecorder +version=1.0.0 +display_name=Live Recorder +desc=原生自包含直播录制系统,内置 PostgreSQL、FFmpeg、Node.js 和 Web 管理界面,支持分片录制、弹幕采集与 OpenList 自动上传 +platform=x86 +source=thirdparty +maintainer=Live Recorder Contributors +os_min_version=1.2.0 +desktop_uidir=ui +desktop_applaunchname=liverecorder.Application +checksum=@CHECKSUM@ +checkport=true +ctl_stop=true diff --git a/fnos/ui/config b/fnos/ui/config new file mode 100644 index 0000000..5d601fe --- /dev/null +++ b/fnos/ui/config @@ -0,0 +1,13 @@ +{ + ".url": { + "liverecorder.Application": { + "title": "Live Recorder", + "icon": "images/icon_{0}.png", + "type": "url", + "protocol": "", + "port": "18080", + "url": "/", + "allUsers": false + } + } +} diff --git a/fnos/wizard/install b/fnos/wizard/install new file mode 100644 index 0000000..d87a34d --- /dev/null +++ b/fnos/wizard/install @@ -0,0 +1,49 @@ +[ + { + "stepTitle": "设置 Live Recorder 管理员密码", + "items": [ + { + "type": "tips", + "helpText": "此密码用于登录 Live Recorder,不是 fnOS 系统密码。用户名固定为 admin。" + }, + { + "type": "password", + "field": "wizard_admin_password", + "label": "管理员密码", + "rules": [ + { + "required": true, + "message": "请输入管理员密码" + }, + { + "min": 10, + "message": "管理员密码至少需要 10 个字符" + }, + { + "max": 256, + "message": "管理员密码不能超过 256 个字符" + } + ] + }, + { + "type": "password", + "field": "wizard_admin_password_confirm", + "label": "再次输入密码", + "rules": [ + { + "required": true, + "message": "请再次输入管理员密码" + }, + { + "min": 10, + "message": "管理员密码至少需要 10 个字符" + }, + { + "max": 256, + "message": "管理员密码不能超过 256 个字符" + } + ] + } + ] + } +] diff --git a/fnos/wizard/upgrade b/fnos/wizard/upgrade new file mode 100644 index 0000000..f17a001 --- /dev/null +++ b/fnos/wizard/upgrade @@ -0,0 +1,11 @@ +[ + { + "stepTitle": "升级 Live Recorder", + "items": [ + { + "type": "tips", + "helpText": "升级会保留 PostgreSQL 数据库、系统设置、上传任务和录制文件。" + } + ] + } +] diff --git a/scripts/build-fnos-package.sh b/scripts/build-fnos-package.sh new file mode 100755 index 0000000..c4429c1 --- /dev/null +++ b/scripts/build-fnos-package.sh @@ -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" diff --git a/scripts/fnos-bookworm.sources.list b/scripts/fnos-bookworm.sources.list new file mode 100644 index 0000000..ad67c67 --- /dev/null +++ b/scripts/fnos-bookworm.sources.list @@ -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 diff --git a/scripts/generate-fnos-icons.mjs b/scripts/generate-fnos-icons.mjs new file mode 100644 index 0000000..f30c32d --- /dev/null +++ b/scripts/generate-fnos-icons.mjs @@ -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); diff --git a/scripts/smoke-fnos-package.sh b/scripts/smoke-fnos-package.sh new file mode 100755 index 0000000..f216188 --- /dev/null +++ b/scripts/smoke-fnos-package.sh @@ -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 '
' "$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' diff --git a/scripts/verify-fnos-package.sh b/scripts/verify-fnos-package.sh new file mode 100755 index 0000000..39a3891 --- /dev/null +++ b/scripts/verify-fnos-package.sh @@ -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" diff --git a/src/LiveRecorder.Application/Services/SystemSettingsService.cs b/src/LiveRecorder.Application/Services/SystemSettingsService.cs index 2dc4e9d..3eb47d7 100644 --- a/src/LiveRecorder.Application/Services/SystemSettingsService.cs +++ b/src/LiveRecorder.Application/Services/SystemSettingsService.cs @@ -130,7 +130,7 @@ public sealed class SystemSettingsService : ISystemSettingsService return new SystemSettingsDto { FfmpegPath = GetValue(lookup, FfmpegPathKey, "ffmpeg"), - OutputRoot = GetValue(lookup, OutputRootKey, "records"), + OutputRoot = GetValue(lookup, OutputRootKey, GetDefaultOutputRoot()), OutputDirectoryTemplate = GetValue(lookup, OutputDirectoryTemplateKey, "{platform}/{yyyy}/{MM}/{dd}/{anchor}"), OutputFileNameTemplate = GetValue(lookup, OutputFileNameTemplateKey, "{HHmmss}_{quality}_{anchor}_{title}_{roomId}{segmentSuffix}"), DefaultQuality = GetValue(lookup, DefaultQualityKey, "origin"), @@ -292,6 +292,12 @@ public sealed class SystemSettingsService : ISystemSettingsService }; } + private static string GetDefaultOutputRoot() + { + var configured = Environment.GetEnvironmentVariable("LIVE_RECORDER_DEFAULT_OUTPUT_ROOT"); + return string.IsNullOrWhiteSpace(configured) ? "records" : configured.Trim(); + } + public async Task UpdateAsync(UpdateSystemSettingsRequest request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); diff --git a/src/LiveRecorder.Infrastructure/Persistence/DatabaseInitializer.cs b/src/LiveRecorder.Infrastructure/Persistence/DatabaseInitializer.cs index 4612685..4627d08 100644 --- a/src/LiveRecorder.Infrastructure/Persistence/DatabaseInitializer.cs +++ b/src/LiveRecorder.Infrastructure/Persistence/DatabaseInitializer.cs @@ -6,6 +6,8 @@ namespace LiveRecorder.Infrastructure.Persistence; public sealed class DatabaseInitializer { + private const string DefaultAdminPasswordEnvironmentVariable = "LIVE_RECORDER_DEFAULT_ADMIN_PASSWORD"; + private readonly LiveRecorderDbContext _dbContext; public DatabaseInitializer(LiveRecorderDbContext dbContext) @@ -28,14 +30,16 @@ public sealed class DatabaseInitializer if (!await _dbContext.UserAccounts.AnyAsync(cancellationToken)) { var now = DateTimeOffset.UtcNow; - var admin = new UserAccount("admin", "Administrator", PasswordHasher.Hash("Admin@123"), now); + var passwordHash = PasswordHasher.Hash(GetDefaultAdminPassword()); + Environment.SetEnvironmentVariable(DefaultAdminPasswordEnvironmentVariable, null); + var admin = new UserAccount("admin", "Administrator", passwordHash, now); await _dbContext.UserAccounts.AddAsync(admin, cancellationToken); } var defaults = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["ffmpeg.path"] = "ffmpeg", - ["recording.output_root"] = "records", + ["recording.output_root"] = GetDefaultOutputRoot(), ["recording.output_directory_template"] = "{platform}/{yyyy}/{MM}/{dd}/{anchor}", ["recording.output_file_name_template"] = "{HHmmss}_{anchor}_{title}_{roomId}{segmentSuffix}", ["recording.default_quality"] = "origin", @@ -167,4 +171,16 @@ public sealed class DatabaseInitializer await _dbContext.SaveChangesAsync(cancellationToken); } + + private static string GetDefaultOutputRoot() + { + var configured = Environment.GetEnvironmentVariable("LIVE_RECORDER_DEFAULT_OUTPUT_ROOT"); + return string.IsNullOrWhiteSpace(configured) ? "records" : configured.Trim(); + } + + private static string GetDefaultAdminPassword() + { + var configured = Environment.GetEnvironmentVariable(DefaultAdminPasswordEnvironmentVariable); + return string.IsNullOrEmpty(configured) ? "Admin@123" : configured; + } } diff --git a/src/LiveRecorder.WebApi/Program.cs b/src/LiveRecorder.WebApi/Program.cs index 039d5b6..b69ccf4 100644 --- a/src/LiveRecorder.WebApi/Program.cs +++ b/src/LiveRecorder.WebApi/Program.cs @@ -247,14 +247,26 @@ builder.Services.AddHostedService(); builder.Services.AddHostedService(); var app = builder.Build(); +var webRootPath = app.Environment.WebRootPath ?? Path.Combine(app.Environment.ContentRootPath, "wwwroot"); +var hasBundledFrontend = File.Exists(Path.Combine(webRootPath, "index.html")); app.UseMiddleware(); app.UseSwagger(); app.UseSwaggerUI(); app.UseCors("frontend"); +if (hasBundledFrontend) +{ + app.UseDefaultFiles(); + app.UseStaticFiles(); +} + app.UseMiddleware(); -app.MapGet("/", () => Results.Redirect("/swagger")); +if (!hasBundledFrontend) +{ + app.MapGet("/", () => Results.Redirect("/swagger")); +} + app.MapControllers(); // ── Health check endpoints ──────────────────────────────────────────── @@ -359,6 +371,11 @@ if (resetRecordingData) return; } +if (hasBundledFrontend) +{ + app.MapFallbackToFile("index.html"); +} + app.Run(); static async Task> ReadRecordingDataCountsAsync(LiveRecorderDbContext dbContext)