- New wrapper to handle tcp socket connection. - Fix some memory leak. Signed-off-by: noisyfox <timemanager.rick@gmail.com>tags/3.3.1
@@ -1,7 +1,7 @@ | |||||
using System; | using System; | ||||
using System.Net; | using System.Net; | ||||
using System.Net.Sockets; | using System.Net.Sockets; | ||||
using Shadowsocks.Util; | |||||
using Shadowsocks.Util.Sockets; | |||||
namespace Shadowsocks.Controller | namespace Shadowsocks.Controller | ||||
{ | { | ||||
@@ -29,7 +29,7 @@ namespace Shadowsocks.Controller | |||||
private byte[] _firstPacket; | private byte[] _firstPacket; | ||||
private int _firstPacketLength; | private int _firstPacketLength; | ||||
private Socket _local; | private Socket _local; | ||||
private Socket _remote; | |||||
private WrappedSocket _remote; | |||||
private bool _closed = false; | private bool _closed = false; | ||||
private bool _localShutdown = false; | private bool _localShutdown = false; | ||||
private bool _remoteShutdown = false; | private bool _remoteShutdown = false; | ||||
@@ -49,7 +49,8 @@ namespace Shadowsocks.Controller | |||||
EndPoint remoteEP = SocketUtil.GetEndPoint("localhost", targetPort); | EndPoint remoteEP = SocketUtil.GetEndPoint("localhost", targetPort); | ||||
// Connect to the remote endpoint. | // Connect to the remote endpoint. | ||||
SocketUtil.BeginConnectTcp(remoteEP, ConnectCallback, null); | |||||
_remote = new WrappedSocket(); | |||||
_remote.BeginConnect(remoteEP, ConnectCallback, null); | |||||
} | } | ||||
catch (Exception e) | catch (Exception e) | ||||
{ | { | ||||
@@ -66,7 +67,8 @@ namespace Shadowsocks.Controller | |||||
} | } | ||||
try | try | ||||
{ | { | ||||
_remote = SocketUtil.EndConnectTcp(ar); | |||||
_remote.EndConnect(ar); | |||||
_remote.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.NoDelay, true); | |||||
HandshakeReceive(); | HandshakeReceive(); | ||||
} | } | ||||
catch (Exception e) | catch (Exception e) | ||||
@@ -243,7 +245,7 @@ namespace Shadowsocks.Controller | |||||
try | try | ||||
{ | { | ||||
_remote.Shutdown(SocketShutdown.Both); | _remote.Shutdown(SocketShutdown.Both); | ||||
_remote.Close(); | |||||
_remote.Dispose(); | |||||
} | } | ||||
catch (SocketException e) | catch (SocketException e) | ||||
{ | { | ||||
@@ -9,7 +9,7 @@ using Shadowsocks.Controller.Strategy; | |||||
using Shadowsocks.Encryption; | using Shadowsocks.Encryption; | ||||
using Shadowsocks.Model; | using Shadowsocks.Model; | ||||
using Shadowsocks.Proxy; | using Shadowsocks.Proxy; | ||||
using Shadowsocks.Util; | |||||
using Shadowsocks.Util.Sockets; | |||||
namespace Shadowsocks.Controller | namespace Shadowsocks.Controller | ||||
{ | { | ||||
@@ -40,7 +40,6 @@ namespace Shadowsocks.Controller | |||||
handler.controller = _controller; | handler.controller = _controller; | ||||
handler.tcprelay = this; | handler.tcprelay = this; | ||||
handler.Start(firstPacket, length); | |||||
IList<TCPHandler> handlersToClose = new List<TCPHandler>(); | IList<TCPHandler> handlersToClose = new List<TCPHandler>(); | ||||
lock (Handlers) | lock (Handlers) | ||||
{ | { | ||||
@@ -59,6 +58,15 @@ namespace Shadowsocks.Controller | |||||
Logging.Debug("Closing timed out TCP connection."); | Logging.Debug("Closing timed out TCP connection."); | ||||
handler1.Close(); | handler1.Close(); | ||||
} | } | ||||
/* | |||||
* Start after we put it into Handlers set. Otherwise if it failed in handler.Start() | |||||
* then it will call handler.Close() before we add it into the set. | |||||
* Then the handler will never release until the next Handle call. Sometimes it will | |||||
* cause odd problems (especially during memory profiling). | |||||
*/ | |||||
handler.Start(firstPacket, length); | |||||
return true; | return true; | ||||
} | } | ||||
@@ -455,7 +463,7 @@ namespace Shadowsocks.Controller | |||||
timer.Dispose(); | timer.Dispose(); | ||||
if (_proxyConnected || _destConnected) | |||||
if (_proxyConnected || _destConnected || _closed) | |||||
{ | { | ||||
return; | return; | ||||
} | } | ||||
@@ -527,7 +535,7 @@ namespace Shadowsocks.Controller | |||||
timer.Enabled = false; | timer.Enabled = false; | ||||
timer.Dispose(); | timer.Dispose(); | ||||
if (_destConnected) | |||||
if (_destConnected || _closed) | |||||
{ | { | ||||
return; | return; | ||||
} | } | ||||
@@ -2,7 +2,7 @@ | |||||
using System.Net; | using System.Net; | ||||
using System.Net.Sockets; | using System.Net.Sockets; | ||||
using System.Threading; | using System.Threading; | ||||
using Shadowsocks.Util; | |||||
using Shadowsocks.Util.Sockets; | |||||
namespace Shadowsocks.Proxy | namespace Shadowsocks.Proxy | ||||
{ | { | ||||
@@ -31,7 +31,7 @@ namespace Shadowsocks.Proxy | |||||
} | } | ||||
} | } | ||||
private Socket _remote; | |||||
private WrappedSocket _remote = new WrappedSocket(); | |||||
public EndPoint LocalEndPoint => _remote.LocalEndPoint; | public EndPoint LocalEndPoint => _remote.LocalEndPoint; | ||||
@@ -55,12 +55,13 @@ namespace Shadowsocks.Proxy | |||||
{ | { | ||||
DestEndPoint = destEndPoint; | DestEndPoint = destEndPoint; | ||||
SocketUtil.BeginConnectTcp(destEndPoint, callback, state); | |||||
_remote.BeginConnect(destEndPoint, callback, state); | |||||
} | } | ||||
public void EndConnectDest(IAsyncResult asyncResult) | public void EndConnectDest(IAsyncResult asyncResult) | ||||
{ | { | ||||
_remote = SocketUtil.EndConnectTcp(asyncResult); | |||||
_remote.EndConnect(asyncResult); | |||||
_remote.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.NoDelay, true); | |||||
} | } | ||||
public void BeginSend(byte[] buffer, int offset, int size, SocketFlags socketFlags, AsyncCallback callback, | public void BeginSend(byte[] buffer, int offset, int size, SocketFlags socketFlags, AsyncCallback callback, | ||||
@@ -92,7 +93,7 @@ namespace Shadowsocks.Proxy | |||||
public void Close() | public void Close() | ||||
{ | { | ||||
_remote?.Close(); | |||||
_remote?.Dispose(); | |||||
} | } | ||||
} | } | ||||
} | } |
@@ -1,12 +1,10 @@ | |||||
using System; | using System; | ||||
using System.Collections.Generic; | |||||
using System.Linq; | |||||
using System.Net; | using System.Net; | ||||
using System.Net.Sockets; | using System.Net.Sockets; | ||||
using System.Text; | using System.Text; | ||||
using System.Threading; | using System.Threading; | ||||
using Shadowsocks.Controller; | using Shadowsocks.Controller; | ||||
using Shadowsocks.Util; | |||||
using Shadowsocks.Util.Sockets; | |||||
namespace Shadowsocks.Proxy | namespace Shadowsocks.Proxy | ||||
{ | { | ||||
@@ -41,7 +39,7 @@ namespace Shadowsocks.Proxy | |||||
public Exception ex { get; set; } | public Exception ex { get; set; } | ||||
} | } | ||||
private Socket _remote; | |||||
private WrappedSocket _remote = new WrappedSocket(); | |||||
private const int Socks5PktMaxSize = 4 + 16 + 2; | private const int Socks5PktMaxSize = 4 + 16 + 2; | ||||
private readonly byte[] _receiveBuffer = new byte[Socks5PktMaxSize]; | private readonly byte[] _receiveBuffer = new byte[Socks5PktMaxSize]; | ||||
@@ -58,7 +56,7 @@ namespace Shadowsocks.Proxy | |||||
ProxyEndPoint = remoteEP; | ProxyEndPoint = remoteEP; | ||||
SocketUtil.BeginConnectTcp(remoteEP, ConnectCallback, st); | |||||
_remote.BeginConnect(remoteEP, ConnectCallback, st); | |||||
} | } | ||||
public void EndConnectProxy(IAsyncResult asyncResult) | public void EndConnectProxy(IAsyncResult asyncResult) | ||||
@@ -168,7 +166,7 @@ namespace Shadowsocks.Proxy | |||||
public void Close() | public void Close() | ||||
{ | { | ||||
_remote?.Close(); | |||||
_remote?.Dispose(); | |||||
} | } | ||||
@@ -177,7 +175,9 @@ namespace Shadowsocks.Proxy | |||||
var state = (Socks5State) ar.AsyncState; | var state = (Socks5State) ar.AsyncState; | ||||
try | try | ||||
{ | { | ||||
_remote = SocketUtil.EndConnectTcp(ar); | |||||
_remote.EndConnect(ar); | |||||
_remote.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.NoDelay, true); | |||||
byte[] handshake = {5, 1, 0}; | byte[] handshake = {5, 1, 0}; | ||||
_remote.BeginSend(handshake, 0, handshake.Length, 0, Socks5HandshakeSendCallback, state); | _remote.BeginSend(handshake, 0, handshake.Length, 0, Socks5HandshakeSendCallback, state); | ||||
@@ -1,132 +0,0 @@ | |||||
using System; | |||||
using System.Net; | |||||
using System.Net.Sockets; | |||||
using System.Threading; | |||||
namespace Shadowsocks.Util | |||||
{ | |||||
public static class SocketUtil | |||||
{ | |||||
private class DnsEndPoint2 : DnsEndPoint | |||||
{ | |||||
public DnsEndPoint2(string host, int port) : base(host, port) | |||||
{ | |||||
} | |||||
public DnsEndPoint2(string host, int port, AddressFamily addressFamily) : base(host, port, addressFamily) | |||||
{ | |||||
} | |||||
public override string ToString() | |||||
{ | |||||
return this.Host + ":" + this.Port; | |||||
} | |||||
} | |||||
public static EndPoint GetEndPoint(string host, int port) | |||||
{ | |||||
IPAddress ipAddress; | |||||
bool parsed = IPAddress.TryParse(host, out ipAddress); | |||||
if (parsed) | |||||
{ | |||||
return new IPEndPoint(ipAddress, port); | |||||
} | |||||
// maybe is a domain name | |||||
return new DnsEndPoint2(host, port); | |||||
} | |||||
private class AutoReleaseAsyncResult : IAsyncResult | |||||
{ | |||||
public bool IsCompleted { get; } = true; | |||||
public WaitHandle AsyncWaitHandle { get; } = null; | |||||
public object AsyncState { get; set; } | |||||
public bool CompletedSynchronously { get; } = true; | |||||
public TcpUserToken UserToken { get; set; } | |||||
~AutoReleaseAsyncResult() | |||||
{ | |||||
UserToken.Dispose(); | |||||
} | |||||
} | |||||
private class TcpUserToken | |||||
{ | |||||
public AsyncCallback Callback { get; private set; } | |||||
public SocketAsyncEventArgs Args { get; private set; } | |||||
public object AsyncState { get; private set; } | |||||
public TcpUserToken(AsyncCallback callback, object state, SocketAsyncEventArgs args) | |||||
{ | |||||
Callback = callback; | |||||
AsyncState = state; | |||||
Args = args; | |||||
} | |||||
public void Dispose() | |||||
{ | |||||
Args?.Dispose(); | |||||
Callback = null; | |||||
Args = null; | |||||
AsyncState = null; | |||||
} | |||||
} | |||||
private static void OnTcpConnectCompleted(object sender, SocketAsyncEventArgs args) | |||||
{ | |||||
args.Completed -= OnTcpConnectCompleted; | |||||
TcpUserToken token = (TcpUserToken) args.UserToken; | |||||
AutoReleaseAsyncResult r = new AutoReleaseAsyncResult | |||||
{ | |||||
AsyncState = token.AsyncState, | |||||
UserToken = token | |||||
}; | |||||
token.Callback(r); | |||||
} | |||||
public static void BeginConnectTcp(EndPoint endPoint, AsyncCallback callback, object state) | |||||
{ | |||||
var arg = new SocketAsyncEventArgs(); | |||||
arg.RemoteEndPoint = endPoint; | |||||
arg.Completed += OnTcpConnectCompleted; | |||||
arg.UserToken = new TcpUserToken(callback, state, arg); | |||||
Socket.ConnectAsync(SocketType.Stream, ProtocolType.Tcp, arg); | |||||
} | |||||
public static Socket EndConnectTcp(IAsyncResult asyncResult) | |||||
{ | |||||
var r = asyncResult as AutoReleaseAsyncResult; | |||||
if (r == null) | |||||
{ | |||||
throw new ArgumentException("Invalid asyncResult.", nameof(asyncResult)); | |||||
} | |||||
var tut = r.UserToken; | |||||
var arg = tut.Args; | |||||
if (arg.SocketError != SocketError.Success) | |||||
{ | |||||
if (arg.ConnectByNameError != null) | |||||
{ | |||||
throw arg.ConnectByNameError; | |||||
} | |||||
var ex = new SocketException((int)arg.SocketError); | |||||
throw ex; | |||||
} | |||||
var so = tut.Args.ConnectSocket; | |||||
so.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.NoDelay, true); | |||||
return so; | |||||
} | |||||
} | |||||
} |
@@ -0,0 +1,72 @@ | |||||
using System; | |||||
using System.Net; | |||||
using System.Net.Sockets; | |||||
namespace Shadowsocks.Util.Sockets | |||||
{ | |||||
public static class SocketUtil | |||||
{ | |||||
private class DnsEndPoint2 : DnsEndPoint | |||||
{ | |||||
public DnsEndPoint2(string host, int port) : base(host, port) | |||||
{ | |||||
} | |||||
public DnsEndPoint2(string host, int port, AddressFamily addressFamily) : base(host, port, addressFamily) | |||||
{ | |||||
} | |||||
public override string ToString() | |||||
{ | |||||
return this.Host + ":" + this.Port; | |||||
} | |||||
} | |||||
public static EndPoint GetEndPoint(string host, int port) | |||||
{ | |||||
IPAddress ipAddress; | |||||
bool parsed = IPAddress.TryParse(host, out ipAddress); | |||||
if (parsed) | |||||
{ | |||||
return new IPEndPoint(ipAddress, port); | |||||
} | |||||
// maybe is a domain name | |||||
return new DnsEndPoint2(host, port); | |||||
} | |||||
public static void FullClose(this System.Net.Sockets.Socket s) | |||||
{ | |||||
try | |||||
{ | |||||
s.Shutdown(SocketShutdown.Both); | |||||
} | |||||
catch (Exception) | |||||
{ | |||||
} | |||||
try | |||||
{ | |||||
s.Disconnect(false); | |||||
} | |||||
catch (Exception) | |||||
{ | |||||
} | |||||
try | |||||
{ | |||||
s.Close(); | |||||
} | |||||
catch (Exception) | |||||
{ | |||||
} | |||||
try | |||||
{ | |||||
s.Dispose(); | |||||
} | |||||
catch (Exception) | |||||
{ | |||||
} | |||||
} | |||||
} | |||||
} |
@@ -0,0 +1,268 @@ | |||||
using System; | |||||
using System.Net; | |||||
using System.Net.Sockets; | |||||
using System.Threading; | |||||
namespace Shadowsocks.Util.Sockets | |||||
{ | |||||
/* | |||||
* A wrapped socket class which support both ipv4 and ipv6 based on the | |||||
* connected remote endpoint. | |||||
* | |||||
* If the server address is host name, then it may have both ipv4 and ipv6 address | |||||
* after resolving. The main idea is we don't want to resolve and choose the address | |||||
* by ourself. Instead, Socket.ConnectAsync() do handle this thing internally by trying | |||||
* each address and returning an established socket connection. Such approach solves | |||||
* two problem: | |||||
* 1. Async DNS resolving. | |||||
* 2. | |||||
*/ | |||||
public class WrappedSocket | |||||
{ | |||||
public EndPoint LocalEndPoint => _activeSocket?.LocalEndPoint; | |||||
private SpinLock _socketSyncLock = new SpinLock(); | |||||
// Only used during connection and close, so it won't cost too much. | |||||
private volatile bool _disposed; | |||||
private bool Connected => _activeSocket != null; | |||||
private Socket _activeSocket; | |||||
public void BeginConnect(EndPoint remoteEP, AsyncCallback callback, object state) | |||||
{ | |||||
if (_disposed) | |||||
{ | |||||
throw new ObjectDisposedException(GetType().FullName); | |||||
} | |||||
if (Connected) | |||||
{ | |||||
throw new SocketException((int) SocketError.IsConnected); | |||||
} | |||||
var arg = new SocketAsyncEventArgs(); | |||||
arg.RemoteEndPoint = remoteEP; | |||||
arg.Completed += OnTcpConnectCompleted; | |||||
arg.UserToken = new TcpUserToken(callback, state); | |||||
Socket.ConnectAsync(SocketType.Stream, ProtocolType.Tcp, arg); | |||||
} | |||||
private class FakeAsyncResult : IAsyncResult | |||||
{ | |||||
public bool IsCompleted { get; } = true; | |||||
public WaitHandle AsyncWaitHandle { get; } = null; | |||||
public object AsyncState { get; set; } | |||||
public bool CompletedSynchronously { get; } = true; | |||||
public Exception InternalException { get; set; } = null; | |||||
} | |||||
private class TcpUserToken | |||||
{ | |||||
public AsyncCallback Callback { get; } | |||||
public object AsyncState { get; } | |||||
public TcpUserToken(AsyncCallback callback, object state) | |||||
{ | |||||
Callback = callback; | |||||
AsyncState = state; | |||||
} | |||||
} | |||||
private void OnTcpConnectCompleted(object sender, SocketAsyncEventArgs args) | |||||
{ | |||||
using (args) | |||||
{ | |||||
args.Completed -= OnTcpConnectCompleted; | |||||
var token = (TcpUserToken) args.UserToken; | |||||
if (args.SocketError != SocketError.Success) | |||||
{ | |||||
var ex = args.ConnectByNameError ?? new SocketException((int) args.SocketError); | |||||
var r = new FakeAsyncResult() | |||||
{ | |||||
AsyncState = token.AsyncState, | |||||
InternalException = ex | |||||
}; | |||||
token.Callback(r); | |||||
} | |||||
else | |||||
{ | |||||
var lockTaken = false; | |||||
if (!_socketSyncLock.IsHeldByCurrentThread) | |||||
{ | |||||
_socketSyncLock.TryEnter(ref lockTaken); | |||||
} | |||||
try | |||||
{ | |||||
if (Connected) | |||||
{ | |||||
args.ConnectSocket.FullClose(); | |||||
} | |||||
else | |||||
{ | |||||
_activeSocket = args.ConnectSocket; | |||||
if (_disposed) | |||||
{ | |||||
_activeSocket.FullClose(); | |||||
} | |||||
var r = new FakeAsyncResult() | |||||
{ | |||||
AsyncState = token.AsyncState | |||||
}; | |||||
token.Callback(r); | |||||
} | |||||
} | |||||
finally | |||||
{ | |||||
if (lockTaken) | |||||
{ | |||||
_socketSyncLock.Exit(); | |||||
} | |||||
} | |||||
} | |||||
} | |||||
} | |||||
public void EndConnect(IAsyncResult asyncResult) | |||||
{ | |||||
if (_disposed) | |||||
{ | |||||
throw new ObjectDisposedException(GetType().FullName); | |||||
} | |||||
var r = asyncResult as FakeAsyncResult; | |||||
if (r == null) | |||||
{ | |||||
throw new ArgumentException("Invalid asyncResult.", nameof(asyncResult)); | |||||
} | |||||
if (r.InternalException != null) | |||||
{ | |||||
throw r.InternalException; | |||||
} | |||||
} | |||||
public void Dispose() | |||||
{ | |||||
if (_disposed) | |||||
{ | |||||
return; | |||||
} | |||||
var lockTaken = false; | |||||
if (!_socketSyncLock.IsHeldByCurrentThread) | |||||
{ | |||||
_socketSyncLock.TryEnter(ref lockTaken); | |||||
} | |||||
try | |||||
{ | |||||
_disposed = true; | |||||
_activeSocket?.FullClose(); | |||||
} | |||||
finally | |||||
{ | |||||
if (lockTaken) | |||||
{ | |||||
_socketSyncLock.Exit(); | |||||
} | |||||
} | |||||
} | |||||
public IAsyncResult BeginSend(byte[] buffer, int offset, int size, SocketFlags socketFlags, | |||||
AsyncCallback callback, | |||||
object state) | |||||
{ | |||||
if (_disposed) | |||||
{ | |||||
throw new ObjectDisposedException(GetType().FullName); | |||||
} | |||||
if (!Connected) | |||||
{ | |||||
throw new SocketException((int) SocketError.NotConnected); | |||||
} | |||||
return _activeSocket.BeginSend(buffer, offset, size, socketFlags, callback, state); | |||||
} | |||||
public int EndSend(IAsyncResult asyncResult) | |||||
{ | |||||
if (_disposed) | |||||
{ | |||||
throw new ObjectDisposedException(GetType().FullName); | |||||
} | |||||
if (!Connected) | |||||
{ | |||||
throw new SocketException((int) SocketError.NotConnected); | |||||
} | |||||
return _activeSocket.EndSend(asyncResult); | |||||
} | |||||
public IAsyncResult BeginReceive(byte[] buffer, int offset, int size, SocketFlags socketFlags, | |||||
AsyncCallback callback, | |||||
object state) | |||||
{ | |||||
if (_disposed) | |||||
{ | |||||
throw new ObjectDisposedException(GetType().FullName); | |||||
} | |||||
if (!Connected) | |||||
{ | |||||
throw new SocketException((int) SocketError.NotConnected); | |||||
} | |||||
return _activeSocket.BeginReceive(buffer, offset, size, socketFlags, callback, state); | |||||
} | |||||
public int EndReceive(IAsyncResult asyncResult) | |||||
{ | |||||
if (_disposed) | |||||
{ | |||||
throw new ObjectDisposedException(GetType().FullName); | |||||
} | |||||
if (!Connected) | |||||
{ | |||||
throw new SocketException((int) SocketError.NotConnected); | |||||
} | |||||
return _activeSocket.EndReceive(asyncResult); | |||||
} | |||||
public void Shutdown(SocketShutdown how) | |||||
{ | |||||
if (_disposed) | |||||
{ | |||||
throw new ObjectDisposedException(GetType().FullName); | |||||
} | |||||
if (!Connected) | |||||
{ | |||||
return; | |||||
} | |||||
_activeSocket.Shutdown(how); | |||||
} | |||||
public void SetSocketOption(SocketOptionLevel optionLevel, SocketOptionName optionName, bool optionValue) | |||||
{ | |||||
SetSocketOption(optionLevel, optionName, optionValue ? 1 : 0); | |||||
} | |||||
public void SetSocketOption(SocketOptionLevel optionLevel, SocketOptionName optionName, int optionValue) | |||||
{ | |||||
if (_disposed) | |||||
{ | |||||
throw new ObjectDisposedException(GetType().FullName); | |||||
} | |||||
if (!Connected) | |||||
{ | |||||
throw new SocketException((int)SocketError.NotConnected); | |||||
} | |||||
_activeSocket.SetSocketOption(optionLevel, optionName, optionValue); | |||||
} | |||||
} | |||||
} |
@@ -179,7 +179,8 @@ | |||||
<Compile Include="StringEx.cs" /> | <Compile Include="StringEx.cs" /> | ||||
<Compile Include="Util\ProcessManagement\Job.cs" /> | <Compile Include="Util\ProcessManagement\Job.cs" /> | ||||
<Compile Include="Util\ProcessManagement\ThreadUtil.cs" /> | <Compile Include="Util\ProcessManagement\ThreadUtil.cs" /> | ||||
<Compile Include="Util\SocketUtil.cs" /> | |||||
<Compile Include="Util\Sockets\SocketUtil.cs" /> | |||||
<Compile Include="Util\Sockets\WrappedSocket.cs" /> | |||||
<Compile Include="Util\Util.cs" /> | <Compile Include="Util\Util.cs" /> | ||||
<Compile Include="View\ConfigForm.cs"> | <Compile Include="View\ConfigForm.cs"> | ||||
<SubType>Form</SubType> | <SubType>Form</SubType> | ||||