Merge pull request #2 from FlowerRealm/feat/sub-usage-progressbar

Add subscription usage/expiry progress + auto refresh
This commit is contained in:
Flower Realm 2025-10-18 21:06:02 +08:00 committed by GitHub
commit 4a9bbdcf9a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 879 additions and 60 deletions

View file

@ -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
View file

@ -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
View file

@ -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
View 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 crossplatform)
- 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#: filescoped 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 platformspecific code isolated (Windows WPF vs Avalonia) to avoid regressions.
## Agent Notes
- Follow this files scope for style and layout; keep patches minimal and targeted.
- Prefer simplifying data flow over adding conditionals; avoid deep nesting.

View file

@ -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.

View file

@ -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)
[![GitHub commit activity](https://img.shields.io/github/commit-activity/m/2dust/v2rayN)](https://github.com/2dust/v2rayN/commits/master)
[![CodeFactor](https://www.codefactor.io/repository/github/2dust/v2rayn/badge)](https://www.codefactor.io/repository/github/2dust/v2rayn)
[![GitHub Releases](https://img.shields.io/github/downloads/2dust/v2rayN/latest/total?logo=github)](https://github.com/2dust/v2rayN/releases)
[![Chat on Telegram](https://img.shields.io/badge/Chat%20on-Telegram-brightgreen.svg)](https://t.me/v2rayn)
[![GitHub commit activity](https://img.shields.io/github/commit-activity/m/FlowerRealm/v2rayN)](https://github.com/FlowerRealm/v2rayN/commits/master)
[![GitHub Releases](https://img.shields.io/github/downloads/FlowerRealm/v2rayN/latest/total?logo=github)](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
View 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
View file

@ -0,0 +1,2 @@
build/

15
debian/control vendored Normal file
View 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
View 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
View 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
View file

@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
exec /usr/lib/v2rayn/v2rayN "$@"

2
debian/source/format vendored Normal file
View file

@ -0,0 +1,2 @@
3.0 (native)

9
debian/v2rayn.desktop vendored Normal file
View 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;

View file

@ -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

View file

@ -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>

View file

@ -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

View file

@ -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__

View file

@ -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>

View file

@ -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]}";
}

View file

@ -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();
}

View file

@ -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 =

View file

@ -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;
}

View file

@ -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);
}

View 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 { }
}
}

View file

@ -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);

View 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);
}
}
}

View file

@ -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

View file

@ -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>

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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"

View file

@ -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");
}
}

View file

@ -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");
}
}

View file

@ -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"

View file

@ -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");
}
}