mirror of
https://github.com/2dust/v2rayN.git
synced 2026-10-17 10:45:02 +03:00
Add Manager folder
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
namespace ServiceLib.Manager;
|
||||
|
||||
public sealed class AppHandler
|
||||
{
|
||||
#region Property
|
||||
|
||||
private static readonly Lazy<AppHandler> _instance = new(() => new());
|
||||
private Config _config;
|
||||
private int? _statePort;
|
||||
private int? _statePort2;
|
||||
private Job? _processJob;
|
||||
public static AppHandler Instance => _instance.Value;
|
||||
public Config Config => _config;
|
||||
|
||||
public int StatePort
|
||||
{
|
||||
get
|
||||
{
|
||||
_statePort ??= Utils.GetFreePort(GetLocalPort(EInboundProtocol.api));
|
||||
return _statePort.Value;
|
||||
}
|
||||
}
|
||||
|
||||
public int StatePort2
|
||||
{
|
||||
get
|
||||
{
|
||||
_statePort2 ??= Utils.GetFreePort(GetLocalPort(EInboundProtocol.api2));
|
||||
return _statePort2.Value + (_config.TunModeItem.EnableTun ? 1 : 0);
|
||||
}
|
||||
}
|
||||
|
||||
public string LinuxSudoPwd { get; set; }
|
||||
|
||||
#endregion Property
|
||||
|
||||
#region Init
|
||||
|
||||
public bool InitApp()
|
||||
{
|
||||
if (Utils.HasWritePermission() == false)
|
||||
{
|
||||
Environment.SetEnvironmentVariable(Global.LocalAppData, "1", EnvironmentVariableTarget.Process);
|
||||
}
|
||||
|
||||
Logging.Setup();
|
||||
var config = ConfigHandler.LoadConfig();
|
||||
if (config == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
_config = config;
|
||||
Thread.CurrentThread.CurrentUICulture = new(_config.UiItem.CurrentLanguage);
|
||||
|
||||
//Under Win10
|
||||
if (Utils.IsWindows() && Environment.OSVersion.Version.Major < 10)
|
||||
{
|
||||
Environment.SetEnvironmentVariable("DOTNET_EnableWriteXorExecute", "0", EnvironmentVariableTarget.User);
|
||||
}
|
||||
|
||||
SQLiteHelper.Instance.CreateTable<SubItem>();
|
||||
SQLiteHelper.Instance.CreateTable<ProfileItem>();
|
||||
SQLiteHelper.Instance.CreateTable<ServerStatItem>();
|
||||
SQLiteHelper.Instance.CreateTable<RoutingItem>();
|
||||
SQLiteHelper.Instance.CreateTable<ProfileExItem>();
|
||||
SQLiteHelper.Instance.CreateTable<DNSItem>();
|
||||
SQLiteHelper.Instance.CreateTable<FullConfigTemplateItem>();
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool InitComponents()
|
||||
{
|
||||
Logging.SaveLog($"v2rayN start up | {Utils.GetRuntimeInfo()}");
|
||||
Logging.LoggingEnabled(_config.GuiItem.EnableLog);
|
||||
|
||||
//First determine the port value
|
||||
_ = StatePort;
|
||||
_ = StatePort2;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool Reset()
|
||||
{
|
||||
_statePort = null;
|
||||
_statePort2 = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
#endregion Init
|
||||
|
||||
#region Config
|
||||
|
||||
public int GetLocalPort(EInboundProtocol protocol)
|
||||
{
|
||||
var localPort = _config.Inbound.FirstOrDefault(t => t.Protocol == nameof(EInboundProtocol.socks))?.LocalPort ?? 10808;
|
||||
return localPort + (int)protocol;
|
||||
}
|
||||
|
||||
public void AddProcess(nint processHandle)
|
||||
{
|
||||
if (Utils.IsWindows())
|
||||
{
|
||||
_processJob ??= new();
|
||||
try
|
||||
{
|
||||
_processJob?.AddProcess(processHandle);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion Config
|
||||
|
||||
#region SqliteHelper
|
||||
|
||||
public async Task<List<SubItem>?> SubItems()
|
||||
{
|
||||
return await SQLiteHelper.Instance.TableAsync<SubItem>().OrderBy(t => t.Sort).ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<SubItem?> GetSubItem(string? subid)
|
||||
{
|
||||
return await SQLiteHelper.Instance.TableAsync<SubItem>().FirstOrDefaultAsync(t => t.Id == subid);
|
||||
}
|
||||
|
||||
public async Task<List<ProfileItem>?> ProfileItems(string subid)
|
||||
{
|
||||
if (subid.IsNullOrEmpty())
|
||||
{
|
||||
return await SQLiteHelper.Instance.TableAsync<ProfileItem>().ToListAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
return await SQLiteHelper.Instance.TableAsync<ProfileItem>().Where(t => t.Subid == subid).ToListAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<string>?> ProfileItemIndexes(string subid)
|
||||
{
|
||||
return (await ProfileItems(subid))?.Select(t => t.IndexId)?.ToList();
|
||||
}
|
||||
|
||||
public async Task<List<ProfileItemModel>?> ProfileItems(string subid, string filter)
|
||||
{
|
||||
var sql = @$"select a.*
|
||||
,b.remarks subRemarks
|
||||
from ProfileItem a
|
||||
left join SubItem b on a.subid = b.id
|
||||
where 1=1 ";
|
||||
if (subid.IsNotEmpty())
|
||||
{
|
||||
sql += $" and a.subid = '{subid}'";
|
||||
}
|
||||
if (filter.IsNotEmpty())
|
||||
{
|
||||
if (filter.Contains('\''))
|
||||
{
|
||||
filter = filter.Replace("'", "");
|
||||
}
|
||||
sql += string.Format(" and (a.remarks like '%{0}%' or a.address like '%{0}%') ", filter);
|
||||
}
|
||||
|
||||
return await SQLiteHelper.Instance.QueryAsync<ProfileItemModel>(sql);
|
||||
}
|
||||
|
||||
public async Task<ProfileItem?> GetProfileItem(string indexId)
|
||||
{
|
||||
if (indexId.IsNullOrEmpty())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return await SQLiteHelper.Instance.TableAsync<ProfileItem>().FirstOrDefaultAsync(it => it.IndexId == indexId);
|
||||
}
|
||||
|
||||
public async Task<ProfileItem?> GetProfileItemViaRemarks(string? remarks)
|
||||
{
|
||||
if (remarks.IsNullOrEmpty())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return await SQLiteHelper.Instance.TableAsync<ProfileItem>().FirstOrDefaultAsync(it => it.Remarks == remarks);
|
||||
}
|
||||
|
||||
public async Task<List<RoutingItem>?> RoutingItems()
|
||||
{
|
||||
return await SQLiteHelper.Instance.TableAsync<RoutingItem>().OrderBy(t => t.Sort).ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<RoutingItem?> GetRoutingItem(string id)
|
||||
{
|
||||
return await SQLiteHelper.Instance.TableAsync<RoutingItem>().FirstOrDefaultAsync(it => it.Id == id);
|
||||
}
|
||||
|
||||
public async Task<List<DNSItem>?> DNSItems()
|
||||
{
|
||||
return await SQLiteHelper.Instance.TableAsync<DNSItem>().ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<DNSItem?> GetDNSItem(ECoreType eCoreType)
|
||||
{
|
||||
return await SQLiteHelper.Instance.TableAsync<DNSItem>().FirstOrDefaultAsync(it => it.CoreType == eCoreType);
|
||||
}
|
||||
|
||||
public async Task<List<FullConfigTemplateItem>?> FullConfigTemplateItem()
|
||||
{
|
||||
return await SQLiteHelper.Instance.TableAsync<FullConfigTemplateItem>().ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<FullConfigTemplateItem?> GetFullConfigTemplateItem(ECoreType eCoreType)
|
||||
{
|
||||
return await SQLiteHelper.Instance.TableAsync<FullConfigTemplateItem>().FirstOrDefaultAsync(it => it.CoreType == eCoreType);
|
||||
}
|
||||
|
||||
#endregion SqliteHelper
|
||||
|
||||
#region Core Type
|
||||
|
||||
public List<string> GetShadowsocksSecurities(ProfileItem profileItem)
|
||||
{
|
||||
var coreType = GetCoreType(profileItem, EConfigType.Shadowsocks);
|
||||
switch (coreType)
|
||||
{
|
||||
case ECoreType.v2fly:
|
||||
return Global.SsSecurities;
|
||||
|
||||
case ECoreType.Xray:
|
||||
return Global.SsSecuritiesInXray;
|
||||
|
||||
case ECoreType.sing_box:
|
||||
return Global.SsSecuritiesInSingbox;
|
||||
}
|
||||
return Global.SsSecuritiesInSingbox;
|
||||
}
|
||||
|
||||
public ECoreType GetCoreType(ProfileItem profileItem, EConfigType eConfigType)
|
||||
{
|
||||
if (profileItem?.CoreType != null)
|
||||
{
|
||||
return (ECoreType)profileItem.CoreType;
|
||||
}
|
||||
|
||||
var item = _config.CoreTypeItem?.FirstOrDefault(it => it.ConfigType == eConfigType);
|
||||
return item?.CoreType ?? ECoreType.Xray;
|
||||
}
|
||||
|
||||
#endregion Core Type
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
using static ServiceLib.Models.ClashProxies;
|
||||
|
||||
namespace ServiceLib.Manager;
|
||||
|
||||
public sealed class ClashApiHandler
|
||||
{
|
||||
private static readonly Lazy<ClashApiHandler> instance = new(() => new());
|
||||
public static ClashApiHandler Instance => instance.Value;
|
||||
|
||||
private static readonly string _tag = "ClashApiHandler";
|
||||
private Dictionary<string, ProxiesItem>? _proxies;
|
||||
public Dictionary<string, object> ProfileContent { get; set; }
|
||||
|
||||
public async Task<Tuple<ClashProxies, ClashProviders>?> GetClashProxiesAsync()
|
||||
{
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
var url = $"{GetApiUrl()}/proxies";
|
||||
var result = await HttpClientHelper.Instance.TryGetAsync(url);
|
||||
var clashProxies = JsonUtils.Deserialize<ClashProxies>(result);
|
||||
|
||||
var url2 = $"{GetApiUrl()}/providers/proxies";
|
||||
var result2 = await HttpClientHelper.Instance.TryGetAsync(url2);
|
||||
var clashProviders = JsonUtils.Deserialize<ClashProviders>(result2);
|
||||
|
||||
if (clashProxies != null || clashProviders != null)
|
||||
{
|
||||
_proxies = clashProxies?.proxies;
|
||||
return new Tuple<ClashProxies, ClashProviders>(clashProxies, clashProviders);
|
||||
}
|
||||
|
||||
await Task.Delay(2000);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public void ClashProxiesDelayTest(bool blAll, List<ClashProxyModel> lstProxy, Action<ClashProxyModel?, string> updateFunc)
|
||||
{
|
||||
Task.Run(async () =>
|
||||
{
|
||||
if (blAll)
|
||||
{
|
||||
if (_proxies == null)
|
||||
{
|
||||
await GetClashProxiesAsync();
|
||||
}
|
||||
lstProxy = new List<ClashProxyModel>();
|
||||
foreach (var kv in _proxies ?? [])
|
||||
{
|
||||
if (Global.notAllowTestType.Contains(kv.Value.type?.ToLower()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
lstProxy.Add(new ClashProxyModel()
|
||||
{
|
||||
Name = kv.Value.name,
|
||||
Type = kv.Value.type?.ToLower(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (lstProxy is not { Count: > 0 })
|
||||
{
|
||||
return;
|
||||
}
|
||||
var urlBase = $"{GetApiUrl()}/proxies";
|
||||
urlBase += @"/{0}/delay?timeout=10000&url=" + AppHandler.Instance.Config.SpeedTestItem.SpeedPingTestUrl;
|
||||
|
||||
var tasks = new List<Task>();
|
||||
foreach (var it in lstProxy)
|
||||
{
|
||||
if (Global.notAllowTestType.Contains(it.Type.ToLower()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var name = it.Name;
|
||||
var url = string.Format(urlBase, name);
|
||||
tasks.Add(Task.Run(async () =>
|
||||
{
|
||||
var result = await HttpClientHelper.Instance.TryGetAsync(url);
|
||||
updateFunc?.Invoke(it, result);
|
||||
}));
|
||||
}
|
||||
await Task.WhenAll(tasks);
|
||||
await Task.Delay(1000);
|
||||
updateFunc?.Invoke(null, "");
|
||||
});
|
||||
}
|
||||
|
||||
public List<ProxiesItem>? GetClashProxyGroups()
|
||||
{
|
||||
try
|
||||
{
|
||||
var fileContent = ProfileContent;
|
||||
if (fileContent is null || fileContent?.ContainsKey("proxy-groups") == false)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return JsonUtils.Deserialize<List<ProxiesItem>>(JsonUtils.Serialize(fileContent["proxy-groups"]));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logging.SaveLog(_tag, ex);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ClashSetActiveProxy(string name, string nameNode)
|
||||
{
|
||||
try
|
||||
{
|
||||
var url = $"{GetApiUrl()}/proxies/{name}";
|
||||
var headers = new Dictionary<string, string>();
|
||||
headers.Add("name", nameNode);
|
||||
await HttpClientHelper.Instance.PutAsync(url, headers);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logging.SaveLog(_tag, ex);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ClashConfigUpdate(Dictionary<string, string> headers)
|
||||
{
|
||||
if (_proxies == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var urlBase = $"{GetApiUrl()}/configs";
|
||||
|
||||
await HttpClientHelper.Instance.PatchAsync(urlBase, headers);
|
||||
}
|
||||
|
||||
public async Task ClashConfigReload(string filePath)
|
||||
{
|
||||
await ClashConnectionClose("");
|
||||
try
|
||||
{
|
||||
var url = $"{GetApiUrl()}/configs?force=true";
|
||||
var headers = new Dictionary<string, string>();
|
||||
headers.Add("path", filePath);
|
||||
await HttpClientHelper.Instance.PutAsync(url, headers);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logging.SaveLog(_tag, ex);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ClashConnections?> GetClashConnectionsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var url = $"{GetApiUrl()}/connections";
|
||||
var result = await HttpClientHelper.Instance.TryGetAsync(url);
|
||||
var clashConnections = JsonUtils.Deserialize<ClashConnections>(result);
|
||||
|
||||
return clashConnections;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logging.SaveLog(_tag, ex);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task ClashConnectionClose(string id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var url = $"{GetApiUrl()}/connections/{id}";
|
||||
await HttpClientHelper.Instance.DeleteAsync(url);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logging.SaveLog(_tag, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private string GetApiUrl()
|
||||
{
|
||||
return $"{Global.HttpProtocol}{Global.Loopback}:{AppHandler.Instance.StatePort2}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using CliWrap;
|
||||
using CliWrap.Buffered;
|
||||
|
||||
namespace ServiceLib.Manager;
|
||||
|
||||
public class CoreAdminHandler
|
||||
{
|
||||
private static readonly Lazy<CoreAdminHandler> _instance = new(() => new());
|
||||
public static CoreAdminHandler Instance => _instance.Value;
|
||||
private Config _config;
|
||||
private Action<bool, string>? _updateFunc;
|
||||
private int _linuxSudoPid = -1;
|
||||
private const string _tag = "CoreAdminHandler";
|
||||
|
||||
public async Task Init(Config config, Action<bool, string> updateFunc)
|
||||
{
|
||||
if (_config != null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
_config = config;
|
||||
_updateFunc = updateFunc;
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void UpdateFunc(bool notify, string msg)
|
||||
{
|
||||
_updateFunc?.Invoke(notify, msg);
|
||||
}
|
||||
|
||||
public async Task<Process?> RunProcessAsLinuxSudo(string fileName, CoreInfo coreInfo, string configPath)
|
||||
{
|
||||
StringBuilder sb = new();
|
||||
sb.AppendLine("#!/bin/bash");
|
||||
var cmdLine = $"{fileName.AppendQuotes()} {string.Format(coreInfo.Arguments, Utils.GetBinConfigPath(configPath).AppendQuotes())}";
|
||||
sb.AppendLine($"sudo -S {cmdLine}");
|
||||
var shFilePath = await FileManager.CreateLinuxShellFile("run_as_sudo.sh", sb.ToString(), true);
|
||||
|
||||
Process proc = new()
|
||||
{
|
||||
StartInfo = new()
|
||||
{
|
||||
FileName = shFilePath,
|
||||
Arguments = "",
|
||||
WorkingDirectory = Utils.GetBinConfigPath(),
|
||||
UseShellExecute = false,
|
||||
RedirectStandardInput = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
StandardOutputEncoding = Encoding.UTF8,
|
||||
StandardErrorEncoding = Encoding.UTF8,
|
||||
}
|
||||
};
|
||||
|
||||
void dataHandler(object sender, DataReceivedEventArgs e)
|
||||
{
|
||||
if (e.Data.IsNotEmpty())
|
||||
{
|
||||
UpdateFunc(false, e.Data + Environment.NewLine);
|
||||
}
|
||||
}
|
||||
|
||||
proc.OutputDataReceived += dataHandler;
|
||||
proc.ErrorDataReceived += dataHandler;
|
||||
|
||||
proc.Start();
|
||||
proc.BeginOutputReadLine();
|
||||
proc.BeginErrorReadLine();
|
||||
|
||||
await Task.Delay(10);
|
||||
await proc.StandardInput.WriteLineAsync(AppHandler.Instance.LinuxSudoPwd);
|
||||
|
||||
await Task.Delay(100);
|
||||
if (proc is null or { HasExited: true })
|
||||
{
|
||||
throw new Exception(ResUI.FailedToRunCore);
|
||||
}
|
||||
|
||||
_linuxSudoPid = proc.Id;
|
||||
|
||||
return proc;
|
||||
}
|
||||
|
||||
public async Task KillProcessAsLinuxSudo()
|
||||
{
|
||||
if (_linuxSudoPid < 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var shellFileName = Utils.IsOSX() ? Global.KillAsSudoOSXShellFileName : Global.KillAsSudoLinuxShellFileName;
|
||||
var shFilePath = await FileManager.CreateLinuxShellFile("kill_as_sudo.sh", EmbedUtils.GetEmbedText(shellFileName), true);
|
||||
if (shFilePath.Contains(' '))
|
||||
{
|
||||
shFilePath = shFilePath.AppendQuotes();
|
||||
}
|
||||
var arg = new List<string>() { "-c", $"sudo -S {shFilePath} {_linuxSudoPid}" };
|
||||
var result = await Cli.Wrap(Global.LinuxBash)
|
||||
.WithArguments(arg)
|
||||
.WithStandardInputPipe(PipeSource.FromString(AppHandler.Instance.LinuxSudoPwd))
|
||||
.ExecuteBufferedAsync();
|
||||
|
||||
UpdateFunc(false, result.StandardOutput.ToString());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logging.SaveLog(_tag, ex);
|
||||
}
|
||||
|
||||
_linuxSudoPid = -1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
|
||||
namespace ServiceLib.Manager;
|
||||
|
||||
/// <summary>
|
||||
/// Core process processing class
|
||||
/// </summary>
|
||||
public class CoreHandler
|
||||
{
|
||||
private static readonly Lazy<CoreHandler> _instance = new(() => new());
|
||||
public static CoreHandler Instance => _instance.Value;
|
||||
private Config _config;
|
||||
private Process? _process;
|
||||
private Process? _processPre;
|
||||
private bool _linuxSudo = false;
|
||||
private Action<bool, string>? _updateFunc;
|
||||
private const string _tag = "CoreHandler";
|
||||
|
||||
public async Task Init(Config config, Action<bool, string> updateFunc)
|
||||
{
|
||||
_config = config;
|
||||
_updateFunc = updateFunc;
|
||||
|
||||
Environment.SetEnvironmentVariable(Global.V2RayLocalAsset, Utils.GetBinPath(""), EnvironmentVariableTarget.Process);
|
||||
Environment.SetEnvironmentVariable(Global.XrayLocalAsset, Utils.GetBinPath(""), EnvironmentVariableTarget.Process);
|
||||
Environment.SetEnvironmentVariable(Global.XrayLocalCert, Utils.GetBinPath(""), EnvironmentVariableTarget.Process);
|
||||
|
||||
//Copy the bin folder to the storage location (for init)
|
||||
if (Environment.GetEnvironmentVariable(Global.LocalAppData) == "1")
|
||||
{
|
||||
var fromPath = Utils.GetBaseDirectory("bin");
|
||||
var toPath = Utils.GetBinPath("");
|
||||
if (fromPath != toPath)
|
||||
{
|
||||
FileManager.CopyDirectory(fromPath, toPath, true, false);
|
||||
}
|
||||
}
|
||||
|
||||
if (Utils.IsNonWindows())
|
||||
{
|
||||
var coreInfo = CoreInfoHandler.Instance.GetCoreInfo();
|
||||
foreach (var it in coreInfo)
|
||||
{
|
||||
if (it.CoreType == ECoreType.v2rayN)
|
||||
{
|
||||
if (Utils.UpgradeAppExists(out var upgradeFileName))
|
||||
{
|
||||
await Utils.SetLinuxChmod(upgradeFileName);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var name in it.CoreExes)
|
||||
{
|
||||
var exe = Utils.GetBinPath(Utils.GetExeName(name), it.CoreType.ToString());
|
||||
if (File.Exists(exe))
|
||||
{
|
||||
await Utils.SetLinuxChmod(exe);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task LoadCore(ProfileItem? node)
|
||||
{
|
||||
if (node == null)
|
||||
{
|
||||
UpdateFunc(false, ResUI.CheckServerSettings);
|
||||
return;
|
||||
}
|
||||
|
||||
var fileName = Utils.GetBinConfigPath(Global.CoreConfigFileName);
|
||||
var result = await CoreConfigHandler.GenerateClientConfig(node, fileName);
|
||||
if (result.Success != true)
|
||||
{
|
||||
UpdateFunc(true, result.Msg);
|
||||
return;
|
||||
}
|
||||
|
||||
UpdateFunc(false, $"{node.GetSummary()}");
|
||||
UpdateFunc(false, $"{Utils.GetRuntimeInfo()}");
|
||||
UpdateFunc(false, string.Format(ResUI.StartService, DateTime.Now.ToString("yyyy/MM/dd HH:mm:ss")));
|
||||
await CoreStop();
|
||||
await Task.Delay(100);
|
||||
|
||||
if (Utils.IsWindows() && _config.TunModeItem.EnableTun)
|
||||
{
|
||||
await Task.Delay(100);
|
||||
await WindowsUtils.RemoveTunDevice();
|
||||
}
|
||||
|
||||
await CoreStart(node);
|
||||
await CoreStartPreService(node);
|
||||
if (_process != null)
|
||||
{
|
||||
UpdateFunc(true, $"{node.GetSummary()}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<int> LoadCoreConfigSpeedtest(List<ServerTestItem> selecteds)
|
||||
{
|
||||
var coreType = selecteds.Exists(t => t.ConfigType is EConfigType.Hysteria2 or EConfigType.TUIC or EConfigType.Anytls) ? ECoreType.sing_box : ECoreType.Xray;
|
||||
var fileName = string.Format(Global.CoreSpeedtestConfigFileName, Utils.GetGuid(false));
|
||||
var configPath = Utils.GetBinConfigPath(fileName);
|
||||
var result = await CoreConfigHandler.GenerateClientSpeedtestConfig(_config, configPath, selecteds, coreType);
|
||||
UpdateFunc(false, result.Msg);
|
||||
if (result.Success != true)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
UpdateFunc(false, string.Format(ResUI.StartService, DateTime.Now.ToString("yyyy/MM/dd HH:mm:ss")));
|
||||
UpdateFunc(false, configPath);
|
||||
|
||||
var coreInfo = CoreInfoHandler.Instance.GetCoreInfo(coreType);
|
||||
var proc = await RunProcess(coreInfo, fileName, true, false);
|
||||
if (proc is null)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
return proc.Id;
|
||||
}
|
||||
|
||||
public async Task<int> LoadCoreConfigSpeedtest(ServerTestItem testItem)
|
||||
{
|
||||
var node = await AppHandler.Instance.GetProfileItem(testItem.IndexId);
|
||||
if (node is null)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
var fileName = string.Format(Global.CoreSpeedtestConfigFileName, Utils.GetGuid(false));
|
||||
var configPath = Utils.GetBinConfigPath(fileName);
|
||||
var result = await CoreConfigHandler.GenerateClientSpeedtestConfig(_config, node, testItem, configPath);
|
||||
if (result.Success != true)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
var coreType = AppHandler.Instance.GetCoreType(node, node.ConfigType);
|
||||
var coreInfo = CoreInfoHandler.Instance.GetCoreInfo(coreType);
|
||||
var proc = await RunProcess(coreInfo, fileName, true, false);
|
||||
if (proc is null)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
return proc.Id;
|
||||
}
|
||||
|
||||
public async Task CoreStop()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_linuxSudo)
|
||||
{
|
||||
await CoreAdminHandler.Instance.KillProcessAsLinuxSudo();
|
||||
_linuxSudo = false;
|
||||
}
|
||||
|
||||
if (_process != null)
|
||||
{
|
||||
await ProcUtils.ProcessKill(_process, Utils.IsWindows());
|
||||
_process = null;
|
||||
}
|
||||
|
||||
if (_processPre != null)
|
||||
{
|
||||
await ProcUtils.ProcessKill(_processPre, Utils.IsWindows());
|
||||
_processPre = null;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logging.SaveLog(_tag, ex);
|
||||
}
|
||||
}
|
||||
|
||||
#region Private
|
||||
|
||||
private async Task CoreStart(ProfileItem node)
|
||||
{
|
||||
var coreType = _config.RunningCoreType = AppHandler.Instance.GetCoreType(node, node.ConfigType);
|
||||
var coreInfo = CoreInfoHandler.Instance.GetCoreInfo(coreType);
|
||||
|
||||
var displayLog = node.ConfigType != EConfigType.Custom || node.DisplayLog;
|
||||
var proc = await RunProcess(coreInfo, Global.CoreConfigFileName, displayLog, true);
|
||||
if (proc is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
_process = proc;
|
||||
}
|
||||
|
||||
private async Task CoreStartPreService(ProfileItem node)
|
||||
{
|
||||
if (_process != null && !_process.HasExited)
|
||||
{
|
||||
var coreType = AppHandler.Instance.GetCoreType(node, node.ConfigType);
|
||||
var itemSocks = await ConfigHandler.GetPreSocksItem(_config, node, coreType);
|
||||
if (itemSocks != null)
|
||||
{
|
||||
var preCoreType = itemSocks.CoreType ?? ECoreType.sing_box;
|
||||
var fileName = Utils.GetBinConfigPath(Global.CorePreConfigFileName);
|
||||
var result = await CoreConfigHandler.GenerateClientConfig(itemSocks, fileName);
|
||||
if (result.Success)
|
||||
{
|
||||
var coreInfo = CoreInfoHandler.Instance.GetCoreInfo(preCoreType);
|
||||
var proc = await RunProcess(coreInfo, Global.CorePreConfigFileName, true, true);
|
||||
if (proc is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
_processPre = proc;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateFunc(bool notify, string msg)
|
||||
{
|
||||
_updateFunc?.Invoke(notify, msg);
|
||||
}
|
||||
|
||||
#endregion Private
|
||||
|
||||
#region Process
|
||||
|
||||
private async Task<Process?> RunProcess(CoreInfo? coreInfo, string configPath, bool displayLog, bool mayNeedSudo)
|
||||
{
|
||||
var fileName = CoreInfoHandler.Instance.GetCoreExecFile(coreInfo, out var msg);
|
||||
if (fileName.IsNullOrEmpty())
|
||||
{
|
||||
UpdateFunc(false, msg);
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (mayNeedSudo
|
||||
&& _config.TunModeItem.EnableTun
|
||||
&& coreInfo.CoreType == ECoreType.sing_box
|
||||
&& Utils.IsNonWindows())
|
||||
{
|
||||
_linuxSudo = true;
|
||||
await CoreAdminHandler.Instance.Init(_config, _updateFunc);
|
||||
return await CoreAdminHandler.Instance.RunProcessAsLinuxSudo(fileName, coreInfo, configPath);
|
||||
}
|
||||
|
||||
return await RunProcessNormal(fileName, coreInfo, configPath, displayLog);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logging.SaveLog(_tag, ex);
|
||||
UpdateFunc(mayNeedSudo, ex.Message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Process?> RunProcessNormal(string fileName, CoreInfo? coreInfo, string configPath, bool displayLog)
|
||||
{
|
||||
Process proc = new()
|
||||
{
|
||||
StartInfo = new()
|
||||
{
|
||||
FileName = fileName,
|
||||
Arguments = string.Format(coreInfo.Arguments, coreInfo.AbsolutePath ? Utils.GetBinConfigPath(configPath).AppendQuotes() : configPath),
|
||||
WorkingDirectory = Utils.GetBinConfigPath(),
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = displayLog,
|
||||
RedirectStandardError = displayLog,
|
||||
CreateNoWindow = true,
|
||||
StandardOutputEncoding = displayLog ? Encoding.UTF8 : null,
|
||||
StandardErrorEncoding = displayLog ? Encoding.UTF8 : null,
|
||||
}
|
||||
};
|
||||
|
||||
if (displayLog)
|
||||
{
|
||||
void dataHandler(object sender, DataReceivedEventArgs e)
|
||||
{
|
||||
if (e.Data.IsNotEmpty())
|
||||
{
|
||||
UpdateFunc(false, e.Data + Environment.NewLine);
|
||||
}
|
||||
}
|
||||
proc.OutputDataReceived += dataHandler;
|
||||
proc.ErrorDataReceived += dataHandler;
|
||||
}
|
||||
proc.Start();
|
||||
|
||||
if (displayLog)
|
||||
{
|
||||
proc.BeginOutputReadLine();
|
||||
proc.BeginErrorReadLine();
|
||||
}
|
||||
|
||||
await Task.Delay(100);
|
||||
AppHandler.Instance.AddProcess(proc.Handle);
|
||||
if (proc is null or { HasExited: true })
|
||||
{
|
||||
throw new Exception(ResUI.FailedToRunCore);
|
||||
}
|
||||
return proc;
|
||||
}
|
||||
|
||||
#endregion Process
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
namespace ServiceLib.Manager;
|
||||
|
||||
public sealed class CoreInfoHandler
|
||||
{
|
||||
private static readonly Lazy<CoreInfoHandler> _instance = new(() => new());
|
||||
private List<CoreInfo>? _coreInfo;
|
||||
public static CoreInfoHandler Instance => _instance.Value;
|
||||
|
||||
public CoreInfoHandler()
|
||||
{
|
||||
InitCoreInfo();
|
||||
}
|
||||
|
||||
public CoreInfo? GetCoreInfo(ECoreType coreType)
|
||||
{
|
||||
if (_coreInfo == null)
|
||||
{
|
||||
InitCoreInfo();
|
||||
}
|
||||
return _coreInfo?.FirstOrDefault(t => t.CoreType == coreType);
|
||||
}
|
||||
|
||||
public List<CoreInfo> GetCoreInfo()
|
||||
{
|
||||
if (_coreInfo == null)
|
||||
{
|
||||
InitCoreInfo();
|
||||
}
|
||||
return _coreInfo ?? [];
|
||||
}
|
||||
|
||||
public string GetCoreExecFile(CoreInfo? coreInfo, out string msg)
|
||||
{
|
||||
var fileName = string.Empty;
|
||||
msg = string.Empty;
|
||||
foreach (var name in coreInfo?.CoreExes)
|
||||
{
|
||||
var vName = Utils.GetBinPath(Utils.GetExeName(name), coreInfo.CoreType.ToString());
|
||||
if (File.Exists(vName))
|
||||
{
|
||||
fileName = vName;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (fileName.IsNullOrEmpty())
|
||||
{
|
||||
msg = string.Format(ResUI.NotFoundCore, Utils.GetBinPath("", coreInfo?.CoreType.ToString()), coreInfo?.CoreExes?.LastOrDefault(), coreInfo?.Url);
|
||||
Logging.SaveLog(msg);
|
||||
}
|
||||
return fileName;
|
||||
}
|
||||
|
||||
private void InitCoreInfo()
|
||||
{
|
||||
var urlN = GetCoreUrl(ECoreType.v2rayN);
|
||||
var urlXray = GetCoreUrl(ECoreType.Xray);
|
||||
var urlMihomo = GetCoreUrl(ECoreType.mihomo);
|
||||
var urlSingbox = GetCoreUrl(ECoreType.sing_box);
|
||||
|
||||
_coreInfo =
|
||||
[
|
||||
new CoreInfo
|
||||
{
|
||||
CoreType = ECoreType.v2rayN,
|
||||
Url = GetCoreUrl(ECoreType.v2rayN),
|
||||
ReleaseApiUrl = urlN.Replace(Global.GithubUrl, Global.GithubApiUrl),
|
||||
DownloadUrlWin64 = urlN + "/download/{0}/v2rayN-windows-64.zip",
|
||||
DownloadUrlWinArm64 = urlN + "/download/{0}/v2rayN-windows-arm64.zip",
|
||||
DownloadUrlLinux64 = urlN + "/download/{0}/v2rayN-linux-64.zip",
|
||||
DownloadUrlLinuxArm64 = urlN + "/download/{0}/v2rayN-linux-arm64.zip",
|
||||
DownloadUrlOSX64 = urlN + "/download/{0}/v2rayN-macos-64.zip",
|
||||
DownloadUrlOSXArm64 = urlN + "/download/{0}/v2rayN-macos-arm64.zip",
|
||||
},
|
||||
|
||||
new CoreInfo
|
||||
{
|
||||
CoreType = ECoreType.v2fly,
|
||||
CoreExes = ["v2ray"],
|
||||
Arguments = "{0}",
|
||||
Url = GetCoreUrl(ECoreType.v2fly),
|
||||
Match = "V2Ray",
|
||||
VersionArg = "-version",
|
||||
},
|
||||
|
||||
new CoreInfo
|
||||
{
|
||||
CoreType = ECoreType.v2fly_v5,
|
||||
CoreExes = ["v2ray"],
|
||||
Arguments = "run -c {0} -format jsonv5",
|
||||
Url = GetCoreUrl(ECoreType.v2fly_v5),
|
||||
Match = "V2Ray",
|
||||
VersionArg = "version",
|
||||
},
|
||||
|
||||
new CoreInfo
|
||||
{
|
||||
CoreType = ECoreType.Xray,
|
||||
CoreExes = ["xray"],
|
||||
Arguments = "run -c {0}",
|
||||
Url = GetCoreUrl(ECoreType.Xray),
|
||||
ReleaseApiUrl = urlXray.Replace(Global.GithubUrl, Global.GithubApiUrl),
|
||||
DownloadUrlWin64 = urlXray + "/download/{0}/Xray-windows-64.zip",
|
||||
DownloadUrlWinArm64 = urlXray + "/download/{0}/Xray-windows-arm64-v8a.zip",
|
||||
DownloadUrlLinux64 = urlXray + "/download/{0}/Xray-linux-64.zip",
|
||||
DownloadUrlLinuxArm64 = urlXray + "/download/{0}/Xray-linux-arm64-v8a.zip",
|
||||
DownloadUrlOSX64 = urlXray + "/download/{0}/Xray-macos-64.zip",
|
||||
DownloadUrlOSXArm64 = urlXray + "/download/{0}/Xray-macos-arm64-v8a.zip",
|
||||
Match = "Xray",
|
||||
VersionArg = "-version",
|
||||
},
|
||||
|
||||
new CoreInfo
|
||||
{
|
||||
CoreType = ECoreType.mihomo,
|
||||
CoreExes = ["mihomo-windows-amd64-compatible", "mihomo-windows-amd64", "mihomo-linux-amd64", "clash", "mihomo"],
|
||||
Arguments = "-f {0}" + PortableMode(),
|
||||
Url = GetCoreUrl(ECoreType.mihomo),
|
||||
ReleaseApiUrl = urlMihomo.Replace(Global.GithubUrl, Global.GithubApiUrl),
|
||||
DownloadUrlWin64 = urlMihomo + "/download/{0}/mihomo-windows-amd64-compatible-{0}.zip",
|
||||
DownloadUrlWinArm64 = urlMihomo + "/download/{0}/mihomo-windows-arm64-{0}.zip",
|
||||
DownloadUrlLinux64 = urlMihomo + "/download/{0}/mihomo-linux-amd64-compatible-{0}.gz",
|
||||
DownloadUrlLinuxArm64 = urlMihomo + "/download/{0}/mihomo-linux-arm64-{0}.gz",
|
||||
DownloadUrlOSX64 = urlMihomo + "/download/{0}/mihomo-darwin-amd64-compatible-{0}.gz",
|
||||
DownloadUrlOSXArm64 = urlMihomo + "/download/{0}/mihomo-darwin-arm64-{0}.gz",
|
||||
Match = "Mihomo",
|
||||
VersionArg = "-v",
|
||||
},
|
||||
|
||||
new CoreInfo
|
||||
{
|
||||
CoreType = ECoreType.hysteria,
|
||||
CoreExes = ["hysteria"],
|
||||
Arguments = "",
|
||||
Url = GetCoreUrl(ECoreType.hysteria),
|
||||
},
|
||||
|
||||
new CoreInfo
|
||||
{
|
||||
CoreType = ECoreType.naiveproxy,
|
||||
CoreExes = [ "naive", "naiveproxy"],
|
||||
Arguments = "{0}",
|
||||
Url = GetCoreUrl(ECoreType.naiveproxy),
|
||||
},
|
||||
|
||||
new CoreInfo
|
||||
{
|
||||
CoreType = ECoreType.tuic,
|
||||
CoreExes = ["tuic-client", "tuic"],
|
||||
Arguments = "-c {0}",
|
||||
Url = GetCoreUrl(ECoreType.tuic),
|
||||
},
|
||||
|
||||
new CoreInfo
|
||||
{
|
||||
CoreType = ECoreType.sing_box,
|
||||
CoreExes = ["sing-box-client", "sing-box"],
|
||||
Arguments = "run -c {0} --disable-color",
|
||||
Url = GetCoreUrl(ECoreType.sing_box),
|
||||
|
||||
ReleaseApiUrl = urlSingbox.Replace(Global.GithubUrl, Global.GithubApiUrl),
|
||||
DownloadUrlWin64 = urlSingbox + "/download/{0}/sing-box-{1}-windows-amd64.zip",
|
||||
DownloadUrlWinArm64 = urlSingbox + "/download/{0}/sing-box-{1}-windows-arm64.zip",
|
||||
DownloadUrlLinux64 = urlSingbox + "/download/{0}/sing-box-{1}-linux-amd64.tar.gz",
|
||||
DownloadUrlLinuxArm64 = urlSingbox + "/download/{0}/sing-box-{1}-linux-arm64.tar.gz",
|
||||
DownloadUrlOSX64 = urlSingbox + "/download/{0}/sing-box-{1}-darwin-amd64.tar.gz",
|
||||
DownloadUrlOSXArm64 = urlSingbox + "/download/{0}/sing-box-{1}-darwin-arm64.tar.gz",
|
||||
Match = "sing-box",
|
||||
VersionArg = "version",
|
||||
},
|
||||
|
||||
new CoreInfo
|
||||
{
|
||||
CoreType = ECoreType.juicity,
|
||||
CoreExes = ["juicity-client", "juicity"],
|
||||
Arguments = "run -c {0}",
|
||||
Url = GetCoreUrl(ECoreType.juicity)
|
||||
},
|
||||
|
||||
new CoreInfo
|
||||
{
|
||||
CoreType = ECoreType.hysteria2,
|
||||
CoreExes = ["hysteria-windows-amd64", "hysteria-linux-amd64", "hysteria"],
|
||||
Arguments = "",
|
||||
Url = GetCoreUrl(ECoreType.hysteria2),
|
||||
},
|
||||
|
||||
new CoreInfo
|
||||
{
|
||||
CoreType = ECoreType.brook,
|
||||
CoreExes = ["brook_windows_amd64", "brook_linux_amd64", "brook"],
|
||||
Arguments = " {0}",
|
||||
Url = GetCoreUrl(ECoreType.brook),
|
||||
AbsolutePath = true,
|
||||
},
|
||||
|
||||
new CoreInfo
|
||||
{
|
||||
CoreType = ECoreType.overtls,
|
||||
CoreExes = [ "overtls-bin", "overtls"],
|
||||
Arguments = "-r client -c {0}",
|
||||
Url = GetCoreUrl(ECoreType.overtls),
|
||||
AbsolutePath = false,
|
||||
},
|
||||
|
||||
new CoreInfo
|
||||
{
|
||||
CoreType = ECoreType.shadowquic,
|
||||
CoreExes = [ "shadowquic", "shadowquic"],
|
||||
Arguments = "-c {0}",
|
||||
Url = GetCoreUrl(ECoreType.shadowquic),
|
||||
AbsolutePath = false,
|
||||
}
|
||||
|
||||
];
|
||||
}
|
||||
|
||||
private static string PortableMode()
|
||||
{
|
||||
return $" -d {Utils.GetBinPath("").AppendQuotes()}";
|
||||
}
|
||||
|
||||
private static string GetCoreUrl(ECoreType eCoreType)
|
||||
{
|
||||
return $"{Global.GithubUrl}/{Global.CoreUrls[eCoreType]}/releases";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using ReactiveUI;
|
||||
|
||||
namespace ServiceLib.Manager;
|
||||
|
||||
public class NoticeHandler
|
||||
{
|
||||
private static readonly Lazy<NoticeHandler> _instance = new(() => new());
|
||||
public static NoticeHandler Instance => _instance.Value;
|
||||
|
||||
public void Enqueue(string? content)
|
||||
{
|
||||
if (content.IsNullOrEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
MessageBus.Current.SendMessage(content, EMsgCommand.SendSnackMsg.ToString());
|
||||
}
|
||||
|
||||
public void SendMessage(string? content)
|
||||
{
|
||||
if (content.IsNullOrEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
MessageBus.Current.SendMessage(content, EMsgCommand.SendMsgView.ToString());
|
||||
}
|
||||
|
||||
public void SendMessageEx(string? content)
|
||||
{
|
||||
if (content.IsNullOrEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
content = $"{DateTime.Now:yyyy/MM/dd HH:mm:ss} {content}";
|
||||
SendMessage(content);
|
||||
}
|
||||
|
||||
public void SendMessageAndEnqueue(string? msg)
|
||||
{
|
||||
Enqueue(msg);
|
||||
SendMessage(msg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
|
||||
namespace ServiceLib.Manager;
|
||||
|
||||
public class PacHandler
|
||||
{
|
||||
private static readonly Lazy<PacHandler> _instance = new(() => new PacHandler());
|
||||
public static PacHandler Instance => _instance.Value;
|
||||
|
||||
private string _configPath;
|
||||
private int _httpPort;
|
||||
private int _pacPort;
|
||||
private TcpListener? _tcpListener;
|
||||
private byte[] _writeContent;
|
||||
private bool _isRunning;
|
||||
private bool _needRestart = true;
|
||||
|
||||
public async Task StartAsync(string configPath, int httpPort, int pacPort)
|
||||
{
|
||||
_needRestart = configPath != _configPath || httpPort != _httpPort || pacPort != _pacPort || !_isRunning;
|
||||
|
||||
_configPath = configPath;
|
||||
_httpPort = httpPort;
|
||||
_pacPort = pacPort;
|
||||
|
||||
await InitText();
|
||||
|
||||
if (_needRestart)
|
||||
{
|
||||
Stop();
|
||||
RunListener();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task InitText()
|
||||
{
|
||||
var path = Path.Combine(_configPath, "pac.txt");
|
||||
|
||||
// Delete the old pac file
|
||||
if (File.Exists(path) && Utils.GetFileHash(path).Equals("b590c07280f058ef05d5394aa2f927fe"))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
var pac = EmbedUtils.GetEmbedText(Global.PacFileName);
|
||||
await File.AppendAllTextAsync(path, pac);
|
||||
}
|
||||
|
||||
var pacText =
|
||||
(await File.ReadAllTextAsync(path)).Replace("__PROXY__", $"PROXY 127.0.0.1:{_httpPort};DIRECT;");
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("HTTP/1.0 200 OK");
|
||||
sb.AppendLine("Content-type:application/x-ns-proxy-autoconfig");
|
||||
sb.AppendLine("Connection:close");
|
||||
sb.AppendLine("Content-Length:" + Encoding.UTF8.GetByteCount(pacText));
|
||||
sb.AppendLine();
|
||||
sb.Append(pacText);
|
||||
_writeContent = Encoding.UTF8.GetBytes(sb.ToString());
|
||||
}
|
||||
|
||||
private void RunListener()
|
||||
{
|
||||
_tcpListener = TcpListener.Create(_pacPort);
|
||||
_isRunning = true;
|
||||
_tcpListener.Start();
|
||||
Task.Factory.StartNew(async () =>
|
||||
{
|
||||
while (_isRunning)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_tcpListener.Pending())
|
||||
{
|
||||
await Task.Delay(10);
|
||||
continue;
|
||||
}
|
||||
|
||||
var client = await _tcpListener.AcceptTcpClientAsync();
|
||||
await Task.Run(() => WriteContent(client));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignored
|
||||
}
|
||||
}
|
||||
}, TaskCreationOptions.LongRunning);
|
||||
}
|
||||
|
||||
private void WriteContent(TcpClient client)
|
||||
{
|
||||
var stream = client.GetStream();
|
||||
stream.Write(_writeContent, 0, _writeContent.Length);
|
||||
stream.Flush();
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
if (_tcpListener == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
_isRunning = false;
|
||||
_tcpListener.Stop();
|
||||
_tcpListener = null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignored
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
//using System.Reactive.Linq;
|
||||
|
||||
namespace ServiceLib.Handler;
|
||||
|
||||
public class ProfileExHandler
|
||||
{
|
||||
private static readonly Lazy<ProfileExHandler> _instance = new(() => new());
|
||||
private ConcurrentBag<ProfileExItem> _lstProfileEx = [];
|
||||
private readonly Queue<string> _queIndexIds = new();
|
||||
public static ProfileExHandler Instance => _instance.Value;
|
||||
private static readonly string _tag = "ProfileExHandler";
|
||||
|
||||
public ProfileExHandler()
|
||||
{
|
||||
//Init();
|
||||
}
|
||||
|
||||
public async Task Init()
|
||||
{
|
||||
await InitData();
|
||||
}
|
||||
|
||||
public async Task<ConcurrentBag<ProfileExItem>> GetProfileExs()
|
||||
{
|
||||
return await Task.FromResult(_lstProfileEx);
|
||||
}
|
||||
|
||||
private async Task InitData()
|
||||
{
|
||||
await SQLiteHelper.Instance.ExecuteAsync($"delete from ProfileExItem where indexId not in ( select indexId from ProfileItem )");
|
||||
|
||||
_lstProfileEx = new(await SQLiteHelper.Instance.TableAsync<ProfileExItem>().ToListAsync());
|
||||
}
|
||||
|
||||
private void IndexIdEnqueue(string indexId)
|
||||
{
|
||||
if (indexId.IsNotEmpty() && !_queIndexIds.Contains(indexId))
|
||||
{
|
||||
_queIndexIds.Enqueue(indexId);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveQueueIndexIds()
|
||||
{
|
||||
var cnt = _queIndexIds.Count;
|
||||
if (cnt > 0)
|
||||
{
|
||||
var lstExists = await SQLiteHelper.Instance.TableAsync<ProfileExItem>().ToListAsync();
|
||||
List<ProfileExItem> lstInserts = [];
|
||||
List<ProfileExItem> lstUpdates = [];
|
||||
|
||||
for (var i = 0; i < cnt; i++)
|
||||
{
|
||||
var id = _queIndexIds.Dequeue();
|
||||
var item = lstExists.FirstOrDefault(t => t.IndexId == id);
|
||||
var itemNew = _lstProfileEx?.FirstOrDefault(t => t.IndexId == id);
|
||||
if (itemNew is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item is not null)
|
||||
{
|
||||
lstUpdates.Add(itemNew);
|
||||
}
|
||||
else
|
||||
{
|
||||
lstInserts.Add(itemNew);
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (lstInserts.Count > 0)
|
||||
{
|
||||
await SQLiteHelper.Instance.InsertAllAsync(lstInserts);
|
||||
}
|
||||
|
||||
if (lstUpdates.Count > 0)
|
||||
{
|
||||
await SQLiteHelper.Instance.UpdateAllAsync(lstUpdates);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logging.SaveLog(_tag, ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ProfileExItem AddProfileEx(string indexId)
|
||||
{
|
||||
var profileEx = new ProfileExItem()
|
||||
{
|
||||
IndexId = indexId,
|
||||
Delay = 0,
|
||||
Speed = 0,
|
||||
Sort = 0,
|
||||
Message = string.Empty
|
||||
};
|
||||
_lstProfileEx.Add(profileEx);
|
||||
IndexIdEnqueue(indexId);
|
||||
return profileEx;
|
||||
}
|
||||
|
||||
private ProfileExItem GetProfileExItem(string? indexId)
|
||||
{
|
||||
return _lstProfileEx.FirstOrDefault(t => t.IndexId == indexId) ?? AddProfileEx(indexId);
|
||||
}
|
||||
|
||||
public async Task ClearAll()
|
||||
{
|
||||
await SQLiteHelper.Instance.ExecuteAsync($"delete from ProfileExItem ");
|
||||
_lstProfileEx = new();
|
||||
}
|
||||
|
||||
public async Task SaveTo()
|
||||
{
|
||||
try
|
||||
{
|
||||
await SaveQueueIndexIds();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logging.SaveLog(_tag, ex);
|
||||
}
|
||||
}
|
||||
|
||||
public void SetTestDelay(string indexId, int delay)
|
||||
{
|
||||
var profileEx = GetProfileExItem(indexId);
|
||||
|
||||
profileEx.Delay = delay;
|
||||
IndexIdEnqueue(indexId);
|
||||
}
|
||||
|
||||
public void SetTestSpeed(string indexId, decimal speed)
|
||||
{
|
||||
var profileEx = GetProfileExItem(indexId);
|
||||
|
||||
profileEx.Speed = speed;
|
||||
IndexIdEnqueue(indexId);
|
||||
}
|
||||
|
||||
public void SetTestMessage(string indexId, string message)
|
||||
{
|
||||
var profileEx = GetProfileExItem(indexId);
|
||||
|
||||
profileEx.Message = message;
|
||||
IndexIdEnqueue(indexId);
|
||||
}
|
||||
|
||||
public void SetSort(string indexId, int sort)
|
||||
{
|
||||
var profileEx = GetProfileExItem(indexId);
|
||||
|
||||
profileEx.Sort = sort;
|
||||
IndexIdEnqueue(indexId);
|
||||
}
|
||||
|
||||
public int GetSort(string indexId)
|
||||
{
|
||||
var profileEx = _lstProfileEx.FirstOrDefault(t => t.IndexId == indexId);
|
||||
if (profileEx == null)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
return profileEx.Sort;
|
||||
}
|
||||
|
||||
public int GetMaxSort()
|
||||
{
|
||||
if (_lstProfileEx.Count <= 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
return _lstProfileEx.Max(t => t == null ? 0 : t.Sort);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
namespace ServiceLib.Manager;
|
||||
|
||||
public class StatisticsHandler
|
||||
{
|
||||
private static readonly Lazy<StatisticsHandler> instance = new(() => new());
|
||||
public static StatisticsHandler Instance => instance.Value;
|
||||
|
||||
private Config _config;
|
||||
private ServerStatItem? _serverStatItem;
|
||||
private List<ServerStatItem> _lstServerStat;
|
||||
private Action<ServerSpeedItem>? _updateFunc;
|
||||
|
||||
private StatisticsXrayService? _statisticsXray;
|
||||
private StatisticsSingboxService? _statisticsSingbox;
|
||||
private static readonly string _tag = "StatisticsHandler";
|
||||
public List<ServerStatItem> ServerStat => _lstServerStat;
|
||||
|
||||
public async Task Init(Config config, Action<ServerSpeedItem> updateFunc)
|
||||
{
|
||||
_config = config;
|
||||
_updateFunc = updateFunc;
|
||||
if (config.GuiItem.EnableStatistics || _config.GuiItem.DisplayRealTimeSpeed)
|
||||
{
|
||||
await InitData();
|
||||
|
||||
_statisticsXray = new StatisticsXrayService(config, UpdateServerStatHandler);
|
||||
_statisticsSingbox = new StatisticsSingboxService(config, UpdateServerStatHandler);
|
||||
}
|
||||
}
|
||||
|
||||
public void Close()
|
||||
{
|
||||
try
|
||||
{
|
||||
_statisticsXray?.Close();
|
||||
_statisticsSingbox?.Close();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logging.SaveLog(_tag, ex);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ClearAllServerStatistics()
|
||||
{
|
||||
await SQLiteHelper.Instance.ExecuteAsync($"delete from ServerStatItem ");
|
||||
_serverStatItem = null;
|
||||
_lstServerStat = new();
|
||||
}
|
||||
|
||||
public async Task SaveTo()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_lstServerStat != null)
|
||||
{
|
||||
await SQLiteHelper.Instance.UpdateAllAsync(_lstServerStat);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logging.SaveLog(_tag, ex);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task CloneServerStatItem(string indexId, string toIndexId)
|
||||
{
|
||||
if (_lstServerStat == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (indexId == toIndexId)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var stat = _lstServerStat.FirstOrDefault(t => t.IndexId == indexId);
|
||||
if (stat == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var toStat = JsonUtils.DeepCopy(stat);
|
||||
toStat.IndexId = toIndexId;
|
||||
await SQLiteHelper.Instance.ReplaceAsync(toStat);
|
||||
_lstServerStat.Add(toStat);
|
||||
}
|
||||
|
||||
private async Task InitData()
|
||||
{
|
||||
await SQLiteHelper.Instance.ExecuteAsync($"delete from ServerStatItem where indexId not in ( select indexId from ProfileItem )");
|
||||
|
||||
var ticks = DateTime.Now.Date.Ticks;
|
||||
await SQLiteHelper.Instance.ExecuteAsync($"update ServerStatItem set todayUp = 0,todayDown=0,dateNow={ticks} where dateNow<>{ticks}");
|
||||
|
||||
_lstServerStat = await SQLiteHelper.Instance.TableAsync<ServerStatItem>().ToListAsync();
|
||||
}
|
||||
|
||||
private void UpdateServerStatHandler(ServerSpeedItem server)
|
||||
{
|
||||
_ = UpdateServerStat(server);
|
||||
}
|
||||
|
||||
private async Task UpdateServerStat(ServerSpeedItem server)
|
||||
{
|
||||
await GetServerStatItem(_config.IndexId);
|
||||
|
||||
if (_serverStatItem is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (server.ProxyUp != 0 || server.ProxyDown != 0)
|
||||
{
|
||||
_serverStatItem.TodayUp += server.ProxyUp;
|
||||
_serverStatItem.TodayDown += server.ProxyDown;
|
||||
_serverStatItem.TotalUp += server.ProxyUp;
|
||||
_serverStatItem.TotalDown += server.ProxyDown;
|
||||
}
|
||||
|
||||
server.IndexId = _config.IndexId;
|
||||
server.TodayUp = _serverStatItem.TodayUp;
|
||||
server.TodayDown = _serverStatItem.TodayDown;
|
||||
server.TotalUp = _serverStatItem.TotalUp;
|
||||
server.TotalDown = _serverStatItem.TotalDown;
|
||||
_updateFunc?.Invoke(server);
|
||||
}
|
||||
|
||||
private async Task GetServerStatItem(string indexId)
|
||||
{
|
||||
var ticks = DateTime.Now.Date.Ticks;
|
||||
if (_serverStatItem != null && _serverStatItem.IndexId != indexId)
|
||||
{
|
||||
_serverStatItem = null;
|
||||
}
|
||||
|
||||
if (_serverStatItem == null)
|
||||
{
|
||||
_serverStatItem = _lstServerStat.FirstOrDefault(t => t.IndexId == indexId);
|
||||
if (_serverStatItem == null)
|
||||
{
|
||||
_serverStatItem = new ServerStatItem
|
||||
{
|
||||
IndexId = indexId,
|
||||
TotalUp = 0,
|
||||
TotalDown = 0,
|
||||
TodayUp = 0,
|
||||
TodayDown = 0,
|
||||
DateNow = ticks
|
||||
};
|
||||
await SQLiteHelper.Instance.ReplaceAsync(_serverStatItem);
|
||||
_lstServerStat.Add(_serverStatItem);
|
||||
}
|
||||
}
|
||||
|
||||
if (_serverStatItem.DateNow != ticks)
|
||||
{
|
||||
_serverStatItem.TodayUp = 0;
|
||||
_serverStatItem.TodayDown = 0;
|
||||
_serverStatItem.DateNow = ticks;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
namespace ServiceLib.Manager;
|
||||
|
||||
public class TaskHandler
|
||||
{
|
||||
private static readonly Lazy<TaskHandler> _instance = new(() => new());
|
||||
public static TaskHandler Instance => _instance.Value;
|
||||
|
||||
public void RegUpdateTask(Config config, Action<bool, string> updateFunc)
|
||||
{
|
||||
Task.Run(() => ScheduledTasks(config, updateFunc));
|
||||
}
|
||||
|
||||
private async Task ScheduledTasks(Config config, Action<bool, string> updateFunc)
|
||||
{
|
||||
Logging.SaveLog("Setup Scheduled Tasks");
|
||||
|
||||
var numOfExecuted = 1;
|
||||
while (true)
|
||||
{
|
||||
//1 minute
|
||||
await Task.Delay(1000 * 60);
|
||||
|
||||
//Execute once 1 minute
|
||||
await UpdateTaskRunSubscription(config, updateFunc);
|
||||
|
||||
//Execute once 20 minute
|
||||
if (numOfExecuted % 20 == 0)
|
||||
{
|
||||
//Logging.SaveLog("Execute save config");
|
||||
|
||||
await ConfigHandler.SaveConfig(config);
|
||||
await ProfileExHandler.Instance.SaveTo();
|
||||
}
|
||||
|
||||
//Execute once 1 hour
|
||||
if (numOfExecuted % 60 == 0)
|
||||
{
|
||||
//Logging.SaveLog("Execute delete expired files");
|
||||
|
||||
FileManager.DeleteExpiredFiles(Utils.GetBinConfigPath(), DateTime.Now.AddHours(-1));
|
||||
FileManager.DeleteExpiredFiles(Utils.GetLogPath(), DateTime.Now.AddMonths(-1));
|
||||
FileManager.DeleteExpiredFiles(Utils.GetTempPath(), DateTime.Now.AddMonths(-1));
|
||||
|
||||
//Check once 1 hour
|
||||
await UpdateTaskRunGeo(config, numOfExecuted / 60, updateFunc);
|
||||
}
|
||||
|
||||
numOfExecuted++;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UpdateTaskRunSubscription(Config config, Action<bool, string> updateFunc)
|
||||
{
|
||||
var updateTime = ((DateTimeOffset)DateTime.Now).ToUnixTimeSeconds();
|
||||
var lstSubs = (await AppHandler.Instance.SubItems())?
|
||||
.Where(t => t.AutoUpdateInterval > 0)
|
||||
.Where(t => updateTime - t.UpdateTime >= t.AutoUpdateInterval * 60)
|
||||
.ToList();
|
||||
|
||||
if (lstSubs is not { Count: > 0 })
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Logging.SaveLog("Execute update subscription");
|
||||
|
||||
foreach (var item in lstSubs)
|
||||
{
|
||||
await SubscriptionHandler.UpdateProcess(config, item.Id, true, (success, msg) =>
|
||||
{
|
||||
updateFunc?.Invoke(success, msg);
|
||||
if (success)
|
||||
{
|
||||
Logging.SaveLog($"Update subscription end. {msg}");
|
||||
}
|
||||
});
|
||||
item.UpdateTime = updateTime;
|
||||
await ConfigHandler.AddSubItem(config, item);
|
||||
await Task.Delay(1000);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UpdateTaskRunGeo(Config config, int hours, Action<bool, string> updateFunc)
|
||||
{
|
||||
if (config.GuiItem.AutoUpdateInterval > 0 && hours > 0 && hours % config.GuiItem.AutoUpdateInterval == 0)
|
||||
{
|
||||
Logging.SaveLog("Execute update geo files");
|
||||
|
||||
var updateHandle = new UpdateService();
|
||||
await updateHandle.UpdateGeoFileAll(config, (success, msg) =>
|
||||
{
|
||||
updateFunc?.Invoke(false, msg);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
using System.Net;
|
||||
using WebDav;
|
||||
|
||||
namespace ServiceLib.Manager;
|
||||
|
||||
public sealed class WebDavHandler
|
||||
{
|
||||
private static readonly Lazy<WebDavHandler> _instance = new(() => new());
|
||||
public static WebDavHandler Instance => _instance.Value;
|
||||
|
||||
private readonly Config? _config;
|
||||
private WebDavClient? _client;
|
||||
private string? _lastDescription;
|
||||
private string _webDir = Global.AppName + "_backup";
|
||||
private readonly string _webFileName = "backup.zip";
|
||||
private readonly string _tag = "WebDav--";
|
||||
|
||||
public WebDavHandler()
|
||||
{
|
||||
_config = AppHandler.Instance.Config;
|
||||
}
|
||||
|
||||
private async Task<bool> GetClient()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_config.WebDavItem.Url.IsNullOrEmpty()
|
||||
|| _config.WebDavItem.UserName.IsNullOrEmpty()
|
||||
|| _config.WebDavItem.Password.IsNullOrEmpty())
|
||||
{
|
||||
throw new ArgumentException("webdav parameter error or null");
|
||||
}
|
||||
if (_client != null)
|
||||
{
|
||||
_client?.Dispose();
|
||||
_client = null;
|
||||
}
|
||||
if (_config.WebDavItem.DirName.IsNullOrEmpty())
|
||||
{
|
||||
_webDir = Global.AppName + "_backup";
|
||||
}
|
||||
else
|
||||
{
|
||||
_webDir = _config.WebDavItem.DirName.TrimEx();
|
||||
}
|
||||
|
||||
var clientParams = new WebDavClientParams
|
||||
{
|
||||
BaseAddress = new Uri(_config.WebDavItem.Url),
|
||||
Credentials = new NetworkCredential(_config.WebDavItem.UserName, _config.WebDavItem.Password)
|
||||
};
|
||||
_client = new WebDavClient(clientParams);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SaveLog(ex);
|
||||
return false;
|
||||
}
|
||||
return await Task.FromResult(true);
|
||||
}
|
||||
|
||||
private async Task<bool> TryCreateDir()
|
||||
{
|
||||
if (_client is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
try
|
||||
{
|
||||
var result2 = await _client.Mkcol(_webDir);
|
||||
if (result2.IsSuccessful)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
SaveLog(result2.Description);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SaveLog(ex);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void SaveLog(string desc)
|
||||
{
|
||||
_lastDescription = desc;
|
||||
Logging.SaveLog(_tag + desc);
|
||||
}
|
||||
|
||||
private void SaveLog(Exception ex)
|
||||
{
|
||||
_lastDescription = ex.Message;
|
||||
Logging.SaveLog(_tag, ex);
|
||||
}
|
||||
|
||||
public async Task<bool> CheckConnection()
|
||||
{
|
||||
if (await GetClient() == false)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
await TryCreateDir();
|
||||
|
||||
try
|
||||
{
|
||||
var testName = "readme_test";
|
||||
var myContent = new StringContent(testName);
|
||||
var result = await _client.PutFile($"{_webDir}/{testName}", myContent);
|
||||
if (result.IsSuccessful)
|
||||
{
|
||||
await _client.Delete($"{_webDir}/{testName}");
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
SaveLog(result.Description);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SaveLog(ex);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public async Task<bool> PutFile(string fileName)
|
||||
{
|
||||
if (await GetClient() == false)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
await TryCreateDir();
|
||||
|
||||
try
|
||||
{
|
||||
await using var fs = File.OpenRead(fileName);
|
||||
var result = await _client.PutFile($"{_webDir}/{_webFileName}", fs); // upload a resource
|
||||
if (result.IsSuccessful)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
SaveLog(result.Description);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SaveLog(ex);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public async Task<bool> GetRawFile(string fileName)
|
||||
{
|
||||
if (await GetClient() == false)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
await TryCreateDir();
|
||||
|
||||
try
|
||||
{
|
||||
var response = await _client.GetRawFile($"{_webDir}/{_webFileName}");
|
||||
if (!response.IsSuccessful)
|
||||
{
|
||||
SaveLog(response.Description);
|
||||
return false;
|
||||
}
|
||||
|
||||
await using var outputFileStream = new FileStream(fileName, FileMode.Create);
|
||||
await response.Stream.CopyToAsync(outputFileStream);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SaveLog(ex);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public string GetLastError() => _lastDescription ?? string.Empty;
|
||||
}
|
||||
Reference in New Issue
Block a user