feat(ui/sub): show subscription traffic and expiry progress + auto refresh

- Add subscription usage/expiry display at Profiles top (WPF/Avalonia)
- Parse `Subscription-Userinfo` header when updating subscriptions
- On startup and per-subscription auto-refresh, fetch header to update UI
- Persist last known usage to guiConfigs/SubUsage.json (atomic write)
- Unify header fetching in SubscriptionInfoManager
- Fix units (B/KB/MB/GB/TB)
- Reduce spacing in progress layout
- Thread-safe map updates + snapshot save

Refs: 2dust/v2rayN#8136
This commit is contained in:
FlowerRealm 2025-10-17 15:07:44 +08:00
parent 0c0ecc359b
commit d04b8fa018
12 changed files with 565 additions and 12 deletions

3
.gitignore vendored
View file

@ -399,3 +399,6 @@ FodyWeavers.xsd
# JetBrains Rider
.idea/
*.sln.iml
# AI Tools
AGENTS.md

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

@ -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,208 @@
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);
if (File.Exists(_storeFile))
{
File.Replace(tmp, _storeFile, null);
}
else
{
File.Move(tmp, _storeFile);
}
}
}
catch { }
}
public async Task FetchHeadersForAll(Config config)
{
try
{
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
{
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

@ -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,22 @@ 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; }
[Reactive]
public int SubExpirePercent { get; set; }
[Reactive]
public string SubExpireText { get; set; }
[Reactive]
public SubItem SelectedMoveToGroup { get; set; }
@ -254,6 +273,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 +305,12 @@ public class ProfilesViewModel : MyReactiveObject
SelectedSub = new();
SelectedMoveToGroup = new();
BlSubInfoVisible = true;
SubUsagePercent = -1;
SubExpirePercent = -1;
SubUsageText = string.Empty;
SubExpireText = string.Empty;
await RefreshSubscriptions();
//await RefreshServers();
}
@ -354,6 +390,97 @@ 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 = "—";
return;
}
var info = pushed != null && pushed.SubId == subId
? pushed
: SubscriptionInfoManager.Instance.Get(subId);
if (info == null)
{
// 先用占位显示
SubUsagePercent = 0;
SubExpirePercent = 0;
SubUsageText = "—";
SubExpireText = "—";
// 尝试即时抓取一次响应头,避免必须“更新订阅”才显示
try { await SubscriptionInfoManager.Instance.FetchHeaderForSub(_config, SelectedSub); } catch { }
info = SubscriptionInfoManager.Instance.Get(subId);
if (info == null)
{
return;
}
}
// Usage
if (info.Total > 0)
{
SubUsagePercent = info.UsagePercent;
SubUsageText = string.Format("{0} / {1} ({2}%)", Utils.HumanFy(info.UsedBytes), Utils.HumanFy(info.Total), SubUsagePercent);
}
else
{
SubUsagePercent = -1;
SubUsageText = string.Format("{0}", Utils.HumanFy(info.UsedBytes));
}
// 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);
}
else
{
SubExpirePercent = -1;
}
}
else
{
SubExpireText = string.Empty;
SubExpirePercent = -1;
}
BlSubInfoVisible = true;
}
catch
{
// 出错也别隐藏
BlSubInfoVisible = true;
}
await Task.CompletedTask;
}
private async Task TryFetchSubInfoHeaderForSelected()
{
try { await SubscriptionInfoManager.Instance.FetchHeaderForSub(_config, SelectedSub); } catch { }
}
private async Task ServerFilterChanged(bool c)

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" />
<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" />
<TextBlock VerticalAlignment="Center" Text="{Binding SubExpireText}" />
</StackPanel>
</StackPanel>
</WrapPanel>
<DataGrid
x:Name="lstProfiles"

View file

@ -76,6 +76,50 @@
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}"
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}"
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"