mirror of
https://github.com/2dust/v2rayN.git
synced 2026-01-18 13:17:05 +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
|
- name: Submit v2ray package to Windows Package Manager Community Repository
|
||||||
run: |
|
run: |
|
||||||
|
|
||||||
$wingetPackage = "2dust.v2rayN"
|
$wingetPackage = "FlowerRealm.v2rayN"
|
||||||
$gitToken = "${{ secrets.PT_WINGET }}"
|
$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
|
$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
|
$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
|
$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
|
$ver = $targetRelease.tag_name
|
||||||
|
|
||||||
# getting latest wingetcreate file
|
# getting latest wingetcreate file
|
||||||
iwr https://aka.ms/wingetcreate/latest -OutFile wingetcreate.exe
|
iwr https://aka.ms/wingetcreate/latest -OutFile wingetcreate.exe
|
||||||
|
|
||||||
Write-Host "Updating with both x64 and arm64 installers"
|
Write-Host "Updating with both x64 and arm64 installers"
|
||||||
Write-Host "Version: $ver"
|
Write-Host "Version: $ver"
|
||||||
Write-Host "x64 URL: $x64InstallerUrl"
|
Write-Host "x64 URL: $x64InstallerUrl"
|
||||||
Write-Host "arm64 URL: $arm64InstallerUrl"
|
Write-Host "arm64 URL: $arm64InstallerUrl"
|
||||||
|
|
||||||
.\wingetcreate.exe update $wingetPackage -s -v $ver -u "$x64InstallerUrl|x64" "$arm64InstallerUrl|arm64" -t $gitToken
|
.\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 is a Code Coverage Tool
|
||||||
*.dotCover
|
*.dotCover
|
||||||
|
修改 README 移除那段安装脚本说明
|
||||||
# AxoCover is a Code Coverage Tool
|
# AxoCover is a Code Coverage Tool
|
||||||
.axoCover/*
|
.axoCover/*
|
||||||
!.axoCover/settings.json
|
!.axoCover/settings.json
|
||||||
|
|
@ -399,3 +399,6 @@ FodyWeavers.xsd
|
||||||
# JetBrains Rider
|
# JetBrains Rider
|
||||||
.idea/
|
.idea/
|
||||||
*.sln.iml
|
*.sln.iml
|
||||||
|
|
||||||
|
# Debian packaging build outputs
|
||||||
|
debian/files
|
||||||
|
|
|
||||||
2
.gitmodules
vendored
2
.gitmodules
vendored
|
|
@ -1,3 +1,3 @@
|
||||||
[submodule "v2rayN/GlobalHotKeys"]
|
[submodule "v2rayN/GlobalHotKeys"]
|
||||||
path = 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.
|
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.>
|
<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
|
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
|
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
|
If the program does terminal interaction, make it output a short
|
||||||
notice like this when it starts in an interactive mode:
|
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 program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
This is free software, and you are welcome to redistribute it
|
This is free software, and you are welcome to redistribute it
|
||||||
under certain conditions; type `show c' for details.
|
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)
|
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 [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://github.com/FlowerRealm/v2rayN/commits/master)
|
||||||
[](https://www.codefactor.io/repository/github/2dust/v2rayn)
|
[](https://github.com/FlowerRealm/v2rayN/releases)
|
||||||
[](https://github.com/2dust/v2rayN/releases)
|
|
||||||
[](https://t.me/v2rayn)
|
|
||||||
|
|
||||||
## How to use
|
## How to use
|
||||||
|
|
||||||
Read the [Wiki](https://github.com/2dust/v2rayN/wiki) for details.
|
Read the [Wiki](https://github.com/FlowerRealm/v2rayN/wiki) for details.
|
||||||
|
|
||||||
## Telegram Channel
|
|
||||||
|
|
||||||
[github_2dust](https://t.me/github_2dust)
|
|
||||||
|
|
|
||||||
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"
|
Version="$3"
|
||||||
|
|
||||||
FileName="v2rayN-${Arch}.zip"
|
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
|
7z x $FileName
|
||||||
cp -rf v2rayN-${Arch}/* $OutputPath
|
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"
|
echo "When this file exists, app will not store configs under this folder" > "${PackagePath}/opt/v2rayN/NotStoreConfigHere.txt"
|
||||||
|
|
||||||
if [ $Arch = "linux-64" ]; then
|
if [ $Arch = "linux-64" ]; then
|
||||||
Arch2="amd64"
|
Arch2="amd64"
|
||||||
else
|
else
|
||||||
Arch2="arm64"
|
Arch2="arm64"
|
||||||
fi
|
fi
|
||||||
|
|
@ -27,7 +27,7 @@ cat >"${PackagePath}/DEBIAN/control" <<-EOF
|
||||||
Package: v2rayN
|
Package: v2rayN
|
||||||
Version: $Version
|
Version: $Version
|
||||||
Architecture: $Arch2
|
Architecture: $Arch2
|
||||||
Maintainer: https://github.com/2dust/v2rayN
|
Maintainer: https://github.com/FlowerRealm/v2rayN
|
||||||
Depends: desktop-file-utils, xdg-utils
|
Depends: desktop-file-utils, xdg-utils
|
||||||
Description: A GUI client for Windows and Linux, support Xray core and sing-box-core and others
|
Description: A GUI client for Windows and Linux, support Xray core and sing-box-core and others
|
||||||
EOF
|
EOF
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ OutputPath="$2"
|
||||||
Version="$3"
|
Version="$3"
|
||||||
|
|
||||||
FileName="v2rayN-${Arch}.zip"
|
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
|
7z x $FileName
|
||||||
cp -rf v2rayN-${Arch}/* $OutputPath
|
cp -rf v2rayN-${Arch}/* $OutputPath
|
||||||
|
|
||||||
|
|
@ -32,7 +32,7 @@ cat >"$PackagePath/v2rayN.app/Contents/Info.plist" <<-EOF
|
||||||
<key>CFBundleIconName</key>
|
<key>CFBundleIconName</key>
|
||||||
<string>AppIcon</string>
|
<string>AppIcon</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
<string>2dust.v2rayN</string>
|
<string>FlowerRealm.v2rayN</string>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
<string>v2rayN</string>
|
<string>v2rayN</string>
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ OutputPath="$2"
|
||||||
OutputArch="v2rayN-${Arch}"
|
OutputArch="v2rayN-${Arch}"
|
||||||
FileName="v2rayN-${Arch}.zip"
|
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"
|
ZipPath64="./$OutputArch"
|
||||||
mkdir $ZipPath64
|
mkdir $ZipPath64
|
||||||
|
|
|
||||||
|
|
@ -193,7 +193,7 @@ choose_channel() {
|
||||||
|
|
||||||
get_latest_tag_latest() {
|
get_latest_tag_latest() {
|
||||||
# Resolve /releases/latest → tag_name
|
# 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?[^"]+"' \
|
| grep -Eo '"tag_name":\s*"v?[^"]+"' \
|
||||||
| head -n1 \
|
| head -n1 \
|
||||||
| sed -E 's/.*"tag_name":\s*"v?([^"]+)".*/\1/'
|
| sed -E 's/.*"tag_name":\s*"v?([^"]+)".*/\1/'
|
||||||
|
|
@ -202,7 +202,7 @@ get_latest_tag_latest() {
|
||||||
get_latest_tag_prerelease() {
|
get_latest_tag_prerelease() {
|
||||||
# Resolve newest prerelease=true tag; prefer jq, fallback to sed/grep (no awk)
|
# Resolve newest prerelease=true tag; prefer jq, fallback to sed/grep (no awk)
|
||||||
local json tag
|
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
|
# 1) Use jq if present
|
||||||
if command -v jq >/dev/null 2>&1; then
|
if command -v jq >/dev/null 2>&1; then
|
||||||
|
|
@ -380,9 +380,9 @@ download_mihomo() {
|
||||||
local outroot="$1"
|
local outroot="$1"
|
||||||
local url=""
|
local url=""
|
||||||
if [[ "$RID_DIR" == "linux-arm64" ]]; then
|
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
|
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
|
fi
|
||||||
echo "[+] Download mihomo: $url"
|
echo "[+] Download mihomo: $url"
|
||||||
mkdir -p "$outroot/bin/mihomo"
|
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-private.srs geoip-cn.srs geoip-facebook.srs geoip-fastly.srs \
|
||||||
geoip-google.srs geoip-netflix.srs geoip-telegram.srs geoip-twitter.srs; do
|
geoip-google.srs geoip-netflix.srs geoip-telegram.srs geoip-twitter.srs; do
|
||||||
curl -fsSL -o "$srss_dir/$f" \
|
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
|
done
|
||||||
for f in \
|
for f in \
|
||||||
geosite-cn.srs geosite-gfw.srs geosite-greatfire.srs \
|
geosite-cn.srs geosite-gfw.srs geosite-greatfire.srs \
|
||||||
geosite-geolocation-cn.srs geosite-category-ads-all.srs geosite-private.srs; do
|
geosite-geolocation-cn.srs geosite-category-ads-all.srs geosite-private.srs; do
|
||||||
curl -fsSL -o "$srss_dir/$f" \
|
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
|
done
|
||||||
|
|
||||||
# Unify to bin/
|
# Unify to bin/
|
||||||
|
|
@ -456,9 +456,9 @@ download_v2rayn_bundle() {
|
||||||
local outroot="$1"
|
local outroot="$1"
|
||||||
local url=""
|
local url=""
|
||||||
if [[ "$RID_DIR" == "linux-arm64" ]]; then
|
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
|
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
|
fi
|
||||||
echo "[+] Try v2rayN bundle archive: $url"
|
echo "[+] Try v2rayN bundle archive: $url"
|
||||||
local tmp zipname
|
local tmp zipname
|
||||||
|
|
@ -608,8 +608,8 @@ Version: __VERSION__
|
||||||
Release: 1%{?dist}
|
Release: 1%{?dist}
|
||||||
Summary: v2rayN (Avalonia) GUI client for Linux (x86_64/aarch64)
|
Summary: v2rayN (Avalonia) GUI client for Linux (x86_64/aarch64)
|
||||||
License: GPL-3.0-only
|
License: GPL-3.0-only
|
||||||
URL: https://github.com/2dust/v2rayN
|
URL: https://github.com/FlowerRealm/v2rayN
|
||||||
BugURL: https://github.com/2dust/v2rayN/issues
|
BugURL: https://github.com/FlowerRealm/v2rayN/issues
|
||||||
ExclusiveArch: aarch64 x86_64
|
ExclusiveArch: aarch64 x86_64
|
||||||
Source0: __PKGROOT__.tar.gz
|
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 vless / vmess / Trojan / http / socks / Anytls / Hysteria2 / Shadowsocks / tuic / WireGuard
|
||||||
Support Red Hat Enterprise Linux / Fedora Linux / Rocky Linux / AlmaLinux / CentOS
|
Support Red Hat Enterprise Linux / Fedora Linux / Rocky Linux / AlmaLinux / CentOS
|
||||||
For more information, Please visit our website
|
For more information, Please visit our website
|
||||||
https://github.com/2dust/v2rayN
|
https://github.com/FlowerRealm/v2rayN
|
||||||
|
|
||||||
%prep
|
%prep
|
||||||
%setup -q -n __PKGROOT__
|
%setup -q -n __PKGROOT__
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
<NoWarn>CA1031;CS1591;NU1507;CA1416;IDE0058</NoWarn>
|
<NoWarn>CA1031;CS1591;NU1507;CA1416;IDE0058</NoWarn>
|
||||||
<Nullable>annotations</Nullable>
|
<Nullable>annotations</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Authors>2dust</Authors>
|
<Authors>FlowerRealm</Authors>
|
||||||
<PackageLicenseExpression>GPL-3.0</PackageLicenseExpression>
|
<PackageLicenseExpression>GPL-3.0</PackageLicenseExpression>
|
||||||
<Copyright>Copyright © 2017-$([System.DateTime]::UtcNow.Year) $(Authors)</Copyright>
|
<Copyright>Copyright © 2017-$([System.DateTime]::UtcNow.Year) $(Authors)</Copyright>
|
||||||
<InvariantGlobalization>false</InvariantGlobalization>
|
<InvariantGlobalization>false</InvariantGlobalization>
|
||||||
|
|
|
||||||
|
|
@ -171,22 +171,22 @@ public class Utils
|
||||||
|
|
||||||
public static string HumanFy(long amount)
|
public static string HumanFy(long amount)
|
||||||
{
|
{
|
||||||
if (amount <= 0)
|
// Bytes → KB → MB → GB → TB → PB
|
||||||
{
|
string[] units = ["B", "KB", "MB", "GB", "TB", "PB"];
|
||||||
return $"{amount:f1} B";
|
|
||||||
}
|
|
||||||
|
|
||||||
string[] units = ["KB", "MB", "GB", "TB", "PB"];
|
|
||||||
var unitIndex = 0;
|
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)
|
while (size >= 1024 && unitIndex < units.Length - 1)
|
||||||
{
|
{
|
||||||
size /= 1024;
|
size /= 1024;
|
||||||
unitIndex++;
|
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]}";
|
return $"{size:f1} {units[unitIndex]}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using System.Reactive;
|
using System.Reactive;
|
||||||
|
using ServiceLib.Models;
|
||||||
|
|
||||||
namespace ServiceLib.Events;
|
namespace ServiceLib.Events;
|
||||||
|
|
||||||
|
|
@ -29,4 +30,7 @@ public static class AppEvents
|
||||||
public static readonly EventChannel<Unit> TestServerRequested = new();
|
public static readonly EventChannel<Unit> TestServerRequested = new();
|
||||||
public static readonly EventChannel<Unit> InboundDisplayRequested = new();
|
public static readonly EventChannel<Unit> InboundDisplayRequested = new();
|
||||||
public static readonly EventChannel<ESysProxyType> SysProxyChangeRequested = 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 GithubUrl = "https://github.com";
|
||||||
public const string GithubApiUrl = "https://api.github.com/repos";
|
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 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 PromotionUrl = @"aHR0cHM6Ly85LjIzNDQ1Ni54eXovYWJjLmh0bWw=";
|
||||||
public const string ConfigFileName = "guiNConfig.json";
|
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 HashSet<EConfigType> SingboxOnlyConfigType = SingboxSupportConfigType.Except(XraySupportConfigType).ToHashSet();
|
||||||
|
|
||||||
public static readonly List<string> DomainStrategies =
|
public static readonly List<string> DomainStrategies =
|
||||||
[
|
[
|
||||||
AsIs,
|
AsIs,
|
||||||
|
|
@ -572,7 +572,7 @@ public class Global
|
||||||
{ ECoreType.overtls, "ShadowsocksR-Live/overtls" },
|
{ ECoreType.overtls, "ShadowsocksR-Live/overtls" },
|
||||||
{ ECoreType.shadowquic, "spongebob888/shadowquic" },
|
{ ECoreType.shadowquic, "spongebob888/shadowquic" },
|
||||||
{ ECoreType.mieru, "enfein/mieru" },
|
{ ECoreType.mieru, "enfein/mieru" },
|
||||||
{ ECoreType.v2rayN, "2dust/v2rayN" },
|
{ ECoreType.v2rayN, "FlowerRealm/v2rayN" },
|
||||||
};
|
};
|
||||||
|
|
||||||
public static readonly List<string> OtherGeoUrls =
|
public static readonly List<string> OtherGeoUrls =
|
||||||
|
|
|
||||||
|
|
@ -20,20 +20,23 @@ public static class ConfigHandler
|
||||||
public static Config? LoadConfig()
|
public static Config? LoadConfig()
|
||||||
{
|
{
|
||||||
Config? config = null;
|
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())
|
if (result.IsNotEmpty())
|
||||||
{
|
{
|
||||||
config = JsonUtils.Deserialize<Config>(result);
|
config = JsonUtils.Deserialize<Config>(result);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
if (File.Exists(Utils.GetConfigPath(_configRes)))
|
if (configFileExists)
|
||||||
{
|
{
|
||||||
Logging.SaveLog("LoadConfig Exception");
|
Logging.SaveLog("LoadConfig Exception");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var isNewConfig = config == null;
|
||||||
config ??= new Config();
|
config ??= new Config();
|
||||||
|
|
||||||
config.CoreBasicItem ??= new()
|
config.CoreBasicItem ??= new()
|
||||||
|
|
@ -171,6 +174,22 @@ public static class ConfigHandler
|
||||||
config.SystemProxyItem.SystemProxyExceptions = Utils.IsWindows() ? Global.SystemProxyExceptionsWindows : Global.SystemProxyExceptionsLinux;
|
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;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
using ServiceLib.Manager;
|
||||||
|
|
||||||
namespace ServiceLib.Handler;
|
namespace ServiceLib.Handler;
|
||||||
|
|
||||||
public static class SubscriptionHandler
|
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)
|
private static async Task<string> DownloadMainSubscription(Config config, SubItem item, bool blProxy, DownloadService downloadHandle)
|
||||||
{
|
{
|
||||||
// Prepare subscription URL and download directly
|
// Prepare URLs
|
||||||
var url = Utils.GetPunycode(item.Url.TrimEx());
|
var originalUrl = Utils.GetPunycode(item.Url.TrimEx());
|
||||||
|
var url = originalUrl;
|
||||||
|
|
||||||
// If conversion is needed
|
// If conversion is needed
|
||||||
if (item.ConvertTarget.IsNotEmpty())
|
if (item.ConvertTarget.IsNotEmpty())
|
||||||
|
|
@ -129,7 +132,7 @@ public static class SubscriptionHandler
|
||||||
? Global.SubConvertUrls.FirstOrDefault()
|
? Global.SubConvertUrls.FirstOrDefault()
|
||||||
: config.ConstItem.SubConvertUrl;
|
: config.ConstItem.SubConvertUrl;
|
||||||
|
|
||||||
url = string.Format(subConvertUrl!, Utils.UrlEncode(url));
|
url = string.Format(subConvertUrl!, Utils.UrlEncode(originalUrl));
|
||||||
|
|
||||||
if (!url.Contains("target="))
|
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);
|
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}");
|
Logging.SaveLog($"Update subscription end. {msg}");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// 同步刷新该订阅的用量/到期信息
|
||||||
|
try { await SubscriptionInfoManager.Instance.FetchHeaderForSub(_config, item); } catch { }
|
||||||
item.UpdateTime = updateTime;
|
item.UpdateTime = updateTime;
|
||||||
await ConfigHandler.AddSubItem(_config, item);
|
await ConfigHandler.AddSubItem(_config, item);
|
||||||
await Task.Delay(1000);
|
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;
|
namespace ServiceLib.Models;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// https://github.com/2dust/v2rayN/wiki/
|
/// https://github.com/FlowerRealm/v2rayN/wiki/
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Serializable]
|
[Serializable]
|
||||||
public class VmessQRCode
|
public class VmessQRCode
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,68 @@ public class DownloadService
|
||||||
return null;
|
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>
|
/// <summary>
|
||||||
/// DownloadString
|
/// DownloadString
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ using System.Reactive.Concurrency;
|
||||||
using System.Reactive.Linq;
|
using System.Reactive.Linq;
|
||||||
using ReactiveUI;
|
using ReactiveUI;
|
||||||
using ReactiveUI.Fody.Helpers;
|
using ReactiveUI.Fody.Helpers;
|
||||||
|
using ServiceLib.Manager;
|
||||||
|
|
||||||
namespace ServiceLib.ViewModels;
|
namespace ServiceLib.ViewModels;
|
||||||
|
|
||||||
|
|
@ -274,8 +275,16 @@ public class MainWindowViewModel : MyReactiveObject
|
||||||
|
|
||||||
BlReloadEnabled = true;
|
BlReloadEnabled = true;
|
||||||
await Reload();
|
await Reload();
|
||||||
|
|
||||||
|
// 开机自动爬取所有订阅的用量/到期头信息(后台执行)
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try { await SubscriptionInfoManager.Instance.FetchHeadersForAll(_config); } catch { }
|
||||||
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#endregion Init
|
#endregion Init
|
||||||
|
|
||||||
#region Actions
|
#region Actions
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,9 @@ using DynamicData;
|
||||||
using DynamicData.Binding;
|
using DynamicData.Binding;
|
||||||
using ReactiveUI;
|
using ReactiveUI;
|
||||||
using ReactiveUI.Fody.Helpers;
|
using ReactiveUI.Fody.Helpers;
|
||||||
|
using ServiceLib.Manager;
|
||||||
|
using ServiceLib.Models;
|
||||||
|
// using ServiceLib.Services; // covered by GlobalUsings
|
||||||
|
|
||||||
namespace ServiceLib.ViewModels;
|
namespace ServiceLib.ViewModels;
|
||||||
|
|
||||||
|
|
@ -34,6 +37,36 @@ public class ProfilesViewModel : MyReactiveObject
|
||||||
[Reactive]
|
[Reactive]
|
||||||
public SubItem SelectedSub { get; set; }
|
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]
|
[Reactive]
|
||||||
public SubItem SelectedMoveToGroup { get; set; }
|
public SubItem SelectedMoveToGroup { get; set; }
|
||||||
|
|
||||||
|
|
@ -254,6 +287,17 @@ public class ProfilesViewModel : MyReactiveObject
|
||||||
.ObserveOn(RxApp.MainThreadScheduler)
|
.ObserveOn(RxApp.MainThreadScheduler)
|
||||||
.Subscribe(async _ => await RefreshSubscriptions());
|
.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
|
AppEvents.DispatcherStatisticsRequested
|
||||||
.AsObservable()
|
.AsObservable()
|
||||||
.ObserveOn(RxApp.MainThreadScheduler)
|
.ObserveOn(RxApp.MainThreadScheduler)
|
||||||
|
|
@ -275,6 +319,14 @@ public class ProfilesViewModel : MyReactiveObject
|
||||||
SelectedSub = new();
|
SelectedSub = new();
|
||||||
SelectedMoveToGroup = new();
|
SelectedMoveToGroup = new();
|
||||||
|
|
||||||
|
BlSubInfoVisible = true;
|
||||||
|
SubUsagePercent = 0;
|
||||||
|
SubExpirePercent = 0;
|
||||||
|
SubUsageText = string.Empty;
|
||||||
|
SubExpireText = string.Empty;
|
||||||
|
SubUsageIndeterminate = true;
|
||||||
|
SubExpireIndeterminate = true;
|
||||||
|
|
||||||
await RefreshSubscriptions();
|
await RefreshSubscriptions();
|
||||||
//await RefreshServers();
|
//await RefreshServers();
|
||||||
}
|
}
|
||||||
|
|
@ -347,6 +399,13 @@ public class ProfilesViewModel : MyReactiveObject
|
||||||
{
|
{
|
||||||
if (!c)
|
if (!c)
|
||||||
{
|
{
|
||||||
|
SubUsageIndeterminate = true;
|
||||||
|
SubExpireIndeterminate = true;
|
||||||
|
SubUsagePercent = 0;
|
||||||
|
SubExpirePercent = 0;
|
||||||
|
SubUsageText = "—";
|
||||||
|
SubExpireText = "—";
|
||||||
|
BlSubInfoVisible = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_config.SubIndexId = SelectedSub?.Id;
|
_config.SubIndexId = SelectedSub?.Id;
|
||||||
|
|
@ -354,6 +413,112 @@ public class ProfilesViewModel : MyReactiveObject
|
||||||
await RefreshServers();
|
await RefreshServers();
|
||||||
|
|
||||||
await _updateView?.Invoke(EViewAction.ProfilesFocus, null);
|
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)
|
private async Task ServerFilterChanged(bool c)
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ public partial class FullConfigTemplateWindow : WindowBase<FullConfigTemplateVie
|
||||||
|
|
||||||
private void linkFullConfigTemplateDoc_Click(object sender, RoutedEventArgs e)
|
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)
|
private void Window_Loaded(object? sender, RoutedEventArgs e)
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,20 @@
|
||||||
Margin="{StaticResource MarginLr4}"
|
Margin="{StaticResource MarginLr4}"
|
||||||
VerticalContentAlignment="Center"
|
VerticalContentAlignment="Center"
|
||||||
Watermark="{x:Static resx:ResUI.MsgServerTitle}" />
|
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>
|
</WrapPanel>
|
||||||
<DataGrid
|
<DataGrid
|
||||||
x:Name="lstProfiles"
|
x:Name="lstProfiles"
|
||||||
|
|
|
||||||
|
|
@ -208,6 +208,6 @@ public partial class RoutingRuleSettingWindow : WindowBase<RoutingRuleSettingVie
|
||||||
|
|
||||||
private void linkCustomRulesetPath4Singbox(object? sender, RoutedEventArgs e)
|
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)
|
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"
|
materialDesign:TextFieldAssist.HasClearButton="True"
|
||||||
AutomationProperties.Name="{x:Static resx:ResUI.MsgServerTitle}"
|
AutomationProperties.Name="{x:Static resx:ResUI.MsgServerTitle}"
|
||||||
Style="{StaticResource DefTextBox}" />
|
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>
|
</WrapPanel>
|
||||||
<DataGrid
|
<DataGrid
|
||||||
x:Name="lstProfiles"
|
x:Name="lstProfiles"
|
||||||
|
|
|
||||||
|
|
@ -201,6 +201,6 @@ public partial class RoutingRuleSettingWindow
|
||||||
|
|
||||||
private void linkCustomRulesetPath4Singbox(object sender, RoutedEventArgs e)
|
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