#!/usr/bin/env bash # LOCUS 安装 / 运维脚本。 # # 三种形态,都由 packaging/build-installer.sh 从这一份生成: # 离线安装器 locus-<版本>-linux-install.sh:末尾接一个 tar.gz,里面是各构建变体的 # <变体>/locus、LICENSE、THIRD_PARTY_NOTICES.txt 和服务模板。 # 安装脚本 linux-setup.sh:没有载荷,官网入口下载对应的 linux-<变体>.tar.gz 后用 --package 交给它。 # 开发形态:直接运行仓库里的这个文件,读 ../../target 下的二进制和同目录的模板。 # # 两种运行方式: # 服务模式(systemd / OpenRC):建系统用户 locus,程序在 /usr/local/bin,开机自启。 # 手动模式(NAS、没有 systemd / OpenRC 的系统、--manual):以当前用户运行,程序和数据 # 放在 <目录>/app 和 <目录>/data,用 <目录>/app/locusctl 启停,不注册开机启动。 # # DESTDIR 非空时只把文件写到 DESTDIR 下,不建用户、不碰 systemd 和防火墙、不启动,用来检查安装结果。 set -euo pipefail VERSION="0.1.0.000065" TARGETS="linux-x86_64.tar.gz x86_64 glibc 2.34 3.2 verified;linux-aarch64.tar.gz aarch64 glibc 2.34 3.2 verified;linux-x86_64-musl.tar.gz x86_64 static - 3.2 experimental;linux-aarch64-musl.tar.gz aarch64 static - 3.2 experimental;linux-armv7-musleabihf.tar.gz armv7 static - 3.2 experimental" PAYLOAD_SHA256="" PAYLOAD_MARKER='__LOCUS_PAYLOAD_BELOW__' TRUST="LOCUS 0.1 assumes a trusted local network. Binding to non-loopback interfaces exposes mutation APIs to that network. Authentication and authorization are outside the 0.1 scope." SERVICE_USER=locus BIN=/usr/local/bin/locus DOC=/usr/local/share/doc/locus CONF_DIR=/etc/locus DEFAULT_DATA=/var/lib/locus DEFAULT_PORT=3033 # 手动模式的目录至少要有这么多空间:程序约 11 MB,自升级时数据目录里还会多放两版。 MANUAL_MIN_KB=204800 R=${DESTDIR:-} [[ $VERSION == @*@ ]] && VERSION=dev SCRIPT_PATH=$(readlink -f "${BASH_SOURCE[0]}" 2>/dev/null || echo "${BASH_SOURCE[0]}") SCRIPT_DIR=$(cd "$(dirname "$SCRIPT_PATH")" && pwd) # 环境识别和选包与官网入口共用;构建时内联。 # shellcheck shell=sh # LOCUS 环境识别与选包。官网入口(sh)和内嵌安装器(bash)共用这一份,构建时原样内联进两者。 # # 只读文件、只调系统自带的命令(uname、id、getconf、ldd、df、docker),不执行下载来的程序, # 不需要 root。不读 MAC、序列号和网络配置。要在 bash 的 set -euo pipefail 下也能跑, # 所以每个可能失败的命令都带兜底。 # # 发行清单(targets)每行六列,用 ; 连成一行传进来: # <文件名> <架构> <最低 libc 版本> <最低内核> <状态> # linux-x86_64.tar.gz x86_64 glibc 2.34 3.2 verified # libc 取 glibc 或 static;static 不看系统 libc。状态取 verified 或 experimental。 # # LD_ROOT 只给测试用:所有文件都从它下面读。 LD_SCHEMA=1 LD_KERNEL_MIN=3.2 LD_DOCKER_ARCHES="x86_64 aarch64" # 还没有 targets 清单的发行版(0.1.0.000037 及以前)。 LD_LEGACY_TARGETS="linux-x86_64.tar.gz x86_64 glibc 2.34 3.2 verified;linux-aarch64.tar.gz aarch64 glibc 2.34 3.2 experimental" ld_t() { if [ "${LD_LANG:-en}" = zh ]; then printf '%s' "$1"; else printf '%s' "$2"; fi; } ld_have() { command -v "$1" >/dev/null 2>&1; } ld_path() { printf '%s%s' "${LD_ROOT:-}" "$1"; } # ld_ver_ge A B:A >= B,逐段按数字比较。不用 sort -V,busybox 的 sort 可能没有。 ld_ver_ge() { awk -v a="$1" -v b="$2" 'BEGIN { na = split(a, x, "."); nb = split(b, y, "."); n = na > nb ? na : nb for (i = 1; i <= n; i++) { p = x[i] + 0; q = y[i] + 0; if (p > q) exit 0; if (p < q) exit 1 } exit 0 }' } # KEY=value 或 KEY="value" 的文件(os-release、DSM 的 VERSION 和 synoinfo.conf)。 ld_kv() { [ -r "$1" ] || return 0 sed -n "s/^$2=//p" "$1" 2>/dev/null | head -n 1 | sed 's/^"\(.*\)"[[:space:]]*$/\1/' || : } # [节] 下的 键 = 值(QNAP 的 uLinux.conf)。 ld_ini() { [ -r "$1" ] || return 0 awk -v s="[$2]" -v k="$3" ' { l = $0; sub(/[ \t\r]+$/, "", l) } l ~ /^\[/ { in_s = (l == s); next } in_s { i = index(l, "="); if (!i) next key = substr(l, 1, i - 1); gsub(/^[ \t]+|[ \t]+$/, "", key) if (key == k) { v = substr(l, i + 1); gsub(/^[ \t]+/, "", v); print v; exit } }' "$1" 2>/dev/null || : } # ---------- 系统 ---------- ld_detect_os() { LD_KERNEL_NAME=$(uname -s 2>/dev/null) || LD_KERNEL_NAME="" LD_OS_FAMILY=linux LD_OS_NAME="" LD_OS_VERSION="" LD_VENDOR="" LD_MODEL="" LD_PLATFORM="" if [ "$LD_KERNEL_NAME" != Linux ]; then LD_OS_FAMILY=other LD_OS_NAME=${LD_KERNEL_NAME:-unknown} return 0 fi if [ -e "$(ld_path /etc.defaults/VERSION)" ]; then LD_OS_FAMILY=dsm LD_OS_NAME=DSM LD_VENDOR=Synology LD_OS_VERSION=$(ld_kv "$(ld_path /etc.defaults/VERSION)" productversion) if [ -z "$LD_OS_VERSION" ]; then LD_OS_VERSION=$(ld_kv "$(ld_path /etc.defaults/VERSION)" majorversion) [ -n "$LD_OS_VERSION" ] && LD_OS_VERSION="$LD_OS_VERSION.$(ld_kv "$(ld_path /etc.defaults/VERSION)" minorversion)" fi LD_MODEL=$(ld_kv "$(ld_path /etc/synoinfo.conf)" upnpmodelname) LD_PLATFORM=$(ld_kv "$(ld_path /etc/synoinfo.conf)" unique | awk -F_ '{print $2}') || LD_PLATFORM="" elif [ -e "$(ld_path /etc/config/uLinux.conf)" ]; then LD_OS_FAMILY=qnap LD_OS_NAME=QTS LD_VENDOR=QNAP if awk '$2 ~ /^\/share\// && $3 == "zfs" { f = 1 } END { exit !f }' "$(ld_path /proc/mounts)" 2>/dev/null; then LD_OS_NAME="QuTS hero" fi LD_MODEL=$(ld_ini "$(ld_path /etc/config/uLinux.conf)" System Model) LD_OS_VERSION=$(ld_ini "$(ld_path /etc/config/uLinux.conf)" System Version) elif [ -r "$(ld_path /etc/os-release)" ]; then LD_OS_NAME=$(ld_kv "$(ld_path /etc/os-release)" PRETTY_NAME) [ -n "$LD_OS_NAME" ] || LD_OS_NAME=$(ld_kv "$(ld_path /etc/os-release)" NAME) LD_OS_VERSION=$(ld_kv "$(ld_path /etc/os-release)" VERSION_ID) fi return 0 } ld_detect_kernel() { LD_KERNEL_RELEASE=$(uname -r 2>/dev/null) || LD_KERNEL_RELEASE="" LD_KERNEL_VERSION=$(printf '%s\n' "$LD_KERNEL_RELEASE" | sed -n 's/^\([0-9][0-9]*\.[0-9][0-9]*\(\.[0-9][0-9]*\)\{0,1\}\).*/\1/p') || LD_KERNEL_VERSION="" if [ -z "$LD_KERNEL_VERSION" ]; then LD_KERNEL_STATUS=unknown elif ld_ver_ge "$LD_KERNEL_VERSION" "$LD_KERNEL_MIN"; then LD_KERNEL_STATUS=ok else LD_KERNEL_STATUS=old fi return 0 } # ---------- CPU 与用户空间 ---------- # 读 ELF 头:LD_E_CLASS(1=32 位,2=64 位)、LD_E_MACHINE、LD_E_FLAGS。不是 ELF 时返回 1。 ld_elf() { [ -r "$1" ] || return 1 # shellcheck disable=SC2046 set -- $(dd if="$1" bs=64 count=1 2>/dev/null | od -An -v -tu1 2>/dev/null) [ $# -ge 52 ] || return 1 [ "$1.$2.$3.$4" = "127.69.76.70" ] || return 1 LD_E_CLASS=$5 if [ "$6" != 1 ]; then LD_E_MACHINE=big-endian LD_E_FLAGS=0 return 0 fi LD_E_MACHINE=$(( ${19} + ${20} * 256 )) if [ "$5" = 1 ]; then LD_E_FLAGS=$(( ${37} + ${38} * 256 + ${39} * 65536 + ${40} * 16777216 )) else LD_E_FLAGS=$(( ${49} + ${50} * 256 + ${51} * 65536 + ${52} * 16777216 )) fi return 0 } # 动态链接器路径(PT_INTERP 在文件头一页里)。静态程序没有。 ld_interp() { dd if="$1" bs=4096 count=1 2>/dev/null | tr -c ' -~' '\n' 2>/dev/null \ | sed -n '/^\/[[:print:]]*ld[-.][[:print:]]*\.so/{p;q;}' 2>/dev/null || : } ld_detect_cpu() { LD_MACHINE=$(uname -m 2>/dev/null) || LD_MACHINE="" case $LD_MACHINE in "") LD_KERNEL_BITS="" ;; x86_64|amd64|aarch64|arm64|ppc64*|s390x|riscv64|mips64*) LD_KERNEL_BITS=64 ;; *) LD_KERNEL_BITS=32 ;; esac LD_ARM_LEVEL="" LD_VFP=unknown case $LD_MACHINE in aarch64|arm64|armv8*) LD_ARM_LEVEL=8 ;; armv7*) LD_ARM_LEVEL=7 ;; armv6*) LD_ARM_LEVEL=6 ;; armv5*) LD_ARM_LEVEL=5 ;; esac cpuinfo=$(ld_path /proc/cpuinfo) if [ -r "$cpuinfo" ]; then if [ -z "$LD_ARM_LEVEL" ]; then LD_ARM_LEVEL=$(sed -n 's/^CPU architecture[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' "$cpuinfo" | head -n 1) || LD_ARM_LEVEL="" fi feats=$(sed -n 's/^Features[[:space:]]*:[[:space:]]*//p' "$cpuinfo" | head -n 1) || feats="" if [ -n "$feats" ]; then case " $feats " in *" vfp "*|*" vfpv3 "*|*" vfpv3d16 "*|*" vfpv4 "*|*" vfpd32 "*|*" fp "*) LD_VFP=yes ;; *) LD_VFP=no ;; esac fi fi # ARMv8 的 AArch32 状态必带 VFP;64 位内核给的是 64 位特性名,可能没有 vfp 字样。 [ "$LD_ARM_LEVEL" = 8 ] && LD_VFP=yes return 0 } # 用户空间按 /bin/sh 这类系统程序的 ELF 头判断,不按 uname -m:64 位内核上可以是 32 位用户空间。 ld_detect_userland() { LD_USER_STATUS=unknown LD_USER_BITS="" LD_USER_MACHINE="" LD_FLOAT="" LD_INTERP="" LD_ELF_FILE="" LD_ARCH="" for f in /bin/sh /bin/busybox /usr/bin/env /bin/ls; do if ld_elf "$(ld_path "$f")"; then LD_ELF_FILE=$f; break; fi done [ -n "$LD_ELF_FILE" ] || return 0 LD_USER_STATUS=ok case $LD_E_CLASS in 1) LD_USER_BITS=32 ;; 2) LD_USER_BITS=64 ;; esac case $LD_E_MACHINE in 62) LD_USER_MACHINE=x86_64 ;; 3) LD_USER_MACHINE=i386 ;; 183) LD_USER_MACHINE=aarch64 ;; 40) LD_USER_MACHINE=arm ;; 8) LD_USER_MACHINE=mips ;; 20) LD_USER_MACHINE=ppc ;; 21) LD_USER_MACHINE=ppc64 ;; 243) LD_USER_MACHINE=riscv ;; 22) LD_USER_MACHINE=s390 ;; *) LD_USER_MACHINE="elf-$LD_E_MACHINE" ;; esac LD_INTERP=$(ld_interp "$(ld_path "$LD_ELF_FILE")") if [ "$LD_USER_MACHINE" = arm ]; then if [ $(( LD_E_FLAGS / 1024 % 2 )) = 1 ]; then LD_FLOAT=hard elif [ $(( LD_E_FLAGS / 512 % 2 )) = 1 ]; then LD_FLOAT=soft else case $LD_INTERP in *armhf*) LD_FLOAT=hard ;; */ld-linux.so.3) LD_FLOAT=soft ;; *) LD_FLOAT=unknown ;; esac fi fi case $LD_USER_MACHINE/$LD_USER_BITS in x86_64/64) LD_ARCH=x86_64 ;; aarch64/64) LD_ARCH=aarch64 ;; i386/32|x86_64/32) LD_ARCH=i686 ;; arm/32) case $LD_ARM_LEVEL in 7|8) LD_ARCH=armv7 ;; 6) LD_ARCH=armv6 ;; 5) LD_ARCH=armv5 ;; *) LD_ARCH=arm ;; esac ;; *) LD_ARCH=$LD_USER_MACHINE ;; esac case $LD_ARCH in aarch64) LD_VFP=yes ;; esac return 0 } # ---------- C 库 ---------- ld_lib_dirs() { for d in /lib /lib64 /usr/lib /usr/lib64; do p=$(ld_path "$d") [ -d "$p" ] && printf '%s\n' "$p" for sub in "$p"/*-linux-gnu*; do [ -d "$sub" ] && printf '%s\n' "$sub"; done done return 0 } ld_detect_libc() { LD_LIBC=unknown LD_LIBC_VERSION="" LD_LIBC_SOURCE="" case $LD_INTERP in *ld-musl*) LD_LIBC=musl LD_LIBC_SOURCE=interpreter; return 0 ;; esac for f in "$(ld_path /lib)"/ld-musl-*; do if [ -e "$f" ]; then LD_LIBC=musl LD_LIBC_SOURCE=loader; return 0; fi done v="" if ld_have getconf; then v=$(getconf GNU_LIBC_VERSION 2>/dev/null | sed -n 's/^glibc[[:space:]]*\([0-9][0-9]*\.[0-9][0-9]*\).*/\1/p') || v="" [ -n "$v" ] && LD_LIBC_SOURCE=getconf fi if [ -z "$v" ] && ld_have ldd; then line=$(ldd --version 2>&1 | head -n 1) || line="" case $line in *musl*) LD_LIBC=musl LD_LIBC_SOURCE=ldd; return 0 ;; *GLIBC*|*"GNU libc"*|*"GNU C Library"*) v=$(printf '%s\n' "$line" | sed -n 's/.*[[:space:]]\([0-9][0-9]*\.[0-9][0-9]*\)[^[:space:]]*[[:space:]]*$/\1/p') || v="" [ -n "$v" ] && LD_LIBC_SOURCE=ldd ;; esac fi dirs=$(ld_lib_dirs) if [ -z "$v" ]; then for d in $dirs; do for f in "$d"/libc-[0-9]*.so; do [ -e "$f" ] || continue v=$(printf '%s\n' "${f##*/}" | sed -n 's/^libc-\([0-9][0-9]*\.[0-9][0-9]*\)\.so$/\1/p') || v="" if [ -n "$v" ]; then LD_LIBC_SOURCE=filename; break 2; fi done done fi if [ -z "$v" ]; then for d in $dirs; do [ -r "$d/libc.so.6" ] || continue v=$(tr -c ' -~' '\n' <"$d/libc.so.6" 2>/dev/null \ | sed -n 's/.*release version \([0-9][0-9]*\.[0-9][0-9]*\).*/\1/p' | head -n 1) || v="" if [ -n "$v" ]; then LD_LIBC_SOURCE=libc_string; break; fi done fi if [ -n "$v" ]; then LD_LIBC=glibc LD_LIBC_VERSION=$v return 0 fi for d in $dirs; do if [ -e "$d/libc.so.6" ]; then LD_LIBC=glibc LD_LIBC_SOURCE=libc_file; break; fi done return 0 } # ---------- 服务管理、身份、工具 ---------- ld_detect_init() { if [ -d "$(ld_path /run/systemd/system)" ] && ld_have systemctl; then LD_INIT=systemd elif ld_have openrc-run || [ -x "$(ld_path /sbin/openrc-run)" ]; then LD_INIT=openrc else LD_INIT=none fi case $LD_OS_FAMILY/$LD_INIT in dsm/*|qnap/*|*/none) LD_SERVICE=manual ;; *) LD_SERVICE=$LD_INIT ;; esac return 0 } ld_detect_identity() { LD_USER=$(id -un 2>/dev/null) || LD_USER="" LD_UID=$(id -u 2>/dev/null) || LD_UID="" if ld_have sudo; then LD_SUDO=yes; else LD_SUDO=no; fi return 0 } ld_detect_tools() { LD_FETCH="" LD_SHA="" LD_TAR=no LD_GZIP=no LD_BASH=no if ld_have curl; then LD_FETCH=curl; elif ld_have wget; then LD_FETCH=wget; fi if ld_have sha256sum; then LD_SHA=sha256sum elif ld_have busybox && busybox --list 2>/dev/null | grep -qx sha256sum; then LD_SHA=busybox elif ld_have openssl; then LD_SHA=openssl elif ld_have shasum; then LD_SHA=shasum fi ld_have tar && LD_TAR=yes ld_have gzip && LD_GZIP=yes ld_have bash && LD_BASH=yes return 0 } ld_sha256() { case ${LD_SHA:-} in sha256sum) sha256sum "$1" | awk '{print $1}' ;; busybox) busybox sha256sum "$1" | awk '{print $1}' ;; openssl) openssl dgst -sha256 "$1" | awk '{print $NF}' ;; shasum) shasum -a 256 "$1" | awk '{print $1}' ;; *) return 1 ;; esac } ld_fetch() { case ${LD_FETCH:-} in curl) curl -fsSL "$1" -o "$2" ;; wget) wget -q -O "$2" "$1" ;; *) return 1 ;; esac } # ---------- 端口、目录、Docker ---------- ld_detect_port() { LD_PORT=${LD_PORT:-3033} hex=$(printf '%04X' "$LD_PORT") LD_PORT_STATE=unknown for f in "$(ld_path /proc/net/tcp)" "$(ld_path /proc/net/tcp6)"; do [ -r "$f" ] || continue [ "$LD_PORT_STATE" = unknown ] && LD_PORT_STATE=free if awk -v p="$hex" 'NR > 1 && $4 == "0A" { n = split($2, a, ":"); if (toupper(a[n]) == p) f = 1 } END { exit !f }' "$f"; then LD_PORT_STATE=busy break fi done return 0 } ld_free_kb() { df -Pk "$1" 2>/dev/null | awk 'NR == 2 { print $4 }' || : } ld_detect_storage() { LD_VOLUMES="" LD_DIR="" LD_DIR_STATE="" LD_DIR_FREE_KB="" LD_DIR_WRITABLE="" mounts=$(ld_path /proc/mounts) case $LD_OS_FAMILY in dsm) LD_VOLUMES=$(awk '$2 ~ /^\/volume[0-9]+$/ && $3 != "tmpfs" { print $2 }' "$mounts" 2>/dev/null | sort -u | tr '\n' ' ') || LD_VOLUMES="" ;; qnap) LD_VOLUMES=$(awk '$2 ~ /^\/share\/[A-Za-z0-9]+_DATA$/ && $3 != "tmpfs" { print $2 }' "$mounts" 2>/dev/null | sort -u | tr '\n' ' ') || LD_VOLUMES="" ;; esac LD_VOLUMES=${LD_VOLUMES% } case $LD_OS_FAMILY in dsm|qnap) [ -n "$LD_VOLUMES" ] || return 0 LD_DIR="${LD_VOLUMES%% *}/locus" ;; *) LD_DIR=/var/lib/locus ;; esac p=$(ld_path "$LD_DIR") if [ -e "$p" ]; then if [ -e "$p/locus.db" ] || [ -e "$p/app/install.env" ] || [ -e "$p/data/locus.db" ]; then LD_DIR_STATE=locus elif [ -n "$(ls -A "$p" 2>/dev/null | head -n 1)" ]; then LD_DIR_STATE=not_empty else LD_DIR_STATE=empty fi else LD_DIR_STATE=absent fi while [ ! -e "$p" ] && [ "$p" != "${LD_ROOT:-}" ] && [ -n "$p" ]; do p=${p%/*}; done [ -n "$p" ] || p=$(ld_path /) LD_DIR_FREE_KB=$(ld_free_kb "$p") if [ -w "$p" ]; then LD_DIR_WRITABLE=yes; else LD_DIR_WRITABLE=no; fi return 0 } ld_detect_docker() { LD_DOCKER=absent LD_DOCKER_ARCH="" ld_have docker || return 0 # 只问本机的 Docker:远程 context 或 tcp:// 的 DOCKER_HOST 指向别的机器。 case ${DOCKER_HOST:-} in unix://*) dh=$DOCKER_HOST ;; *) dh="" ;; esac if out=$(DOCKER_CONTEXT=default DOCKER_HOST=$dh docker version --format '{{.Server.Arch}}' 2>&1); then LD_DOCKER=ok case $out in amd64|x86_64) LD_DOCKER_ARCH=x86_64 ;; arm64|aarch64) LD_DOCKER_ARCH=aarch64 ;; arm|armv7*) LD_DOCKER_ARCH=armv7 ;; *) LD_DOCKER_ARCH=$(printf '%s' "$out" | head -n 1) ;; esac return 0 fi case $out in *"ermission denied"*) LD_DOCKER=no_permission ;; *"Cannot connect"*|*"ailed to connect"*|*"daemon running"*|*"onnection refused"*|*"o such file"*) LD_DOCKER=daemon_unavailable ;; *) LD_DOCKER=error ;; esac return 0 } ld_docker_has_image() { case " $LD_DOCKER_ARCHES " in *" $1 "*) return 0 ;; esac return 1 } ld_detect() { ld_detect_os ld_detect_kernel ld_detect_cpu ld_detect_userland ld_detect_libc ld_detect_init ld_detect_identity ld_detect_tools ld_detect_port ld_detect_storage ld_detect_docker } # ---------- 选包 ---------- # ld_select TARGETS:选出 LD_PICK(文件名)。选不出时 LD_PICK 为空,LD_REASON 是原因。 ld_select() { LD_PICK="" LD_PICK_LIBC="" LD_PICK_STATUS="" LD_REASON="" LD_NEED="" if [ "$LD_OS_FAMILY" = other ]; then LD_REASON=os_not_linux; return 0; fi if [ "$LD_USER_STATUS" != ok ]; then LD_REASON=userland_unknown; return 0; fi if [ "$LD_KERNEL_STATUS" = unknown ]; then LD_REASON=kernel_unknown; return 0; fi if [ "$LD_KERNEL_STATUS" = old ]; then LD_REASON=kernel_too_old LD_NEED=$LD_KERNEL_MIN; return 0; fi rest=$1 saw=0 reason="" need="" while [ -n "$rest" ]; do line=${rest%%;*} case $rest in *\;*) rest=${rest#*;} ;; *) rest="" ;; esac # shellcheck disable=SC2086 set -- $line [ $# -ge 6 ] || continue [ "$2" = "$LD_ARCH" ] || continue saw=1 if ! ld_ver_ge "$LD_KERNEL_VERSION" "$5"; then reason=kernel_too_old need=$5; continue; fi case $3 in glibc) case $LD_LIBC in glibc) if [ -z "$LD_LIBC_VERSION" ]; then reason=libc_version_unknown; continue; fi if ! ld_ver_ge "$LD_LIBC_VERSION" "$4"; then reason=glibc_too_old need=$4; continue; fi ;; musl) [ -n "$reason" ] || reason=musl_no_package; continue ;; *) [ -n "$reason" ] || reason=libc_unknown; continue ;; esac ;; static) ;; *) continue ;; esac if [ "$2" = armv7 ] && [ "$LD_VFP" != yes ]; then reason=no_vfp; continue; fi LD_PICK=$1 LD_PICK_LIBC=$3 LD_PICK_STATUS=$6 return 0 done if [ "$saw" = 0 ]; then case $LD_ARCH in x86_64|aarch64|armv7) reason=no_package_for_arch ;; *) reason=arch_unsupported ;; esac fi LD_REASON=$reason LD_NEED=$need return 0 } # ld_select_asset TARGETS ASSET:只看清单里名为 ASSET 的那一行。 ld_select_asset() { rows="" rest=$1 while [ -n "$rest" ]; do line=${rest%%;*} case $rest in *\;*) rest=${rest#*;} ;; *) rest="" ;; esac case "$line " in "$2 "*) rows="$rows$line;" ;; esac done if [ -z "$rows" ]; then LD_PICK="" LD_PICK_LIBC="" LD_PICK_STATUS="" LD_REASON=package_missing LD_NEED=$2 return 0 fi ld_select "$rows" } # 已安装的包:服务模式记在 /etc/locus/install.env,手动模式记在 <目录>/app/install.env。 # 0.1.0.000037 及以前的服务安装没有记录,当时只有 GNU 包,按 linux-<架构>.tar.gz 算。 ld_installed_package() { LD_INSTALLED="" # 给了目录就只看那个目录:同一台机器上的服务安装和它无关。 if [ -n "${1:-}" ]; then f="$(ld_path "$1")/app/install.env" [ -r "$f" ] && { LD_INSTALLED=$(sed -n 's/^INSTALLED_PACKAGE=//p' "$f" | head -n 1) || LD_INSTALLED=""; } return 0 fi f=$(ld_path /etc/locus/install.env) [ -r "$f" ] || return 0 LD_INSTALLED=$(sed -n 's/^INSTALLED_PACKAGE=//p' "$f" | head -n 1) || LD_INSTALLED="" [ -n "$LD_INSTALLED" ] && return 0 if [ -x "$(ld_path /usr/local/bin/locus)" ]; then case $LD_ARCH in x86_64|aarch64) LD_INSTALLED="linux-$LD_ARCH.tar.gz" ;; esac fi return 0 } # ld_keep TARGETS:已经装了某个包时,升级只能用同一个包,不换构建变体(musl 不换成 GNU,ARMv7 不换成 ARM64)。 ld_keep() { LD_KEEP_FAILED=0 [ -n "$LD_INSTALLED" ] || return 0 [ "$LD_INSTALLED" = "$LD_PICK" ] && return 0 ld_select_asset "$1" "$LD_INSTALLED" [ -n "$LD_PICK" ] || LD_KEEP_FAILED=1 return 0 } # native / docker / other_host ld_advice() { if [ -n "$LD_PICK" ]; then LD_ADVICE=native elif [ "$LD_DOCKER" = ok ] && ld_docker_has_image "$LD_DOCKER_ARCH"; then LD_ADVICE=docker else LD_ADVICE=other_host fi return 0 } # ---------- 文字 ---------- ld_arch_label() { case $LD_ARCH in x86_64) a=x86_64 ;; aarch64) a="ARM64 (aarch64)" ;; armv7) a=ARMv7 ;; armv6) a=ARMv6 ;; armv5) a=ARMv5 ;; "") a=$(ld_t "未知(uname:${LD_MACHINE:-?})" "unknown (uname: ${LD_MACHINE:-?})") ;; *) a=$LD_ARCH ;; esac [ -n "$LD_USER_BITS" ] && a="$a$(ld_t ",$LD_USER_BITS 位" ", $LD_USER_BITS-bit")" case $LD_FLOAT in hard) a="$a$(ld_t ",硬浮点" ", hard-float")" ;; soft) a="$a$(ld_t ",软浮点" ", soft-float")" ;; unknown) a="$a$(ld_t ",浮点 ABI 未知" ", float ABI unknown")" ;; esac if [ -n "$LD_KERNEL_BITS" ] && [ -n "$LD_USER_BITS" ] && [ "$LD_KERNEL_BITS" != "$LD_USER_BITS" ]; then a="$a$(ld_t "(内核 $LD_KERNEL_BITS 位)" " (kernel $LD_KERNEL_BITS-bit)")" fi printf '%s' "$a" } ld_libc_label() { case $LD_LIBC in glibc) printf 'glibc %s' "${LD_LIBC_VERSION:-$(ld_t "版本未知" "version unknown")}" ;; musl) printf 'musl' ;; *) ld_t "未知" "unknown" ;; esac } ld_reason_text() { if [ "${LD_KEEP_FAILED:-0}" = 1 ]; then ld_t "已安装的是 $LD_INSTALLED,升级不换成别的构建;" "$LD_INSTALLED is installed and upgrades keep that build; " fi case $LD_REASON in package_missing) ld_t "这一版没有 $LD_NEED" "this release has no $LD_NEED" ;; os_not_linux) ld_t "LOCUS 只支持 Linux" "LOCUS runs on Linux only" ;; userland_unknown) ld_t "认不出这台机器的程序格式(读不到系统程序的 ELF 头),不猜测" "cannot tell this machine's program format (no readable ELF header in system programs); not guessing" ;; kernel_unknown) ld_t "读不出内核版本" "cannot read the kernel version" ;; kernel_too_old) ld_t "内核 $LD_KERNEL_VERSION 低于最低要求 $LD_NEED" "kernel $LD_KERNEL_VERSION is older than the minimum $LD_NEED" ;; glibc_too_old) ld_t "glibc $LD_LIBC_VERSION 低于当前发布包要求的 $LD_NEED" "glibc $LD_LIBC_VERSION is older than the $LD_NEED the current package needs" ;; libc_version_unknown) ld_t "有 glibc,但读不出版本" "glibc is present but its version cannot be read" ;; libc_unknown) ld_t "认不出这台机器的 C 库" "cannot identify the C library" ;; musl_no_package) ld_t "这台机器使用 musl,当前发布版本没有可用的包" "this machine uses musl; the current release has no package for it" ;; no_vfp) ld_t "CPU 没有 VFP 浮点单元" "the CPU has no VFP floating-point unit" ;; no_package_for_arch) ld_t "当前发布版本没有 $(ld_arch_label) 的包" "the current release has no package for $(ld_arch_label)" ;; arch_unsupported) ld_t "不支持 $(ld_arch_label)" "$(ld_arch_label) is not supported" ;; *) printf '%s' "$LD_REASON" ;; esac } ld_kb_label() { awk -v k="${1:-0}" 'BEGIN { split("KB MB GB TB", u, " "); i = 1 while (k >= 1024 && i < 4) { k /= 1024; i++ } printf(k < 10 ? "%.1f %s" : "%.0f %s", k, u[i]) }' } ld_docker_text() { case $LD_DOCKER in ok) s="$(ld_t "可用,服务端" "available, server") ${LD_DOCKER_ARCH:-?}" ;; absent) s=$(ld_t "未检测到" "not found") ;; no_permission) s=$(ld_t "已安装,但当前用户没有权限访问(需要 root 或 docker 组)" "installed, but this user may not use it (needs root or the docker group)") ;; daemon_unavailable) s=$(ld_t "有 docker 命令,但 Docker 服务没有运行或连不上" "the docker command exists but the daemon is not running or unreachable") ;; *) s=$(ld_t "有 docker 命令,但查询失败" "the docker command exists but the query failed") ;; esac a=${LD_DOCKER_ARCH:-$LD_ARCH} if [ -n "$a" ] && ! ld_docker_has_image "$a"; then s="$s$(ld_t ";LOCUS 不提供 $a 镜像" "; LOCUS publishes no $a image")" fi printf '%s' "$s" } ld_row() { printf '%s%s\n' "$1" "$2"; } ld_report_text() { sep=$(ld_t ":" ": ") if [ -n "$LD_MODEL" ]; then ld_row "$(ld_t "设备" "Device")$sep" "$LD_VENDOR $LD_MODEL"; fi sys=${LD_OS_NAME:-$(ld_t "未识别的 Linux" "unrecognised Linux")} [ -n "$LD_OS_VERSION" ] && case $sys in *"$LD_OS_VERSION"*) ;; *) sys="$sys $LD_OS_VERSION" ;; esac ld_row "$(ld_t "系统" "System")$sep" "$sys" ld_row "$(ld_t "内核" "Kernel")$sep" "${LD_KERNEL_RELEASE:-?}" ld_row "$(ld_t "架构" "Architecture")$sep" "$(ld_arch_label)" ld_row "$(ld_t "C 库" "C library")$sep" "$(ld_libc_label)" ident="${LD_USER:-?} (UID ${LD_UID:-?})" ld_row "$(ld_t "运行身份" "Running as")$sep" "$ident" case $LD_SERVICE in systemd) svc="systemd" ;; openrc) svc="OpenRC" ;; *) svc=$(ld_t "手动运行,不注册开机启动" "manual; nothing starts on boot") ;; esac ld_row "$(ld_t "服务管理" "Service")$sep" "$svc" tools="" [ -n "$LD_FETCH" ] && tools="$LD_FETCH" || tools=$(ld_t "缺少 curl / wget" "no curl / wget") if [ -n "$LD_SHA" ]; then tools="$tools, sha256: $LD_SHA"; else tools="$tools, $(ld_t "缺少 SHA-256 校验工具" "no SHA-256 tool")"; fi [ "$LD_TAR" = yes ] || tools="$tools, $(ld_t "缺少 tar" "no tar")" [ "$LD_GZIP" = yes ] || tools="$tools, $(ld_t "缺少 gzip" "no gzip")" ld_row "$(ld_t "工具" "Tools")$sep" "$tools" case $LD_PORT_STATE in free) port=$(ld_t "空闲" "free") ;; busy) port=$(ld_t "已被占用" "in use") ;; *) port=$(ld_t "无法检查" "not checked") ;; esac ld_row "$(ld_t "端口 $LD_PORT" "Port $LD_PORT")$sep" "$port" case $LD_OS_FAMILY in dsm|qnap) if [ -n "$LD_VOLUMES" ]; then ld_row "$(ld_t "数据卷" "Volumes")$sep" "$LD_VOLUMES" else ld_row "$(ld_t "数据卷" "Volumes")$sep" "$(ld_t "没有找到" "none found")" fi ;; esac if [ -n "$LD_DIR" ]; then d="$LD_DIR" case $LD_DIR_STATE in locus) d="$d$(ld_t "(已有 LOCUS 数据)" " (has LOCUS data)")" ;; not_empty) d="$d$(ld_t "(已存在且不为空)" " (exists, not empty)")" ;; esac [ -n "$LD_DIR_FREE_KB" ] && d="$d, $(ld_t "可用" "free") $(ld_kb_label "$LD_DIR_FREE_KB")" if [ "$LD_DIR_WRITABLE" = yes ]; then d="$d, $(ld_t "可写" "writable")"; else d="$d, $(ld_t "当前用户不可写" "not writable by this user")"; fi ld_row "$(ld_t "建议目录" "Suggested directory")$sep" "$d" fi if [ -n "$LD_INSTALLED" ]; then ld_row "$(ld_t "已安装" "Installed")$sep" "$LD_INSTALLED"; fi if [ -n "$LD_PICK" ]; then case $LD_PICK_STATUS in verified) st=$(ld_t "已验证" "verified") ;; *) st=$(ld_t "实验性" "experimental") ;; esac ld_row "$(ld_t "原生安装" "Native install")$sep" "$(ld_t "可以,使用 $LD_PICK($st)" "possible with $LD_PICK ($st)")" else ld_row "$(ld_t "原生安装" "Native install")$sep" "$(ld_t "不可以:" "not possible: ")$(ld_reason_text)" fi ld_row "Docker$sep" "$(ld_docker_text)" case $LD_ADVICE in native) case $LD_OS_FAMILY in dsm|qnap) adv=$(ld_t "手动模式:程序和数据放在数据卷上,以 ${LD_USER:-?} 的身份运行,不注册开机启动" "manual mode: program and data on a data volume, running as ${LD_USER:-?}, nothing starts on boot") ;; *) case $LD_SERVICE in manual) adv=$(ld_t "手动模式:以 ${LD_USER:-?} 的身份运行,不注册开机启动" "manual mode: running as ${LD_USER:-?}, nothing starts on boot") ;; *) adv=$(ld_t "可以用安装脚本原生安装" "install natively with the installer") ;; esac ;; esac ;; docker) adv=$(ld_t "原生包不适用,可以用 Docker 镜像运行" "no native package fits; run the Docker image") ;; *) adv=$(ld_t "使用受支持的 Linux 主机部署" "deploy on a supported Linux host") ;; esac ld_row "$(ld_t "建议" "Advice")$sep" "$adv" case $LD_OS_FAMILY in dsm|qnap) if [ "$LD_UID" = 0 ]; then ld_row "" "$(ld_t "注意:当前是 UID 0,手动模式下 LOCUS 将以 root 运行" "Note: this is UID 0; in manual mode LOCUS would run as root")" fi ;; esac return 0 } # ---------- JSON ---------- ld_js() { if [ -z "$1" ]; then printf 'null'; return 0; fi printf '"%s"' "$(printf '%s' "$1" | tr -d '\000-\037' | sed 's/\\/\\\\/g; s/"/\\"/g')" } ld_jn() { if [ -z "$1" ]; then printf 'null'; else printf '%s' "$1"; fi; } ld_jb() { case $1 in yes|true|1) printf 'true' ;; no|false|0) printf 'false' ;; *) printf 'null' ;; esac; } ld_ja() { printf '[' first=1 for item in $1; do [ "$first" = 1 ] || printf ', ' ld_js "$item" first=0 done printf ']' } ld_report_json() { root=no; [ "$LD_UID" = 0 ] && root=yes pick_status=unsupported; [ -n "$LD_PICK" ] && pick_status=supported image=no; ld_docker_has_image "${LD_DOCKER_ARCH:-$LD_ARCH}" && image=yes cat <&2; } err() { printf '%s✗%s %s\n' "$RED" "$RST" "$(t "$1" "$2")" >&2; } die() { err "$1" "$2"; exit 1; } step() { printf '\n%s[%s]%s %s%s%s\n' "$CYN" "$1" "$RST" "$BLD" "$(t "$2" "$3")" "$RST"; } # ---------- 选项 ---------- ASSUME_YES=0 OPT_JSON=0 PURGE=0 FIREWALL_CHOICE="" OPT_BIND="" OPT_PORT="" OPT_DATA="" OPT_HOSTS="" OPT_HOSTS_SET=0 OPT_LANG=${LOCUS_LANG:-} OPT_DIR="" OPT_PACKAGE="" OPT_MANUAL=0 EXTRACT_DIR="" CMD="" usage() { if (( ZH )); then cat <.tar.gz(同目录要有 SHA256SUMS),官网入口会自动传 EOF else cat <.tar.gz (SHA256SUMS must sit next to it); the web entry passes it EOF fi } ORIGINAL_ARGS=("$@") while (( $# )); do case "$1" in install|update|configure|start|stop|restart|status|uninstall|menu|help|diagnose) CMD=$1 ;; --json) OPT_JSON=1 ;; extract) CMD=extract; EXTRACT_DIR=${2:-}; [[ -n $EXTRACT_DIR ]] && shift ;; -y|--yes) ASSUME_YES=1 ;; --bind) OPT_BIND=${2:?}; shift ;; --bind=*) OPT_BIND=${1#*=} ;; --port) OPT_PORT=${2:?}; shift ;; --port=*) OPT_PORT=${1#*=} ;; --data) OPT_DATA=${2:?}; shift ;; --data=*) OPT_DATA=${1#*=} ;; --allowed-hosts) OPT_HOSTS=${2-}; OPT_HOSTS_SET=1; shift ;; --allowed-hosts=*) OPT_HOSTS=${1#*=}; OPT_HOSTS_SET=1 ;; --firewall) FIREWALL_CHOICE=yes ;; --no-firewall) FIREWALL_CHOICE=no ;; --purge) PURGE=1 ;; --dir) OPT_DIR=${2:?}; shift ;; --dir=*) OPT_DIR=${1#*=} ;; --package) OPT_PACKAGE=${2:?}; shift ;; --package=*) OPT_PACKAGE=${1#*=} ;; --manual) OPT_MANUAL=1 ;; --force) die "--force 已取消:不兼容的系统不能强行安装,用 diagnose 查看原因" \ "--force is gone: an incompatible system cannot be forced; run diagnose to see why" ;; --lang) OPT_LANG=${2:?}; shift ;; --lang=*) OPT_LANG=${1#*=} ;; -h|--help) CMD=help ;; *) usage >&2; die "未知参数:$1" "unknown argument: $1" ;; esac shift done if (( ! ASSUME_YES )) && ! { : /dev/null; then ASSUME_YES=1 fi # 先选语言。选好后写进 ORIGINAL_ARGS:sudo 重新运行时会清掉环境变量,只有参数能带过去。 case $OPT_LANG in zh) ZH=1 ;; en) ZH=0 ;; *) if (( ! ASSUME_YES )) && [[ ${CMD:-menu} != help && ${CMD:-menu} != extract && ${CMD:-menu} != diagnose ]]; then printf '\n 1) 中文\n 2) English\n 语言 / Language %s[%s]%s: ' "$DIM" "$(( ZH ? 1 : 2 ))" "$RST" >/dev/tty IFS= read -r answer /dev/tty else printf ' %s: ' "$prompt" >/dev/tty fi IFS= read -r answer /dev/tty IFS= read -r answer /dev/tty for option in "$@"; do printf ' %d) %s\n' "$i" "$option" >/dev/tty i=$((i + 1)) done while :; do answer="" printf ' %s %s[%s]%s: ' "$(t "选择" "Choose")" "$DIM" "$default" "$RST" >/dev/tty IFS= read -r answer = 1 && answer < i )); then REPLY=$answer return fi done } # ---------- 选包与载荷 ---------- MODE="" PKG_FILE="" PKG_VARIANT="" HOST_CHECKED=0 host_detect() { (( HOST_CHECKED )) && return 0 LD_LANG=$( (( ZH )) && echo zh || echo en ) LD_RELEASE=$VERSION ld_run "$TARGETS" "$OPT_DIR" # --dir 指向已有的手动安装时沿用手动模式,有 systemd 的机器上也不改成服务。 if [[ $LD_SERVICE == manual ]] || (( OPT_MANUAL )) \ || { [[ -n $OPT_DIR ]] && grep -qx 'INSTALLED_MODE=manual' "$R$OPT_DIR/app/install.env" 2>/dev/null; }; then MODE=manual else MODE=service fi HOST_CHECKED=1 } use_pick() { PKG_FILE=$LD_PICK PKG_VARIANT=${LD_PICK#linux-} PKG_VARIANT=${PKG_VARIANT%.tar.gz} } # 选不出包就停,不猜、不降级,也没有跳过检查的开关。 host_check() { [[ -n $PKG_VARIANT ]] && return 0 host_detect if [[ -z $LD_PICK ]]; then ld_report_text >&2 die "LOCUS $VERSION 不能在这台机器上原生安装:$(ld_reason_text)" \ "LOCUS $VERSION cannot be installed natively here: $(ld_reason_text)" fi use_pick } # 手动模式选定目录后再看一次那里装的是哪个包,升级不换构建变体。 keep_for_dir() { ld_installed_package "$1" ld_select "$TARGETS" ld_keep "$TARGETS" if [[ -z $LD_PICK ]]; then die "LOCUS $VERSION 不能装进 $1:$(ld_reason_text)" "LOCUS $VERSION cannot be installed into $1: $(ld_reason_text)" fi if [[ -n $PKG_ROOT && $LD_PICK != "$PKG_FILE" ]]; then die "已经取出的是 $PKG_FILE,而 $1 里装的是 $LD_INSTALLED" "$PKG_FILE was unpacked, but $1 holds $LD_INSTALLED" fi use_pick } has_payload() { grep -a -qx "$PAYLOAD_MARKER" "$SCRIPT_PATH" 2>/dev/null } ROLLBACK=() cleanup() { local dir if [[ -n $EXTRACT_TMP && -d $EXTRACT_TMP ]]; then rm -rf "$EXTRACT_TMP"; fi for dir in ${ROLLBACK[@]+"${ROLLBACK[@]}"}; do [[ -e $dir ]] || continue rm -rf "$dir" err "安装没有完成,已删除这次新建的 $dir" "the install did not finish; removed $dir, which it had created" done } trap cleanup EXIT # 程序的 ELF 头要和构建变体对得上:机器类型一致,musl 版是静态的(没有动态链接器)。 check_binary() { local file=$1 machine interp ld_elf "$file" || die "$file 不是可执行程序" "$file is not an executable" case $PKG_VARIANT in x86_64*) machine=62 ;; aarch64*) machine=183 ;; armv7*) machine=40 ;; *) die "不认识的构建变体 $PKG_VARIANT" "unknown build variant $PKG_VARIANT" ;; esac [[ $LD_E_MACHINE == "$machine" ]] || die "$file 的机器类型和 $PKG_VARIANT 不符" "$file does not match $PKG_VARIANT" interp=$(ld_interp "$file") case $PKG_VARIANT in *musl*) [[ -z $interp ]] || die "$file 应该是静态程序" "$file should be statically linked" ;; *) [[ -n $interp ]] || die "$file 应该动态链接 glibc" "$file should link glibc dynamically" ;; esac } # 官网入口下载的单个包:同目录的 SHA256SUMS 必须对得上,文件名必须正好是选出的那个包。 unpack_package() { local file=$OPT_PACKAGE name sums want top [[ $file == /* ]] || file=$PWD/$file [[ -f $file ]] || die "找不到 $file" "$file not found" name=${file##*/} # 官网上叫 linux-<变体>.tar.gz,GitHub Releases 上带版本号前缀。 case $name in "$PKG_FILE"|"locus-$VERSION-$PKG_FILE") ;; locus-*-"$PKG_FILE") [[ $VERSION == dev ]] || die "$name 不是 LOCUS $VERSION 的包" "$name is not a LOCUS $VERSION package" ;; *) die "$name 不是这台机器要用的 $PKG_FILE" "$name is not $PKG_FILE, which this machine needs" ;; esac sums=${file%/*}/SHA256SUMS [[ -f $sums ]] || die "$name 旁边没有 SHA256SUMS,无法校验" "no SHA256SUMS next to $name; cannot verify it" want=$(awk -v n="$name" '$2 == n || $2 == "*" n { print $1; exit }' "$sums") [[ -n $want ]] || die "SHA256SUMS 里没有 $name" "SHA256SUMS has no $name" [[ $(ld_sha256 "$file") == "$want" ]] || die "$name 校验失败,请重新下载" "$name checksum mismatch; download it again" mkdir -p "$EXTRACT_TMP/raw" gzip -dc "$file" | tar -xf - -C "$EXTRACT_TMP/raw" if [[ $VERSION == dev ]]; then top=$(ls -d "$EXTRACT_TMP/raw/locus-"*"-linux-$PKG_VARIANT" 2>/dev/null | head -n 1) else top=$EXTRACT_TMP/raw/locus-$VERSION-linux-$PKG_VARIANT fi [[ -n $top && -f $top/locus ]] || die "$name 不是 LOCUS $VERSION 的 $PKG_VARIANT 包" "$name is not the LOCUS $VERSION $PKG_VARIANT package" mkdir -p "$EXTRACT_TMP/$PKG_VARIANT" "$EXTRACT_TMP/systemd" "$EXTRACT_TMP/openrc" mv "$top/locus" "$EXTRACT_TMP/$PKG_VARIANT/locus" mv "$top/LICENSE" "$top/THIRD_PARTY_NOTICES.txt" "$EXTRACT_TMP/" 2>/dev/null || true [[ -f $top/locus.service ]] && mv "$top/locus.service" "$EXTRACT_TMP/systemd/locus.service" [[ -f $top/locus.openrc ]] && mv "$top/locus.openrc" "$EXTRACT_TMP/openrc/locus" rm -rf "$EXTRACT_TMP/raw" } unpack_payload() { local line line=$(awk -v m="$PAYLOAD_MARKER" '$0 == m { print NR + 1; exit }' "$SCRIPT_PATH") [[ -n $line ]] || die "安装包损坏:找不到载荷" "installer is damaged: payload not found" tail -n "+$line" "$SCRIPT_PATH" >"$EXTRACT_TMP/payload.tar.gz" [[ $(ld_sha256 "$EXTRACT_TMP/payload.tar.gz") == "$PAYLOAD_SHA256" ]] \ || die "安装包校验失败,请重新下载" "installer checksum mismatch; download it again" gzip -dc "$EXTRACT_TMP/payload.tar.gz" | tar -xf - -C "$EXTRACT_TMP" rm -f "$EXTRACT_TMP/payload.tar.gz" } unpack_dev() { local repo built="" triple candidate repo=$(cd "$SCRIPT_DIR/../.." && pwd) case $PKG_VARIANT in x86_64) triple=x86_64-unknown-linux-gnu ;; aarch64) triple=aarch64-unknown-linux-gnu ;; x86_64-musl) triple=x86_64-unknown-linux-musl ;; aarch64-musl) triple=aarch64-unknown-linux-musl ;; armv7-musleabihf) triple=armv7-unknown-linux-musleabihf ;; *) triple=none ;; esac mkdir -p "$EXTRACT_TMP/$PKG_VARIANT" "$EXTRACT_TMP/systemd" "$EXTRACT_TMP/openrc" for candidate in "$repo/target/$triple/release/locus" "$repo/target/release/locus"; do if [[ -x $candidate ]]; then built=$candidate; break; fi done [[ -n $built ]] || die "开发形态:找不到编译好的 locus,先运行 cargo build --release" \ "dev mode: no built locus found; run cargo build --release first" cp "$built" "$EXTRACT_TMP/$PKG_VARIANT/locus" cp "$SCRIPT_DIR/locus.service" "$EXTRACT_TMP/systemd/locus.service" cp "$SCRIPT_DIR/locus.openrc" "$EXTRACT_TMP/openrc/locus" cp "$repo/LICENSE" "$EXTRACT_TMP/LICENSE" cp "$repo/THIRD_PARTY_NOTICES.txt" "$EXTRACT_TMP/THIRD_PARTY_NOTICES.txt" } resolve_pkg() { [[ -n $PKG_ROOT ]] && return 0 host_check [[ -n $LD_SHA ]] || die "需要 SHA-256 校验工具(sha256sum、busybox、openssl 或 shasum)" \ "a SHA-256 tool is required (sha256sum, busybox, openssl or shasum)" EXTRACT_TMP=$(mktemp -d "${TMPDIR:-/tmp}/locus-install.XXXXXX") if [[ -n $OPT_PACKAGE ]]; then unpack_package elif has_payload; then unpack_payload elif [[ $VERSION == dev ]]; then unpack_dev else die "这个安装脚本不带程序,要用 --package 指定 $PKG_FILE(官网入口会自动下载并传入)" \ "this script carries no program; pass $PKG_FILE with --package (the web entry does this for you)" fi PKG_ROOT=$EXTRACT_TMP [[ -f $PKG_ROOT/$PKG_VARIANT/locus ]] || die "这个安装包不含 $PKG_VARIANT 版本" "this installer has no $PKG_VARIANT build" chmod 0755 "$PKG_ROOT/$PKG_VARIANT/locus" check_binary "$PKG_ROOT/$PKG_VARIANT/locus" [[ -f $PKG_ROOT/LICENSE && -f $PKG_ROOT/THIRD_PARTY_NOTICES.txt ]] \ || die "安装包缺少许可声明" "installer is missing the license notices" } # ---------- 环境 ---------- OS_NAME=unknown INIT=none FIREWALL=none PKG_MANAGER="" detect_env() { OS_NAME=${LD_OS_NAME:-unknown} case $LD_INIT in systemd|openrc) INIT=$LD_INIT ;; esac if command -v firewall-cmd >/dev/null && [[ $(firewall-cmd --state 2>/dev/null) == running ]]; then FIREWALL=firewalld elif command -v ufw >/dev/null && ufw status 2>/dev/null | grep -qi '^status: active'; then FIREWALL=ufw fi for manager in apt-get dnf yum zypper pacman; do if command -v "$manager" >/dev/null; then PKG_MANAGER=$manager; break; fi done } has_ca_bundle() { local file for file in /etc/ssl/certs/ca-certificates.crt /etc/pki/tls/certs/ca-bundle.crt \ /etc/ssl/ca-bundle.pem /etc/ssl/cert.pem /etc/pki/tls/cacert.pem; do [[ -s $file ]] && return 0 done return 1 } install_ca() { case $PKG_MANAGER in apt-get) DEBIAN_FRONTEND=noninteractive apt-get install -y ca-certificates ;; dnf|yum) "$PKG_MANAGER" install -y ca-certificates ;; zypper) zypper --non-interactive install ca-certificates ;; pacman) pacman -S --noconfirm --needed ca-certificates ;; *) return 1 ;; esac } check_env() { step 1/5 "检查环境" "Checking this machine" detect_env host_check ok "系统:$OS_NAME" "System: $OS_NAME" ok "架构:$(ld_arch_label)" "Architecture: $(ld_arch_label)" ok "C 库:$(ld_libc_label)" "C library: $(ld_libc_label)" if [[ $LD_PICK_STATUS == verified ]]; then ok "安装包:$LD_PICK" "Package: $LD_PICK" else warn "安装包:$LD_PICK,实验性,还没有在这类机器上实测过" "Package: $LD_PICK, experimental; not yet tested on this kind of machine" fi case $INIT in systemd) ok "服务管理:systemd" "Service manager: systemd" ;; openrc) ok "服务管理:OpenRC" "Service manager: OpenRC" ;; *) warn "没有找到 systemd 或 OpenRC,只能安装程序,需要自己负责启动" "neither systemd nor OpenRC found; only the program will be installed and you start it yourself" ;; esac if has_ca_bundle; then ok "CA 证书:已就绪" "CA certificates: present" else warn "没有找到系统 CA 证书,HTTPS 站点的到达检查和图标获取会失败" "no system CA bundle found; HTTPS reach checks and icon fetching will fail" if [[ -n $PKG_MANAGER && -z $R ]] && confirm "$(t "用 $PKG_MANAGER 安装 ca-certificates?" "Install ca-certificates with $PKG_MANAGER?")" y; then install_ca && ok "已安装 ca-certificates" "ca-certificates installed" || warn "安装失败,继续" "install failed; continuing" fi fi case $FIREWALL in firewalld) ok "防火墙:firewalld(运行中)" "Firewall: firewalld (running)" ;; ufw) ok "防火墙:ufw(已启用)" "Firewall: ufw (active)" ;; *) ok "防火墙:未检测到启用中的 firewalld / ufw" "Firewall: no active firewalld / ufw" ;; esac } # ---------- 设置 ---------- LOCUS_BIND="" LOCUS_DATA="" LOCUS_ALLOWED_HOSTS="" HOST="" PORT="" INSTALLED_VERSION="" INSTALLED_INIT="" INSTALLED_FIREWALL="" OPEN_FIREWALL=no MOVE_DATA=0 OLD_DATA="" # load_settings [目录]:默认读服务模式的 /etc/locus,手动模式传 <目录>/app。 load_settings() { local conf=${1:-$R$CONF_DIR} if [[ -f $conf/locus.env ]]; then # shellcheck source=/dev/null . "$conf/locus.env" fi if [[ -f $conf/install.env ]]; then # shellcheck source=/dev/null . "$conf/install.env" fi if [[ -n $LOCUS_BIND ]]; then PORT=${LOCUS_BIND##*:} HOST=${LOCUS_BIND%:*} HOST=${HOST#[} HOST=${HOST%]} fi } installed() { [[ -f $R$CONF_DIR/install.env && -x $R$BIN ]] } lan_addresses() { local found="" if command -v ip >/dev/null; then # 容器和虚拟机的内部网桥从局域网上打不开,不列出来。 found=$(ip -4 -o addr show scope global 2>/dev/null \ | awk '$2 !~ /^(docker[0-9]*|br-|veth|virbr|cni|flannel|podman|lxcbr|lxdbr)/ { split($4, a, "/"); print a[1] }') || found="" fi if [[ -z $found ]]; then found=$(hostname -I 2>/dev/null | tr ' ' '\n' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$') || found="" fi # busybox 的 hostname 没有 -I,老 NAS 上也可能没有 ip。 if [[ -z $found ]] && command -v ifconfig >/dev/null; then found=$(ifconfig 2>/dev/null | sed -n 's/.*inet \(addr:\)\{0,1\}\([0-9][0-9.]*\).*/\2/p' | grep -v '^127\.') || found="" fi [[ -n $found ]] && printf '%s\n' "$found" return 0 } port_busy() { local LD_PORT=$1 ld_detect_port [[ $LD_PORT_STATE == busy ]] } service_active() { case $INIT in systemd) systemctl is-active --quiet locus.service 2>/dev/null ;; openrc) rc-service locus status >/dev/null 2>&1 ;; *) return 1 ;; esac } wizard() { step 2/5 "监听地址" "Listen address" wizard_bind step 3/5 "端口" "Port" wizard_port step 4/5 "数据目录" "Data directory" wizard_data step 5/5 "访问名称与防火墙" "Host names and firewall" wizard_hosts wizard_firewall compose_bind } compose_bind() { if [[ $HOST == *:* ]]; then LOCUS_BIND="[$HOST]:$PORT"; else LOCUS_BIND="$HOST:$PORT"; fi } wizard_bind() { local current_host=${HOST:-0.0.0.0} default_choice=1 if [[ -n $OPT_BIND ]]; then HOST=$OPT_BIND else case $current_host in 0.0.0.0|::) default_choice=1 ;; 127.0.0.1|::1) default_choice=2 ;; *) default_choice=3 ;; esac log " 其他设备要打开 LOCUS,选局域网;只在这台机器上用,选仅本机。" \ " Pick local network if other devices should open LOCUS; this machine only otherwise." choose "$(t "监听在哪里?" "Where should LOCUS listen?")" "$default_choice" \ "$(t "局域网,所有网卡(0.0.0.0)" "Local network, all interfaces (0.0.0.0)")" \ "$(t "仅本机(127.0.0.1)" "This machine only (127.0.0.1)")" \ "$(t "指定一个地址" "A specific address")" case $REPLY in 1) HOST=0.0.0.0 ;; 2) HOST=127.0.0.1 ;; 3) local suggested=$current_host [[ $suggested == 0.0.0.0 || $suggested == 127.0.0.1 ]] && suggested=$(lan_addresses | head -1) log " 本机地址:$(lan_addresses | tr '\n' ' ')" " Addresses here: $(lan_addresses | tr '\n' ' ')" ask "$(t "地址" "Address")" "$suggested" HOST=$REPLY ;; esac fi if [[ $HOST != 127.* && $HOST != "::1" ]]; then printf ' %s%s%s\n' "$YLW" "$TRUST" "$RST" fi } wizard_port() { local current_port=${PORT:-$DEFAULT_PORT} PORT=${OPT_PORT:-} while :; do if [[ -z $PORT ]]; then ask "$(t "端口" "Port")" "$current_port"; PORT=$REPLY; fi if [[ ! $PORT =~ ^[0-9]+$ ]] || (( PORT < 1 || PORT > 65535 )); then warn "端口无效:$PORT" "invalid port: $PORT" elif port_busy "$PORT" && ! { { service_active || manual_running; } && [[ $PORT == "$current_port" ]]; }; then warn "端口 $PORT 已被其他程序占用" "port $PORT is used by another program" else ok "端口 $PORT" "port $PORT" break fi (( ASSUME_YES )) && die "端口不可用" "port unavailable" PORT="" done } wizard_data() { local current_data=${LOCUS_DATA:-$DEFAULT_DATA} if [[ -n $OPT_DATA ]]; then LOCUS_DATA=$OPT_DATA else log " 数据库、主题、语言包和背景图都放在这里。" " The database, themes, locales and background live here." ask "$(t "数据目录" "Data directory")" "$current_data" LOCUS_DATA=$REPLY fi [[ $LOCUS_DATA == /* ]] || die "数据目录必须是绝对路径" "the data directory must be an absolute path" if [[ $LOCUS_DATA != "$current_data" && -d $R$current_data ]] && installed; then MOVE_DATA=0 if confirm "$(t "把现有数据从 $current_data 移到 $LOCUS_DATA?" "Move existing data from $current_data to $LOCUS_DATA?")" y; then MOVE_DATA=1 OLD_DATA=$current_data fi fi } wizard_hosts() { if (( OPT_HOSTS_SET )); then LOCUS_ALLOWED_HOSTS=$OPT_HOSTS elif [[ $HOST != 127.* && $HOST != "::1" ]]; then log " 本机 IP、localhost、主机名和 主机名.local 已默认允许。通过反向代理或自定义域名访问时,把那个名字填在这里。" \ " This machine's IPs, localhost, host name and hostname.local are allowed already. Add names used by a reverse proxy or custom domain." ask "$(t "额外允许的主机名(逗号分隔,可留空)" "Extra allowed host names (comma separated, optional)")" "$LOCUS_ALLOWED_HOSTS" LOCUS_ALLOWED_HOSTS=${REPLY// /} fi } wizard_firewall() { OPEN_FIREWALL=no if [[ $FIREWALL != none && $HOST != 127.* && $HOST != "::1" && -z $R ]]; then if [[ $FIREWALL_CHOICE == yes ]]; then OPEN_FIREWALL=yes elif [[ $FIREWALL_CHOICE != no ]]; then log " $FIREWALL 正在运行。不放行的话,其他设备打不开页面,mDNS / SSDP 发现也收不到回应。" \ " $FIREWALL is running. Without these rules other devices cannot open the page and mDNS / SSDP discovery gets no answers." if confirm "$(t "放行 $PORT/tcp、mDNS(5353/udp)和 SSDP(1900/udp)?" "Allow $PORT/tcp, mDNS (5353/udp) and SSDP (1900/udp)?")" y; then OPEN_FIREWALL=yes fi fi else ok "无需调整防火墙" "No firewall changes needed" fi } service_label() { case $INIT in systemd) echo "systemd locus.service ($(t "开机自启" "starts on boot"))" ;; openrc) echo "OpenRC /etc/init.d/locus ($(t "开机自启" "starts on boot"))" ;; *) t "无,需要手动启动" "none, start it yourself" ;; esac } row() { local ascii bytes width ascii=$(printf '%s' "$1" | LC_ALL=C tr -d '\200-\377' | LC_ALL=C wc -c) bytes=$(printf '%s' "$1" | LC_ALL=C wc -c) width=$((ascii + (bytes - ascii) / 3 * 2)) printf ' %s%*s %s\n' "$1" $((12 - width)) "" "$2" } summary() { printf '\n%s%s%s\n' "$BLD" "$(t "确认安装设置" "Review")" "$RST" row "$(t "程序" "Program")" "$BIN ($VERSION, $PKG_VARIANT)" row "$(t "监听" "Listen")" "$LOCUS_BIND" row "$(t "数据目录" "Data")" "$LOCUS_DATA" row "$(t "额外主机名" "Extra hosts")" "${LOCUS_ALLOWED_HOSTS:-$(t "无" "none")}" row "$(t "服务" "Service")" "$(service_label)" row "$(t "运行身份" "Runs as")" "$SERVICE_USER" if [[ $OPEN_FIREWALL == yes ]]; then row "$(t "防火墙" "Firewall")" "$FIREWALL: $PORT/tcp, 5353/udp, 1900/udp" fi echo } # ---------- 动作 ---------- need_root() { [[ -n $R ]] && return 0 [[ $EUID -eq 0 ]] && return 0 if command -v sudo >/dev/null && (( ! ASSUME_YES )); then log "需要 root 权限,改用 sudo 重新运行。" "Root is required; re-running with sudo." exec sudo bash "$SCRIPT_PATH" ${ORIGINAL_ARGS[@]+"${ORIGINAL_ARGS[@]}"} fi die "请用 root 运行:sudo bash $(basename "$SCRIPT_PATH")" "run as root: sudo bash $(basename "$SCRIPT_PATH")" } nologin_shell() { local shell for shell in /usr/sbin/nologin /sbin/nologin /usr/bin/nologin /bin/false; do [[ -x $shell ]] && { echo "$shell"; return; } done echo /bin/false } ensure_user() { [[ -n $R ]] && return 0 # Alpine 默认没有 shadow 的 groupadd/useradd,用 busybox 的 addgroup/adduser。 if ! grep -q "^$SERVICE_USER:" /etc/group; then if command -v groupadd >/dev/null; then groupadd --system "$SERVICE_USER" else addgroup -S "$SERVICE_USER" fi fi if ! id -u "$SERVICE_USER" >/dev/null 2>&1; then if command -v useradd >/dev/null; then useradd --system --gid "$SERVICE_USER" --home-dir "$LOCUS_DATA" --no-create-home \ --shell "$(nologin_shell)" --comment "LOCUS service" "$SERVICE_USER" else adduser -S -D -H -h "$LOCUS_DATA" -s "$(nologin_shell)" -G "$SERVICE_USER" -g "LOCUS service" "$SERVICE_USER" fi ok "已创建系统用户 $SERVICE_USER" "created system user $SERVICE_USER" fi } install_binary() { resolve_pkg local tmp mkdir -p "$R$(dirname "$BIN")" tmp=$(mktemp "$R$(dirname "$BIN")/.locus.XXXXXX") install -m 0755 "$PKG_ROOT/$PKG_VARIANT/locus" "$tmp" mv -f "$tmp" "$R$BIN" command -v restorecon >/dev/null && [[ -z $R ]] && restorecon "$BIN" 2>/dev/null || true ok "已安装程序 $BIN" "installed $BIN" install_docs } install_docs() { resolve_pkg mkdir -p "$R$DOC" install -m 0644 "$PKG_ROOT/LICENSE" "$R$DOC/LICENSE" install -m 0644 "$PKG_ROOT/THIRD_PARTY_NOTICES.txt" "$R$DOC/THIRD_PARTY_NOTICES.txt" ok "许可声明写入 $DOC" "license notices written to $DOC" } write_settings() { mkdir -p "$R$CONF_DIR" { echo "# $(t "LOCUS 运行设置。改完执行:" "LOCUS runtime settings. After editing run:") $(restart_hint)" echo "# $(t "改数据目录请重新运行安装脚本的「修改设置」,服务的可写目录要跟着改。" "To move the data directory re-run the installer's configure command; the service's writable path must follow.")" printf 'LOCUS_BIND=%s\n' "$LOCUS_BIND" printf 'LOCUS_DATA=%s\n' "$LOCUS_DATA" printf 'LOCUS_ALLOWED_HOSTS=%s\n' "$LOCUS_ALLOWED_HOSTS" } >"$R$CONF_DIR/locus.env" chmod 0644 "$R$CONF_DIR/locus.env" { printf 'INSTALLED_VERSION=%s\n' "$VERSION" printf 'INSTALLED_PACKAGE=%s\n' "$PKG_FILE" printf 'INSTALLED_INIT=%s\n' "$INIT" printf 'INSTALLED_FIREWALL=%s\n' "${INSTALLED_FIREWALL:-}" } >"$R$CONF_DIR/install.env" ok "设置写入 $CONF_DIR/locus.env" "settings written to $CONF_DIR/locus.env" } prepare_data() { if [[ ${MOVE_DATA:-0} == 1 && -d $R$OLD_DATA ]]; then mkdir -p "$R$(dirname "$LOCUS_DATA")" if [[ -e $R$LOCUS_DATA ]]; then warn "$LOCUS_DATA 已存在,不移动旧数据" "$LOCUS_DATA exists; old data not moved" else mv "$R$OLD_DATA" "$R$LOCUS_DATA" ok "数据已移到 $LOCUS_DATA" "data moved to $LOCUS_DATA" fi fi mkdir -p "$R$LOCUS_DATA" chmod 0750 "$R$LOCUS_DATA" if [[ -z $R ]]; then chown -R "$SERVICE_USER:$SERVICE_USER" "$LOCUS_DATA" fi } render() { local home_rule="" case $LOCUS_DATA in /home/*|/root|/root/*|/run/user/*) home_rule="/^ProtectHome=/d" ;; esac sed -e "s|@BIN@|$BIN|g" -e "s|@CONF_DIR@|$CONF_DIR|g" -e "s|@DATA@|$LOCUS_DATA|g" ${home_rule:+-e "$home_rule"} "$1" } install_service() { resolve_pkg case $INIT in systemd) mkdir -p "$R/etc/systemd/system" render "$PKG_ROOT/systemd/locus.service" >"$R/etc/systemd/system/locus.service" chmod 0644 "$R/etc/systemd/system/locus.service" if [[ -z $R ]]; then systemctl daemon-reload systemctl enable locus.service >/dev/null 2>&1 fi ok "已建立 systemd 服务 locus.service(开机自启)" "systemd service locus.service installed (starts on boot)" ;; openrc) mkdir -p "$R/etc/init.d" render "$PKG_ROOT/openrc/locus" >"$R/etc/init.d/locus" chmod 0755 "$R/etc/init.d/locus" [[ -z $R ]] && rc-update add locus default >/dev/null 2>&1 || true ok "已建立 OpenRC 服务 /etc/init.d/locus(开机自启)" "OpenRC service /etc/init.d/locus installed (starts on boot)" ;; esac } open_firewall() { [[ $OPEN_FIREWALL == yes && -z $R ]] || return 0 case $FIREWALL in firewalld) firewall-cmd --permanent --add-port="$PORT/tcp" >/dev/null firewall-cmd --permanent --add-service=mdns >/dev/null 2>&1 || firewall-cmd --permanent --add-port=5353/udp >/dev/null firewall-cmd --permanent --add-service=ssdp >/dev/null 2>&1 || firewall-cmd --permanent --add-port=1900/udp >/dev/null firewall-cmd --reload >/dev/null ;; ufw) ufw allow "$PORT/tcp" comment LOCUS >/dev/null ufw allow 5353/udp comment "LOCUS mDNS" >/dev/null ufw allow 1900/udp comment "LOCUS SSDP" >/dev/null ;; esac INSTALLED_FIREWALL="$FIREWALL:$PORT" ok "防火墙已放行 $PORT/tcp、5353/udp、1900/udp" "firewall allows $PORT/tcp, 5353/udp, 1900/udp" } close_firewall() { [[ -n ${INSTALLED_FIREWALL:-} && -z $R ]] || return 0 local kind=${INSTALLED_FIREWALL%%:*} port=${INSTALLED_FIREWALL##*:} case $kind in firewalld) command -v firewall-cmd >/dev/null || return 0 firewall-cmd --permanent --remove-port="$port/tcp" >/dev/null 2>&1 || true firewall-cmd --reload >/dev/null 2>&1 || true ;; ufw) command -v ufw >/dev/null || return 0 ufw delete allow "$port/tcp" >/dev/null 2>&1 || true ;; esac log "已撤销防火墙对 $port/tcp 的放行(mDNS / SSDP 规则可能被其他程序使用,保留)" \ "removed the firewall rule for $port/tcp (mDNS / SSDP rules are kept; other programs may use them)" } restart_hint() { case $INIT in systemd) echo "systemctl restart locus" ;; openrc) echo "rc-service locus restart" ;; *) echo "$(t "重新启动 locus" "restart locus")" ;; esac } service_do() { [[ -n $R ]] && return 0 case $INIT in systemd) systemctl "$1" locus.service ;; openrc) rc-service locus "$1" ;; *) return 1 ;; esac } http_status() { local host=$1 port=$2 line="" [[ $host == 0.0.0.0 ]] && host=127.0.0.1 [[ $host == "::" ]] && host=::1 local hostheader=$host [[ $host == *:* ]] && hostheader="[$host]" if command -v curl >/dev/null; then curl -s -o /dev/null -m 5 -w '%{http_code}' "http://$hostheader:$port/api/projections/start-page/pins" 2>/dev/null || true return 0 fi line=$( (exec 3<>"/dev/tcp/$host/$port" || exit 1 printf 'GET /api/projections/start-page/pins HTTP/1.0\r\nHost: %s:%s\r\nConnection: close\r\n\r\n' "$hostheader" "$port" >&3 IFS= read -r -t 5 status <&3 && printf '%s' "$status") 2>/dev/null || true) awk '{print $2}' <<<"$line" } health_check() { local i code="" for (( i = 0; i < 20; i++ )); do code=$(http_status "$HOST" "$PORT") [[ $code == 200 ]] && break sleep 1 done if [[ $code == 200 ]]; then ok "LOCUS 已在 $LOCUS_BIND 响应" "LOCUS answers on $LOCUS_BIND" return 0 fi err "20 秒内没有收到 LOCUS 的响应" "no answer from LOCUS within 20 seconds" if [[ $MODE == manual ]]; then log " 查看日志:tail -n 80 $MANUAL_DIR/data/locus.log" " Check the log: tail -n 80 $MANUAL_DIR/data/locus.log" return 1 fi case $INIT in systemd) log " 查看日志:journalctl -u locus -n 80 --no-pager" " Check the log: journalctl -u locus -n 80 --no-pager" ;; openrc) log " 查看状态:rc-service locus status" " Check status: rc-service locus status" ;; esac return 1 } show_endpoints() { printf '\n%s%s%s\n' "$BLD" "$(t "在浏览器打开" "Open in a browser")" "$RST" if [[ $HOST == 0.0.0.0 || $HOST == "::" ]]; then local address for address in $(lan_addresses); do printf ' http://%s:%s\n' "$address" "$PORT"; done local name # Arch 等系统默认不装 hostname 命令。 name=$(hostname 2>/dev/null || cat /proc/sys/kernel/hostname 2>/dev/null) || name="" [[ -n $name && $name != localhost ]] && printf ' http://%s.local:%s\n' "$name" "$PORT" elif [[ $HOST == *:* ]]; then printf ' http://[%s]:%s\n' "$HOST" "$PORT" else printf ' http://%s:%s\n' "$HOST" "$PORT" fi printf '\n%s%s%s\n' "$BLD" "$(t "日常管理" "Day to day")" "$RST" case $INIT in systemd) printf ' systemctl status|restart|stop locus\n' printf ' journalctl -u locus -f\n' ;; openrc) printf ' rc-service locus status|restart|stop\n' ;; esac printf ' %s: %s\n' "$(t "设置" "Settings")" "$CONF_DIR/locus.env" printf ' %s: %s\n' "$(t "数据" "Data")" "$LOCUS_DATA" printf ' %s: sudo bash %s\n' "$(t "菜单" "Menu")" "$(basename "$SCRIPT_PATH")" echo } start_and_check() { if [[ -n $R ]]; then ok "DESTDIR 模式:跳过启动" "DESTDIR mode: not starting" return 0 fi if [[ $INIT == none ]]; then log "启动命令:" "Start it with:" printf ' sudo -u %s env LOCUS_ALLOWED_HOSTS=%q %s --bind %s --data %s\n' "$SERVICE_USER" "$LOCUS_ALLOWED_HOSTS" "$BIN" "$LOCUS_BIND" "$LOCUS_DATA" return 0 fi service_do restart health_check || return 1 } # ---------- 手动模式 ---------- # # 以运行安装器的用户身份运行,不切换身份,不建用户,不碰开机启动、防火墙和系统目录。 # <目录>/app 放程序、许可声明、设置和 locusctl;<目录>/data 放数据库、日志、自升级的新版本。 MANUAL_DIR="" DIR_STATE="" manual_default_dir() { case $LD_OS_FAMILY in dsm|qnap) printf '%s' "$LD_DIR" ;; *) if [[ $LD_UID == 0 ]]; then printf '/opt/locus'; else printf '%s/locus' "${HOME:-/tmp}"; fi ;; esac } manual_existing_dir() { local dir=${OPT_DIR:-$(manual_default_dir)} dir=${dir%/} [[ -n $dir && -f $R$dir/app/install.env ]] \ || die "没有找到 LOCUS 的手动安装${dir:+($dir)},用 --dir 指定安装目录" \ "no manual LOCUS install found${dir:+ in $dir}; pass the install directory with --dir" MANUAL_DIR=$dir } # 检查安装目录:绝对路径、NAS 上必须在数据卷里、已有内容只能是 LOCUS、当前用户能写、空间够。 # 不改任何上层目录的权限。新建时记下第一个由安装器建出来的目录,失败就删掉它。 manual_check_dir() { local dir=${1%/} vol inside=0 probe free top="" [[ $dir == /* ]] || die "目录必须是绝对路径:$dir" "the directory must be an absolute path: $dir" [[ $dir =~ ^/[A-Za-z0-9._@+/-]+$ && $dir != *//* && $dir != */../* && $dir != */.. ]] \ || die "目录只能用字母、数字和 . _ - @ + /:$dir" "use only letters, digits and . _ - @ + / in the directory: $dir" case $LD_OS_FAMILY in dsm|qnap) for vol in $LD_VOLUMES; do [[ $dir == "$vol"/?* ]] && inside=1; done (( inside )) || die "NAS 上要装在数据卷里的子目录(${LD_VOLUMES:-没有找到数据卷})。系统分区在重启或固件更新后可能被清掉。" \ "on a NAS, install into a directory on a data volume (${LD_VOLUMES:-none found}); the system partition may be wiped on reboot or firmware update." ;; esac local p=$R$dir if [[ -f $p/app/install.env ]]; then DIR_STATE=installed elif [[ -e $p && ! -d $p ]]; then die "$dir 已存在但不是目录" "$dir exists and is not a directory" elif [[ -d $p && -n $(ls -A "$p" 2>/dev/null) ]]; then die "$dir 不是空目录,也不是 LOCUS 的安装。换一个目录,或先自己清理它。" \ "$dir is not empty and holds no LOCUS install; pick another directory or clear it yourself." elif [[ -d $p ]]; then DIR_STATE=empty else DIR_STATE=absent fi probe=$p while [[ ! -e $probe ]]; do top=$probe; probe=${probe%/*}; [[ -n $probe ]] || probe=/; done [[ -d $probe && -w $probe ]] \ || die "$LD_USER(UID $LD_UID)不能写入 ${probe#"$R"}。安装器不会改上层目录的权限;换一个这个用户能写的目录,或改用有权限的用户运行。" \ "$LD_USER (UID $LD_UID) cannot write to ${probe#"$R"}. The installer does not change permissions of parent directories; pick a directory this user can write to, or run as a user who can." free=$(ld_free_kb "$probe") if [[ -n $free ]] && (( free < MANUAL_MIN_KB )); then die "${probe#"$R"} 只剩 $(ld_kb_label "$free"),至少要 $(ld_kb_label "$MANUAL_MIN_KB")" \ "${probe#"$R"} has only $(ld_kb_label "$free") free; at least $(ld_kb_label "$MANUAL_MIN_KB") is needed" fi MANUAL_DIR=$dir MANUAL_TOP=$top } manual_running() { [[ -x $R$MANUAL_DIR/app/locusctl && -z $R ]] && "$MANUAL_DIR/app/locusctl" status >/dev/null 2>&1 } write_locusctl() { local app=$MANUAL_DIR/app data=$MANUAL_DIR/data lang lang=$( (( ZH )) && echo zh || echo en ) sed -e "s|@APP@|$app|g" -e "s|@DATA@|$data|g" -e "s|@LANG@|$lang|g" >"$R$app/locusctl" <<'CTL' #!/bin/sh # LOCUS 手动运行:start | stop | restart | status。安装器生成;不注册开机启动, # 设备重启后要再运行一次 start。设置在同目录的 locus.env,改完 restart。 APP='@APP@' DATA='@DATA@' PIDFILE="$DATA/locus.pid" LOG="$DATA/locus.log" m() { if [ '@LANG@' = zh ]; then printf '%s\n' "$1"; else printf '%s\n' "$2"; fi; } set -a . "$APP/locus.env" set +a # PID 文件里的进程必须是这里的 locus(安装的那一版或数据目录里自升级的新版),不误停别的程序。 running() { [ -f "$PIDFILE" ] || return 1 pid=$(cat "$PIDFILE" 2>/dev/null) case $pid in ''|*[!0-9]*) return 1 ;; esac [ -d "/proc/$pid" ] || return 1 exe=$(readlink "/proc/$pid/exe" 2>/dev/null) || exe="" exe=${exe% (deleted)} case $exe in "$APP/locus"|"$DATA/bin/locus-"*) return 0 ;; esac cmd=$(tr '\0' ' ' <"/proc/$pid/cmdline" 2>/dev/null) || cmd="" case $cmd in "$APP/locus "*|"$DATA/bin/locus-"*) return 0 ;; esac return 1 } start() { if running; then m "LOCUS 已在运行(PID $pid)" "LOCUS is already running (PID $pid)"; return 0; fi mkdir -p "$DATA" || exit 1 if [ -f "$LOG" ] && [ "$(wc -c <"$LOG")" -gt 5242880 ]; then mv -f "$LOG" "$LOG.1"; fi cd "$DATA" || exit 1 ( trap '' HUP; exec "$APP/locus" --bind "$LOCUS_BIND" --data "$LOCUS_DATA" ) >>"$LOG" 2>&1 "$PIDFILE" i=0 while [ $i -lt 3 ]; do sleep 1; i=$((i + 1)); done if running; then m "LOCUS 已启动(PID $pid,$LOCUS_BIND),日志 $LOG" "LOCUS started (PID $pid, $LOCUS_BIND); log: $LOG" return 0 fi rm -f "$PIDFILE" m "LOCUS 没有启动起来,日志最后几行:" "LOCUS did not start; the end of the log:" tail -n 20 "$LOG" 2>/dev/null exit 1 } stop() { if ! running; then rm -f "$PIDFILE"; m "LOCUS 没有在运行" "LOCUS is not running"; return 0; fi kill "$pid" 2>/dev/null i=0 while [ $i -lt 15 ] && kill -0 "$pid" 2>/dev/null; do sleep 1; i=$((i + 1)); done kill -0 "$pid" 2>/dev/null && kill -9 "$pid" 2>/dev/null rm -f "$PIDFILE" m "LOCUS 已停止" "LOCUS stopped" } case ${1:-} in start) start ;; stop) stop ;; restart) stop; start ;; status) if running; then m "运行中(PID $pid,$LOCUS_BIND)" "running (PID $pid, $LOCUS_BIND)"; exit 0; fi m "未运行" "not running"; exit 3 ;; *) echo "usage: $0 start|stop|restart|status" >&2; exit 2 ;; esac CTL chmod 0755 "$R$app/locusctl" } # manual_write_settings 版本:configure 时传已装的版本,不改记录。 manual_write_settings() { local app=$R$MANUAL_DIR/app { echo "# $(t "LOCUS 运行设置。改完执行:" "LOCUS runtime settings. After editing run:") $MANUAL_DIR/app/locusctl restart" printf 'LOCUS_BIND=%s\n' "$LOCUS_BIND" printf 'LOCUS_DATA=%s\n' "$MANUAL_DIR/data" printf 'LOCUS_ALLOWED_HOSTS=%s\n' "$LOCUS_ALLOWED_HOSTS" } >"$app/locus.env" { printf 'INSTALLED_VERSION=%s\n' "$1" printf 'INSTALLED_PACKAGE=%s\n' "$PKG_FILE" printf 'INSTALLED_MODE=manual\n' printf 'INSTALLED_USER=%s\n' "$LD_USER" printf 'INSTALLED_UID=%s\n' "$LD_UID" } >"$app/install.env" chmod 0644 "$app/locus.env" "$app/install.env" } identity_lines() { ok "运行身份:${LD_USER:-?}(UID ${LD_UID:-?}),不切换用户" "Runs as: ${LD_USER:-?} (UID ${LD_UID:-?}); no user switch" if [[ $LD_UID == 0 ]]; then warn "LOCUS 将以 root 运行。想换成普通用户,用那个用户运行安装器,目录要选它能写的。" \ "LOCUS will run as root. To use an ordinary user, run the installer as that user with a directory it can write to." fi } package_line() { if [[ $LD_PICK_STATUS == verified ]]; then ok "安装包:$PKG_FILE" "Package: $PKG_FILE" else warn "安装包:$PKG_FILE,实验性,还没有在这类机器上实测过" "Package: $PKG_FILE, experimental; not yet tested on this kind of machine" fi } manual_summary() { printf '\n%s%s%s\n' "$BLD" "$(t "确认安装设置" "Review")" "$RST" row "$(t "程序" "Program")" "$MANUAL_DIR/app/locus ($VERSION, $PKG_VARIANT)" row "$(t "数据目录" "Data")" "$MANUAL_DIR/data" row "$(t "监听" "Listen")" "$LOCUS_BIND" row "$(t "额外主机名" "Extra hosts")" "${LOCUS_ALLOWED_HOSTS:-$(t "无" "none")}" row "$(t "运行身份" "Runs as")" "${LD_USER:-?} (UID ${LD_UID:-?})" row "$(t "启动方式" "Start")" "$(t "手动,不注册开机启动" "manual; nothing starts on boot")" echo } manual_endpoints() { printf '\n%s%s%s\n' "$BLD" "$(t "在浏览器打开" "Open in a browser")" "$RST" if [[ $HOST == 0.0.0.0 || $HOST == "::" ]]; then local address for address in $(lan_addresses); do printf ' http://%s:%s\n' "$address" "$PORT"; done elif [[ $HOST == *:* ]]; then printf ' http://[%s]:%s\n' "$HOST" "$PORT" else printf ' http://%s:%s\n' "$HOST" "$PORT" fi printf '\n%s%s%s\n' "$BLD" "$(t "日常管理" "Day to day")" "$RST" printf ' %s/app/locusctl start|stop|restart|status\n' "$MANUAL_DIR" printf ' %s: %s/data/locus.log\n' "$(t "日志" "Log")" "$MANUAL_DIR" printf ' %s: %s/app/locus.env\n' "$(t "设置" "Settings")" "$MANUAL_DIR" log " 设备重启后 LOCUS 不会自己启动,要再运行一次 locusctl start。" \ " LOCUS does not start by itself after the device reboots; run locusctl start again." echo } manual_install() { local was_running=0 upgrade=0 dir host_check printf '\n%sLOCUS %s%s %s%s%s\n' "$BLD" "$VERSION" "$RST" "$DIM" "$(t "手动模式" "manual mode")" "$RST" step 1/4 "检查环境" "Checking this machine" if [[ -n $LD_MODEL ]]; then ok "设备:$LD_VENDOR $LD_MODEL" "Device: $LD_VENDOR $LD_MODEL"; fi ok "架构:$(ld_arch_label),C 库:$(ld_libc_label)" "Architecture: $(ld_arch_label), C library: $(ld_libc_label)" identity_lines step 2/4 "安装目录" "Install directory" if [[ -n $OPT_DIR ]]; then dir=$OPT_DIR elif (( ASSUME_YES )); then case $LD_OS_FAMILY in dsm|qnap) die "NAS 上无人值守安装要用 --dir 指定目录${LD_DIR:+(建议 $LD_DIR)}" \ "unattended installs on a NAS need --dir${LD_DIR:+ (suggested: $LD_DIR)}" ;; esac dir=$(manual_default_dir) else if [[ -n $LD_VOLUMES ]]; then log " 数据卷:$LD_VOLUMES" " Data volumes: $LD_VOLUMES"; fi log " 程序放在 <目录>/app,数据放在 <目录>/data。" " The program goes to /app and the data to /data." ask "$(t "安装目录" "Install directory")" "$(manual_default_dir)" dir=$REPLY fi [[ -n $dir ]] || die "没有安装目录" "no install directory" manual_check_dir "$dir" keep_for_dir "$MANUAL_DIR" package_line if [[ $DIR_STATE == installed ]]; then upgrade=1 load_settings "$R$MANUAL_DIR/app" ok "$MANUAL_DIR 里已经装了 LOCUS ${INSTALLED_VERSION:-?},升级到 $VERSION,保留设置和数据" \ "$MANUAL_DIR holds LOCUS ${INSTALLED_VERSION:-?}; upgrading to $VERSION and keeping settings and data" same_owner else ok "$MANUAL_DIR($(t "新建" "new"))" "$MANUAL_DIR (new)" step 3/4 "监听地址、端口和主机名" "Address, port and host names" wizard_bind wizard_port wizard_hosts compose_bind fi manual_summary if (( upgrade )); then confirm "$(t "开始升级?" "Upgrade now?")" y || die "已取消,没有做任何改动" "cancelled; nothing was changed" else confirm "$(t "开始安装?" "Install now?")" y || die "已取消,没有做任何改动" "cancelled; nothing was changed" fi step 4/4 "安装" "Installing" resolve_pkg local app=$R$MANUAL_DIR/app data=$R$MANUAL_DIR/data if (( upgrade )); then manual_running && was_running=1 (( was_running )) && "$MANUAL_DIR/app/locusctl" stop else if [[ -n $MANUAL_TOP ]]; then ROLLBACK=("$MANUAL_TOP"); else ROLLBACK=("$app" "$data"); fi mkdir -p "$app" "$data" chmod 0700 "$data" fi cp "$PKG_ROOT/$PKG_VARIANT/locus" "$app/.locus.new" chmod 0755 "$app/.locus.new" if (( upgrade )) && [[ -f $app/locus ]]; then cp -p "$app/locus" "$app/locus.prev"; fi mv -f "$app/.locus.new" "$app/locus" cp "$PKG_ROOT/LICENSE" "$PKG_ROOT/THIRD_PARTY_NOTICES.txt" "$app/" chmod 0644 "$app/LICENSE" "$app/THIRD_PARTY_NOTICES.txt" manual_write_settings "$VERSION" write_locusctl ROLLBACK=() ok "程序在 $MANUAL_DIR/app,数据在 $MANUAL_DIR/data" "program in $MANUAL_DIR/app, data in $MANUAL_DIR/data" if [[ -n $R ]]; then ok "DESTDIR 模式:跳过启动" "DESTDIR mode: not starting" elif (( upgrade && ! was_running )); then log "升级前 LOCUS 没有在运行,这次也不启动。" "LOCUS was not running before the upgrade, so it is not started now." else "$MANUAL_DIR/app/locusctl" start || { (( upgrade )) && log " 回到旧版:mv $MANUAL_DIR/app/locus.prev $MANUAL_DIR/app/locus" \ " Roll back: mv $MANUAL_DIR/app/locus.prev $MANUAL_DIR/app/locus" exit 1 } health_check || exit 1 fi if (( upgrade )); then ok "已更新到 $VERSION,设置和数据保持不变" "updated to $VERSION; settings and data kept" else printf '\n%s%s%s\n' "$GRN" "$(t "LOCUS $VERSION 安装完成。" "LOCUS $VERSION is installed.")" "$RST" fi manual_endpoints } # 启动和改设置必须由装它的用户来做,不悄悄换成别的身份(root 也不例外)。 same_owner() { if [[ -n ${INSTALLED_UID:-} && $INSTALLED_UID != "$LD_UID" ]]; then die "这个安装属于 UID $INSTALLED_UID(${INSTALLED_USER:-?}),现在是 UID $LD_UID。请用原来的用户操作,不会悄悄换身份。" \ "this install belongs to UID $INSTALLED_UID (${INSTALLED_USER:-?}), not UID $LD_UID; use the original user (no silent identity change)." fi } manual_configure() { manual_existing_dir load_settings "$R$MANUAL_DIR/app" same_owner PKG_FILE=${INSTALLED_PACKAGE:-} step 1/1 "监听地址、端口和主机名" "Address, port and host names" wizard_bind wizard_port wizard_hosts compose_bind manual_summary confirm "$(t "保存并重启?" "Save and restart?")" y || die "已取消" "cancelled" if manual_running; then "$MANUAL_DIR/app/locusctl" stop; fi manual_write_settings "${INSTALLED_VERSION:-$VERSION}" if [[ -z $R ]]; then "$MANUAL_DIR/app/locusctl" start && health_check || exit 1 fi manual_endpoints } manual_ctl() { manual_existing_dir load_settings "$R$MANUAL_DIR/app" [[ -z $R ]] || return 0 if [[ $1 == status ]]; then printf '%sLOCUS %s%s %s/app/locus (%s)\n' "$BLD" "${INSTALLED_VERSION:-?}" "$RST" "$MANUAL_DIR" "${INSTALLED_PACKAGE:-?}" "$MANUAL_DIR/app/locusctl" status || true manual_endpoints return 0 fi [[ $1 == stop ]] || same_owner "$MANUAL_DIR/app/locusctl" "$1" [[ $1 == stop ]] || health_check } manual_uninstall() { manual_existing_dir load_settings "$R$MANUAL_DIR/app" warn "将停止 LOCUS,删除 $MANUAL_DIR/app。" "This stops LOCUS and removes $MANUAL_DIR/app." if (( ! PURGE )) && (( ! ASSUME_YES )); then confirm "$(t "同时删除数据目录 $MANUAL_DIR/data(数据库、主题、背景图)?" "Also delete the data directory $MANUAL_DIR/data (database, themes, background)?")" n && PURGE=1 fi if (( ! ASSUME_YES )); then confirm "$(t "确认卸载?" "Uninstall now?")" n || { log "已取消" "cancelled"; return 0; } fi if manual_running; then "$MANUAL_DIR/app/locusctl" stop; fi rm -rf "${R}${MANUAL_DIR:?}/app" if (( PURGE )); then rm -rf "${R}${MANUAL_DIR:?}/data" rmdir "$R$MANUAL_DIR" 2>/dev/null || true ok "LOCUS 已卸载,数据已删除" "LOCUS removed, including its data" else ok "LOCUS 已卸载,数据保留在 $MANUAL_DIR/data" "LOCUS removed; data kept in $MANUAL_DIR/data" fi } # ---------- 命令 ---------- do_install() { need_root resolve_pkg printf '\n%sLOCUS %s%s %sLocal Object & Capability Unified Space%s\n' "$BLD" "$VERSION" "$RST" "$DIM" "$RST" load_settings if installed; then log "已经安装了 LOCUS ${INSTALLED_VERSION:-?}。" "LOCUS ${INSTALLED_VERSION:-?} is already installed." if confirm "$(t "升级到 $VERSION 并保留现有设置和数据?" "Upgrade to $VERSION and keep settings and data?")" y; then do_update return fi fi check_env wizard summary confirm "$(t "开始安装?" "Install now?")" y || die "已取消,没有做任何改动" "cancelled; nothing was changed" echo service_active && service_do stop || true ensure_user install_binary prepare_data open_firewall write_settings install_service start_and_check || exit 1 printf '\n%s%s%s\n' "$GRN" "$(t "LOCUS $VERSION 安装完成。" "LOCUS $VERSION is installed.")" "$RST" show_endpoints } do_update() { need_root installed || die "还没有安装 LOCUS,先运行 install" "LOCUS is not installed; run install first" load_settings detect_env resolve_pkg local was_active=0 service_active && was_active=1 (( was_active )) && service_do stop install_binary prepare_data INIT=${INSTALLED_INIT:-$INIT} write_settings install_service if (( was_active )) || [[ $INIT != none ]]; then start_and_check || exit 1 fi ok "已更新到 $VERSION,设置和数据保持不变" "updated to $VERSION; settings and data kept" show_endpoints } do_configure() { need_root installed || die "还没有安装 LOCUS,先运行 install" "LOCUS is not installed; run install first" load_settings detect_env local old_port=$PORT wizard summary confirm "$(t "保存并重启服务?" "Save and restart the service?")" y || die "已取消" "cancelled" service_active && service_do stop || true prepare_data if [[ $OPEN_FIREWALL == yes && $PORT != "$old_port" ]]; then close_firewall; fi open_firewall write_settings install_service start_and_check || exit 1 show_endpoints } do_status() { load_settings detect_env if ! installed; then log "LOCUS 还没有安装。" "LOCUS is not installed." return 0 fi printf '%sLOCUS %s%s %s\n' "$BLD" "${INSTALLED_VERSION:-?}" "$RST" "$BIN" case $INIT in systemd) systemctl --no-pager status locus.service 2>/dev/null | head -12 || true ;; openrc) rc-service locus status || true ;; esac echo health_check || true show_endpoints } do_uninstall() { need_root load_settings detect_env installed || [[ -f $R/etc/systemd/system/locus.service || -f $R/etc/init.d/locus ]] \ || die "没有找到 LOCUS 的安装" "no LOCUS installation found" local data=${LOCUS_DATA:-$DEFAULT_DATA} warn "将停止服务,删除程序、许可声明、服务和 $CONF_DIR。" "This stops the service and removes the program, the license notices, the service and $CONF_DIR." if (( ! PURGE )) && (( ! ASSUME_YES )); then confirm "$(t "同时删除数据目录 $data(数据库、主题、背景图)?" "Also delete the data directory $data (database, themes, background)?")" n && PURGE=1 fi if (( ! ASSUME_YES )); then confirm "$(t "确认卸载?" "Uninstall now?")" n || { log "已取消" "cancelled"; return 0; } fi if [[ -z $R ]]; then case $INIT in systemd) systemctl disable --now locus.service >/dev/null 2>&1 || true ;; openrc) rc-service locus stop >/dev/null 2>&1 || true; rc-update del locus default >/dev/null 2>&1 || true ;; esac fi rm -f "$R/etc/systemd/system/locus.service" "$R/etc/init.d/locus" [[ -z $R && $INIT == systemd ]] && systemctl daemon-reload close_firewall rm -f "$R$BIN" rm -rf "$R${DOC:?}" "$R${CONF_DIR:?}" if (( PURGE )); then rm -rf "$R${data:?}" if [[ -z $R ]] && id -u "$SERVICE_USER" >/dev/null 2>&1; then if command -v userdel >/dev/null; then userdel "$SERVICE_USER" 2>/dev/null || true grep -q "^$SERVICE_USER:" /etc/group && groupdel "$SERVICE_USER" 2>/dev/null || true else deluser "$SERVICE_USER" 2>/dev/null || true grep -q "^$SERVICE_USER:" /etc/group && delgroup "$SERVICE_USER" 2>/dev/null || true fi fi ok "LOCUS 已卸载,数据和用户 $SERVICE_USER 已删除" "LOCUS removed, including data and user $SERVICE_USER" else ok "LOCUS 已卸载,数据保留在 $data" "LOCUS removed; data kept in $data" fi } do_extract() { [[ -n $EXTRACT_DIR ]] || die "用法:extract DIR" "usage: extract DIR" resolve_pkg mkdir -p "$EXTRACT_DIR" cp "$PKG_ROOT/$PKG_VARIANT/locus" "$PKG_ROOT/LICENSE" "$PKG_ROOT/THIRD_PARTY_NOTICES.txt" "$EXTRACT_DIR/" chmod 0755 "$EXTRACT_DIR/locus" chmod 0644 "$EXTRACT_DIR/LICENSE" "$EXTRACT_DIR/THIRD_PARTY_NOTICES.txt" ok "已解压 $EXTRACT_DIR/locus($PKG_FILE)" "extracted $EXTRACT_DIR/locus ($PKG_FILE)" } do_diagnose() { host_detect if (( OPT_JSON )); then ld_report_json; else ld_report_text; fi } menu() { need_root resolve_pkg while :; do load_settings detect_env local state if installed; then if service_active; then state=$(t "已安装 ${INSTALLED_VERSION:-?},运行中,$LOCUS_BIND" "installed ${INSTALLED_VERSION:-?}, running on $LOCUS_BIND") else state=$(t "已安装 ${INSTALLED_VERSION:-?},未运行" "installed ${INSTALLED_VERSION:-?}, not running"); fi else state=$(t "未安装" "not installed") fi printf '\n%sLOCUS %s %s%s %s%s%s\n' "$CYN" "$VERSION" "$(t "安装 / 运维" "setup")" "$RST" "$DIM" "$state" "$RST" if installed; then printf ' 1) %s\n' "$(t "升级到 $VERSION(保留设置和数据)" "Upgrade to $VERSION (keep settings and data)")" else printf ' 1) %s\n' "$(t "安装(向导)" "Install (guided)")" fi printf ' 2) %s\n' "$(t "修改设置(地址、端口、数据目录、主机名)" "Change settings (address, port, data, host names)")" printf ' 3) %s\n' "$(t "启动 / 重启服务" "Start / restart the service")" printf ' 4) %s\n' "$(t "停止服务" "Stop the service")" printf ' 5) %s\n' "$(t "状态与访问地址" "Status and addresses")" printf ' 6) %s\n' "$(t "卸载" "Uninstall")" printf ' 0) %s\n' "$(t "退出" "Quit")" local choice="" printf '%s [0-6]: ' "$(t "选择" "Choose")" >/dev/tty IFS= read -r choice