Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions MainWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@
</Grid.ColumnDefinitions>

<ToggleButton Grid.Column="0"
Command="{x:Bind ViewModel.MiniStartStopCommand, Mode=OneWay}"
Command="{x:Bind ViewModel.MiniStartStopCommand}"
IsChecked="{x:Bind ViewModel.MiniIsRunning, Mode=OneWay}"
Width="40" Height="40"
CornerRadius="20"
Expand Down Expand Up @@ -169,8 +169,8 @@

<views:ServerListControl Grid.Column="0"
x:Name="ServerList"
ViewModel="{x:Bind ViewModel.ServerList, Mode=OneWay}"
SwitchToSelectedServerCommand="{x:Bind ViewModel.SwitchToSelectedServerCommand, Mode=OneWay}" />
ViewModel="{x:Bind ViewModel.ServerList, Mode=OneTime}"
SwitchToSelectedServerCommand="{x:Bind ViewModel.SwitchToSelectedServerCommand, Mode=OneTime}" />

<Grid Grid.Column="1"
VerticalAlignment="Stretch">
Expand All @@ -184,11 +184,11 @@

<views:ServerDetailControl Grid.Row="0"
x:Name="ServerDetail"
ViewModel="{x:Bind ViewModel.ServerDetail, Mode=OneWay}" />
ViewModel="{x:Bind ViewModel.ServerDetail, Mode=OneTime}" />

<views:ControlPanelControl x:Name="ControlPanel"
Grid.Row="1"
ViewModel="{x:Bind ViewModel.ControlPanel, Mode=OneWay}" />
ViewModel="{x:Bind ViewModel.ControlPanel, Mode=OneTime}" />
</Grid>

<!-- Personalize view — lazily realized in code-behind on first show
Expand Down
6 changes: 4 additions & 2 deletions Services/DialogService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -185,10 +185,12 @@ void SyncDialogButtons()
var txtFinalmask = new TextBox
{
Header = "Finalmask (JSON)",
Text = existing?.Finalmask ?? string.Empty,
// AcceptsReturn must be set BEFORE Text — initializer assigns properties in
// declared order, and Text setter in single-line mode truncates at the first \r.
AcceptsReturn = true,
Height = 104,
TextWrapping = TextWrapping.NoWrap
TextWrapping = TextWrapping.NoWrap,
Text = (existing?.Finalmask ?? string.Empty).Replace("\r\n", "\r").Replace("\n", "\r"),
};

// Row containers for conditional visibility
Expand Down
59 changes: 58 additions & 1 deletion Services/NodeLinkParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using XrayUI.Models;

namespace XrayUI.Services
Expand Down Expand Up @@ -287,7 +288,7 @@ int GetInt(string key, int def = 0) =>

var query = ParseQuery(uri.Query);
var sni = Q(query, "sni", string.Empty) ?? string.Empty;
var finalmask = FinalmaskJson.NormalizeForStorage(Q(query, "fm"));
var finalmask = BuildHysteria2Finalmask(query);
var allowInsecure = IsTruthy(Q(query, "allowInsecure")) || IsTruthy(Q(query, "insecure"));

return new ServerEntry
Expand All @@ -311,6 +312,62 @@ int GetInt(string key, int def = 0) =>
}
}

private static string BuildHysteria2Finalmask(Dictionary<string, string> query)
{
var finalmask = FinalmaskJson.NormalizeForStorage(
Q(query, "fm") ?? Q(query, "finalmask"));

var obfs = Q(query, "obfs");
if (!string.Equals(obfs, "salamander", StringComparison.OrdinalIgnoreCase))
return finalmask;

var obfsPassword = Q(query, "obfs-password")
?? Q(query, "obfs_password")
?? Q(query, "obfsPassword");
if (obfsPassword is null)
return finalmask;

return AddHysteria2SalamanderMask(finalmask, obfsPassword);
}

private static string AddHysteria2SalamanderMask(string finalmask, string password)
{
var parsed = FinalmaskJson.Parse(finalmask);
if (parsed is not null and not JsonObject)
return finalmask;

if (parsed is null && !string.IsNullOrWhiteSpace(finalmask))
return finalmask;

var root = parsed as JsonObject ?? [];
var udp = root["udp"] as JsonArray;
if (udp is null)
{
udp = [];
root["udp"] = udp;
}

foreach (var item in udp)
{
if (item is JsonObject itemObject
&& string.Equals(itemObject["type"]?.GetValue<string>(), "salamander", StringComparison.OrdinalIgnoreCase))
{
return root.ToJsonString(AppJsonSerializerContext.WriteReadable);
}
}

udp.Insert(0, new JsonObject
{
["type"] = "salamander",
["settings"] = new JsonObject
{
["password"] = password
}
});

return root.ToJsonString(AppJsonSerializerContext.WriteReadable);
}

// Trojan

private static ServerEntry? ParseTrojan(string link)
Expand Down
62 changes: 55 additions & 7 deletions Services/NodeLinkSerializer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -168,25 +168,37 @@ public static class NodeLinkSerializer
sb.Append(':');
sb.Append(s.Port);

var finalmask = FinalmaskJson.NormalizeForShare(s.Finalmask);
bool hasQuery = !string.IsNullOrEmpty(s.Sni) || s.AllowInsecure || !string.IsNullOrEmpty(finalmask);
var (hasSalamanderObfs, obfsPassword, finalmask) =
ExtractHysteria2SalamanderObfs(FinalmaskJson.NormalizeForShare(s.Finalmask));
bool hasQuery = !string.IsNullOrEmpty(s.Sni)
|| s.AllowInsecure
|| hasSalamanderObfs
|| !string.IsNullOrEmpty(finalmask);
if (hasQuery)
{
sb.Append('?');
bool first = true;
if (!string.IsNullOrEmpty(s.Sni))

void AddParam(string key, string value)
{
AppendParam(sb, "sni", s.Sni, first: true);
AppendParam(sb, key, value, first);
first = false;
}

if (!string.IsNullOrEmpty(s.Sni))
AddParam("sni", s.Sni);
if (s.AllowInsecure)
{
AppendParam(sb, "insecure", "1", first: first);
first = false;
AddParam("insecure", "1");
}
if (hasSalamanderObfs)
{
AddParam("obfs", "salamander");
AddParam("obfs-password", obfsPassword ?? string.Empty);
}
if (!string.IsNullOrEmpty(finalmask))
{
AppendParam(sb, "fm", finalmask, first: first);
AddParam("fm", finalmask);
}
}

Expand All @@ -201,6 +213,42 @@ public static class NodeLinkSerializer

// ── Trojan ───────────────────────────────────────────────────────────

private static (bool hasObfs, string? password, string finalmask) ExtractHysteria2SalamanderObfs(string finalmask)
{
// FinalmaskJson.Parse returns a freshly-parsed node owned by this call, so we
// mutate `root` and `udp` in place rather than cloning.
var parsed = FinalmaskJson.Parse(finalmask);
if (parsed is not JsonObject root)
return (false, null, finalmask);

if (root["udp"] is not JsonArray udp)
return (false, null, finalmask);

string? password = null;
for (int i = 0; i < udp.Count; i++)
{
if (udp[i] is JsonObject itemObject
&& string.Equals(itemObject["type"]?.GetValue<string>(), "salamander", StringComparison.OrdinalIgnoreCase))
{
password = (itemObject["settings"] as JsonObject)?["password"]?.GetValue<string>() ?? string.Empty;
udp.RemoveAt(i);
break;
}
}

if (password is null)
return (false, null, finalmask);

if (udp.Count == 0)
root.Remove("udp");

var cleanedFinalmask = root.Count == 0
? string.Empty
: FinalmaskJson.NormalizeForShare(root.ToJsonString());

return (true, password, cleanedFinalmask);
}

private static string? ToTrojanLink(ServerEntry s)
{
if (string.IsNullOrEmpty(s.Password)) return null;
Expand Down
47 changes: 43 additions & 4 deletions ViewModels/ServerListViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -727,14 +727,27 @@ private async Task RefreshSubscriptionAsync(SubscriptionEntry sub)

// Preserve Ids for nodes that survived the refresh so LastAutoConnectServerId
// (and any other Id-based reference) keeps pointing at the same logical node.
var oldByEndpoint = new Dictionary<string, ServerEntry>(removed.Count);
var oldByIdentity = new Dictionary<string, Queue<ServerEntry>>(StringComparer.Ordinal);
foreach (var s in removed)
oldByEndpoint[$"{s.Protocol}://{s.Host}:{s.Port}"] = s;
{
var key = BuildNodeIdentityKey(s);
if (!oldByIdentity.TryGetValue(key, out var matches))
{
matches = new Queue<ServerEntry>();
oldByIdentity[key] = matches;
}
matches.Enqueue(s);
}

var reusedIds = new HashSet<string>(StringComparer.Ordinal);
foreach (var e in newEntries)
{
if (oldByEndpoint.TryGetValue($"{e.Protocol}://{e.Host}:{e.Port}", out var match))
if (oldByIdentity.TryGetValue(BuildNodeIdentityKey(e), out var matches)
&& matches.Count > 0)
{
e.Id = match.Id;
var match = matches.Dequeue();
if (!string.IsNullOrWhiteSpace(match.Id) && reusedIds.Add(match.Id))
e.Id = match.Id;
e.IsFavorite = match.IsFavorite;
}
}
Expand All @@ -760,6 +773,32 @@ private async Task RefreshSubscriptionAsync(SubscriptionEntry sub)
}
}

// Case-insensitive identifiers (host, protocol, uuid, etc.) are lowercased so a
// subscription that re-emits "Example.com" still matches a stored "example.com".
// Case-sensitive material (credentials, paths, base64/hex keys) is only trimmed.
private static string BuildNodeIdentityKey(ServerEntry s) =>
string.Join("|",
NormalizeIdentityPart(s.Protocol),
NormalizeIdentityPart(s.Host),
s.Port.ToString(System.Globalization.CultureInfo.InvariantCulture),
NormalizeIdentityPart(s.Network),
NormalizeIdentityPart(s.Security),
s.Path?.Trim() ?? string.Empty,
NormalizeIdentityPart(s.WsHost),
NormalizeIdentityPart(s.Sni),
NormalizeIdentityPart(s.Encryption),
s.Username?.Trim() ?? string.Empty,
s.Password?.Trim() ?? string.Empty,
NormalizeIdentityPart(s.Uuid),
s.AlterId.ToString(System.Globalization.CultureInfo.InvariantCulture),
s.VlessEncryption?.Trim() ?? string.Empty,
s.Flow?.Trim() ?? string.Empty,
s.PublicKey?.Trim() ?? string.Empty,
s.ShortId?.Trim() ?? string.Empty);

private static string NormalizeIdentityPart(string? value) =>
value?.Trim().ToLowerInvariant() ?? string.Empty;

private async Task<bool> DeleteSubscriptionAsync(SubscriptionEntry sub)
{
if (IsSubscriptionLocked(sub.Id))
Expand Down
27 changes: 26 additions & 1 deletion Views/LogWindow.xaml.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml.Media;
Expand Down Expand Up @@ -28,6 +29,8 @@ public sealed partial class LogWindow

// Set from background thread when new lines arrive; consumed on UI thread.
private volatile bool _dirty;
private int _linesReceivedSinceFlush; // Background-thread increments; UI thread reads + clears.
private int _prevBufferCount;

public LogWindow(
XrayService xray,
Expand Down Expand Up @@ -72,6 +75,7 @@ private void OnLogReceived(object? sender, string line)
{
// Called from background thread. Do NOT touch the UI here —
// just mark dirty; the timer will re-render on the UI thread.
Interlocked.Increment(ref _linesReceivedSinceFlush);
_dirty = true;
}

Expand All @@ -85,12 +89,31 @@ private void OnFlushTick(DispatcherQueueTimer sender, object args)
if (!_dirty) return;
_dirty = false;

var autoScroll = AutoScrollToggle.IsChecked == true;
var prevOffset = LogScrollViewer.VerticalOffset;
var prevExtent = LogScrollViewer.ExtentHeight;
var prevCount = _prevBufferCount;
var received = Interlocked.Exchange(ref _linesReceivedSinceFlush, 0);

RenderLog();
var newCount = _prevBufferCount; // RenderLog just updated this.

if (AutoScrollToggle.IsChecked == true)
if (autoScroll)
{
LogScrollViewer.ChangeView(null, double.MaxValue, null, disableAnimation: true);
}
else
{
// Ring buffer evicted some lines: (received) − (net buffer growth) = lines pushed out.
// Shift the scroll offset down by that height so visible content stays anchored.
var evicted = Math.Max(0, received - (newCount - prevCount));
if (evicted > 0 && prevCount > 0 && prevExtent > 0)
{
var lineHeight = prevExtent / prevCount;
var target = Math.Max(0, prevOffset - evicted * lineHeight);
LogScrollViewer.ChangeView(null, target, null, disableAnimation: true);
}
}
}

// ── Rendering ──────────────────────────────────────────────────────────
Expand All @@ -101,6 +124,7 @@ private void RenderLog()
var lines = _xray.GetLogBuffer();
LogTextBlock.Text = string.Join('\n', lines);
LineCountText.Text = $"({lines.Count} 行)";
_prevBufferCount = lines.Count;
}

private async Task InitializeMaskAddressMenuAsync()
Expand Down Expand Up @@ -143,6 +167,7 @@ private void CopyButton_Click(object sender, RoutedEventArgs e)
private void ClearButton_Click(object sender, RoutedEventArgs e)
{
_xray.ClearLogBuffer();
Interlocked.Exchange(ref _linesReceivedSinceFlush, 0);
RenderLog();
}

Expand Down
2 changes: 1 addition & 1 deletion Views/ServerDetailControl.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
VerticalAlignment="Center" />

<Grid ColumnSpacing="12"
<Grid ColumnSpacing="12"
HorizontalAlignment="Center">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
Expand Down
9 changes: 8 additions & 1 deletion Views/ServerDetailControl.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,20 @@ public sealed partial class ServerDetailControl
{
public ServerDetailViewModel ViewModel { get; set; } = null!;

// The three AI service Borders share this handler. Without a guard, each Border's
// Loaded fire would re-add AIShadowCastGrid to all three Shadows — three Borders ×
// three receivers = nine entries, with duplicates causing wasted compositor work.
private bool _shadowsWired;

public ServerDetailControl()
{
this.InitializeComponent();
}

private void ShadowRect_Loaded(object sender, RoutedEventArgs e)
{
if (_shadowsWired) return;
_shadowsWired = true;
Shadow1.Receivers.Add(AIShadowCastGrid);
Shadow2.Receivers.Add(AIShadowCastGrid);
Shadow3.Receivers.Add(AIShadowCastGrid);
Expand Down
Loading