diff --git a/WinWARP.Core/Helper/FlagEmojiParser.cs b/WinWARP.Core/Helper/FlagEmojiParser.cs
index 20d7d03..2e051ca 100644
--- a/WinWARP.Core/Helper/FlagEmojiParser.cs
+++ b/WinWARP.Core/Helper/FlagEmojiParser.cs
@@ -12,8 +12,9 @@ public static class FlagEmojiParser
///
/// Parses a string and returns a list of ISO 3166-1 alpha-2 country codes found as emojis.
///
- public static List ExtractCountryCodes(string text)
+ public static List ExtractCountryCodes(string text, out List countryIndicators)
{
+ countryIndicators = [];
var countryCodes = new List();
if (string.IsNullOrEmpty(text)) return countryCodes;
@@ -45,6 +46,7 @@ public static class FlagEmojiParser
var c2 = (char)('A' + letterOffset);
countryCodes.Add($"{c1}{c2}");
+ countryIndicators.Add($"{char.ConvertFromUtf32(RegionalIndicatorStart + firstLetterCode.Value)}{char.ConvertFromUtf32(RegionalIndicatorStart + letterOffset)}");
// Reset to look for the next flag
firstLetterCode = null;
@@ -59,4 +61,9 @@ public static class FlagEmojiParser
return countryCodes;
}
+
+ public static List ExtractCountryCodes(string text)
+ {
+ return ExtractCountryCodes(text, out _);
+ }
}
\ No newline at end of file
diff --git a/WinWARP.Core/Models/Entities/Server.cs b/WinWARP.Core/Models/Entities/Server.cs
index 446b2aa..df094bb 100644
--- a/WinWARP.Core/Models/Entities/Server.cs
+++ b/WinWARP.Core/Models/Entities/Server.cs
@@ -34,4 +34,14 @@ public abstract class Server
{
get; set;
} = string.Empty;
+
+ public bool IsDeleted
+ {
+ get; set;
+ } = false;
+
+ public bool IsDisabled
+ {
+ get; set;
+ } = false;
}
diff --git a/WinWARP.Core/Models/Entities/Subscription.cs b/WinWARP.Core/Models/Entities/Subscription.cs
index 4d92758..c78d7a3 100644
--- a/WinWARP.Core/Models/Entities/Subscription.cs
+++ b/WinWARP.Core/Models/Entities/Subscription.cs
@@ -54,9 +54,29 @@ public class Subscription
get; set;
} = string.Empty;
-
public List Servers
{
get; set;
} = [];
+
+ public long Upload
+ {
+ get; set;
+ } = 0;
+
+ public long Download
+ {
+ get; set;
+ } = 0;
+
+ public long Total
+ {
+ get; set;
+ } = 0;
+
+ public DateTime ExpireDate
+ {
+ get; set;
+ } = DateTime.MaxValue;
+
}
diff --git a/WinWARP.Core/Services/Implementations/SubscriptionFetcher.cs b/WinWARP.Core/Services/Implementations/SubscriptionFetcher.cs
index f7144c4..aeae0f7 100644
--- a/WinWARP.Core/Services/Implementations/SubscriptionFetcher.cs
+++ b/WinWARP.Core/Services/Implementations/SubscriptionFetcher.cs
@@ -1,14 +1,22 @@
using System;
using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
using System.Text;
+using System.Text.RegularExpressions;
using WinWARP.Core.Helper;
using WinWARP.Core.Models.Entities;
using WinWARP.Core.Services.Interfaces;
+using static System.Runtime.InteropServices.JavaScript.JSType;
+using static Microsoft.EntityFrameworkCore.DbLoggerCategory.Database;
namespace WinWARP.Core.Services.Implementations;
-public class SubscriptionFetcher : ISubscriptionFetcher
+public partial class SubscriptionFetcher(IDialogService dialogService) : ISubscriptionFetcher
{
+ private static readonly HttpClient _httpClient = new HttpClient();
+ private readonly IDialogService _dialogService = dialogService;
+
public bool IsUrlValid(string subscriptionUrl)
{
Uri? validatedUri;
@@ -23,13 +31,334 @@ public class SubscriptionFetcher : ISubscriptionFetcher
public async Task FetchAsync(string subscriptionUrl)
{
- // TODO: Implement the actual fetching logic here, e.g., using HttpClient to get the subscription data from the URL.
- await Task.Delay(10); // Simulate an asynchronous operation
- return new Subscription
+ _httpClient.Timeout = TimeSpan.FromSeconds(10);
+ var subscription = new Subscription
{
- DefaultTitle = "Default Subscription",
- Link = subscriptionUrl,
- Servers = [new() { DefaultTitle = "Default Server", Region = "NO", Title = "Not default title" }]
+ Link = subscriptionUrl
};
+
+ try
+ {
+ using var response = await _httpClient.GetAsync(subscriptionUrl);
+ response.EnsureSuccessStatusCode();
+
+ response.Headers.TryGetValues("Profile-Web-Page-Url", out var profileWebPage);
+ subscription.WebPage = profileWebPage?.FirstOrDefault() ?? string.Empty;
+
+ response.Headers.TryGetValues("Profile-Title", out var profileTitle);
+ subscription.DefaultTitle = GetSubscriptionTitle(profileTitle?.FirstOrDefault());
+
+ response.Headers.TryGetValues("Profile-Update-Interval", out var profileUpdateInterval);
+ if (profileUpdateInterval != null && int.TryParse(profileUpdateInterval.FirstOrDefault(), out var updateInterval)) subscription.DefaultUpdateInterval = updateInterval;
+
+ response.Headers.TryGetValues("Profile-Support-Url", out var profileSupportUrl);
+ subscription.SupportURL = profileSupportUrl?.FirstOrDefault() ?? string.Empty;
+
+ response.Headers.TryGetValues("Subscription-Userinfo", out var profileNetworkData);
+ var (Upload, Download, Total, Expire) = ParseNetworkData(profileNetworkData?.FirstOrDefault() ?? string.Empty);
+ subscription.Upload = Upload;
+ subscription.Download = Download;
+ subscription.Total = Total;
+ subscription.ExpireDate = Expire;
+
+ subscription.Servers = ParseServerData(await response.Content.ReadAsStringAsync());
+ }
+ catch (Exception ex) when (!Debugger.IsAttached)
+ {
+ _dialogService.ShowErrorAsync(ex.Message);
+ }
+
+ return subscription;
}
+
+ #region Titles
+ private static string GetSubscriptionTitle(string title)
+ {
+ if (string.IsNullOrWhiteSpace(title))
+ {
+ return "New subscription";
+ }
+ else if (title.StartsWith("base64:"))
+ {
+ var base64Data = title[7..];
+ try
+ {
+ var stringData = Encoding.UTF8.GetString(Convert.FromBase64String(base64Data));
+
+ return stringData[..(Math.Min(stringData.Length, 100))];
+ }
+ catch
+ {
+ return "New subscription";
+ }
+ }
+ else
+ {
+ return title[..(Math.Min(title.Length, 100))];
+ }
+ }
+
+ private static string GetServerTitle(string title, out string region)
+ {
+ if (string.IsNullOrEmpty(title))
+ {
+ region = string.Empty;
+ return "New server";
+ }
+
+ title = title[1..]; // skip the leading '#' character
+
+ region = FlagEmojiParser.ExtractCountryCodes(title, out var countryIndicators).FirstOrDefault() ?? string.Empty;
+ if (!string.IsNullOrEmpty(region))
+ {
+ title = title.Replace(countryIndicators.FirstOrDefault() ?? string.Empty, string.Empty).Trim();
+ return title[..Math.Min(title.Length, 100)];
+ }
+ return title[..Math.Min(title.Length, 100)];
+ }
+ #endregion
+
+ #region SubscriptionParsing
+ private static (long Upload, long Download, long Total, DateTime Expire) ParseNetworkData(string data)
+ {
+ if (string.IsNullOrWhiteSpace(data))
+ throw new ArgumentException("Input string cannot be null or empty.", nameof(data));
+
+ // Dictionary to temporarily store the parsed key-value pairs
+ var values = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ // Split the string by semicolon to get individual pairs (e.g., "upload=0")
+ var pairs = data.Split(';', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
+ foreach (var keyValue in from string pair in pairs
+ let keyValue = pair.Split('=', 2)
+ where keyValue.Length == 2
+ select keyValue)
+ {
+ values[keyValue[0].Trim()] = keyValue[1].Trim();
+ }
+
+ // Helper to safely parse numbers, defaulting to 0 if missing or invalid
+ long GetLongValue(string key) =>
+ values.TryGetValue(key, out var val) && long.TryParse(val, out var result) ? result : 0;
+
+ var upload = GetLongValue("upload");
+ var download = GetLongValue("download");
+ var total = GetLongValue("total");
+ var expireUnix = GetLongValue("expire");
+
+ var expireDateTime = DateTimeOffset.FromUnixTimeSeconds(expireUnix).UtcDateTime;
+
+ return (upload, download, total, expireDateTime);
+ }
+
+ [GeneratedRegex(@"^[A-Za-z0-9+/]*={0,2}$")]
+ private static partial Regex Base64Regex();
+
+ private static List ParseServerData(string data)
+ {
+ if (string.IsNullOrWhiteSpace(data)) return [];
+
+ data = data.Trim();
+
+ if (TryGetBase64String(data, out var decodedText))
+ {
+ return [.. ParseText(decodedText)];
+ }
+ else if (data.StartsWith('{') || data.StartsWith('['))
+ {
+ return ParseJson(data);
+ }
+ else if (data.StartsWith("---") || data.Contains(':'))
+ {
+ return ParseYaml(data);
+ }
+ else
+ {
+ return [.. ParseText(data)];
+ }
+ }
+ #endregion
+
+ #region ServersParsing
+ private static List ParseJson(string jsonData)
+ {
+ throw new NotImplementedException();
+ }
+
+ private static List ParseYaml(string yamlData)
+ {
+ throw new NotImplementedException();
+ }
+
+ private static IEnumerable ParseText(string textData)
+ {
+ foreach (var line in textData.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries))
+ {
+ var server = ParseServerFromLink(line);
+ if (server != null)
+ {
+ yield return server;
+ }
+ }
+ }
+
+ private static bool TryGetBase64String(string input, out string decodedText)
+ {
+ decodedText = null;
+
+ // Sanitize edge-case variants: URL-safe Base64 fixes ('-' -> '+', '_' -> '/')
+ var sanitized = input.Replace('-', '+').Replace('_', '/').Replace("\r", "").Replace("\n", "").Trim();
+
+ // Fix missing padding if client/server skipped it
+ var mod = sanitized.Length % 4;
+ if (mod > 0) sanitized += new string('=', 4 - mod);
+
+ // Regex validation for pure Base64 characters
+ if (!Base64Regex().IsMatch(sanitized)) return false;
+
+ try
+ {
+ var bytes = Convert.FromBase64String(sanitized);
+ decodedText = Encoding.UTF8.GetString(bytes);
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private static SubscriptionServer ParseServerFromLink(string serverData)
+ {
+ var uri = new Uri(serverData);
+
+ if (uri.Scheme == "ss" || uri.Scheme == "vmess" || uri.Scheme == "vless" || uri.Scheme == "trojan")
+ {
+ var title = Uri.UnescapeDataString(uri.Fragment);
+ return new SubscriptionServer
+ {
+ DefaultTitle = GetServerTitle(title, out var region),
+ Region = FlagEmojiParser.ExtractCountryCodes(title).FirstOrDefault() ?? string.Empty,
+ ConnectionString = serverData
+ };
+ }
+ else
+ {
+ return null;
+ }
+ }
+ #endregion
+
+ #region Tags
+ public List GetTags(string connection)
+ {
+ if (string.IsNullOrWhiteSpace(connection)) return null;
+
+ if (connection.StartsWith('{') || connection.StartsWith('[')) return [.. GetJsonTags(connection)];
+ else return [.. GetTextTags(connection)];
+ }
+
+ private static IEnumerable GetTextTags(string connection)
+ {
+ var input = connection.Trim();
+ var scheme = string.Empty;
+ var query = string.Empty;
+ var lowered = input.ToLowerInvariant();
+
+ if (Uri.TryCreate(input, UriKind.Absolute, out var uri))
+ {
+ scheme = uri.Scheme ?? string.Empty;
+ query = uri.Query ?? string.Empty;
+ }
+ else
+ {
+ var m = UrlCheck().Match(input);
+ if (m.Success)
+ {
+ scheme = m.Groups[1].Value;
+ var idx = input.IndexOf('?');
+ if (idx >= 0) query = input[idx..];
+ }
+ }
+
+ // 1) Protocol tag (Uri.Scheme)
+ if (!string.IsNullOrEmpty(scheme))
+ yield return scheme.ToUpperInvariant();
+
+ // 2) Security tag (one of: reality, vision, tls)
+ var security = string.Empty;
+ if (lowered.Contains("reality") || scheme.Contains("reality", StringComparison.OrdinalIgnoreCase))
+ {
+ security = "reality";
+ }
+ else if (lowered.Contains("vision") || scheme.Contains("vision", StringComparison.OrdinalIgnoreCase))
+ {
+ security = "vision";
+ }
+ else if (scheme.EndsWith("+tls", StringComparison.OrdinalIgnoreCase) ||
+ scheme.Equals("https", StringComparison.OrdinalIgnoreCase) ||
+ scheme.Equals("wss", StringComparison.OrdinalIgnoreCase) ||
+ lowered.Contains("tls") ||
+ lowered.Contains("secure=true") ||
+ SecurityCheck().IsMatch(query))
+ {
+ security = "tls";
+ }
+
+ if (!string.IsNullOrEmpty(security))
+ yield return security.ToUpperInvariant();
+
+ // 3) Transport tag (one of: tcp, ws, grpc, h2, xhttp)
+ var transport = string.Empty;
+
+ if (scheme.Equals("ws", StringComparison.OrdinalIgnoreCase) ||
+ scheme.Equals("wss", StringComparison.OrdinalIgnoreCase) ||
+ lowered.Contains("transport=ws") ||
+ lowered.Contains("websocket"))
+ {
+ transport = "ws";
+ }
+ else if (scheme.Contains("grpc", StringComparison.OrdinalIgnoreCase) ||
+ lowered.Contains("transport=grpc") ||
+ lowered.Contains("grpc/"))
+ {
+ transport = "grpc";
+ }
+ else if (scheme.Equals("h2", StringComparison.OrdinalIgnoreCase) ||
+ lowered.Contains("http2") ||
+ lowered.Contains("alpn=h2") ||
+ lowered.Contains("transport=h2"))
+ {
+ transport = "h2";
+ }
+ else if (lowered.Contains("xhttp") || lowered.Contains("x-http") || lowered.Contains("transport=xhttp"))
+ {
+ transport = "xhttp";
+ }
+ else if (scheme.Equals("tcp", StringComparison.OrdinalIgnoreCase) ||
+ lowered.Contains("transport=tcp") ||
+ lowered.Contains("tcp/") ||
+ lowered.Contains(':'))
+ {
+ transport = "tcp";
+ }
+ else
+ {
+ transport = "tcp";
+ }
+
+ if (!string.IsNullOrEmpty(transport))
+ yield return transport.ToUpperInvariant();
+ }
+
+ private IEnumerable GetJsonTags(string json)
+ {
+ throw new NotImplementedException();
+ }
+
+ [GeneratedRegex(@"^([a-zA-Z][\w+\-\.]*):")]
+ private static partial Regex UrlCheck();
+ [GeneratedRegex(@"(\b|&)security=(tls|true)(&|$)", RegexOptions.IgnoreCase, "en-001")]
+ private static partial Regex SecurityCheck();
+ #endregion
}
diff --git a/WinWARP.Core/Services/Interfaces/IDialogService.cs b/WinWARP.Core/Services/Interfaces/IDialogService.cs
index e3c4446..72df9ec 100644
--- a/WinWARP.Core/Services/Interfaces/IDialogService.cs
+++ b/WinWARP.Core/Services/Interfaces/IDialogService.cs
@@ -8,4 +8,5 @@ namespace WinWARP.Core.Services.Interfaces;
public interface IDialogService
{
Task<(CommonDialogResult Result, string Title, string URL, int UpdateInterval)> ShowAddSubscriptionAsync();
+ Task ShowErrorAsync(string message);
}
diff --git a/WinWARP.Core/Services/Interfaces/ISubscriptionFetcher.cs b/WinWARP.Core/Services/Interfaces/ISubscriptionFetcher.cs
index f26e2d4..9b84696 100644
--- a/WinWARP.Core/Services/Interfaces/ISubscriptionFetcher.cs
+++ b/WinWARP.Core/Services/Interfaces/ISubscriptionFetcher.cs
@@ -9,4 +9,5 @@ public interface ISubscriptionFetcher
{
public bool IsUrlValid(string url);
public Task FetchAsync(string url);
+ public List GetTags(string connectionString);
}
diff --git a/WinWARP.Core/ViewModels/SubsSettingsViewModel.cs b/WinWARP.Core/ViewModels/SubsSettingsViewModel.cs
index ca2d178..0540511 100644
--- a/WinWARP.Core/ViewModels/SubsSettingsViewModel.cs
+++ b/WinWARP.Core/ViewModels/SubsSettingsViewModel.cs
@@ -69,7 +69,9 @@ public partial class SubsSettingsViewModel(ISubscriptionDbService dbService, ISu
Subscriptions.Add(new SubscriptionViewModel(subscription));
foreach (var server in subscription.Servers)
{
- Servers.Add(new SubscriptionServerViewModel(server) { Tags = { "VLESS", "WS", "TLS" } });
+ var serverVM = new SubscriptionServerViewModel(server);
+ foreach (var tag in _fetcher.GetTags(server.ConnectionString)) serverVM.Tags.Add(tag);
+ Servers.Add(serverVM);
}
ShowLoadingIndicator = false;
}
diff --git a/WinWARP.Ui/Services/DialogService.cs b/WinWARP.Ui/Services/DialogService.cs
index adbc1bd..ab195d7 100644
--- a/WinWARP.Ui/Services/DialogService.cs
+++ b/WinWARP.Ui/Services/DialogService.cs
@@ -14,14 +14,10 @@ using WinWARP.Ui.Views.Dialogs;
namespace WinWARP.Ui.Services;
-public class DialogService : IDialogService
+public class DialogService(XamlRootTracker tracker, ILocalizationService localizationService) : IDialogService
{
- private readonly XamlRootTracker _tracker;
-
- public DialogService(XamlRootTracker tracker)
- {
- _tracker = tracker;
- }
+ private readonly XamlRootTracker _tracker = tracker;
+ private readonly ILocalizationService _localizationService = localizationService;
public async Task<(CommonDialogResult Result, string Title, string URL, int UpdateInterval)> ShowAddSubscriptionAsync()
{
@@ -44,4 +40,17 @@ public class DialogService : IDialogService
return (mappedResult, dialog.ViewModel.CustomName, dialog.ViewModel.SubscriptionLink, dialog.ViewModel.UpdateInterval);
}
+
+ public async Task ShowErrorAsync(string message)
+ {
+ var dialog = new ContentDialog
+ {
+ Title = _localizationService.GetString("ErrorDialog.Title"),
+ Content = message,
+ CloseButtonText = _localizationService.GetString("ErrorDialog.CloseButtonText"),
+ XamlRoot = _tracker.XamlRoot,
+ Style = Application.Current.Resources["DefaultContentDialogStyle"] as Style
+ };
+ await dialog.ShowAsync();
+ }
}
diff --git a/WinWARP.Ui/Strings/en-US/Resources.resw b/WinWARP.Ui/Strings/en-US/Resources.resw
index aa26dec..b588222 100644
--- a/WinWARP.Ui/Strings/en-US/Resources.resw
+++ b/WinWARP.Ui/Strings/en-US/Resources.resw
@@ -267,4 +267,10 @@
Auto
+
+ OK
+
+
+ Error occurred:
+
\ No newline at end of file
diff --git a/WinWARP.Ui/Strings/ru-RU/Resources.resw b/WinWARP.Ui/Strings/ru-RU/Resources.resw
index 761f351..75eadb9 100644
--- a/WinWARP.Ui/Strings/ru-RU/Resources.resw
+++ b/WinWARP.Ui/Strings/ru-RU/Resources.resw
@@ -267,4 +267,10 @@
Автоматически
+
+ Ок
+
+
+ Произошла ошибка:
+
\ No newline at end of file
diff --git a/WinWARP.Ui/Views/Components/ServerListElement.xaml b/WinWARP.Ui/Views/Components/ServerListElement.xaml
index 7e1a5bf..cb3492a 100644
--- a/WinWARP.Ui/Views/Components/ServerListElement.xaml
+++ b/WinWARP.Ui/Views/Components/ServerListElement.xaml
@@ -16,7 +16,7 @@
-
+
@@ -31,7 +31,9 @@
-
+
@@ -76,7 +78,7 @@
-
+
diff --git a/WinWARP.Ui/Views/SubsSettingsPage.xaml b/WinWARP.Ui/Views/SubsSettingsPage.xaml
index b243ccf..0be0686 100644
--- a/WinWARP.Ui/Views/SubsSettingsPage.xaml
+++ b/WinWARP.Ui/Views/SubsSettingsPage.xaml
@@ -11,6 +11,7 @@
mc:Ignorable="d">
+
@@ -28,4 +29,6 @@
+
+
diff --git a/WinWARP.Ui/WinWARP.Ui.csproj b/WinWARP.Ui/WinWARP.Ui.csproj
index 663e051..85a1297 100644
--- a/WinWARP.Ui/WinWARP.Ui.csproj
+++ b/WinWARP.Ui/WinWARP.Ui.csproj
@@ -127,6 +127,7 @@
+