commit 69f3c30fabce556319cd3f66c055adf722f55e83 Author: mrlee <470349233@qq.com> Date: Fri Oct 2 14:59:57 2026 +0800 feat: scaffold native fnOS application stage 1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f91f5d0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.build/ +dist/ +*.fpk +havline/app/server/amd64/havline +havline/app/server/arm64/havline diff --git a/README.md b/README.md new file mode 100644 index 0000000..22bdd37 --- /dev/null +++ b/README.md @@ -0,0 +1,38 @@ +# Havline Native fnOS 应用 + +本目录保存 Havline 的飞牛 Native 应用工程,不依赖 Docker。 + +阶段一范围: + +- Go 主程序原生运行 +- 统一网关 `/app/havline` +- Unix Socket `app.sock` +- `cmd/main` 启动、停止和状态检查 +- amd64 与 arm64 双架构二进制 + +当前不包含: + +- Nginx 二进制 +- frpc +- cloudflared + +这些依赖将在后续阶段加入。 + +## 构建 + +```powershell +cd F:\Fonu-main\fnos-native +.\build.ps1 +``` + +输出: + +```text +dist/havline-native-1.9.0.fpk +``` + +构建脚本会: + +1. 使用 `VITE_BASE_PATH=/app/havline/` 构建前端; +2. 交叉编译 amd64 与 arm64 Go 二进制; +3. 调用 `fnpack build` 打包。 diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..b5ec156 --- /dev/null +++ b/build.ps1 @@ -0,0 +1,106 @@ +param( + [string]$FnpackPath = "", + [string]$GoBin = "go" +) + +$ErrorActionPreference = "Stop" +$root = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +$project = Join-Path $PSScriptRoot "havline" +$mainRepo = Join-Path $root "havline" +$version = (Get-Content -Raw (Join-Path $mainRepo "VERSION")).Trim() + +function Copy-Tree { + param( + [string]$Source, + [string]$Destination + ) + + New-Item -ItemType Directory -Force $Destination | Out-Null + robocopy $Source $Destination /E /NFL /NDL /NJH /NJS /NP | Out-Null + if ($LASTEXITCODE -ge 8) { + throw "复制失败:$Source -> $Destination,退出码:$LASTEXITCODE" + } +} + +if (-not (Test-Path -LiteralPath $project)) { + throw "找不到 Native 应用工程:$project" +} + +if (-not $FnpackPath) { + $localTool = Join-Path $root "fnos\fnpack.exe" + $FnpackPath = if (Test-Path -LiteralPath $localTool) { $localTool } else { "fnpack" } +} + +Push-Location (Join-Path $mainRepo "web") +try { + $env:VITE_BASE_PATH = "/app/havline/" + npm run build + if ($LASTEXITCODE -ne 0) { + throw "前端构建失败,退出码:$LASTEXITCODE" + } +} +finally { + Pop-Location +} + +$webDist = Join-Path $mainRepo "web\dist" +$embedDist = Join-Path $mainRepo "cmd\havline\web\dist" +New-Item -ItemType Directory -Force $embedDist | Out-Null +robocopy $webDist $embedDist /E /NFL /NDL /NJH /NJS /NP | Out-Null +if ($LASTEXITCODE -ge 8) { + throw "复制前端产物失败,robocopy 退出码:$LASTEXITCODE" +} + +$stamp = Get-Date -Format "yyyyMMddHHmmss" +$stage = Join-Path $PSScriptRoot ".build\havline-$stamp" +New-Item -ItemType Directory -Force $stage | Out-Null +robocopy $project $stage /E /XF .DS_Store /NFL /NDL /NJH /NJS /NP | Out-Null +if ($LASTEXITCODE -ge 8) { + throw "复制 Native 应用工程失败,robocopy 退出码:$LASTEXITCODE" +} + +Copy-Tree -Source (Join-Path $mainRepo "migrations") -Destination (Join-Path $stage "app\migrations") +Copy-Tree -Source (Join-Path $mainRepo "web\assets\image") -Destination (Join-Path $stage "app\web\assets\image") + +$env:CGO_ENABLED = "0" +$env:GOOS = "linux" + +foreach ($arch in @("amd64", "arm64")) { + $outputDir = Join-Path $stage "app\server\$arch" + New-Item -ItemType Directory -Force $outputDir | Out-Null + $env:GOARCH = $arch + + Push-Location $mainRepo + try { + & $GoBin build -trimpath ` + -ldflags "-X github.com/havline/havline/internal/version.Version=$version" ` + -o (Join-Path $outputDir "havline") ` + ./cmd/havline + if ($LASTEXITCODE -ne 0) { + throw "编译 $arch 二进制失败,退出码:$LASTEXITCODE" + } + } + finally { + Pop-Location + } +} + +Push-Location $PSScriptRoot +try { + & $FnpackPath build -d $stage + if ($LASTEXITCODE -ne 0) { + throw "fnpack build 失败,退出码:$LASTEXITCODE" + } + + $output = Join-Path $PSScriptRoot "havline.fpk" + $dist = Join-Path $PSScriptRoot "dist" + New-Item -ItemType Directory -Force $dist | Out-Null + $target = Join-Path $dist "havline-native-$version.fpk" + Copy-Item -LiteralPath $output -Destination $target -Force + $sizeMB = [math]::Round((Get-Item $target).Length / 1MB, 1) + Write-Output "FPK: $target" + Write-Output "FPK 大小:$sizeMB MB" +} +finally { + Pop-Location +} diff --git a/havline/ICON.PNG b/havline/ICON.PNG new file mode 100644 index 0000000..99aae30 Binary files /dev/null and b/havline/ICON.PNG differ diff --git a/havline/ICON_256.PNG b/havline/ICON_256.PNG new file mode 100644 index 0000000..c8533df Binary files /dev/null and b/havline/ICON_256.PNG differ diff --git a/havline/app/ui/config b/havline/app/ui/config new file mode 100644 index 0000000..8934b6d --- /dev/null +++ b/havline/app/ui/config @@ -0,0 +1,17 @@ +{ + ".url": { + "havline.main": { + "title": "Havline", + "icon": "images/icon_{0}.png", + "type": "iframe", + "protocol": "", + "gatewayPrefix": "/app/havline", + "gatewaySocket": "app.sock", + "url": "/app/havline", + "allUsers": false, + "control": { + "accessPerm": "readonly" + } + } + } +} diff --git a/havline/app/ui/images/icon_256.png b/havline/app/ui/images/icon_256.png new file mode 100644 index 0000000..c8533df Binary files /dev/null and b/havline/app/ui/images/icon_256.png differ diff --git a/havline/app/ui/images/icon_64.png b/havline/app/ui/images/icon_64.png new file mode 100644 index 0000000..99aae30 Binary files /dev/null and b/havline/app/ui/images/icon_64.png differ diff --git a/havline/cmd/config_callback b/havline/cmd/config_callback new file mode 100644 index 0000000..db644a3 --- /dev/null +++ b/havline/cmd/config_callback @@ -0,0 +1,12 @@ +#!/bin/bash +set -euo pipefail + +for var in wizard_nginx_http_port wizard_nginx_https_port; do + value="${!var:-}" + if [ -n "$value" ] && ! [[ "$value" =~ ^[0-9]+$ ]]; then + echo "$var 必须是数字端口" > "$TRIM_TEMP_LOGFILE" + exit 1 + fi +done + +exit 0 diff --git a/havline/cmd/config_init b/havline/cmd/config_init new file mode 100644 index 0000000..06bd986 --- /dev/null +++ b/havline/cmd/config_init @@ -0,0 +1,2 @@ +#!/bin/bash +exit 0 diff --git a/havline/cmd/install_callback b/havline/cmd/install_callback new file mode 100644 index 0000000..7774e12 --- /dev/null +++ b/havline/cmd/install_callback @@ -0,0 +1,22 @@ +#!/bin/bash +set -euo pipefail + +mkdir -p "$TRIM_PKGVAR/logs" +chown -R "$TRIM_USERNAME:$TRIM_GROUPNAME" "$TRIM_PKGVAR" + +ARCH="$(uname -m)" +case "$ARCH" in + x86_64|amd64) ARCH_DIR="amd64" ;; + aarch64|arm64) ARCH_DIR="arm64" ;; + *) echo "不支持的设备架构:$ARCH" > "$TRIM_TEMP_LOGFILE"; exit 1 ;; +esac + +BIN="$TRIM_APPDEST/server/$ARCH_DIR/havline" +if [ ! -f "$BIN" ]; then + echo "找不到原生二进制:$BIN" > "$TRIM_TEMP_LOGFILE" + exit 1 +fi + +chmod +x "$BIN" +echo "Havline 数据目录已准备:$TRIM_PKGVAR" +exit 0 diff --git a/havline/cmd/install_init b/havline/cmd/install_init new file mode 100644 index 0000000..06bd986 --- /dev/null +++ b/havline/cmd/install_init @@ -0,0 +1,2 @@ +#!/bin/bash +exit 0 diff --git a/havline/cmd/main b/havline/cmd/main new file mode 100644 index 0000000..f9b373b --- /dev/null +++ b/havline/cmd/main @@ -0,0 +1,118 @@ +#!/bin/bash +set -u + +PID_FILE="${TRIM_PKGVAR}/havline.pid" +LOG_FILE="${TRIM_PKGVAR}/logs/app.log" +SOCKET_PATH="${TRIM_APPDEST}/app.sock" + +mkdir -p "${TRIM_PKGVAR}/logs" + +log_msg() { + echo "$(date '+%Y-%m-%d %H:%M:%S') - $1" >> "$LOG_FILE" +} + +check_process() { + local pid="$1" + kill -0 "$pid" 2>/dev/null +} + +status_app() { + if [ -f "$PID_FILE" ]; then + local pid + pid="$(head -n 1 "$PID_FILE" | tr -d '[:space:]')" + if [ -n "$pid" ] && check_process "$pid"; then + return 0 + fi + rm -f "$PID_FILE" + fi + return 1 +} + +arch_dir() { + case "$(uname -m)" in + x86_64|amd64) echo "amd64" ;; + aarch64|arm64) echo "arm64" ;; + *) return 1 ;; + esac +} + +start_app() { + if status_app; then + return 0 + fi + + local arch + arch="$(arch_dir)" || { + echo "不支持的设备架构:$(uname -m)" > "$TRIM_TEMP_LOGFILE" + exit 1 + } + + local bin="${TRIM_APPDEST}/server/${arch}/havline" + if [ ! -x "$bin" ]; then + echo "找不到原生二进制:$bin" > "$TRIM_TEMP_LOGFILE" + exit 1 + fi + + export HAVLINE_DATA_DIR="$TRIM_PKGVAR" + export HAVLINE_LISTEN="127.0.0.1:6893" + export HAVLINE_GATEWAY_SOCKET="$SOCKET_PATH" + export HAVLINE_GATEWAY_PREFIX="/app/havline" + export HAVLINE_UPDATER_SOCKET="" + export HAVLINE_NGINX_HTTP_PORT="${wizard_nginx_http_port:-18080}" + export HAVLINE_NGINX_HTTPS_PORT="${wizard_nginx_https_port:-9443}" + export TZ="${wizard_timezone:-Asia/Shanghai}" + + if [ -n "${wizard_initial_admin_password:-}" ]; then + export HAVLINE_INITIAL_ADMIN_PASSWORD="$wizard_initial_admin_password" + fi + + rm -f "$SOCKET_PATH" + log_msg "Starting Havline native" + cd "$TRIM_APPDEST" || exit 1 + nohup "$bin" >> "$LOG_FILE" 2>&1 & + echo "$!" > "$PID_FILE" + sleep 1 + status_app +} + +stop_app() { + if [ ! -f "$PID_FILE" ]; then + rm -f "$SOCKET_PATH" + return 0 + fi + + local pid + pid="$(head -n 1 "$PID_FILE" | tr -d '[:space:]')" + if [ -n "$pid" ] && check_process "$pid"; then + kill -TERM "$pid" 2>> "$LOG_FILE" || true + local count=0 + while check_process "$pid" && [ "$count" -lt 15 ]; do + sleep 1 + count=$((count + 1)) + done + if check_process "$pid"; then + kill -KILL "$pid" 2>> "$LOG_FILE" || true + fi + fi + + rm -f "$PID_FILE" "$SOCKET_PATH" +} + +case "${1:-}" in + start) + start_app + ;; + stop) + stop_app + ;; + status) + if status_app; then + exit 0 + fi + exit 3 + ;; + *) + echo "Unknown command: ${1:-}" > "$TRIM_TEMP_LOGFILE" + exit 1 + ;; +esac diff --git a/havline/cmd/uninstall_callback b/havline/cmd/uninstall_callback new file mode 100644 index 0000000..0e8c309 --- /dev/null +++ b/havline/cmd/uninstall_callback @@ -0,0 +1,9 @@ +#!/bin/bash +set -u + +if [ "${wizard_uninstall_mode:-keep}" = "purge" ]; then + rm -rf "$TRIM_PKGVAR" +fi + +rm -f "$TRIM_APPDEST/app.sock" +exit 0 diff --git a/havline/cmd/uninstall_init b/havline/cmd/uninstall_init new file mode 100644 index 0000000..06bd986 --- /dev/null +++ b/havline/cmd/uninstall_init @@ -0,0 +1,2 @@ +#!/bin/bash +exit 0 diff --git a/havline/cmd/upgrade_callback b/havline/cmd/upgrade_callback new file mode 100644 index 0000000..c22a518 --- /dev/null +++ b/havline/cmd/upgrade_callback @@ -0,0 +1,14 @@ +#!/bin/bash +set -euo pipefail + +if [ -f "$TRIM_PKGVAR/havline.db" ] && [ ! -r "$TRIM_PKGVAR/havline.db" ]; then + echo "数据库不可读:$TRIM_PKGVAR/havline.db" > "$TRIM_TEMP_LOGFILE" + exit 1 +fi + +if [ -f "$TRIM_PKGVAR/session_secret" ] && [ ! -r "$TRIM_PKGVAR/session_secret" ]; then + echo "会话密钥不可读:$TRIM_PKGVAR/session_secret" > "$TRIM_TEMP_LOGFILE" + exit 1 +fi + +exit 0 diff --git a/havline/cmd/upgrade_init b/havline/cmd/upgrade_init new file mode 100644 index 0000000..06bd986 --- /dev/null +++ b/havline/cmd/upgrade_init @@ -0,0 +1,2 @@ +#!/bin/bash +exit 0 diff --git a/havline/config/privilege b/havline/config/privilege new file mode 100644 index 0000000..cff6a3a --- /dev/null +++ b/havline/config/privilege @@ -0,0 +1,7 @@ +{ + "defaults": { + "run-as": "package" + }, + "username": "havline", + "groupname": "havline" +} diff --git a/havline/config/resource b/havline/config/resource new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/havline/config/resource @@ -0,0 +1 @@ +{} diff --git a/havline/manifest b/havline/manifest new file mode 100644 index 0000000..2aec020 --- /dev/null +++ b/havline/manifest @@ -0,0 +1,14 @@ +appname = havline +version = 1.9.0 +display_name = Havline +desc = NAS 公网访问管理:原生应用阶段一 +platform = all +source = thirdparty +maintainer = Havline +maintainer_url = https://jayhub.top/mrlee/Havline +distributor = Havline +desktop_uidir = ui +desktop_applaunchname = havline.main +ctl_stop = true +os_min_version = 1.1.3100 +changelog = 原生应用阶段一:Go 主程序、统一网关和启停脚本 diff --git a/havline/wizard/config b/havline/wizard/config new file mode 100644 index 0000000..03a5a08 --- /dev/null +++ b/havline/wizard/config @@ -0,0 +1,17 @@ +[ + { + "stepTitle": "基础设置", + "items": [ + { + "type": "select", + "field": "wizard_timezone", + "label": "时区", + "initValue": "Asia/Shanghai", + "options": [ + { "label": "Asia/Shanghai", "value": "Asia/Shanghai" }, + { "label": "UTC", "value": "UTC" } + ] + } + ] + } +] diff --git a/havline/wizard/install b/havline/wizard/install new file mode 100644 index 0000000..f0f3fca --- /dev/null +++ b/havline/wizard/install @@ -0,0 +1,35 @@ +[ + { + "stepTitle": "管理员初始化", + "items": [ + { + "type": "password", + "field": "wizard_initial_admin_password", + "label": "初始管理员密码", + "rules": [ + { "required": true, "message": "请输入初始管理员密码" }, + { "min": 8, "message": "密码至少 8 位" } + ] + }, + { + "type": "tips", + "helpText": "首次启动时使用该密码创建 admin 管理员。登录后请在系统设置中修改密码。" + } + ] + }, + { + "stepTitle": "基础设置", + "items": [ + { + "type": "select", + "field": "wizard_timezone", + "label": "时区", + "initValue": "Asia/Shanghai", + "options": [ + { "label": "Asia/Shanghai", "value": "Asia/Shanghai" }, + { "label": "UTC", "value": "UTC" } + ] + } + ] + } +] diff --git a/havline/wizard/uninstall b/havline/wizard/uninstall new file mode 100644 index 0000000..3545dd5 --- /dev/null +++ b/havline/wizard/uninstall @@ -0,0 +1,21 @@ +[ + { + "stepTitle": "卸载选项", + "items": [ + { + "type": "radio", + "field": "wizard_uninstall_mode", + "label": "数据处理", + "initValue": "keep", + "options": [ + { "label": "保留数据(推荐)", "value": "keep" }, + { "label": "删除全部数据", "value": "purge" } + ] + }, + { + "type": "tips", + "helpText": "删除数据将移除数据库、证书、Nginx 配置、FRP 与 Cloudflare 配置,且无法恢复。" + } + ] + } +]