From 737da7d40c46cfd4dc072f28963224f2b1b95dfd Mon Sep 17 00:00:00 2001 From: DHR60 Date: Tue, 5 May 2026 14:14:38 +0800 Subject: [PATCH] Inner uri import and export --- v2rayN/ServiceLib/Global.cs | 1 + v2rayN/ServiceLib/Handler/ConfigHandler.cs | 52 ++++ v2rayN/ServiceLib/Handler/Fmt/InnerFmt.cs | 222 ++++++++++++++++++ v2rayN/ServiceLib/Resx/ResUI.Designer.cs | 9 + v2rayN/ServiceLib/Resx/ResUI.fa-Ir.resx | 3 + v2rayN/ServiceLib/Resx/ResUI.fr.resx | 3 + v2rayN/ServiceLib/Resx/ResUI.hu.resx | 3 + v2rayN/ServiceLib/Resx/ResUI.resx | 3 + v2rayN/ServiceLib/Resx/ResUI.ru.resx | 3 + v2rayN/ServiceLib/Resx/ResUI.zh-Hans.resx | 3 + v2rayN/ServiceLib/Resx/ResUI.zh-Hant.resx | 3 + .../ViewModels/ProfilesViewModel.cs | 31 +++ .../v2rayN.Desktop/Views/ProfilesView.axaml | 1 + .../Views/ProfilesView.axaml.cs | 1 + v2rayN/v2rayN/Views/ProfilesView.xaml | 4 + v2rayN/v2rayN/Views/ProfilesView.xaml.cs | 1 + 16 files changed, 343 insertions(+) create mode 100644 v2rayN/ServiceLib/Handler/Fmt/InnerFmt.cs diff --git a/v2rayN/ServiceLib/Global.cs b/v2rayN/ServiceLib/Global.cs index 4d4f541a..ca167fa3 100644 --- a/v2rayN/ServiceLib/Global.cs +++ b/v2rayN/ServiceLib/Global.cs @@ -64,6 +64,7 @@ public class Global public const string HttpsProtocol = "https://"; public const string SocksProtocol = "socks://"; public const string Socks5Protocol = "socks5://"; + public const string InnerUriProtocol = "v2rayn://"; public const string AsIs = "AsIs"; public const string IPIfNonMatch = "IPIfNonMatch"; public const string IPOnDemand = "IPOnDemand"; diff --git a/v2rayN/ServiceLib/Handler/ConfigHandler.cs b/v2rayN/ServiceLib/Handler/ConfigHandler.cs index c4c7b8e9..9c4d4d5e 100644 --- a/v2rayN/ServiceLib/Handler/ConfigHandler.cs +++ b/v2rayN/ServiceLib/Handler/ConfigHandler.cs @@ -1767,6 +1767,53 @@ public static class ConfigHandler return -1; } + private static async Task AddBatchServers4InnerUri(Config config, string strData, string subid, bool isSub) + { + if (strData.IsNullOrEmpty()) + { + return -1; + } + + if (isSub && subid.IsNotEmpty()) + { + await RemoveServersViaSubid(config, subid, isSub); + } + + var lstServer = InnerFmt.Resolve(strData, subid); + if (lstServer?.Count > 0) + { + var counter = 0; + foreach (var profileItem in lstServer) + { + profileItem.Subid = subid; + profileItem.IsSub = isSub; + + var addStatus = profileItem.ConfigType switch + { + EConfigType.VMess => await AddVMessServer(config, profileItem), + EConfigType.Shadowsocks => await AddShadowsocksServer(config, profileItem), + EConfigType.SOCKS => await AddSocksServer(config, profileItem), + EConfigType.Trojan => await AddTrojanServer(config, profileItem), + EConfigType.VLESS => await AddVlessServer(config, profileItem), + EConfigType.Hysteria2 => await AddHysteria2Server(config, profileItem), + EConfigType.TUIC => await AddTuicServer(config, profileItem), + EConfigType.WireGuard => await AddWireguardServer(config, profileItem), + EConfigType.Anytls => await AddAnytlsServer(config, profileItem), + EConfigType.Naive => await AddNaiveServer(config, profileItem), + _ => -1, + }; + if (addStatus == 0) + { + counter++; + } + } + await SaveConfig(config); + return counter; + } + + return -1; + } + /// /// Main entry point for adding batch servers from various formats /// Tries different parsing methods to import as many servers as possible @@ -1815,6 +1862,11 @@ public static class ConfigHandler counter = await AddBatchServers4Wireguard(config, strData, subid, isSub); } + if (counter < 1) + { + counter = await AddBatchServers4InnerUri(config, strData, subid, isSub); + } + //maybe other sub if (counter < 1) { diff --git a/v2rayN/ServiceLib/Handler/Fmt/InnerFmt.cs b/v2rayN/ServiceLib/Handler/Fmt/InnerFmt.cs new file mode 100644 index 00000000..e540f1cd --- /dev/null +++ b/v2rayN/ServiceLib/Handler/Fmt/InnerFmt.cs @@ -0,0 +1,222 @@ +namespace ServiceLib.Handler.Fmt; + +public class InnerFmt +{ + private static readonly Lazy SessionSalt = new(() => Utils.GetGuid(false)); + + public static List? Resolve(string strData, string subid) + { + var list = new List(); + // Overwrite externally imported indexIds to avoid possible sources of attacks + var indexIdMap = new Dictionary(); + using (var reader = new StringReader(strData)) + { + while (reader.ReadLine() is { } line) + { + if (line.IsNullOrEmpty()) + { + continue; + } + var trimmedLine = line.Trim(); + if (!line.StartsWith(Global.InnerUriProtocol, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + var profileItem = ResolveSingle(trimmedLine); + if (profileItem is null) + { + continue; + } + if (profileItem.ConfigType == EConfigType.Custom) + { + // Unsupported, also to avoid possible sources of attacks, skip it + continue; + } + if (profileItem.ConfigVersion != 4) + { + continue; + } + // overwrite indexId + var newIndexId = Utils.GetGuid(false); + if (!profileItem.IndexId.IsNullOrEmpty()) + { + // Ignore duplicated indexId + indexIdMap[profileItem.IndexId] = newIndexId; + } + profileItem.IndexId = newIndexId; + list.Add(profileItem); + } + } + // For group-type profile items, also overwrite the ChildItems and ChildSubId + foreach (var item in list.Where(i => i.ConfigType.IsGroupType())) + { + var protocolExtra = item.GetProtocolExtra(); + // Only allow "self" as a special value for SubChildItems to avoid possible sources of attacks, + // which means it will be replaced with the subid, otherwise set it to null + //if (!protocolExtra.SubChildItems.IsNullOrEmpty()) + if (protocolExtra.SubChildItems == "self") + { + protocolExtra = protocolExtra with + { + SubChildItems = subid + }; + } + else + { + protocolExtra = protocolExtra with + { + SubChildItems = null + }; + } + if (Utils.String2List(protocolExtra.ChildItems) is { Count: > 0 } childIndexIds) + { + var newChildIndexIds = childIndexIds + .Select(id => indexIdMap.GetValueOrDefault(id, null)) + .Where(id => !id.IsNullOrEmpty()) + .ToList(); + protocolExtra = protocolExtra with + { + ChildItems = Utils.List2String(newChildIndexIds) + }; + } + else + { + protocolExtra = protocolExtra with + { + ChildItems = null + }; + } + item.SetProtocolExtra(protocolExtra); + } + return list; + } + + public static string? ToUri(List items) + { + var sb = new StringBuilder(); + foreach (var item in items) + { + if (item.ConfigType == EConfigType.Custom) + { + continue; + } + var itemClone = JsonUtils.DeepCopy(item); + if (itemClone is null) + { + continue; + } + // overwrite indexId + var originalIndexId = itemClone.IndexId; + var newIndexId = GetReproducibleExportId(originalIndexId); + itemClone.IndexId = newIndexId; + if (itemClone.ConfigType.IsGroupType()) + { + var protocolExtra = itemClone.GetProtocolExtra(); + if (!protocolExtra.SubChildItems.IsNullOrEmpty()) + { + protocolExtra = protocolExtra with + { + SubChildItems = "self" + }; + } + if (Utils.String2List(protocolExtra.ChildItems) is { Count: > 0 } childIndexIds) + { + var newChildIndexIds = childIndexIds + .Select(GetReproducibleExportId) + .Where(id => !id.IsNullOrEmpty()) + .ToList(); + protocolExtra = protocolExtra with + { + ChildItems = Utils.List2String(newChildIndexIds) + }; + } + itemClone.SetProtocolExtra(protocolExtra); + } + var uri = ToUriSingle(itemClone); + if (!uri.IsNullOrEmpty()) + { + sb.AppendLine(uri); + } + } + return sb.Length > 0 ? sb.ToString() : null; + } + + private static ProfileItem? ResolveSingle(string str) + { + // format: v2rayn://vless/{url-safe base64 encoded_string} + var parsedUri = Utils.TryUri(str); + if (parsedUri is null) + { + return null; + } + var segment = parsedUri.AbsolutePath.TrimStart('/'); + var decodedResult = Utils.Base64Decode(segment); + var jsonNode = JsonUtils.ParseJson(decodedResult); + if (jsonNode is not JsonObject jsonObj) + { + return null; + } + // flatten + // move jsonObj.ProtoExtraObj to jsonObj.ProtoExtra (string) + // move jsonObj.TransportExtraObj to jsonObj.TransportExtra (string) + if (jsonObj.TryGetPropertyValue("ProtoExtraObj", out var protoExtraNode) + && protoExtraNode is JsonObject protoExtraObj) + { + jsonObj["ProtoExtra"] = JsonUtils.Serialize(protoExtraObj, false); + jsonObj.Remove("ProtoExtraObj"); + } + if (jsonObj.TryGetPropertyValue("TransportExtraObj", out var transportExtraNode) + && transportExtraNode is JsonObject transportExtraObj) + { + jsonObj["TransportExtra"] = JsonUtils.Serialize(transportExtraObj, false); + jsonObj.Remove("TransportExtraObj"); + } + var profileItem = JsonUtils.Deserialize(JsonUtils.Serialize(jsonObj, false)); + return profileItem; + } + + private static string? ToUriSingle(ProfileItem item) + { + var jsonNode = JsonUtils.ParseJson(JsonUtils.Serialize(item, false)); + if (jsonNode is not JsonObject jsonObj) + { + return null; + } + // unflatten + // move jsonObj.ProtoExtra (string) to jsonObj.ProtoExtraObj + // move jsonObj.TransportExtra (string) to jsonObj.TransportExtraObj + if (jsonObj.TryGetPropertyValue("ProtoExtra", out var protoExtraNode) + && protoExtraNode is JsonValue protoExtraValue + && protoExtraValue.TryGetValue(out var protoExtraStr) + && !protoExtraStr.IsNullOrEmpty() + && JsonUtils.ParseJson(protoExtraStr) is JsonObject protoExtraObj) + { + jsonObj["ProtoExtraObj"] = protoExtraObj; + jsonObj.Remove("ProtoExtra"); + } + if (jsonObj.TryGetPropertyValue("TransportExtra", out var transportExtraNode) + && transportExtraNode is JsonValue transportExtraValue + && transportExtraValue.TryGetValue(out var transportExtraStr) + && !transportExtraStr.IsNullOrEmpty() + && JsonUtils.ParseJson(transportExtraStr) is JsonObject transportExtraObj) + { + jsonObj["TransportExtraObj"] = transportExtraObj; + jsonObj.Remove("TransportExtra"); + } + var jsonStr = JsonUtils.Serialize(jsonObj, false); + var encodedStr = Utils.Base64Encode(jsonStr).Replace('+', '-').Replace('/', '_').Replace("=", ""); + return $"{Global.InnerUriProtocol}{item.ConfigType.ToString().ToLower()}/{encodedStr}"; + } + + private static string GetReproducibleExportId(string originalIndexId) + { + if (originalIndexId.IsNullOrEmpty()) + { + return originalIndexId; + } + + var hash = HashCode.Combine(SessionSalt.Value, originalIndexId) & 0x7FFFFFFF; + var bytes = BitConverter.GetBytes(hash); + return Convert.ToBase64String(bytes).Replace("=", ""); + } +} diff --git a/v2rayN/ServiceLib/Resx/ResUI.Designer.cs b/v2rayN/ServiceLib/Resx/ResUI.Designer.cs index 81efe4a5..0661316d 100644 --- a/v2rayN/ServiceLib/Resx/ResUI.Designer.cs +++ b/v2rayN/ServiceLib/Resx/ResUI.Designer.cs @@ -1041,6 +1041,15 @@ namespace ServiceLib.Resx { } } + /// + /// 查找类似 Backup to Clipboard 的本地化字符串。 + /// + public static string menuExport4Backup { + get { + return ResourceManager.GetString("menuExport4Backup", resourceCulture); + } + } + /// /// 查找类似 Export 的本地化字符串。 /// diff --git a/v2rayN/ServiceLib/Resx/ResUI.fa-Ir.resx b/v2rayN/ServiceLib/Resx/ResUI.fa-Ir.resx index e21d3fec..ca5f5e45 100644 --- a/v2rayN/ServiceLib/Resx/ResUI.fa-Ir.resx +++ b/v2rayN/ServiceLib/Resx/ResUI.fa-Ir.resx @@ -1728,4 +1728,7 @@ The "Get Certificate" action may fail if a self-signed certificate is used or if PreSharedKey + + Backup to Clipboard + \ No newline at end of file diff --git a/v2rayN/ServiceLib/Resx/ResUI.fr.resx b/v2rayN/ServiceLib/Resx/ResUI.fr.resx index 898acc2a..c84610b1 100644 --- a/v2rayN/ServiceLib/Resx/ResUI.fr.resx +++ b/v2rayN/ServiceLib/Resx/ResUI.fr.resx @@ -1725,4 +1725,7 @@ The "Get Certificate" action may fail if a self-signed certificate is used or if PreSharedKey + + Backup to Clipboard + \ No newline at end of file diff --git a/v2rayN/ServiceLib/Resx/ResUI.hu.resx b/v2rayN/ServiceLib/Resx/ResUI.hu.resx index 4eb6fc35..7b6c4dc0 100644 --- a/v2rayN/ServiceLib/Resx/ResUI.hu.resx +++ b/v2rayN/ServiceLib/Resx/ResUI.hu.resx @@ -1728,4 +1728,7 @@ The "Get Certificate" action may fail if a self-signed certificate is used or if PreSharedKey + + Backup to Clipboard + \ No newline at end of file diff --git a/v2rayN/ServiceLib/Resx/ResUI.resx b/v2rayN/ServiceLib/Resx/ResUI.resx index b5d832c6..c2a7df7a 100644 --- a/v2rayN/ServiceLib/Resx/ResUI.resx +++ b/v2rayN/ServiceLib/Resx/ResUI.resx @@ -1728,4 +1728,7 @@ The "Get Certificate" action may fail if a self-signed certificate is used or if PreSharedKey + + Backup to Clipboard + \ No newline at end of file diff --git a/v2rayN/ServiceLib/Resx/ResUI.ru.resx b/v2rayN/ServiceLib/Resx/ResUI.ru.resx index fa9bbce1..5f692fef 100644 --- a/v2rayN/ServiceLib/Resx/ResUI.ru.resx +++ b/v2rayN/ServiceLib/Resx/ResUI.ru.resx @@ -1728,4 +1728,7 @@ PreSharedKey + + Backup to Clipboard + \ No newline at end of file diff --git a/v2rayN/ServiceLib/Resx/ResUI.zh-Hans.resx b/v2rayN/ServiceLib/Resx/ResUI.zh-Hans.resx index c6a6378b..c8d65400 100644 --- a/v2rayN/ServiceLib/Resx/ResUI.zh-Hans.resx +++ b/v2rayN/ServiceLib/Resx/ResUI.zh-Hans.resx @@ -1725,4 +1725,7 @@ PreSharedKey + + 备份至剪贴板 (多选) + \ No newline at end of file diff --git a/v2rayN/ServiceLib/Resx/ResUI.zh-Hant.resx b/v2rayN/ServiceLib/Resx/ResUI.zh-Hant.resx index 821485e8..e10fe66d 100644 --- a/v2rayN/ServiceLib/Resx/ResUI.zh-Hant.resx +++ b/v2rayN/ServiceLib/Resx/ResUI.zh-Hant.resx @@ -1725,4 +1725,7 @@ PreSharedKey + + Backup to Clipboard + \ No newline at end of file diff --git a/v2rayN/ServiceLib/ViewModels/ProfilesViewModel.cs b/v2rayN/ServiceLib/ViewModels/ProfilesViewModel.cs index 88e8b679..609ae0d1 100644 --- a/v2rayN/ServiceLib/ViewModels/ProfilesViewModel.cs +++ b/v2rayN/ServiceLib/ViewModels/ProfilesViewModel.cs @@ -72,6 +72,7 @@ public class ProfilesViewModel : MyReactiveObject public ReactiveCommand Export2ClientConfigClipboardCmd { get; } public ReactiveCommand Export2ShareUrlCmd { get; } public ReactiveCommand Export2ShareUrlBase64Cmd { get; } + public ReactiveCommand Export2InnerUriCmd { get; } public ReactiveCommand AddSubCmd { get; } public ReactiveCommand EditSubCmd { get; } @@ -212,6 +213,10 @@ public class ProfilesViewModel : MyReactiveObject { await Export2ShareUrlAsync(true); }, canEditRemove); + Export2InnerUriCmd = ReactiveCommand.CreateFromTask(async () => + { + await Export2InnerUrlAsync(); + }, canEditRemove); //Subscription AddSubCmd = ReactiveCommand.CreateFromTask(async () => @@ -840,6 +845,32 @@ public class ProfilesViewModel : MyReactiveObject } } + public async Task Export2InnerUrlAsync() + { + var lstSelected = await GetProfileItems(true); + if (lstSelected == null) + { + return; + } + + var result = string.Empty; + + await Task.Run(() => + { + result = InnerFmt.ToUri(lstSelected); + }); + + if (!result.IsNullOrEmpty()) + { + await _updateView?.Invoke(EViewAction.SetClipboardData, result); + NoticeManager.Instance.SendMessage(ResUI.BatchExportURLSuccessfully); + } + else + { + NoticeManager.Instance.Enqueue(ResUI.OperationFailed); + } + } + #endregion Add Servers #region Subscription diff --git a/v2rayN/v2rayN.Desktop/Views/ProfilesView.axaml b/v2rayN/v2rayN.Desktop/Views/ProfilesView.axaml index 42fab7c2..3afcbc3e 100644 --- a/v2rayN/v2rayN.Desktop/Views/ProfilesView.axaml +++ b/v2rayN/v2rayN.Desktop/Views/ProfilesView.axaml @@ -192,6 +192,7 @@ Header="{x:Static resx:ResUI.menuExport2ShareUrl}" InputGesture="Ctrl+C" /> + diff --git a/v2rayN/v2rayN.Desktop/Views/ProfilesView.axaml.cs b/v2rayN/v2rayN.Desktop/Views/ProfilesView.axaml.cs index cca3378f..f16ac5ce 100644 --- a/v2rayN/v2rayN.Desktop/Views/ProfilesView.axaml.cs +++ b/v2rayN/v2rayN.Desktop/Views/ProfilesView.axaml.cs @@ -88,6 +88,7 @@ public partial class ProfilesView : ReactiveUserControl this.BindCommand(ViewModel, vm => vm.Export2ClientConfigClipboardCmd, v => v.menuExport2ClientConfigClipboard).DisposeWith(disposables); this.BindCommand(ViewModel, vm => vm.Export2ShareUrlCmd, v => v.menuExport2ShareUrl).DisposeWith(disposables); this.BindCommand(ViewModel, vm => vm.Export2ShareUrlBase64Cmd, v => v.menuExport2ShareUrlBase64).DisposeWith(disposables); + this.BindCommand(ViewModel, vm => vm.Export2InnerUriCmd, v => v.menuExport4Backup).DisposeWith(disposables); AppEvents.AppExitRequested .AsObservable() diff --git a/v2rayN/v2rayN/Views/ProfilesView.xaml b/v2rayN/v2rayN/Views/ProfilesView.xaml index 97d3af3f..8c8a9335 100644 --- a/v2rayN/v2rayN/Views/ProfilesView.xaml +++ b/v2rayN/v2rayN/Views/ProfilesView.xaml @@ -242,6 +242,10 @@ x:Name="menuExport2ShareUrlBase64" Height="{StaticResource MenuItemHeight}" Header="{x:Static resx:ResUI.menuExport2ShareUrlBase64}" /> + diff --git a/v2rayN/v2rayN/Views/ProfilesView.xaml.cs b/v2rayN/v2rayN/Views/ProfilesView.xaml.cs index 4445194d..a73e7018 100644 --- a/v2rayN/v2rayN/Views/ProfilesView.xaml.cs +++ b/v2rayN/v2rayN/Views/ProfilesView.xaml.cs @@ -82,6 +82,7 @@ public partial class ProfilesView this.BindCommand(ViewModel, vm => vm.Export2ClientConfigClipboardCmd, v => v.menuExport2ClientConfigClipboard).DisposeWith(disposables); this.BindCommand(ViewModel, vm => vm.Export2ShareUrlCmd, v => v.menuExport2ShareUrl).DisposeWith(disposables); this.BindCommand(ViewModel, vm => vm.Export2ShareUrlBase64Cmd, v => v.menuExport2ShareUrlBase64).DisposeWith(disposables); + this.BindCommand(ViewModel, vm => vm.Export2InnerUriCmd, v => v.menuExport4Backup).DisposeWith(disposables); AppEvents.AppExitRequested .AsObservable()