mirror of
https://github.com/2dust/v2rayN.git
synced 2026-01-17 19:39:34 +00:00
Merge pull request #2 from FlowerRealm/feat/sub-usage-progressbar
Add subscription usage/expiry progress + auto refresh
This commit is contained in:
commit
4a9bbdcf9a
37 changed files with 879 additions and 60 deletions
12
.github/workflows/winget-publish.yml
vendored
12
.github/workflows/winget-publish.yml
vendored
|
|
@ -16,24 +16,24 @@ jobs:
|
|||
- name: Submit v2ray package to Windows Package Manager Community Repository
|
||||
run: |
|
||||
|
||||
$wingetPackage = "2dust.v2rayN"
|
||||
$wingetPackage = "FlowerRealm.v2rayN"
|
||||
$gitToken = "${{ secrets.PT_WINGET }}"
|
||||
|
||||
$github = Invoke-RestMethod -uri "https://api.github.com/repos/2dust/v2rayN/releases"
|
||||
$github = Invoke-RestMethod -uri "https://api.github.com/repos/FlowerRealm/v2rayN/releases"
|
||||
|
||||
$targetRelease = $github | Where-Object -Property prerelease -match 'False' | Select -First 1
|
||||
|
||||
|
||||
$x64InstallerUrl = $targetRelease | Select -ExpandProperty assets -First 1 | Where-Object -Property name -match 'v2rayN-windows-64\.zip' | Select -ExpandProperty browser_download_url
|
||||
$arm64InstallerUrl = $targetRelease | Select -ExpandProperty assets -First 1 | Where-Object -Property name -match 'v2rayN-windows-arm64\.zip' | Select -ExpandProperty browser_download_url
|
||||
|
||||
|
||||
$ver = $targetRelease.tag_name
|
||||
|
||||
# getting latest wingetcreate file
|
||||
iwr https://aka.ms/wingetcreate/latest -OutFile wingetcreate.exe
|
||||
|
||||
|
||||
Write-Host "Updating with both x64 and arm64 installers"
|
||||
Write-Host "Version: $ver"
|
||||
Write-Host "x64 URL: $x64InstallerUrl"
|
||||
Write-Host "arm64 URL: $arm64InstallerUrl"
|
||||
|
||||
|
||||
.\wingetcreate.exe update $wingetPackage -s -v $ver -u "$x64InstallerUrl|x64" "$arm64InstallerUrl|arm64" -t $gitToken
|
||||
|
|
|
|||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -139,7 +139,7 @@ _TeamCity*
|
|||
|
||||
# DotCover is a Code Coverage Tool
|
||||
*.dotCover
|
||||
|
||||
修改 README 移除那段安装脚本说明
|
||||
# AxoCover is a Code Coverage Tool
|
||||
.axoCover/*
|
||||
!.axoCover/settings.json
|
||||
|
|
@ -399,3 +399,6 @@ FodyWeavers.xsd
|
|||
# JetBrains Rider
|
||||
.idea/
|
||||
*.sln.iml
|
||||
|
||||
# Debian packaging build outputs
|
||||
debian/files
|
||||
|
|
|
|||
2
.gitmodules
vendored
2
.gitmodules
vendored
|
|
@ -1,3 +1,3 @@
|
|||
[submodule "v2rayN/GlobalHotKeys"]
|
||||
path = v2rayN/GlobalHotKeys
|
||||
url = https://github.com/2dust/GlobalHotKeys
|
||||
url = https://github.com/FlowerRealm/GlobalHotKeys
|
||||
|
|
|
|||
59
AGENTS.md
Normal file
59
AGENTS.md
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Modules
|
||||
- Solution: `v2rayN/v2rayN.sln` (C#/.NET 8)
|
||||
- Apps: `v2rayN/v2rayN` (Windows WPF), `v2rayN/v2rayN.Desktop` (Avalonia cross‑platform)
|
||||
- Core library: `v2rayN/ServiceLib` (models, services, `Resx` resources)
|
||||
- Hotkeys: `v2rayN/GlobalHotKeys` (library + examples); tests in `GlobalHotKeys.Test`
|
||||
- Artifacts: `v2rayN/Release/`, `v2rayN/publish/`, `build-output/`
|
||||
- Packaging scripts (root): `package-*.sh`
|
||||
|
||||
## Build, Test, and Development
|
||||
- Prerequisites:
|
||||
- .NET 8 SDK on all platforms.
|
||||
- Building the WPF app (`v2rayN/v2rayN`) requires the Windows Desktop targeting pack. Install via `dotnet workload install windowsdesktop` or use a Windows machine with Visual Studio build tools.
|
||||
- Build all projects (no publish artifacts): `dotnet build v2rayN/v2rayN.sln -c Release`
|
||||
- Run Avalonia app (Linux/macOS/Windows): `dotnet run --project v2rayN/v2rayN.Desktop -c Debug`
|
||||
- Run WPF app (Windows only): `dotnet run --project v2rayN/v2rayN -c Debug`
|
||||
- Publish (examples):
|
||||
- Avalonia Linux x64 self-contained: `dotnet publish v2rayN/v2rayN.Desktop/v2rayN.Desktop.csproj -c Release -r linux-x64 --self-contained true -o v2rayN/Release/linux-64`
|
||||
- WPF Windows x64 self-contained: `dotnet publish v2rayN/v2rayN/v2rayN.csproj -c Release -r win-x64 -p:EnableWindowsTargeting=true --self-contained true -o v2rayN/Release/windows-64`
|
||||
- Use other runtime identifiers (`win-arm64`, `osx-x64`, etc.) similarly.
|
||||
- Tests (NUnit): `dotnet test v2rayN/GlobalHotKeys/src/GlobalHotKeys.Test -c Release --collect:"XPlat Code Coverage"`
|
||||
|
||||
### Quick Linux x64 build (single executable)
|
||||
|
||||
Minimal commands to produce a self-contained Linux x64 executable (with normal output):
|
||||
|
||||
```
|
||||
git submodule update --init --recursive
|
||||
dotnet publish v2rayN/v2rayN.Desktop/v2rayN.Desktop.csproj \
|
||||
-c Release -r linux-x64 --self-contained true \
|
||||
-o v2rayN/Release/linux-64
|
||||
echo ./v2rayN/Release/linux-64/v2rayN
|
||||
```
|
||||
|
||||
## Coding Style & Naming
|
||||
- Formatting from `.editorconfig`: UTF-8, CRLF, spaces=4.
|
||||
- C#: file‑scoped namespaces; System usings first; braces required; prefer `var` when type is apparent; PascalCase for types/members; fields/private locals use camelCase.
|
||||
- Lint/format: `dotnet format` before pushing.
|
||||
- XAML/Avalonia: keep views thin; move logic to ViewModels in `ServiceLib`/`*ViewModels`.
|
||||
|
||||
## Testing Guidelines
|
||||
- Framework: NUnit with `GlobalHotKeys.Test`.
|
||||
- Conventions: classes `*Tests`, methods `[Test]` with Arrange/Act/Assert; keep tests isolated and deterministic.
|
||||
- Run locally with `dotnet test` and ensure coverage for hotkey registration/ID reuse.
|
||||
|
||||
## Commit & Pull Requests
|
||||
- Commits: short imperative subject (e.g., "Fix…", "Update…"), optional scope, reference issues/PRs (e.g., `(#8123)`).
|
||||
- PRs must include: problem summary, rationale, user impact, steps to verify, screenshots for UI, target OS/runtime.
|
||||
- CI parity: changes must pass `dotnet build` and `dotnet test` locally; do not commit artifacts under `Release/` or `publish/`.
|
||||
|
||||
## Security & Configuration
|
||||
- Do not commit secrets or downloaded core binaries; rely on packaging scripts/CI.
|
||||
- Submodules: clone/update with `--recursive` when needed.
|
||||
- Keep platform‑specific code isolated (Windows WPF vs Avalonia) to avoid regressions.
|
||||
|
||||
## Agent Notes
|
||||
- Follow this file’s scope for style and layout; keep patches minimal and targeted.
|
||||
- Prefer simplifying data flow over adding conditionals; avoid deep nesting.
|
||||
4
LICENSE
4
LICENSE
|
|
@ -632,7 +632,7 @@ state the exclusion of warranty; and each file should have at least
|
|||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) 2019-Present 2dust
|
||||
Copyright (C) 2019-Present FlowerRealm
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
|
|
@ -652,7 +652,7 @@ Also add information on how to contact you by electronic and paper mail.
|
|||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
v2rayN Copyright (C) 2019-Present 2dust
|
||||
v2rayN Copyright (C) 2019-Present FlowerRealm
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
|
|
|||
14
README.md
14
README.md
|
|
@ -2,17 +2,11 @@
|
|||
|
||||
A GUI client for Windows, Linux and macOS, support [Xray](https://github.com/XTLS/Xray-core)
|
||||
and [sing-box](https://github.com/SagerNet/sing-box)
|
||||
and [others](https://github.com/2dust/v2rayN/wiki/List-of-supported-cores)
|
||||
and [others](https://github.com/FlowerRealm/v2rayN/wiki/List-of-supported-cores)
|
||||
|
||||
[](https://github.com/2dust/v2rayN/commits/master)
|
||||
[](https://www.codefactor.io/repository/github/2dust/v2rayn)
|
||||
[](https://github.com/2dust/v2rayN/releases)
|
||||
[](https://t.me/v2rayn)
|
||||
[](https://github.com/FlowerRealm/v2rayN/commits/master)
|
||||
[](https://github.com/FlowerRealm/v2rayN/releases)
|
||||
|
||||
## How to use
|
||||
|
||||
Read the [Wiki](https://github.com/2dust/v2rayN/wiki) for details.
|
||||
|
||||
## Telegram Channel
|
||||
|
||||
[github_2dust](https://t.me/github_2dust)
|
||||
Read the [Wiki](https://github.com/FlowerRealm/v2rayN/wiki) for details.
|
||||
|
|
|
|||
5
debian/changelog
vendored
Normal file
5
debian/changelog
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
v2rayn (7.15.3-0ppa1) noble; urgency=medium
|
||||
|
||||
* Initial PPA packaging (binary repackaging, no network at build time).
|
||||
|
||||
-- FlowerRealm <flower_realm@outlook.com> Fri, 18 Oct 2024 20:30:00 +0000
|
||||
2
debian/clean
vendored
Normal file
2
debian/clean
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
build/
|
||||
|
||||
15
debian/control
vendored
Normal file
15
debian/control
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
Source: v2rayn
|
||||
Section: net
|
||||
Priority: optional
|
||||
Maintainer: FlowerRealm <flower_realm@outlook.com>
|
||||
Standards-Version: 4.7.0
|
||||
Rules-Requires-Root: no
|
||||
Build-Depends: debhelper-compat (= 13)
|
||||
|
||||
Package: v2rayn
|
||||
Architecture: amd64 arm64
|
||||
Depends: ${misc:Depends}, ${shlibs:Depends}, desktop-file-utils, xdg-utils
|
||||
Description: v2rayN (Avalonia) — Linux 桌面客户端
|
||||
v2rayN 是一个支持多核心的跨平台 GUI 客户端(Avalonia),
|
||||
支持 Xray-core、sing-box 等核心。该包以预编译二进制重打包
|
||||
的方式发布,便于在 Launchpad PPA 无网络环境构建。
|
||||
14
debian/copyright
vendored
Normal file
14
debian/copyright
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
|
||||
Upstream-Name: v2rayN
|
||||
Source: https://github.com/FlowerRealm/v2rayN
|
||||
|
||||
Files: *
|
||||
Copyright: 2017-2025 FlowerRealm
|
||||
License: GPL-3.0
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License version 3.
|
||||
.
|
||||
On Debian systems, the complete text of the GNU General Public
|
||||
License version 3 can be found in
|
||||
/usr/share/common-licenses/GPL-3.
|
||||
|
||||
50
debian/rules
vendored
Executable file
50
debian/rules
vendored
Executable file
|
|
@ -0,0 +1,50 @@
|
|||
#!/usr/bin/make -f
|
||||
|
||||
export DEB_BUILD_MAINT_OPTIONS=hardening=+all
|
||||
export DOTNET_CLI_TELEMETRY_OPTOUT=1
|
||||
|
||||
# 推断目标 RID(按构建机架构)
|
||||
DEB_HOST_ARCH ?= $(shell dpkg-architecture -qDEB_HOST_ARCH)
|
||||
ifeq ($(DEB_HOST_ARCH),amd64)
|
||||
RID := linux-x64
|
||||
else ifeq ($(DEB_HOST_ARCH),arm64)
|
||||
RID := linux-arm64
|
||||
else
|
||||
$(error Unsupported architecture $(DEB_HOST_ARCH))
|
||||
endif
|
||||
|
||||
# 预编译产物期望位置(随源包一起上传)
|
||||
PREBUILT_DIR := packaging/prebuilt/$(RID)
|
||||
OUT_DIR := build/out
|
||||
|
||||
%:
|
||||
dh $@
|
||||
|
||||
override_dh_auto_build:
|
||||
@echo "[info] 使用预编译产物重打包: $(PREBUILT_DIR)"
|
||||
test -d "$(PREBUILT_DIR)" || { \
|
||||
echo "[error] 未找到预编译目录 $(PREBUILT_DIR)"; \
|
||||
echo "请在打包前将 dotnet publish 的输出放入该目录 (参考 README)"; \
|
||||
exit 1; \
|
||||
}
|
||||
rm -rf "$(OUT_DIR)" && mkdir -p "$(OUT_DIR)"
|
||||
cp -a "$(PREBUILT_DIR)/"* "$(OUT_DIR)/"
|
||||
|
||||
override_dh_auto_install:
|
||||
# 安装主体到 /usr/lib/v2rayn
|
||||
mkdir -p debian/v2rayn/usr/lib/v2rayn
|
||||
cp -a "$(OUT_DIR)/"* debian/v2rayn/usr/lib/v2rayn/
|
||||
|
||||
# 包装启动器到 /usr/bin
|
||||
install -Dm0755 debian/scripts/v2rayn debian/v2rayn/usr/bin/v2rayn
|
||||
|
||||
# 安装 desktop 条目与图标
|
||||
install -Dm0644 debian/v2rayn.desktop debian/v2rayn/usr/share/applications/v2rayN.desktop
|
||||
# 优先使用构建输出中的图标
|
||||
if [ -f "$(OUT_DIR)/v2rayN.png" ]; then \
|
||||
install -Dm0644 "$(OUT_DIR)/v2rayN.png" debian/v2rayn/usr/share/icons/hicolor/256x256/apps/v2rayN.png; \
|
||||
fi
|
||||
|
||||
override_dh_auto_clean:
|
||||
rm -rf "$(OUT_DIR)"
|
||||
|
||||
4
debian/scripts/v2rayn
vendored
Normal file
4
debian/scripts/v2rayn
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
exec /usr/lib/v2rayn/v2rayN "$@"
|
||||
|
||||
2
debian/source/format
vendored
Normal file
2
debian/source/format
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
3.0 (native)
|
||||
|
||||
9
debian/v2rayn.desktop
vendored
Normal file
9
debian/v2rayn.desktop
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
[Desktop Entry]
|
||||
Name=v2rayN
|
||||
Comment=A GUI client for Xray-core/sing-box (Avalonia)
|
||||
Exec=v2rayn
|
||||
Icon=v2rayN
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Categories=Network;Application;
|
||||
|
||||
|
|
@ -5,7 +5,7 @@ OutputPath="$2"
|
|||
Version="$3"
|
||||
|
||||
FileName="v2rayN-${Arch}.zip"
|
||||
wget -nv -O $FileName "https://github.com/2dust/v2rayN-core-bin/raw/refs/heads/master/$FileName"
|
||||
wget -nv -O $FileName "https://github.com/FlowerRealm/v2rayN-core-bin/raw/refs/heads/master/$FileName"
|
||||
7z x $FileName
|
||||
cp -rf v2rayN-${Arch}/* $OutputPath
|
||||
|
||||
|
|
@ -16,7 +16,7 @@ cp -rf $OutputPath "${PackagePath}/opt/v2rayN"
|
|||
echo "When this file exists, app will not store configs under this folder" > "${PackagePath}/opt/v2rayN/NotStoreConfigHere.txt"
|
||||
|
||||
if [ $Arch = "linux-64" ]; then
|
||||
Arch2="amd64"
|
||||
Arch2="amd64"
|
||||
else
|
||||
Arch2="arm64"
|
||||
fi
|
||||
|
|
@ -27,7 +27,7 @@ cat >"${PackagePath}/DEBIAN/control" <<-EOF
|
|||
Package: v2rayN
|
||||
Version: $Version
|
||||
Architecture: $Arch2
|
||||
Maintainer: https://github.com/2dust/v2rayN
|
||||
Maintainer: https://github.com/FlowerRealm/v2rayN
|
||||
Depends: desktop-file-utils, xdg-utils
|
||||
Description: A GUI client for Windows and Linux, support Xray core and sing-box-core and others
|
||||
EOF
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ OutputPath="$2"
|
|||
Version="$3"
|
||||
|
||||
FileName="v2rayN-${Arch}.zip"
|
||||
wget -nv -O $FileName "https://github.com/2dust/v2rayN-core-bin/raw/refs/heads/master/$FileName"
|
||||
wget -nv -O $FileName "https://github.com/FlowerRealm/v2rayN-core-bin/raw/refs/heads/master/$FileName"
|
||||
7z x $FileName
|
||||
cp -rf v2rayN-${Arch}/* $OutputPath
|
||||
|
||||
|
|
@ -32,7 +32,7 @@ cat >"$PackagePath/v2rayN.app/Contents/Info.plist" <<-EOF
|
|||
<key>CFBundleIconName</key>
|
||||
<string>AppIcon</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>2dust.v2rayN</string>
|
||||
<string>FlowerRealm.v2rayN</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>v2rayN</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ OutputPath="$2"
|
|||
OutputArch="v2rayN-${Arch}"
|
||||
FileName="v2rayN-${Arch}.zip"
|
||||
|
||||
wget -nv -O $FileName "https://github.com/2dust/v2rayN-core-bin/raw/refs/heads/master/$FileName"
|
||||
wget -nv -O $FileName "https://github.com/FlowerRealm/v2rayN-core-bin/raw/refs/heads/master/$FileName"
|
||||
|
||||
ZipPath64="./$OutputArch"
|
||||
mkdir $ZipPath64
|
||||
|
|
|
|||
|
|
@ -193,7 +193,7 @@ choose_channel() {
|
|||
|
||||
get_latest_tag_latest() {
|
||||
# Resolve /releases/latest → tag_name
|
||||
curl -fsSL "https://api.github.com/repos/2dust/v2rayN/releases/latest" \
|
||||
curl -fsSL "https://api.github.com/repos/FlowerRealm/v2rayN/releases/latest" \
|
||||
| grep -Eo '"tag_name":\s*"v?[^"]+"' \
|
||||
| head -n1 \
|
||||
| sed -E 's/.*"tag_name":\s*"v?([^"]+)".*/\1/'
|
||||
|
|
@ -202,7 +202,7 @@ get_latest_tag_latest() {
|
|||
get_latest_tag_prerelease() {
|
||||
# Resolve newest prerelease=true tag; prefer jq, fallback to sed/grep (no awk)
|
||||
local json tag
|
||||
json="$(curl -fsSL "https://api.github.com/repos/2dust/v2rayN/releases?per_page=20")" || return 1
|
||||
json="$(curl -fsSL "https://api.github.com/repos/FlowerRealm/v2rayN/releases?per_page=20")" || return 1
|
||||
|
||||
# 1) Use jq if present
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
|
|
@ -380,9 +380,9 @@ download_mihomo() {
|
|||
local outroot="$1"
|
||||
local url=""
|
||||
if [[ "$RID_DIR" == "linux-arm64" ]]; then
|
||||
url="https://raw.githubusercontent.com/2dust/v2rayN-core-bin/refs/heads/master/v2rayN-linux-arm64/bin/mihomo/mihomo"
|
||||
url="https://raw.githubusercontent.com/FlowerRealm/v2rayN-core-bin/refs/heads/master/v2rayN-linux-arm64/bin/mihomo/mihomo"
|
||||
else
|
||||
url="https://raw.githubusercontent.com/2dust/v2rayN-core-bin/refs/heads/master/v2rayN-linux-64/bin/mihomo/mihomo"
|
||||
url="https://raw.githubusercontent.com/FlowerRealm/v2rayN-core-bin/refs/heads/master/v2rayN-linux-64/bin/mihomo/mihomo"
|
||||
fi
|
||||
echo "[+] Download mihomo: $url"
|
||||
mkdir -p "$outroot/bin/mihomo"
|
||||
|
|
@ -438,13 +438,13 @@ download_geo_assets() {
|
|||
geoip-private.srs geoip-cn.srs geoip-facebook.srs geoip-fastly.srs \
|
||||
geoip-google.srs geoip-netflix.srs geoip-telegram.srs geoip-twitter.srs; do
|
||||
curl -fsSL -o "$srss_dir/$f" \
|
||||
"https://raw.githubusercontent.com/2dust/sing-box-rules/rule-set-geoip/$f" || true
|
||||
"https://raw.githubusercontent.com/FlowerRealm/sing-box-rules/rule-set-geoip/$f" || true
|
||||
done
|
||||
for f in \
|
||||
geosite-cn.srs geosite-gfw.srs geosite-greatfire.srs \
|
||||
geosite-geolocation-cn.srs geosite-category-ads-all.srs geosite-private.srs; do
|
||||
curl -fsSL -o "$srss_dir/$f" \
|
||||
"https://raw.githubusercontent.com/2dust/sing-box-rules/rule-set-geosite/$f" || true
|
||||
"https://raw.githubusercontent.com/FlowerRealm/sing-box-rules/rule-set-geosite/$f" || true
|
||||
done
|
||||
|
||||
# Unify to bin/
|
||||
|
|
@ -456,9 +456,9 @@ download_v2rayn_bundle() {
|
|||
local outroot="$1"
|
||||
local url=""
|
||||
if [[ "$RID_DIR" == "linux-arm64" ]]; then
|
||||
url="https://raw.githubusercontent.com/2dust/v2rayN-core-bin/refs/heads/master/v2rayN-linux-arm64.zip"
|
||||
url="https://raw.githubusercontent.com/FlowerRealm/v2rayN-core-bin/refs/heads/master/v2rayN-linux-arm64.zip"
|
||||
else
|
||||
url="https://raw.githubusercontent.com/2dust/v2rayN-core-bin/refs/heads/master/v2rayN-linux-64.zip"
|
||||
url="https://raw.githubusercontent.com/FlowerRealm/v2rayN-core-bin/refs/heads/master/v2rayN-linux-64.zip"
|
||||
fi
|
||||
echo "[+] Try v2rayN bundle archive: $url"
|
||||
local tmp zipname
|
||||
|
|
@ -608,8 +608,8 @@ Version: __VERSION__
|
|||
Release: 1%{?dist}
|
||||
Summary: v2rayN (Avalonia) GUI client for Linux (x86_64/aarch64)
|
||||
License: GPL-3.0-only
|
||||
URL: https://github.com/2dust/v2rayN
|
||||
BugURL: https://github.com/2dust/v2rayN/issues
|
||||
URL: https://github.com/FlowerRealm/v2rayN
|
||||
BugURL: https://github.com/FlowerRealm/v2rayN/issues
|
||||
ExclusiveArch: aarch64 x86_64
|
||||
Source0: __PKGROOT__.tar.gz
|
||||
|
||||
|
|
@ -622,7 +622,7 @@ v2rayN Linux for Red Hat Enterprise Linux
|
|||
Support vless / vmess / Trojan / http / socks / Anytls / Hysteria2 / Shadowsocks / tuic / WireGuard
|
||||
Support Red Hat Enterprise Linux / Fedora Linux / Rocky Linux / AlmaLinux / CentOS
|
||||
For more information, Please visit our website
|
||||
https://github.com/2dust/v2rayN
|
||||
https://github.com/FlowerRealm/v2rayN
|
||||
|
||||
%prep
|
||||
%setup -q -n __PKGROOT__
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
<NoWarn>CA1031;CS1591;NU1507;CA1416;IDE0058</NoWarn>
|
||||
<Nullable>annotations</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Authors>2dust</Authors>
|
||||
<Authors>FlowerRealm</Authors>
|
||||
<PackageLicenseExpression>GPL-3.0</PackageLicenseExpression>
|
||||
<Copyright>Copyright © 2017-$([System.DateTime]::UtcNow.Year) $(Authors)</Copyright>
|
||||
<InvariantGlobalization>false</InvariantGlobalization>
|
||||
|
|
|
|||
|
|
@ -171,22 +171,22 @@ public class Utils
|
|||
|
||||
public static string HumanFy(long amount)
|
||||
{
|
||||
if (amount <= 0)
|
||||
{
|
||||
return $"{amount:f1} B";
|
||||
}
|
||||
|
||||
string[] units = ["KB", "MB", "GB", "TB", "PB"];
|
||||
// Bytes → KB → MB → GB → TB → PB
|
||||
string[] units = ["B", "KB", "MB", "GB", "TB", "PB"];
|
||||
var unitIndex = 0;
|
||||
double size = amount;
|
||||
double size = amount < 0 ? 0 : (double)amount;
|
||||
|
||||
// Loop and divide by 1024 until a suitable unit is found
|
||||
while (size >= 1024 && unitIndex < units.Length - 1)
|
||||
{
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
// For bytes, show integer without decimal; for others keep 1 decimal
|
||||
if (unitIndex == 0)
|
||||
{
|
||||
return $"{(long)size} {units[unitIndex]}";
|
||||
}
|
||||
return $"{size:f1} {units[unitIndex]}";
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using System.Reactive;
|
||||
using ServiceLib.Models;
|
||||
|
||||
namespace ServiceLib.Events;
|
||||
|
||||
|
|
@ -29,4 +30,7 @@ public static class AppEvents
|
|||
public static readonly EventChannel<Unit> TestServerRequested = new();
|
||||
public static readonly EventChannel<Unit> InboundDisplayRequested = new();
|
||||
public static readonly EventChannel<ESysProxyType> SysProxyChangeRequested = new();
|
||||
|
||||
// Fired when subscription usage/expiry info updates
|
||||
public static readonly EventChannel<SubscriptionUsageInfo> SubscriptionInfoUpdated = new();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ public class Global
|
|||
public const string GithubUrl = "https://github.com";
|
||||
public const string GithubApiUrl = "https://api.github.com/repos";
|
||||
public const string GeoUrl = "https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/{0}.dat";
|
||||
public const string SingboxRulesetUrl = @"https://raw.githubusercontent.com/2dust/sing-box-rules/rule-set-{0}/{1}.srs";
|
||||
public const string SingboxRulesetUrl = @"https://raw.githubusercontent.com/FlowerRealm/sing-box-rules/rule-set-{0}/{1}.srs";
|
||||
|
||||
public const string PromotionUrl = @"aHR0cHM6Ly85LjIzNDQ1Ni54eXovYWJjLmh0bWw=";
|
||||
public const string ConfigFileName = "guiNConfig.json";
|
||||
|
|
@ -316,7 +316,7 @@ public class Global
|
|||
];
|
||||
|
||||
public static readonly HashSet<EConfigType> SingboxOnlyConfigType = SingboxSupportConfigType.Except(XraySupportConfigType).ToHashSet();
|
||||
|
||||
|
||||
public static readonly List<string> DomainStrategies =
|
||||
[
|
||||
AsIs,
|
||||
|
|
@ -572,7 +572,7 @@ public class Global
|
|||
{ ECoreType.overtls, "ShadowsocksR-Live/overtls" },
|
||||
{ ECoreType.shadowquic, "spongebob888/shadowquic" },
|
||||
{ ECoreType.mieru, "enfein/mieru" },
|
||||
{ ECoreType.v2rayN, "2dust/v2rayN" },
|
||||
{ ECoreType.v2rayN, "FlowerRealm/v2rayN" },
|
||||
};
|
||||
|
||||
public static readonly List<string> OtherGeoUrls =
|
||||
|
|
|
|||
|
|
@ -20,20 +20,23 @@ public static class ConfigHandler
|
|||
public static Config? LoadConfig()
|
||||
{
|
||||
Config? config = null;
|
||||
var result = EmbedUtils.LoadResource(Utils.GetConfigPath(_configRes));
|
||||
var configPath = Utils.GetConfigPath(_configRes);
|
||||
var configFileExists = File.Exists(configPath);
|
||||
var result = EmbedUtils.LoadResource(configPath);
|
||||
if (result.IsNotEmpty())
|
||||
{
|
||||
config = JsonUtils.Deserialize<Config>(result);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (File.Exists(Utils.GetConfigPath(_configRes)))
|
||||
if (configFileExists)
|
||||
{
|
||||
Logging.SaveLog("LoadConfig Exception");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
var isNewConfig = config == null;
|
||||
config ??= new Config();
|
||||
|
||||
config.CoreBasicItem ??= new()
|
||||
|
|
@ -171,6 +174,22 @@ public static class ConfigHandler
|
|||
config.SystemProxyItem.SystemProxyExceptions = Utils.IsWindows() ? Global.SystemProxyExceptionsWindows : Global.SystemProxyExceptionsLinux;
|
||||
}
|
||||
|
||||
if (isNewConfig && !configFileExists)
|
||||
{
|
||||
try
|
||||
{
|
||||
var ret = SaveConfig(config).GetAwaiter().GetResult();
|
||||
if (ret != 0)
|
||||
{
|
||||
Logging.SaveLog($"{_tag}: Failed to create default config file.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logging.SaveLog($"{_tag}: Failed to create default config file", ex);
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
using ServiceLib.Manager;
|
||||
|
||||
namespace ServiceLib.Handler;
|
||||
|
||||
public static class SubscriptionHandler
|
||||
|
|
@ -119,8 +121,9 @@ public static class SubscriptionHandler
|
|||
|
||||
private static async Task<string> DownloadMainSubscription(Config config, SubItem item, bool blProxy, DownloadService downloadHandle)
|
||||
{
|
||||
// Prepare subscription URL and download directly
|
||||
var url = Utils.GetPunycode(item.Url.TrimEx());
|
||||
// Prepare URLs
|
||||
var originalUrl = Utils.GetPunycode(item.Url.TrimEx());
|
||||
var url = originalUrl;
|
||||
|
||||
// If conversion is needed
|
||||
if (item.ConvertTarget.IsNotEmpty())
|
||||
|
|
@ -129,7 +132,7 @@ public static class SubscriptionHandler
|
|||
? Global.SubConvertUrls.FirstOrDefault()
|
||||
: config.ConstItem.SubConvertUrl;
|
||||
|
||||
url = string.Format(subConvertUrl!, Utils.UrlEncode(url));
|
||||
url = string.Format(subConvertUrl!, Utils.UrlEncode(originalUrl));
|
||||
|
||||
if (!url.Contains("target="))
|
||||
{
|
||||
|
|
@ -142,7 +145,36 @@ public static class SubscriptionHandler
|
|||
}
|
||||
}
|
||||
|
||||
// Download and return result directly
|
||||
// 1) Fetch final URL with headers (content + header if any)
|
||||
var (userHeader, content) = await downloadHandle.TryGetWithHeaders(url, blProxy, item.UserAgent);
|
||||
var hadHeader = false;
|
||||
if (userHeader.IsNotEmpty())
|
||||
{
|
||||
SubscriptionInfoManager.Instance.UpdateFromHeader(item.Id, new[] { userHeader });
|
||||
hadHeader = true;
|
||||
}
|
||||
|
||||
// 2) If no header captured and using converter, try original URL only for header
|
||||
if (!hadHeader && item.ConvertTarget.IsNotEmpty())
|
||||
{
|
||||
try
|
||||
{
|
||||
var (userHeader2, _) = await downloadHandle.TryGetWithHeaders(originalUrl, blProxy, item.UserAgent, timeoutSeconds: 10);
|
||||
if (userHeader2.IsNotEmpty())
|
||||
{
|
||||
SubscriptionInfoManager.Instance.UpdateFromHeader(item.Id, new[] { userHeader2 });
|
||||
hadHeader = true;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
if (content.IsNotEmpty())
|
||||
{
|
||||
return content!;
|
||||
}
|
||||
|
||||
// 3) Fallback: plain download string
|
||||
return await DownloadSubscriptionContent(downloadHandle, url, blProxy, item.UserAgent);
|
||||
}
|
||||
|
||||
|
|
|
|||
261
v2rayN/ServiceLib/Manager/SubscriptionInfoManager.cs
Normal file
261
v2rayN/ServiceLib/Manager/SubscriptionInfoManager.cs
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
using System;
|
||||
using System.Text.RegularExpressions;
|
||||
using ServiceLib.Common;
|
||||
using ServiceLib.Models;
|
||||
|
||||
namespace ServiceLib.Manager;
|
||||
|
||||
public sealed class SubscriptionInfoManager
|
||||
{
|
||||
private static readonly Lazy<SubscriptionInfoManager> _instance = new(() => new());
|
||||
public static SubscriptionInfoManager Instance => _instance.Value;
|
||||
|
||||
private readonly Dictionary<string, SubscriptionUsageInfo> _map = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly string _storeFile = Utils.GetConfigPath("SubUsage.json");
|
||||
private readonly object _saveLock = new();
|
||||
private readonly object _mapLock = new();
|
||||
|
||||
private SubscriptionInfoManager()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(_storeFile))
|
||||
{
|
||||
var txt = File.ReadAllText(_storeFile);
|
||||
var data = JsonUtils.Deserialize<Dictionary<string, SubscriptionUsageInfo>>(txt);
|
||||
if (data != null)
|
||||
{
|
||||
foreach (var kv in data)
|
||||
{
|
||||
if (kv.Value != null)
|
||||
{
|
||||
_map[kv.Key] = kv.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
public SubscriptionUsageInfo? Get(string subId)
|
||||
{
|
||||
if (subId.IsNullOrEmpty()) return null;
|
||||
lock (_mapLock)
|
||||
{
|
||||
_map.TryGetValue(subId, out var info);
|
||||
return info;
|
||||
}
|
||||
}
|
||||
|
||||
public void Update(string subId, SubscriptionUsageInfo info)
|
||||
{
|
||||
if (subId.IsNullOrEmpty() || info == null) return;
|
||||
info.SubId = subId;
|
||||
lock (_mapLock)
|
||||
{
|
||||
_map[subId] = info;
|
||||
}
|
||||
AppEvents.SubscriptionInfoUpdated.Publish(info);
|
||||
SaveCopy();
|
||||
}
|
||||
|
||||
public void Clear(string subId)
|
||||
{
|
||||
if (subId.IsNullOrEmpty()) return;
|
||||
lock (_mapLock)
|
||||
{
|
||||
_map.Remove(subId);
|
||||
}
|
||||
SaveCopy();
|
||||
}
|
||||
|
||||
// Common subscription header: "Subscription-Userinfo: upload=123; download=456; total=789; expire=1700000000"
|
||||
public void UpdateFromHeader(string subId, IEnumerable<string>? headerValues)
|
||||
{
|
||||
if (headerValues == null) return;
|
||||
var raw = headerValues.FirstOrDefault();
|
||||
if (raw.IsNullOrEmpty()) return;
|
||||
|
||||
var info = ParseUserinfo(raw);
|
||||
if (info != null)
|
||||
{
|
||||
Update(subId, info);
|
||||
}
|
||||
}
|
||||
|
||||
private static SubscriptionUsageInfo? ParseUserinfo(string raw)
|
||||
{
|
||||
try
|
||||
{
|
||||
var info = new SubscriptionUsageInfo();
|
||||
|
||||
var rx = new Regex(@"(?i)(upload|download|total|expire)\s*=\s*([0-9]+)", RegexOptions.Compiled);
|
||||
foreach (Match m in rx.Matches(raw))
|
||||
{
|
||||
var key = m.Groups[1].Value.ToLowerInvariant();
|
||||
if (!long.TryParse(m.Groups[2].Value, out var val)) continue;
|
||||
switch (key)
|
||||
{
|
||||
case "upload": info.Upload = val; break;
|
||||
case "download": info.Download = val; break;
|
||||
case "total": info.Total = val; break;
|
||||
case "expire": info.ExpireEpoch = val; break;
|
||||
}
|
||||
}
|
||||
|
||||
if (info.Total == 0 && info.Upload == 0 && info.Download == 0 && info.ExpireEpoch == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return info;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void SaveCopy()
|
||||
{
|
||||
try
|
||||
{
|
||||
Dictionary<string, SubscriptionUsageInfo> snapshot;
|
||||
lock (_mapLock)
|
||||
{
|
||||
snapshot = new(_map);
|
||||
}
|
||||
|
||||
var txt = JsonUtils.Serialize(snapshot, true, true);
|
||||
var tmp = _storeFile + ".tmp";
|
||||
|
||||
lock (_saveLock)
|
||||
{
|
||||
File.WriteAllText(tmp, txt);
|
||||
|
||||
try
|
||||
{
|
||||
if (File.Exists(_storeFile))
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Replace(tmp, _storeFile, null);
|
||||
return;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Fallback to cross-platform strategy below.
|
||||
}
|
||||
catch (PlatformNotSupportedException)
|
||||
{
|
||||
// Fallback to cross-platform strategy below.
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
File.Move(tmp, _storeFile, true);
|
||||
return;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
File.Copy(tmp, _storeFile, true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
File.Move(tmp, _storeFile);
|
||||
return;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(tmp))
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(tmp);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
public async Task FetchHeadersForAll(Config config)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (config == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var subs = await AppManager.Instance.SubItems();
|
||||
if (subs is not { Count: > 0 }) return;
|
||||
foreach (var s in subs)
|
||||
{
|
||||
await FetchHeaderForSub(config, s);
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
public async Task FetchHeaderForSub(Config config, SubItem s)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (config == null || s == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var originalUrl = Utils.GetPunycode(s.Url.TrimEx());
|
||||
if (originalUrl.IsNullOrEmpty()) return;
|
||||
|
||||
string url = originalUrl;
|
||||
if (s.ConvertTarget.IsNotEmpty())
|
||||
{
|
||||
var subConvertUrl = config.ConstItem.SubConvertUrl.IsNullOrEmpty()
|
||||
? Global.SubConvertUrls.FirstOrDefault()
|
||||
: config.ConstItem.SubConvertUrl;
|
||||
if (subConvertUrl.IsNotEmpty())
|
||||
{
|
||||
url = string.Format(subConvertUrl!, Utils.UrlEncode(originalUrl));
|
||||
if (!url.Contains("target=")) url += string.Format("&target={0}", s.ConvertTarget);
|
||||
if (!url.Contains("config=")) url += string.Format("&config={0}", Global.SubConvertConfig.FirstOrDefault());
|
||||
}
|
||||
}
|
||||
|
||||
var dl = new DownloadService();
|
||||
// via proxy then direct
|
||||
foreach (var blProxy in new[] { true, false })
|
||||
{
|
||||
var (userHeader, _) = await dl.TryGetWithHeaders(url, blProxy, s.UserAgent, timeoutSeconds: 8);
|
||||
if (userHeader.IsNotEmpty())
|
||||
{
|
||||
UpdateFromHeader(s.Id, new[] { userHeader });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// fallback to original url if converted
|
||||
if (s.ConvertTarget.IsNotEmpty())
|
||||
{
|
||||
foreach (var blProxy in new[] { true, false })
|
||||
{
|
||||
var (userHeader2, _) = await dl.TryGetWithHeaders(originalUrl, blProxy, s.UserAgent, timeoutSeconds: 6);
|
||||
if (userHeader2.IsNotEmpty())
|
||||
{
|
||||
UpdateFromHeader(s.Id, new[] { userHeader2 });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
|
|
@ -79,6 +79,8 @@ public class TaskManager
|
|||
Logging.SaveLog($"Update subscription end. {msg}");
|
||||
}
|
||||
});
|
||||
// 同步刷新该订阅的用量/到期信息
|
||||
try { await SubscriptionInfoManager.Instance.FetchHeaderForSub(_config, item); } catch { }
|
||||
item.UpdateTime = updateTime;
|
||||
await ConfigHandler.AddSubItem(_config, item);
|
||||
await Task.Delay(1000);
|
||||
|
|
|
|||
48
v2rayN/ServiceLib/Models/SubscriptionUsageInfo.cs
Normal file
48
v2rayN/ServiceLib/Models/SubscriptionUsageInfo.cs
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
namespace ServiceLib.Models;
|
||||
|
||||
public class SubscriptionUsageInfo
|
||||
{
|
||||
public string SubId { get; set; } = string.Empty;
|
||||
|
||||
// Bytes
|
||||
public long Upload { get; set; }
|
||||
public long Download { get; set; }
|
||||
public long Total { get; set; }
|
||||
|
||||
// Unix epoch seconds; 0 if unknown
|
||||
public long ExpireEpoch { get; set; }
|
||||
|
||||
public long UsedBytes => Math.Max(0, Upload + Download);
|
||||
|
||||
public int UsagePercent
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Total <= 0) return -1;
|
||||
var p = (int)Math.Round(UsedBytes * 100.0 / Total);
|
||||
return Math.Clamp(p, 0, 100);
|
||||
}
|
||||
}
|
||||
|
||||
public DateTimeOffset? ExpireAt
|
||||
{
|
||||
get
|
||||
{
|
||||
if (ExpireEpoch <= 0) return null;
|
||||
try { return DateTimeOffset.FromUnixTimeSeconds(ExpireEpoch).ToLocalTime(); }
|
||||
catch { return null; }
|
||||
}
|
||||
}
|
||||
|
||||
public int DaysLeft
|
||||
{
|
||||
get
|
||||
{
|
||||
var exp = ExpireAt;
|
||||
if (exp == null) return -1;
|
||||
var days = (int)Math.Ceiling((exp.Value - DateTimeOffset.Now).TotalDays);
|
||||
return Math.Max(days, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3,7 +3,7 @@ using System.Text.Json.Serialization;
|
|||
namespace ServiceLib.Models;
|
||||
|
||||
/// <summary>
|
||||
/// https://github.com/2dust/v2rayN/wiki/
|
||||
/// https://github.com/FlowerRealm/v2rayN/wiki/
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class VmessQRCode
|
||||
|
|
|
|||
|
|
@ -133,6 +133,68 @@ public class DownloadService
|
|||
return null;
|
||||
}
|
||||
|
||||
// Best-effort: get specific header (Subscription-Userinfo) and content in one request.
|
||||
// Returns: (userinfoHeaderValue, content)
|
||||
public async Task<(string? userInfoHeader, string? content)> TryGetWithHeaders(string url, bool blProxy, string userAgent, int timeoutSeconds = 15)
|
||||
{
|
||||
try
|
||||
{
|
||||
SetSecurityProtocol(AppManager.Instance.Config.GuiItem.EnableSecurityProtocolTls13);
|
||||
var webProxy = await GetWebProxy(blProxy);
|
||||
var handler = new SocketsHttpHandler()
|
||||
{
|
||||
Proxy = webProxy,
|
||||
UseProxy = webProxy != null,
|
||||
AllowAutoRedirect = true
|
||||
};
|
||||
using var client = new HttpClient(handler);
|
||||
|
||||
if (userAgent.IsNullOrEmpty())
|
||||
{
|
||||
userAgent = Utils.GetVersion(false);
|
||||
}
|
||||
client.DefaultRequestHeaders.UserAgent.TryParseAdd(userAgent);
|
||||
|
||||
Uri uri = new(url);
|
||||
if (uri.UserInfo.IsNotEmpty())
|
||||
{
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", Utils.Base64Encode(uri.UserInfo));
|
||||
}
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds));
|
||||
using var resp = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cts.Token);
|
||||
|
||||
// Try read target header regardless of status code
|
||||
string? userHeader = null;
|
||||
if (resp.Headers != null)
|
||||
{
|
||||
if (resp.Headers.TryGetValues("Subscription-Userinfo", out var vals) ||
|
||||
resp.Headers.TryGetValues("subscription-userinfo", out vals))
|
||||
{
|
||||
userHeader = vals?.FirstOrDefault();
|
||||
}
|
||||
}
|
||||
|
||||
// Read content only on success; otherwise empty to trigger fallback
|
||||
string? content = null;
|
||||
if (resp.IsSuccessStatusCode)
|
||||
{
|
||||
content = await resp.Content.ReadAsStringAsync(cts.Token);
|
||||
}
|
||||
return (userHeader, content);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logging.SaveLog(_tag, ex);
|
||||
Error?.Invoke(this, new ErrorEventArgs(ex));
|
||||
if (ex.InnerException != null)
|
||||
{
|
||||
Error?.Invoke(this, new ErrorEventArgs(ex.InnerException));
|
||||
}
|
||||
}
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DownloadString
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ using System.Reactive.Concurrency;
|
|||
using System.Reactive.Linq;
|
||||
using ReactiveUI;
|
||||
using ReactiveUI.Fody.Helpers;
|
||||
using ServiceLib.Manager;
|
||||
|
||||
namespace ServiceLib.ViewModels;
|
||||
|
||||
|
|
@ -274,8 +275,16 @@ public class MainWindowViewModel : MyReactiveObject
|
|||
|
||||
BlReloadEnabled = true;
|
||||
await Reload();
|
||||
|
||||
// 开机自动爬取所有订阅的用量/到期头信息(后台执行)
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try { await SubscriptionInfoManager.Instance.FetchHeadersForAll(_config); } catch { }
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
#endregion Init
|
||||
|
||||
#region Actions
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@ using DynamicData;
|
|||
using DynamicData.Binding;
|
||||
using ReactiveUI;
|
||||
using ReactiveUI.Fody.Helpers;
|
||||
using ServiceLib.Manager;
|
||||
using ServiceLib.Models;
|
||||
// using ServiceLib.Services; // covered by GlobalUsings
|
||||
|
||||
namespace ServiceLib.ViewModels;
|
||||
|
||||
|
|
@ -34,6 +37,36 @@ public class ProfilesViewModel : MyReactiveObject
|
|||
[Reactive]
|
||||
public SubItem SelectedSub { get; set; }
|
||||
|
||||
// Subscription usage/expiry display
|
||||
[Reactive]
|
||||
public bool BlSubInfoVisible { get; set; }
|
||||
|
||||
[Reactive]
|
||||
public int SubUsagePercent { get; set; }
|
||||
|
||||
[Reactive]
|
||||
public string SubUsageText { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 当订阅用量仍在请求、或远端未提供总流量时保持进度条在“未知/占位”马灯状态,避免显示 0% 等误导性的数值。
|
||||
/// 该值直接绑定到视图层进度条的 <c>IsIndeterminate</c> 属性。
|
||||
/// </summary>
|
||||
[Reactive]
|
||||
public bool SubUsageIndeterminate { get; set; }
|
||||
|
||||
[Reactive]
|
||||
public int SubExpirePercent { get; set; }
|
||||
|
||||
[Reactive]
|
||||
public string SubExpireText { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 在未能计算到期日或仍在加载时,让到期进度条保持“不确定”马灯动画,提示用户等待最新数据。
|
||||
/// 该值直接绑定到视图层进度条的 <c>IsIndeterminate</c> 属性。
|
||||
/// </summary>
|
||||
[Reactive]
|
||||
public bool SubExpireIndeterminate { get; set; }
|
||||
|
||||
[Reactive]
|
||||
public SubItem SelectedMoveToGroup { get; set; }
|
||||
|
||||
|
|
@ -254,6 +287,17 @@ public class ProfilesViewModel : MyReactiveObject
|
|||
.ObserveOn(RxApp.MainThreadScheduler)
|
||||
.Subscribe(async _ => await RefreshSubscriptions());
|
||||
|
||||
AppEvents.SubscriptionInfoUpdated
|
||||
.AsObservable()
|
||||
.ObserveOn(RxApp.MainThreadScheduler)
|
||||
.Subscribe(async info => await UpdateSubInfoDisplay(info));
|
||||
|
||||
// 核心Reload后,再尝试抓一次头,避免启动时代理未就绪导致不显示
|
||||
AppEvents.ReloadRequested
|
||||
.AsObservable()
|
||||
.ObserveOn(RxApp.MainThreadScheduler)
|
||||
.Subscribe(async _ => await TryFetchSubInfoHeaderForSelected());
|
||||
|
||||
AppEvents.DispatcherStatisticsRequested
|
||||
.AsObservable()
|
||||
.ObserveOn(RxApp.MainThreadScheduler)
|
||||
|
|
@ -275,6 +319,14 @@ public class ProfilesViewModel : MyReactiveObject
|
|||
SelectedSub = new();
|
||||
SelectedMoveToGroup = new();
|
||||
|
||||
BlSubInfoVisible = true;
|
||||
SubUsagePercent = 0;
|
||||
SubExpirePercent = 0;
|
||||
SubUsageText = string.Empty;
|
||||
SubExpireText = string.Empty;
|
||||
SubUsageIndeterminate = true;
|
||||
SubExpireIndeterminate = true;
|
||||
|
||||
await RefreshSubscriptions();
|
||||
//await RefreshServers();
|
||||
}
|
||||
|
|
@ -347,6 +399,13 @@ public class ProfilesViewModel : MyReactiveObject
|
|||
{
|
||||
if (!c)
|
||||
{
|
||||
SubUsageIndeterminate = true;
|
||||
SubExpireIndeterminate = true;
|
||||
SubUsagePercent = 0;
|
||||
SubExpirePercent = 0;
|
||||
SubUsageText = "—";
|
||||
SubExpireText = "—";
|
||||
BlSubInfoVisible = true;
|
||||
return;
|
||||
}
|
||||
_config.SubIndexId = SelectedSub?.Id;
|
||||
|
|
@ -354,6 +413,112 @@ public class ProfilesViewModel : MyReactiveObject
|
|||
await RefreshServers();
|
||||
|
||||
await _updateView?.Invoke(EViewAction.ProfilesFocus, null);
|
||||
|
||||
// Update subscription info area for selected sub
|
||||
await UpdateSubInfoDisplay(null);
|
||||
}
|
||||
|
||||
private async Task UpdateSubInfoDisplay(SubscriptionUsageInfo? pushed)
|
||||
{
|
||||
try
|
||||
{
|
||||
var subId = SelectedSub?.Id;
|
||||
if (subId.IsNullOrEmpty())
|
||||
{
|
||||
// 保持显示,但用占位
|
||||
SubUsagePercent = 0;
|
||||
SubExpirePercent = 0;
|
||||
SubUsageText = "—";
|
||||
SubExpireText = "—";
|
||||
SubUsageIndeterminate = true;
|
||||
SubExpireIndeterminate = true;
|
||||
BlSubInfoVisible = true;
|
||||
return;
|
||||
}
|
||||
|
||||
var info = pushed != null && pushed.SubId == subId
|
||||
? pushed
|
||||
: SubscriptionInfoManager.Instance.Get(subId);
|
||||
|
||||
if (info == null)
|
||||
{
|
||||
// 先用占位显示
|
||||
SubUsagePercent = 0;
|
||||
SubExpirePercent = 0;
|
||||
SubUsageText = "—";
|
||||
SubExpireText = "—";
|
||||
SubUsageIndeterminate = true;
|
||||
SubExpireIndeterminate = true;
|
||||
// 尝试即时抓取一次响应头,避免必须“更新订阅”才显示
|
||||
try { await SubscriptionInfoManager.Instance.FetchHeaderForSub(_config, SelectedSub); } catch { }
|
||||
info = SubscriptionInfoManager.Instance.Get(subId);
|
||||
if (info == null)
|
||||
{
|
||||
BlSubInfoVisible = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
if (info.Total > 0)
|
||||
{
|
||||
SubUsagePercent = info.UsagePercent;
|
||||
SubUsageText = string.Format("{0} / {1} ({2}%)", Utils.HumanFy(info.UsedBytes), Utils.HumanFy(info.Total), SubUsagePercent);
|
||||
SubUsageIndeterminate = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
SubUsagePercent = 0;
|
||||
SubUsageText = string.Format("{0}", Utils.HumanFy(info.UsedBytes));
|
||||
SubUsageIndeterminate = true;
|
||||
}
|
||||
|
||||
// Expire
|
||||
if (info.ExpireAt != null)
|
||||
{
|
||||
var daysLeft = info.DaysLeft;
|
||||
SubExpireText = daysLeft >= 0
|
||||
? $"{daysLeft}d — {info.ExpireAt:yyyy-MM-dd}"
|
||||
: $"{info.ExpireAt:yyyy-MM-dd}";
|
||||
|
||||
// 可视化“剩余时间百分比”,基准按天自适应:<=31天用月基准、<=92天用季度、否则按365天
|
||||
if (daysLeft >= 0)
|
||||
{
|
||||
int baseDays = daysLeft <= 31 ? 31 : (daysLeft <= 92 ? 92 : 365);
|
||||
var percentRemain = (int)Math.Round(Math.Min(daysLeft, baseDays) * 100.0 / baseDays);
|
||||
SubExpirePercent = Math.Clamp(percentRemain, 0, 100);
|
||||
SubExpireIndeterminate = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
SubExpirePercent = 0;
|
||||
SubExpireIndeterminate = true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
SubExpireText = string.Empty;
|
||||
SubExpirePercent = 0;
|
||||
SubExpireIndeterminate = true;
|
||||
}
|
||||
|
||||
BlSubInfoVisible = true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 出错也别隐藏
|
||||
BlSubInfoVisible = true;
|
||||
SubUsageIndeterminate = true;
|
||||
SubExpireIndeterminate = true;
|
||||
SubUsagePercent = 0;
|
||||
SubExpirePercent = 0;
|
||||
}
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task TryFetchSubInfoHeaderForSelected()
|
||||
{
|
||||
try { await SubscriptionInfoManager.Instance.FetchHeaderForSub(_config, SelectedSub); } catch { }
|
||||
}
|
||||
|
||||
private async Task ServerFilterChanged(bool c)
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ public partial class FullConfigTemplateWindow : WindowBase<FullConfigTemplateVie
|
|||
|
||||
private void linkFullConfigTemplateDoc_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
ProcUtils.ProcessStart("https://github.com/2dust/v2rayN/wiki/Description-of-some-ui#%E5%AE%8C%E6%95%B4%E9%85%8D%E7%BD%AE%E6%A8%A1%E6%9D%BF%E8%AE%BE%E7%BD%AE");
|
||||
ProcUtils.ProcessStart("https://github.com/FlowerRealm/v2rayN/wiki/Description-of-some-ui#%E5%AE%8C%E6%95%B4%E9%85%8D%E7%BD%AE%E6%A8%A1%E6%9D%BF%E8%AE%BE%E7%BD%AE");
|
||||
}
|
||||
|
||||
private void Window_Loaded(object? sender, RoutedEventArgs e)
|
||||
|
|
|
|||
|
|
@ -74,6 +74,20 @@
|
|||
Margin="{StaticResource MarginLr4}"
|
||||
VerticalContentAlignment="Center"
|
||||
Watermark="{x:Static resx:ResUI.MsgServerTitle}" />
|
||||
|
||||
<!-- Subscription usage and expiry -->
|
||||
<StackPanel Margin="{StaticResource MarginLr8}" IsVisible="{Binding BlSubInfoVisible}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6" HorizontalAlignment="Left" Margin="0,0,0,2">
|
||||
<TextBlock Width="56" VerticalAlignment="Center" Text="流量" />
|
||||
<ProgressBar Height="16" Minimum="0" Maximum="100" Value="{Binding SubUsagePercent}" Width="240" IsIndeterminate="{Binding SubUsageIndeterminate}" />
|
||||
<TextBlock VerticalAlignment="Center" Text="{Binding SubUsageText}" />
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Spacing="6" HorizontalAlignment="Left">
|
||||
<TextBlock Width="56" VerticalAlignment="Center" Text="到期" />
|
||||
<ProgressBar Height="16" Minimum="0" Maximum="100" Value="{Binding SubExpirePercent}" Width="240" IsIndeterminate="{Binding SubExpireIndeterminate}" />
|
||||
<TextBlock VerticalAlignment="Center" Text="{Binding SubExpireText}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</WrapPanel>
|
||||
<DataGrid
|
||||
x:Name="lstProfiles"
|
||||
|
|
|
|||
|
|
@ -208,6 +208,6 @@ public partial class RoutingRuleSettingWindow : WindowBase<RoutingRuleSettingVie
|
|||
|
||||
private void linkCustomRulesetPath4Singbox(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
ProcUtils.ProcessStart("https://github.com/2dust/v2rayCustomRoutingList/blob/master/singbox_custom_ruleset_example.json");
|
||||
ProcUtils.ProcessStart("https://github.com/FlowerRealm/v2rayCustomRoutingList/blob/master/singbox_custom_ruleset_example.json");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,6 @@ public partial class FullConfigTemplateWindow
|
|||
|
||||
private void linkFullConfigTemplateDoc_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
ProcUtils.ProcessStart("https://github.com/2dust/v2rayN/wiki/Description-of-some-ui#%E5%AE%8C%E6%95%B4%E9%85%8D%E7%BD%AE%E6%A8%A1%E6%9D%BF%E8%AE%BE%E7%BD%AE");
|
||||
ProcUtils.ProcessStart("https://github.com/FlowerRealm/v2rayN/wiki/Description-of-some-ui#%E5%AE%8C%E6%95%B4%E9%85%8D%E7%BD%AE%E6%A8%A1%E6%9D%BF%E8%AE%BE%E7%BD%AE");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,6 +76,52 @@
|
|||
materialDesign:TextFieldAssist.HasClearButton="True"
|
||||
AutomationProperties.Name="{x:Static resx:ResUI.MsgServerTitle}"
|
||||
Style="{StaticResource DefTextBox}" />
|
||||
|
||||
<!-- Subscription usage and expiry -->
|
||||
<StackPanel
|
||||
Margin="{StaticResource MarginLeftRight8}"
|
||||
VerticalAlignment="Center"
|
||||
Orientation="Vertical"
|
||||
Visibility="{Binding BlSubInfoVisible, Converter={StaticResource BoolToVisConverter}}">
|
||||
<Grid Margin="0,0,0,2">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Grid.Column="0" Width="120" VerticalAlignment="Center" Text="流量" />
|
||||
<ProgressBar
|
||||
Grid.Column="1"
|
||||
Height="16"
|
||||
Minimum="0"
|
||||
Maximum="100"
|
||||
Value="{Binding SubUsagePercent}"
|
||||
IsIndeterminate="{Binding SubUsageIndeterminate}"
|
||||
Style="{StaticResource MaterialDesignProgressBar}"
|
||||
Margin="4,0,4,0"
|
||||
Width="240" />
|
||||
<TextBlock Grid.Column="2" VerticalAlignment="Center" Text="{Binding SubUsageText}" Margin="0,0,0,0" />
|
||||
</Grid>
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Grid.Column="0" Width="120" VerticalAlignment="Center" Text="到期" />
|
||||
<ProgressBar
|
||||
Grid.Column="1"
|
||||
Height="16"
|
||||
Minimum="0"
|
||||
Maximum="100"
|
||||
Value="{Binding SubExpirePercent}"
|
||||
IsIndeterminate="{Binding SubExpireIndeterminate}"
|
||||
Style="{StaticResource MaterialDesignProgressBar}"
|
||||
Margin="4,0,4,0"
|
||||
Width="240" />
|
||||
<TextBlock Grid.Column="2" VerticalAlignment="Center" Text="{Binding SubExpireText}" Margin="0,0,0,0" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</WrapPanel>
|
||||
<DataGrid
|
||||
x:Name="lstProfiles"
|
||||
|
|
|
|||
|
|
@ -201,6 +201,6 @@ public partial class RoutingRuleSettingWindow
|
|||
|
||||
private void linkCustomRulesetPath4Singbox(object sender, RoutedEventArgs e)
|
||||
{
|
||||
ProcUtils.ProcessStart("https://github.com/2dust/v2rayCustomRoutingList/blob/master/singbox_custom_ruleset_example.json");
|
||||
ProcUtils.ProcessStart("https://github.com/FlowerRealm/v2rayCustomRoutingList/blob/master/singbox_custom_ruleset_example.json");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue