mirror of
https://github.com/baoweise-bot/aimili-vpngate.git
synced 2026-09-05 23:56:55 +08:00
Release AimiliVPN V2.1 stable channel
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
.git
|
||||
.github
|
||||
__pycache__
|
||||
*.pyc
|
||||
vpngate_data
|
||||
dist
|
||||
build
|
||||
tests
|
||||
scratch
|
||||
_site
|
||||
@@ -0,0 +1,95 @@
|
||||
name: Publish formal release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test Python ${{ matrix.python-version }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.9", "3.11", "3.13"]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Verify version tag
|
||||
shell: bash
|
||||
run: test "${GITHUB_REF_NAME}" = "v$(tr -d '\r\n' < VERSION)"
|
||||
- name: Compile Python sources
|
||||
run: python -m py_compile vpngate_manager.py vpn_utils.py proxy_server.py snapshot_utils.py
|
||||
- name: Validate installation script
|
||||
run: bash -n install.sh
|
||||
- name: Validate Docker Compose configuration
|
||||
run: docker compose -f compose.yaml config >/dev/null
|
||||
- name: Run unit tests
|
||||
run: python -m unittest discover -s tests -v
|
||||
|
||||
release:
|
||||
name: Build Linux release archives
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- name: Build architecture-labelled archives
|
||||
run: python scripts/build_release_archives.py --output-dir dist
|
||||
- name: Create GitHub formal release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
shell: bash
|
||||
run: |
|
||||
title="AimiliVPN V$(cut -d. -f1,2 VERSION) 正式版"
|
||||
if gh release view "${GITHUB_REF_NAME}" --repo "${GITHUB_REPOSITORY}" >/dev/null 2>&1; then
|
||||
gh release upload "${GITHUB_REF_NAME}" dist/* --clobber --repo "${GITHUB_REPOSITORY}"
|
||||
gh release edit "${GITHUB_REF_NAME}" --repo "${GITHUB_REPOSITORY}" --title "${title}" --notes-file RELEASE_NOTES.md
|
||||
else
|
||||
gh release create "${GITHUB_REF_NAME}" dist/* --repo "${GITHUB_REPOSITORY}" --title "${title}" --notes-file RELEASE_NOTES.md --verify-tag
|
||||
fi
|
||||
|
||||
docker:
|
||||
name: Build multi-architecture Docker image
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: docker/setup-qemu-action@v4
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
- uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Resolve image tags
|
||||
id: version
|
||||
shell: bash
|
||||
run: |
|
||||
version="${GITHUB_REF_NAME#v}"
|
||||
minor="$(printf '%s' "${version}" | cut -d. -f1,2)"
|
||||
echo "version=${version}" >> "${GITHUB_OUTPUT}"
|
||||
echo "minor=${minor}" >> "${GITHUB_OUTPUT}"
|
||||
- uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/386,linux/arm64,linux/arm/v7
|
||||
build-args: BUILD_VERSION=${{ steps.version.outputs.version }}
|
||||
tags: |
|
||||
ghcr.io/${{ github.repository }}:${{ steps.version.outputs.version }}
|
||||
ghcr.io/${{ github.repository }}:${{ steps.version.outputs.minor }}
|
||||
ghcr.io/${{ github.repository }}:latest
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
provenance: mode=max
|
||||
sbom: true
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
ARG BUILD_VERSION=dev
|
||||
|
||||
LABEL org.opencontainers.image.title="AimiliVPN" \
|
||||
org.opencontainers.image.description="VPNGate node manager with HTTP and SOCKS5 proxy" \
|
||||
org.opencontainers.image.source="https://github.com/baoweise-bot/aimili-vpngate" \
|
||||
org.opencontainers.image.version="${BUILD_VERSION}"
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
iproute2 \
|
||||
iptables \
|
||||
openvpn \
|
||||
procps \
|
||||
psmisc \
|
||||
python3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY VERSION README.md LICENSE ./
|
||||
COPY vpngate_manager.py vpn_utils.py proxy_server.py snapshot_utils.py ./
|
||||
COPY mirror ./mirror
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
VPNGATE_DATA_DIR=/data \
|
||||
UI_HOST=0.0.0.0 \
|
||||
UI_PORT=8787 \
|
||||
LOCAL_PROXY_HOST=127.0.0.1 \
|
||||
LOCAL_PROXY_PORT=7928
|
||||
|
||||
RUN mkdir -p /data \
|
||||
&& python3 -m py_compile vpngate_manager.py vpn_utils.py proxy_server.py snapshot_utils.py
|
||||
|
||||
VOLUME ["/data"]
|
||||
EXPOSE 8787/tcp 7928/tcp
|
||||
STOPSIGNAL SIGTERM
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
||||
CMD python3 -c "import os,socket; s=socket.create_connection(('127.0.0.1',int(os.environ.get('UI_PORT','8787'))),3); s.close()"
|
||||
|
||||
CMD ["python3", "vpngate_manager.py"]
|
||||
@@ -1,5 +1,9 @@
|
||||
# AimiliVPN 🌐
|
||||
|
||||
[](https://github.com/baoweise-bot/aimili-vpngate/releases/latest)
|
||||
[](https://github.com/baoweise-bot/aimili-vpngate/tree/main)
|
||||
[](https://github.com/baoweise-bot/aimili-vpngate/pkgs/container/aimili-vpngate)
|
||||
|
||||
Bilingual: [中文](#中文) | [English](#english)
|
||||
|
||||
---
|
||||
@@ -11,6 +15,38 @@ AimiliVPN 是一款基于官方 VPNGate 开放协议的高性能、零依赖 VPN
|
||||
|
||||
---
|
||||
|
||||
### 📌 当前正式版本:V2.1
|
||||
|
||||
V2.1 是项目启用正式版本标志后的首个稳定版本。仓库、安装器、命令行更新和 Web 更新检测现在全部统一使用 **`main` 主分支正式通道**。
|
||||
|
||||
#### V2.1 更新进展
|
||||
|
||||
- **节点来源容灾**:依次尝试 VPNGate 官方 HTTPS、官方 HTTP、GitHub Pages HTTPS、GitHub Pages HTTP、VPS 本地最近有效快照和仓库内置初始快照。
|
||||
- **获取与切换修复**:缩短被 VPNGate 域名封锁的 VPS 等待时间;切换新节点前先完成预检,目标失败时保留当前可用连接。
|
||||
- **节点可视化**:恢复延迟列,优先显示本机实测延迟;没有实测值时显示 VPNGate 官方预估值并明确标注“仅供参考”。
|
||||
- **国家筛选**:支持带国旗和节点数量的实时多选筛选,选择范围保存到本机,并作用于手动更新和后台周期同步。
|
||||
- **节点操作**:恢复单节点“检测”按钮,补齐收藏、检测、连接和断开状态逻辑。
|
||||
- **镜像同步**:GitHub Pages 每 15 分钟同步并校验官方节点快照,官方 API 被屏蔽时自动回退。
|
||||
- **Web 更新检测**:页面顶部显示 `V2.1 正式版`,可直接检查 GitHub 最新稳定 Release;只展示 `main` 和正式版下载入口。
|
||||
- **正式发布链路**:GitHub 标签自动运行 Python 兼容测试、构建四类 Linux 发行包、生成 SHA-256 校验文件并发布多架构 Docker 镜像。
|
||||
|
||||
#### 系统与架构兼容性
|
||||
|
||||
| 类型 | 正式支持范围 | GitHub 发行标识 |
|
||||
| --- | --- | --- |
|
||||
| Linux x64 | Intel/AMD 64 位 VPS | `linux-amd64` |
|
||||
| Linux x86 | Intel/AMD 32 位系统 | `linux-386` |
|
||||
| Linux ARM64 | AArch64、ARMv8 VPS/开发板 | `linux-arm64` |
|
||||
| Linux ARM32 | ARMv7 设备 | `linux-armv7` |
|
||||
| Linux 发行版 | Debian、Ubuntu、CentOS、RHEL、Rocky、AlmaLinux、Fedora、Oracle Linux、Amazon Linux、Alpine | 使用同一正式核心 |
|
||||
| Docker | Linux 主机上的 amd64、386、arm64、arm/v7 | GHCR 多架构镜像 |
|
||||
|
||||
> AimiliVPN 依赖 Linux 的 TUN、OpenVPN、iptables 和策略路由,因此不发布虚假的 Windows/macOS 原生兼容包。Windows 或 macOS 只能作为代理客户端使用,不能直接运行完整网关;Docker Desktop 同样不等同于具备宿主机 TUN 能力的 Linux 服务器。
|
||||
|
||||
项目由纯 Python 标准库组成,不需要为 CPU 编译不同的 Python 二进制。GitHub Actions 会为每种架构生成经过相同测试的正式发行包,并实际构建对应架构的 Docker 镜像。
|
||||
|
||||
---
|
||||
|
||||
### 🌟 VPS 优选推荐:跑 AimiliVPN 更稳更省心
|
||||
[](https://bandwagonhost.com/aff.php?aff=81790)
|
||||
[](https://my.racknerd.com/aff.php?aff=18708)
|
||||
@@ -30,15 +66,51 @@ AimiliVPN 是一款基于官方 VPNGate 开放协议的高性能、零依赖 VPN
|
||||
|
||||
---
|
||||
|
||||
### 🚀 一键极速部署 (支持 Debian/Ubuntu/CentOS/Alpine 等 Linux 系统)
|
||||
### 🚀 安装与正式版更新
|
||||
|
||||
在您的 Linux VPS 上以 root 用户执行以下对应命令:
|
||||
#### 方法一:从 main 主分支一键安装(推荐)
|
||||
|
||||
在 Linux VPS 上以 root 用户执行:
|
||||
|
||||
#### 🌟 正式稳定版本 (main 分支)
|
||||
```bash
|
||||
bash <(curl -Ls https://raw.githubusercontent.com/baoweise-bot/aimili-vpngate/main/install.sh)
|
||||
```
|
||||
> 💡 **小贴士**:部署完成后,终端会输出管理网页的专属链接(含随机安全后缀,如 `http://your_vps_ip:8787/u71e9IXp4TPx`)。在终端中输入 `ml` 命令可以随时调出交互式命令行管理菜单。
|
||||
|
||||
部署完成后,终端会输出管理网页专属链接。输入 `ml update` 时只会获取并切换到 `origin/main`,不会检测或切换任何测试分支。
|
||||
|
||||
#### 方法二:GitHub 正式发行包
|
||||
|
||||
[Releases 页面](https://github.com/baoweise-bot/aimili-vpngate/releases/latest)提供以下文件:
|
||||
|
||||
- `aimilivpn-v2.1.0-linux-amd64.tar.gz`:x64 / x86_64。
|
||||
- `aimilivpn-v2.1.0-linux-386.tar.gz`:x86 32 位。
|
||||
- `aimilivpn-v2.1.0-linux-arm64.tar.gz`:ARM64 / AArch64。
|
||||
- `aimilivpn-v2.1.0-linux-armv7.tar.gz`:ARMv7 32 位。
|
||||
- `sha256sums.txt`:所有发行包的 SHA-256 校验值。
|
||||
|
||||
#### 方法三:Docker / Docker Compose
|
||||
|
||||
Docker 镜像地址:`ghcr.io/baoweise-bot/aimili-vpngate:2.1`。仓库中的 [`compose.yaml`](./compose.yaml) 已配置主机网络、`NET_ADMIN` 和 TUN 设备:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
docker logs -f aimilivpn
|
||||
```
|
||||
|
||||
也可以直接运行:
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name aimilivpn \
|
||||
--restart unless-stopped \
|
||||
--network host \
|
||||
--cap-add NET_ADMIN \
|
||||
--device /dev/net/tun:/dev/net/tun \
|
||||
-v aimilivpn-data:/data \
|
||||
ghcr.io/baoweise-bot/aimili-vpngate:2.1
|
||||
```
|
||||
|
||||
> Docker 方式只支持具备 `/dev/net/tun` 的 Linux 主机。管理页面默认端口为 `8787`,本机 HTTP/SOCKS5 代理默认端口为 `7928`。
|
||||
|
||||
---
|
||||
|
||||
@@ -85,6 +157,9 @@ bash <(curl -Ls https://raw.githubusercontent.com/baoweise-bot/aimili-vpngate/ma
|
||||
### 🛠️ 核心功能与操作说明
|
||||
|
||||
* **合并操作面板**:将“更新节点”与“立即检测补齐”合并,一键触发多线程拉取与测速。
|
||||
* **正式版更新检测**:Web 顶部版本菜单可以检查 GitHub 最新稳定 Release,并提供 `main` 主分支和正式版下载入口。
|
||||
* **多国家发现范围**:节点表可实时勾选多个国家;点击“更新节点”后保存范围并影响后台周期拉取。
|
||||
* **延迟来源区分**:实测延迟正常显示,官方 Ping 回退值使用弱化样式并标注为预估。
|
||||
* **网关状态面板**:
|
||||
- **系统诊断**:检测网关心跳及后台各个子守护线程(网页服务、VPN连接管理、出站网关服务)是否正常运行。若有脚本未运行,会提示具体的异常原因。
|
||||
- **本地代理出口检测**:在网页端直接一键检测 VPS 后台对海外的实际连通状况,并回显真实的代理出站 IP 和所在地理位置。
|
||||
@@ -171,7 +246,7 @@ AimiliVPN is a high-performance, zero-dependency VPN proxy gateway built entirel
|
||||
|
||||
Run the corresponding command on your Linux VPS as root:
|
||||
|
||||
#### 🌟 Stable Release (main branch)
|
||||
#### 🌟 V2.1 Formal Release (main branch only)
|
||||
```bash
|
||||
bash <(curl -Ls https://raw.githubusercontent.com/baoweise-bot/aimili-vpngate/main/install.sh)
|
||||
```
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# AimiliVPN V2.1 正式版
|
||||
|
||||
V2.1 是仅从 `main` 主分支发布的首个正式版本标志。
|
||||
|
||||
## 本次更新
|
||||
|
||||
- 节点来源按“VPNGate 官方 HTTPS -> 官方 HTTP -> GitHub Pages HTTPS -> GitHub Pages HTTP -> VPS 本地快照 -> 内置初始快照”自动回退。
|
||||
- 修复节点获取缓慢、连接断开和切换失败时误伤现有连接的问题。
|
||||
- 恢复节点延迟列,区分本机实测值与 VPNGate 官方预估值。
|
||||
- 加入国旗、实时多选国家筛选、国家范围持久化和单节点测试。
|
||||
- Web 管理端加入正式版更新检测,只检查 GitHub 最新稳定 Release,并只保留 `main` 主分支入口。
|
||||
- `install.sh` 和 `ml update` 统一只更新 `origin/main`。
|
||||
- GitHub Release 提供 Linux `amd64`、`386`、`arm64`、`armv7` 发行包与 SHA-256 校验文件。
|
||||
- GHCR 提供相同四种架构的 Docker 镜像。
|
||||
|
||||
## 兼容范围
|
||||
|
||||
应用依赖 Linux TUN、OpenVPN、iptables 和策略路由,因此正式支持 Linux 主机。Docker 也必须运行在具备 `/dev/net/tun` 的 Linux 主机上,并授予 `NET_ADMIN` 能力。
|
||||
@@ -0,0 +1,22 @@
|
||||
services:
|
||||
aimilivpn:
|
||||
image: ghcr.io/baoweise-bot/aimili-vpngate:2.1
|
||||
container_name: aimilivpn
|
||||
network_mode: host
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
devices:
|
||||
- /dev/net/tun:/dev/net/tun
|
||||
environment:
|
||||
VPNGATE_DATA_DIR: /data
|
||||
UI_HOST: "0.0.0.0"
|
||||
UI_PORT: "8787"
|
||||
LOCAL_PROXY_HOST: "127.0.0.1"
|
||||
LOCAL_PROXY_PORT: "7928"
|
||||
volumes:
|
||||
- aimilivpn-data:/data
|
||||
init: true
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
aimilivpn-data:
|
||||
+16
-29
@@ -82,15 +82,7 @@ fi
|
||||
|
||||
# 4. Clone or pull the repository
|
||||
INSTALL_DIR="/opt/aimilivpn"
|
||||
# 默认部署分支(在 bate 分支设为 bate;在 main 分支设为 main)
|
||||
DEFAULT_DEPLOY_BRANCH="main"
|
||||
|
||||
# 自动检测本地已安装版本当前所在的分支
|
||||
CURRENT_BRANCH=""
|
||||
if [ -d "${INSTALL_DIR}/.git" ]; then
|
||||
CURRENT_BRANCH=$(cd "${INSTALL_DIR}" && git rev-parse --abbrev-ref HEAD 2>/dev/null)
|
||||
fi
|
||||
DEPLOY_BRANCH="${CURRENT_BRANCH:-$DEFAULT_DEPLOY_BRANCH}"
|
||||
DEPLOY_BRANCH="main"
|
||||
|
||||
echo -e "\n${YELLOW}[2/4] 正在从 GitHub 部署源代码到 ${INSTALL_DIR} (目标分支: ${DEPLOY_BRANCH})...${PLAIN}"
|
||||
if [ -f "${INSTALL_DIR}/.local_dev" ]; then
|
||||
@@ -99,8 +91,8 @@ else
|
||||
if [ -d "${INSTALL_DIR}" ]; then
|
||||
echo -e " -> 目录 ${INSTALL_DIR} 已存在,正在更新并强制覆盖本地源码..."
|
||||
cd "${INSTALL_DIR}"
|
||||
git fetch --all || true
|
||||
git checkout "${DEPLOY_BRANCH}" || git checkout -b "${DEPLOY_BRANCH}" "origin/${DEPLOY_BRANCH}" || true
|
||||
git fetch origin "${DEPLOY_BRANCH}" || true
|
||||
git checkout -B "${DEPLOY_BRANCH}" "origin/${DEPLOY_BRANCH}" || true
|
||||
echo -e " -> 正在强制重置本地源码至 origin/${DEPLOY_BRANCH} ..."
|
||||
if git reset --hard "origin/${DEPLOY_BRANCH}"; then
|
||||
echo -e "${GREEN} -> 源码更新成功!${PLAIN}"
|
||||
@@ -113,13 +105,14 @@ else
|
||||
fi
|
||||
else
|
||||
echo -e " -> 正在克隆 GitHub 仓库 ${GITHUB_URL} (分支: ${DEPLOY_BRANCH}) ..."
|
||||
if git clone -b "${DEPLOY_BRANCH}" "${GITHUB_URL}" "${INSTALL_DIR}"; then
|
||||
if git clone --branch "${DEPLOY_BRANCH}" --single-branch "${GITHUB_URL}" "${INSTALL_DIR}"; then
|
||||
echo -e "${GREEN} -> 克隆成功!${PLAIN}"
|
||||
else
|
||||
echo -e " -> 尝试默认克隆..."
|
||||
if git clone "${GITHUB_URL}" "${INSTALL_DIR}"; then
|
||||
cd "${INSTALL_DIR}"
|
||||
git checkout "${DEPLOY_BRANCH}" || git checkout -b "${DEPLOY_BRANCH}" "origin/${DEPLOY_BRANCH}" || true
|
||||
git fetch origin "${DEPLOY_BRANCH}"
|
||||
git checkout -B "${DEPLOY_BRANCH}" "origin/${DEPLOY_BRANCH}"
|
||||
echo -e "${GREEN} -> 克隆成功!${PLAIN}"
|
||||
else
|
||||
echo -e "${RED} -> 错误: 无法克隆仓库 ${GITHUB_URL},请检查网络!${PLAIN}"
|
||||
@@ -516,7 +509,7 @@ def show_logs():
|
||||
time.sleep(2)
|
||||
|
||||
def update_service():
|
||||
print("正在获取远程更新并检测版本...", flush=True)
|
||||
print("正在从 GitHub main 主分支检测正式版更新...", flush=True)
|
||||
if os.path.exists(INSTALL_DIR):
|
||||
try:
|
||||
os.chdir(INSTALL_DIR)
|
||||
@@ -525,20 +518,13 @@ def update_service():
|
||||
time.sleep(3)
|
||||
return
|
||||
|
||||
# Fetch remote origin updates
|
||||
subprocess.run(["git", "fetch", "--all"], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
|
||||
# Detect remote branch (prefer current local branch, fallback to origin/main or origin/master)
|
||||
curr = subprocess.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], capture_output=True, text=True)
|
||||
branch = curr.stdout.strip() if curr.returncode == 0 else ""
|
||||
if not branch or branch == "HEAD":
|
||||
branch = "main"
|
||||
for b in ["main", "master"]:
|
||||
chk = subprocess.run(["git", "rev-parse", "--verify", f"origin/{b}"], capture_output=True, text=True)
|
||||
if chk.returncode == 0:
|
||||
branch = b
|
||||
break
|
||||
|
||||
branch = "main"
|
||||
subprocess.run(
|
||||
["git", "fetch", "origin", branch],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
local_commit = subprocess.run(["git", "rev-parse", "HEAD"], capture_output=True, text=True).stdout.strip()
|
||||
remote_commit = subprocess.run(["git", "rev-parse", f"origin/{branch}"], capture_output=True, text=True).stdout.strip()
|
||||
|
||||
@@ -557,7 +543,8 @@ def update_service():
|
||||
time.sleep(1.5)
|
||||
return
|
||||
|
||||
print(f"\n正在强制重置本地代码至 origin/{branch} ...", flush=True)
|
||||
print(f"\n正在切换并重置到正式版 origin/{branch} ...", flush=True)
|
||||
subprocess.run(["git", "checkout", "-B", branch, f"origin/{branch}"], check=True)
|
||||
subprocess.run(["git", "reset", "--hard", f"origin/{branch}"], check=True)
|
||||
|
||||
# Clean up python cache files
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import shutil
|
||||
import tarfile
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
TARGETS = {
|
||||
"amd64": {"cpu": "x86_64 / x64", "docker_platform": "linux/amd64"},
|
||||
"386": {"cpu": "x86 / i386 32-bit", "docker_platform": "linux/386"},
|
||||
"arm64": {"cpu": "ARM64 / AArch64", "docker_platform": "linux/arm64"},
|
||||
"armv7": {"cpu": "ARMv7 32-bit", "docker_platform": "linux/arm/v7"},
|
||||
}
|
||||
|
||||
RELEASE_FILES = [
|
||||
"VERSION",
|
||||
"README.md",
|
||||
"RELEASE_NOTES.md",
|
||||
"LICENSE",
|
||||
"install.sh",
|
||||
"vpngate_manager.py",
|
||||
"vpn_utils.py",
|
||||
"proxy_server.py",
|
||||
"snapshot_utils.py",
|
||||
"Dockerfile",
|
||||
"compose.yaml",
|
||||
]
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def build_archives(root: Path, output_dir: Path) -> list[Path]:
|
||||
version = (root / "VERSION").read_text(encoding="utf-8").strip()
|
||||
version_parts = version.split(".")
|
||||
if len(version_parts) not in (2, 3) or any(not part.isdigit() for part in version_parts):
|
||||
raise ValueError("VERSION 必须是点分隔的数字版本号")
|
||||
|
||||
missing = [name for name in RELEASE_FILES if not (root / name).is_file()]
|
||||
if missing:
|
||||
raise FileNotFoundError(f"发行文件缺失: {', '.join(missing)}")
|
||||
if not (root / "mirror").is_dir():
|
||||
raise FileNotFoundError("发行文件缺失: mirror")
|
||||
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
archives: list[Path] = []
|
||||
with tempfile.TemporaryDirectory(prefix="aimilivpn-release-") as temp_name:
|
||||
temp_root = Path(temp_name)
|
||||
for architecture, metadata in TARGETS.items():
|
||||
package_name = f"aimilivpn-v{version}-linux-{architecture}"
|
||||
package_root = temp_root / package_name
|
||||
package_root.mkdir()
|
||||
|
||||
for name in RELEASE_FILES:
|
||||
shutil.copy2(root / name, package_root / name)
|
||||
shutil.copytree(root / "mirror", package_root / "mirror")
|
||||
build_info = {
|
||||
"product": "AimiliVPN",
|
||||
"version": version,
|
||||
"release": f"V{'.'.join(version.split('.')[:2])} 正式版",
|
||||
"branch": "main",
|
||||
"operating_system": "Linux",
|
||||
"architecture": architecture,
|
||||
**metadata,
|
||||
"supported_distributions": [
|
||||
"Debian",
|
||||
"Ubuntu",
|
||||
"CentOS",
|
||||
"RHEL",
|
||||
"Rocky Linux",
|
||||
"AlmaLinux",
|
||||
"Fedora",
|
||||
"Oracle Linux",
|
||||
"Amazon Linux",
|
||||
"Alpine Linux",
|
||||
],
|
||||
}
|
||||
(package_root / "BUILD_INFO.json").write_text(
|
||||
json.dumps(build_info, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
archive_path = output_dir / f"{package_name}.tar.gz"
|
||||
with tarfile.open(archive_path, "w:gz") as archive:
|
||||
archive.add(package_root, arcname=package_name)
|
||||
archives.append(archive_path)
|
||||
|
||||
checksum_lines = [f"{sha256(path)} {path.name}" for path in archives]
|
||||
checksum_path = output_dir / "sha256sums.txt"
|
||||
checksum_path.write_text("\n".join(checksum_lines) + "\n", encoding="ascii")
|
||||
return archives
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="构建 AimiliVPN Linux 多架构发行包")
|
||||
parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1])
|
||||
parser.add_argument("--output-dir", type=Path, default=Path("dist"))
|
||||
args = parser.parse_args()
|
||||
|
||||
archives = build_archives(args.root.resolve(), args.output_dir.resolve())
|
||||
for archive in archives:
|
||||
print(archive)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
@@ -393,6 +394,59 @@ class ManagerLogicTests(unittest.TestCase):
|
||||
self.assertIn('class="country-option-input"', manager.INDEX_HTML)
|
||||
self.assertIn('${testBtn}', manager.INDEX_HTML)
|
||||
|
||||
def test_web_update_controls_only_expose_stable_main_channel(self) -> None:
|
||||
self.assertEqual("2.1.0", manager.APP_VERSION)
|
||||
self.assertEqual("V2.1 正式版", manager.APP_VERSION_LABEL)
|
||||
self.assertIn("检测更新", manager.INDEX_HTML)
|
||||
self.assertIn("/api/check_update", manager.INDEX_HTML)
|
||||
self.assertIn("/tree/main", manager.INDEX_HTML)
|
||||
self.assertIn("/releases/latest", manager.INDEX_HTML)
|
||||
self.assertNotIn("/tree/bate", manager.INDEX_HTML)
|
||||
self.assertNotIn(">测试版<", manager.INDEX_HTML)
|
||||
|
||||
def test_installer_updates_only_from_main(self) -> None:
|
||||
install_text = (manager.ROOT_DIR / "install.sh").read_text(encoding="utf-8")
|
||||
|
||||
self.assertIn('DEPLOY_BRANCH="main"', install_text)
|
||||
self.assertIn('branch = "main"', install_text)
|
||||
self.assertNotIn("CURRENT_BRANCH", install_text)
|
||||
self.assertNotIn("origin/master", install_text)
|
||||
self.assertNotIn("bate", install_text.lower())
|
||||
|
||||
def test_latest_release_check_ignores_non_version_name_text(self) -> None:
|
||||
release = {
|
||||
"tag_name": "v2.2.0",
|
||||
"name": "AimiliVPN V2.2 正式版",
|
||||
"published_at": "2026-09-01T00:00:00Z",
|
||||
"draft": False,
|
||||
"prerelease": False,
|
||||
}
|
||||
with mock.patch.object(manager, "fetch_api_text", return_value=json.dumps(release)) as fetch_mock:
|
||||
result = manager.check_latest_release()
|
||||
|
||||
self.assertTrue(result["ok"])
|
||||
self.assertTrue(result["update_available"])
|
||||
self.assertEqual("2.2.0", result["latest_version"])
|
||||
self.assertEqual("v2.2.0", result["latest_tag"])
|
||||
self.assertEqual(
|
||||
"https://github.com/baoweise-bot/aimili-vpngate/releases/tag/v2.2.0",
|
||||
result["release_url"],
|
||||
)
|
||||
fetch_mock.assert_called_once_with(manager.GITHUB_LATEST_RELEASE_API, True)
|
||||
|
||||
def test_latest_release_check_reports_current_formal_version(self) -> None:
|
||||
release = {
|
||||
"tag_name": "v2.1.0",
|
||||
"name": "AimiliVPN V2.1 正式版",
|
||||
"draft": False,
|
||||
"prerelease": False,
|
||||
}
|
||||
with mock.patch.object(manager, "fetch_api_text", return_value=json.dumps(release)):
|
||||
result = manager.check_latest_release()
|
||||
|
||||
self.assertFalse(result["update_available"])
|
||||
self.assertEqual("V2.1 正式版", result["current_version_label"])
|
||||
|
||||
def test_fetch_uses_github_mirror_after_official_sources(self) -> None:
|
||||
csv_text = valid_snapshot()
|
||||
|
||||
|
||||
+199
-11
@@ -119,6 +119,18 @@ UI_PORT = env_int("UI_PORT", 8787, 1, 65535)
|
||||
INVALID_BACKOFF_SECONDS = env_int("INVALID_BACKOFF_SECONDS", 30 * 60, 1)
|
||||
|
||||
ROOT_DIR = Path(sys.executable).resolve().parent if globals().get("__compiled__") else Path(__file__).resolve().parent
|
||||
DEFAULT_APP_VERSION = "2.1.0"
|
||||
try:
|
||||
_version_text = (ROOT_DIR / "VERSION").read_text(encoding="utf-8").strip()
|
||||
except OSError:
|
||||
_version_text = DEFAULT_APP_VERSION
|
||||
APP_VERSION = _version_text if re.fullmatch(r"\d+\.\d+(?:\.\d+)?", _version_text) else DEFAULT_APP_VERSION
|
||||
APP_VERSION_SHORT = ".".join(APP_VERSION.split(".")[:2])
|
||||
APP_VERSION_LABEL = f"V{APP_VERSION_SHORT} 正式版"
|
||||
GITHUB_REPOSITORY = "baoweise-bot/aimili-vpngate"
|
||||
GITHUB_REPOSITORY_URL = f"https://github.com/{GITHUB_REPOSITORY}"
|
||||
GITHUB_MAIN_BRANCH_URL = f"{GITHUB_REPOSITORY_URL}/tree/main"
|
||||
GITHUB_LATEST_RELEASE_API = f"https://api.github.com/repos/{GITHUB_REPOSITORY}/releases/latest"
|
||||
DATA_DIR = Path(os.environ["VPNGATE_DATA_DIR"]).resolve() if os.environ.get("VPNGATE_DATA_DIR") else ROOT_DIR / "vpngate_data"
|
||||
CONFIG_DIR = DATA_DIR / "configs"
|
||||
NODES_FILE = DATA_DIR / "nodes.json"
|
||||
@@ -415,6 +427,8 @@ def get_state() -> dict[str, Any]:
|
||||
state.setdefault("last_check_message", "")
|
||||
state.setdefault("pending_node_id", "")
|
||||
state.setdefault("blacklisted_nodes", 0)
|
||||
state["app_version"] = APP_VERSION
|
||||
state["app_version_label"] = APP_VERSION_LABEL
|
||||
|
||||
# Pre-populate settings inputs in UI
|
||||
ui_cfg = load_ui_config()
|
||||
@@ -696,7 +710,7 @@ def fetch_api_text(url: str | None = None, use_ssl_verify: bool = True) -> str:
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
headers={
|
||||
"User-Agent": "Mozilla/5.0 vpngate-openvpn-manager/2.0",
|
||||
"User-Agent": f"Mozilla/5.0 AimiliVPN/{APP_VERSION}",
|
||||
"Accept": "text/plain,*/*",
|
||||
},
|
||||
)
|
||||
@@ -722,6 +736,37 @@ def fetch_api_text(url: str | None = None, use_ssl_verify: bool = True) -> str:
|
||||
with urllib.request.urlopen(request, timeout=API_FETCH_TIMEOUT_SECONDS) as response:
|
||||
return read_limited(response).decode("utf-8", errors="replace")
|
||||
|
||||
def parse_release_version(value: Any) -> tuple[int, int, int]:
|
||||
match = re.search(r"(?i)(?:^|[^a-z0-9])v?(\d+)(?:\.(\d+))?(?:\.(\d+))?", str(value or "").strip())
|
||||
if not match:
|
||||
raise ValueError("GitHub Release 版本号格式无效")
|
||||
return tuple(int(part or 0) for part in match.groups())
|
||||
|
||||
def check_latest_release() -> dict[str, Any]:
|
||||
payload = json.loads(fetch_api_text(GITHUB_LATEST_RELEASE_API, True))
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("GitHub Release API 返回格式无效")
|
||||
if payload.get("draft") or payload.get("prerelease"):
|
||||
raise ValueError("GitHub 最新版本不是正式版")
|
||||
|
||||
latest_tag = str(payload.get("tag_name") or "").strip()
|
||||
latest_version = parse_release_version(latest_tag)
|
||||
current_version = parse_release_version(APP_VERSION)
|
||||
release_url = f"{GITHUB_REPOSITORY_URL}/releases/tag/{urllib.parse.quote(latest_tag, safe='')}"
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"current_version": APP_VERSION,
|
||||
"current_version_label": APP_VERSION_LABEL,
|
||||
"latest_version": ".".join(str(part) for part in latest_version),
|
||||
"latest_tag": latest_tag,
|
||||
"latest_name": str(payload.get("name") or latest_tag),
|
||||
"published_at": str(payload.get("published_at") or ""),
|
||||
"update_available": latest_version > current_version,
|
||||
"release_url": release_url,
|
||||
"main_branch_url": GITHUB_MAIN_BRANCH_URL,
|
||||
}
|
||||
|
||||
def is_certificate_verification_error(exc: BaseException) -> bool:
|
||||
import ssl
|
||||
|
||||
@@ -3613,6 +3658,12 @@ INDEX_HTML = r"""<!doctype html>
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
}
|
||||
#github_dropdown {
|
||||
left: 0;
|
||||
right: auto;
|
||||
width: min(280px, calc(100vw - 40px));
|
||||
min-width: 0;
|
||||
}
|
||||
main {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
@@ -3646,19 +3697,80 @@ INDEX_HTML = r"""<!doctype html>
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
.dropdown-content a {
|
||||
.dropdown-content a,
|
||||
.dropdown-content button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.dropdown-content a:hover {
|
||||
.dropdown-content a:hover,
|
||||
.dropdown-content button:hover:not(:disabled),
|
||||
.dropdown-content a:focus-visible,
|
||||
.dropdown-content button:focus-visible {
|
||||
background: rgba(255,255,255,0.08);
|
||||
outline: none;
|
||||
}
|
||||
.dropdown-content button:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: wait;
|
||||
}
|
||||
.github-dropdown {
|
||||
min-width: 250px;
|
||||
padding: 6px;
|
||||
}
|
||||
.version-current {
|
||||
padding: 9px 10px 10px;
|
||||
margin-bottom: 4px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
.version-current-label {
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.version-current-meta {
|
||||
margin-top: 3px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
}
|
||||
.update-check-status {
|
||||
min-height: 34px;
|
||||
margin: 6px 6px 0;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.update-check-status.available {
|
||||
border-color: rgba(245, 158, 11, 0.35);
|
||||
color: #fbbf24;
|
||||
background: rgba(245, 158, 11, 0.08);
|
||||
}
|
||||
.update-check-status.current {
|
||||
border-color: rgba(16, 185, 129, 0.3);
|
||||
color: #34d399;
|
||||
background: rgba(16, 185, 129, 0.08);
|
||||
}
|
||||
.update-check-status.error {
|
||||
border-color: rgba(244, 63, 94, 0.3);
|
||||
color: #fb7185;
|
||||
background: rgba(244, 63, 94, 0.08);
|
||||
}
|
||||
|
||||
/* Modal styles */
|
||||
@@ -3795,14 +3907,23 @@ INDEX_HTML = r"""<!doctype html>
|
||||
<div class="btn-group">
|
||||
|
||||
<div class="dropdown">
|
||||
<button id="github_btn" class="btn-primary" style="background: rgba(255, 255, 255, 0.08); border: 1px solid var(--border-color); color: var(--text-primary);">
|
||||
<button id="github_btn" class="btn-primary" type="button" aria-expanded="false" aria-controls="github_dropdown" style="background: rgba(255, 255, 255, 0.08); border: 1px solid var(--border-color); color: var(--text-primary);">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16" style="vertical-align: middle; margin-right: 4px;"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>
|
||||
GITHUB
|
||||
<span id="github_version_label">V2.1 正式版</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" style="width:12px; height:12px; margin-left: 2px;" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3"><path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" /></svg>
|
||||
</button>
|
||||
<div id="github_dropdown" class="dropdown-content">
|
||||
<a href="https://github.com/baoweise-bot/aimili-vpngate" target="_blank">正式版</a>
|
||||
<a href="https://github.com/baoweise-bot/aimili-vpngate/tree/bate" target="_blank">测试版</a>
|
||||
<div id="github_dropdown" class="dropdown-content github-dropdown">
|
||||
<div class="version-current">
|
||||
<div id="current_version_label" class="version-current-label">V2.1 正式版</div>
|
||||
<div class="version-current-meta">唯一更新通道:main 主分支</div>
|
||||
</div>
|
||||
<button id="check_update_btn" type="button" onclick="checkForUpdate(event)">
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" style="width:14px; height:14px;" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 1121.21 8H18.5" /></svg>
|
||||
检测更新
|
||||
</button>
|
||||
<a href="https://github.com/baoweise-bot/aimili-vpngate/tree/main" target="_blank" rel="noopener noreferrer">GitHub main 主分支</a>
|
||||
<a id="latest_release_link" href="https://github.com/baoweise-bot/aimili-vpngate/releases/latest" target="_blank" rel="noopener noreferrer">下载最新正式版</a>
|
||||
<div id="update_check_status" class="update-check-status" role="status" aria-live="polite">点击“检测更新”查询 GitHub 最新正式版。</div>
|
||||
</div>
|
||||
</div>
|
||||
<a href="https://t.me/arestemple" target="_blank" class="btn-telegram">
|
||||
@@ -3814,7 +3935,7 @@ INDEX_HTML = r"""<!doctype html>
|
||||
更新节点
|
||||
</button>
|
||||
<div class="dropdown">
|
||||
<button id="admin_btn" class="btn-primary" style="background: rgba(255, 255, 255, 0.08); border: 1px solid var(--border-color); color: var(--text-primary);">
|
||||
<button id="admin_btn" class="btn-primary" type="button" aria-expanded="false" aria-controls="admin_dropdown" style="background: rgba(255, 255, 255, 0.08); border: 1px solid var(--border-color); color: var(--text-primary);">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" style="width:16px; height:16px;" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg>
|
||||
管理员
|
||||
<svg xmlns="http://www.w3.org/2000/svg" style="width:12px; height:12px; margin-left: 2px;" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3"><path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" /></svg>
|
||||
@@ -4510,6 +4631,10 @@ function stableSortNodes() {
|
||||
}
|
||||
|
||||
function render(){
|
||||
const versionLabel = state.app_version_label || "V2.1 正式版";
|
||||
if ($("github_version_label")) $("github_version_label").textContent = versionLabel;
|
||||
if ($("current_version_label")) $("current_version_label").textContent = versionLabel;
|
||||
|
||||
const activeNodeId = state.active_openvpn_node_id;
|
||||
const activeNode = nodes.find(n => n && (n.active || n.id === activeNodeId));
|
||||
|
||||
@@ -5022,12 +5147,48 @@ const adminDropdown = $("admin_dropdown");
|
||||
const githubBtn = $("github_btn");
|
||||
const githubDropdown = $("github_dropdown");
|
||||
|
||||
async function checkForUpdate(event) {
|
||||
if (event) event.stopPropagation();
|
||||
const button = $("check_update_btn");
|
||||
const statusBox = $("update_check_status");
|
||||
const releaseLink = $("latest_release_link");
|
||||
if (!button || !statusBox) return;
|
||||
|
||||
button.disabled = true;
|
||||
statusBox.className = "update-check-status";
|
||||
statusBox.textContent = "正在连接 GitHub 检查最新正式版...";
|
||||
try {
|
||||
const response = await fetch("./api/check_update", { cache: "no-store" });
|
||||
const result = await response.json();
|
||||
if (!response.ok || !result.ok) {
|
||||
throw new Error(result.error || "更新检查失败");
|
||||
}
|
||||
if (releaseLink && result.release_url) releaseLink.href = result.release_url;
|
||||
if (result.update_available) {
|
||||
statusBox.className = "update-check-status available";
|
||||
statusBox.textContent = `发现正式版 ${result.latest_tag},当前为 ${result.current_version_label}。请打开正式版下载页更新。`;
|
||||
} else {
|
||||
statusBox.className = "update-check-status current";
|
||||
statusBox.textContent = `当前 ${result.current_version_label} 已是最新正式版。`;
|
||||
}
|
||||
} catch (error) {
|
||||
statusBox.className = "update-check-status error";
|
||||
statusBox.textContent = error.message || "无法连接 GitHub,请稍后重试。";
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (adminBtn && adminDropdown) {
|
||||
adminBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
const isShow = adminDropdown.style.display === "block";
|
||||
adminDropdown.style.display = isShow ? "none" : "block";
|
||||
if (githubDropdown) githubDropdown.style.display = "none";
|
||||
adminBtn.setAttribute("aria-expanded", isShow ? "false" : "true");
|
||||
if (githubDropdown) {
|
||||
githubDropdown.style.display = "none";
|
||||
if (githubBtn) githubBtn.setAttribute("aria-expanded", "false");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5036,13 +5197,32 @@ if (githubBtn && githubDropdown) {
|
||||
e.stopPropagation();
|
||||
const isShow = githubDropdown.style.display === "block";
|
||||
githubDropdown.style.display = isShow ? "none" : "block";
|
||||
if (adminDropdown) adminDropdown.style.display = "none";
|
||||
githubBtn.setAttribute("aria-expanded", isShow ? "false" : "true");
|
||||
if (adminDropdown) {
|
||||
adminDropdown.style.display = "none";
|
||||
if (adminBtn) adminBtn.setAttribute("aria-expanded", "false");
|
||||
}
|
||||
};
|
||||
githubDropdown.onclick = event => event.stopPropagation();
|
||||
}
|
||||
|
||||
document.addEventListener("click", () => {
|
||||
if (adminDropdown) adminDropdown.style.display = "none";
|
||||
if (githubDropdown) githubDropdown.style.display = "none";
|
||||
if (adminBtn) adminBtn.setAttribute("aria-expanded", "false");
|
||||
if (githubBtn) githubBtn.setAttribute("aria-expanded", "false");
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", event => {
|
||||
if (event.key !== "Escape") return;
|
||||
const githubWasOpen = githubBtn && githubBtn.getAttribute("aria-expanded") === "true";
|
||||
const adminWasOpen = adminBtn && adminBtn.getAttribute("aria-expanded") === "true";
|
||||
if (adminDropdown) adminDropdown.style.display = "none";
|
||||
if (githubDropdown) githubDropdown.style.display = "none";
|
||||
if (adminBtn) adminBtn.setAttribute("aria-expanded", "false");
|
||||
if (githubBtn) githubBtn.setAttribute("aria-expanded", "false");
|
||||
if (githubWasOpen && githubBtn) githubBtn.focus();
|
||||
else if (adminWasOpen && adminBtn) adminBtn.focus();
|
||||
});
|
||||
|
||||
let showFavoritesOnly = false;
|
||||
@@ -6017,6 +6197,14 @@ class Handler(BaseHTTPRequestHandler):
|
||||
del stripped["config_text"]
|
||||
stripped_nodes.append(stripped)
|
||||
self.send_json({"nodes": stripped_nodes, "state": get_state()})
|
||||
elif effective_path == "/api/check_update":
|
||||
try:
|
||||
self.send_json(check_latest_release())
|
||||
except Exception as exc:
|
||||
self.send_json(
|
||||
{"ok": False, "error": f"无法检查 GitHub 正式版更新: {exc}"},
|
||||
HTTPStatus.BAD_GATEWAY,
|
||||
)
|
||||
elif effective_path.startswith("/configs/"):
|
||||
filename = urllib.parse.unquote(effective_path.removeprefix("/configs/"))
|
||||
with lock:
|
||||
|
||||
Reference in New Issue
Block a user