SEBWIN-220: Finally found a solution to run keyboard & mouse hooks in sepearate threads.
This commit is contained in:
parent
b31ba1c2d5
commit
b50c208f46
10 changed files with 87 additions and 37 deletions
|
@ -96,16 +96,16 @@ namespace SafeExamBrowser.Client
|
||||||
operations.Enqueue(new RuntimeConnectionOperation(logger, runtimeProxy, startupToken));
|
operations.Enqueue(new RuntimeConnectionOperation(logger, runtimeProxy, startupToken));
|
||||||
operations.Enqueue(new ConfigurationOperation(configuration, logger, runtimeProxy));
|
operations.Enqueue(new ConfigurationOperation(configuration, logger, runtimeProxy));
|
||||||
operations.Enqueue(new DelegateOperation(UpdateAppConfig));
|
operations.Enqueue(new DelegateOperation(UpdateAppConfig));
|
||||||
operations.Enqueue(new DelayedInitializationOperation(BuildCommunicationHostOperation));
|
operations.Enqueue(new LazyInitializationOperation(BuildCommunicationHostOperation));
|
||||||
|
operations.Enqueue(new LazyInitializationOperation(BuildKeyboardInterceptorOperation));
|
||||||
// TODO
|
// TODO
|
||||||
//operations.Enqueue(new DelayedInitializationOperation(BuildKeyboardInterceptorOperation));
|
|
||||||
//operations.Enqueue(new WindowMonitorOperation(logger, windowMonitor));
|
//operations.Enqueue(new WindowMonitorOperation(logger, windowMonitor));
|
||||||
operations.Enqueue(new DelayedInitializationOperation(BuildProcessMonitorOperation));
|
operations.Enqueue(new LazyInitializationOperation(BuildProcessMonitorOperation));
|
||||||
operations.Enqueue(new DisplayMonitorOperation(displayMonitor, logger, Taskbar));
|
operations.Enqueue(new DisplayMonitorOperation(displayMonitor, logger, Taskbar));
|
||||||
operations.Enqueue(new DelayedInitializationOperation(BuildTaskbarOperation));
|
operations.Enqueue(new LazyInitializationOperation(BuildTaskbarOperation));
|
||||||
operations.Enqueue(new DelayedInitializationOperation(BuildBrowserOperation));
|
operations.Enqueue(new LazyInitializationOperation(BuildBrowserOperation));
|
||||||
operations.Enqueue(new ClipboardOperation(logger, nativeMethods));
|
operations.Enqueue(new ClipboardOperation(logger, nativeMethods));
|
||||||
//operations.Enqueue(new DelayedInitializationOperation(BuildMouseInterceptorOperation));
|
operations.Enqueue(new LazyInitializationOperation(BuildMouseInterceptorOperation));
|
||||||
operations.Enqueue(new DelegateOperation(UpdateClientControllerDependencies));
|
operations.Enqueue(new DelegateOperation(UpdateClientControllerDependencies));
|
||||||
|
|
||||||
var sequence = new OperationSequence(logger, operations);
|
var sequence = new OperationSequence(logger, operations);
|
||||||
|
|
|
@ -16,7 +16,7 @@ using SafeExamBrowser.Core.Operations;
|
||||||
namespace SafeExamBrowser.Core.UnitTests.Operations
|
namespace SafeExamBrowser.Core.UnitTests.Operations
|
||||||
{
|
{
|
||||||
[TestClass]
|
[TestClass]
|
||||||
public class DelayedInitializationOperationTests
|
public class LazyInitializationOperationTests
|
||||||
{
|
{
|
||||||
private Mock<IOperation> operationMock;
|
private Mock<IOperation> operationMock;
|
||||||
|
|
||||||
|
@ -37,7 +37,7 @@ namespace SafeExamBrowser.Core.UnitTests.Operations
|
||||||
return operationMock.Object;
|
return operationMock.Object;
|
||||||
};
|
};
|
||||||
|
|
||||||
var sut = new DelayedInitializationOperation(initialize);
|
var sut = new LazyInitializationOperation(initialize);
|
||||||
|
|
||||||
sut.Perform();
|
sut.Perform();
|
||||||
|
|
||||||
|
@ -53,7 +53,7 @@ namespace SafeExamBrowser.Core.UnitTests.Operations
|
||||||
return operationMock.Object;
|
return operationMock.Object;
|
||||||
};
|
};
|
||||||
|
|
||||||
var sut = new DelayedInitializationOperation(initialize);
|
var sut = new LazyInitializationOperation(initialize);
|
||||||
|
|
||||||
sut.Repeat();
|
sut.Repeat();
|
||||||
}
|
}
|
||||||
|
@ -67,7 +67,7 @@ namespace SafeExamBrowser.Core.UnitTests.Operations
|
||||||
return operationMock.Object;
|
return operationMock.Object;
|
||||||
};
|
};
|
||||||
|
|
||||||
var sut = new DelayedInitializationOperation(initialize);
|
var sut = new LazyInitializationOperation(initialize);
|
||||||
|
|
||||||
sut.Revert();
|
sut.Revert();
|
||||||
}
|
}
|
||||||
|
@ -83,7 +83,7 @@ namespace SafeExamBrowser.Core.UnitTests.Operations
|
||||||
operationMock.Setup(o => o.Perform()).Returns(OperationResult.Success);
|
operationMock.Setup(o => o.Perform()).Returns(OperationResult.Success);
|
||||||
operationMock.Setup(o => o.Repeat()).Returns(OperationResult.Failed);
|
operationMock.Setup(o => o.Repeat()).Returns(OperationResult.Failed);
|
||||||
|
|
||||||
var sut = new DelayedInitializationOperation(initialize);
|
var sut = new LazyInitializationOperation(initialize);
|
||||||
var perform = sut.Perform();
|
var perform = sut.Perform();
|
||||||
var repeat = sut.Repeat();
|
var repeat = sut.Repeat();
|
||||||
|
|
||||||
|
@ -101,7 +101,7 @@ namespace SafeExamBrowser.Core.UnitTests.Operations
|
||||||
return operationMock.Object;
|
return operationMock.Object;
|
||||||
};
|
};
|
||||||
|
|
||||||
var sut = new DelayedInitializationOperation(initialize)
|
var sut = new LazyInitializationOperation(initialize)
|
||||||
{
|
{
|
||||||
ProgressIndicator = new Mock<IProgressIndicator>().Object
|
ProgressIndicator = new Mock<IProgressIndicator>().Object
|
||||||
};
|
};
|
||||||
|
@ -128,7 +128,7 @@ namespace SafeExamBrowser.Core.UnitTests.Operations
|
||||||
return new Mock<IOperation>().Object;
|
return new Mock<IOperation>().Object;
|
||||||
};
|
};
|
||||||
|
|
||||||
var sut = new DelayedInitializationOperation(initialize);
|
var sut = new LazyInitializationOperation(initialize);
|
||||||
|
|
||||||
sut.Perform();
|
sut.Perform();
|
||||||
sut.Repeat();
|
sut.Repeat();
|
|
@ -79,7 +79,7 @@
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Compile Include="Operations\CommunicationOperationTests.cs" />
|
<Compile Include="Operations\CommunicationOperationTests.cs" />
|
||||||
<Compile Include="Operations\DelayedInitializationOperationTests.cs" />
|
<Compile Include="Operations\LazyInitializationOperationTests.cs" />
|
||||||
<Compile Include="Operations\I18nOperationTests.cs" />
|
<Compile Include="Operations\I18nOperationTests.cs" />
|
||||||
<Compile Include="Operations\DelegateOperationTests.cs" />
|
<Compile Include="Operations\DelegateOperationTests.cs" />
|
||||||
<Compile Include="OperationModel\OperationSequenceTests.cs" />
|
<Compile Include="OperationModel\OperationSequenceTests.cs" />
|
||||||
|
|
|
@ -13,18 +13,18 @@ using SafeExamBrowser.Contracts.UserInterface;
|
||||||
namespace SafeExamBrowser.Core.Operations
|
namespace SafeExamBrowser.Core.Operations
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A wrapper operation to allow for a delayed (just-in-time) instantiation of an operation. Is useful when e.g. dependencies for a
|
/// A wrapper operation to allow for a lazy (just-in-time) instantiation of an operation, initialized on <see cref="Perform"/>.
|
||||||
/// certain operation are not available during execution of the composition root, but rather only after a preceding operation within
|
/// Is useful when e.g. dependencies for a certain operation are not available during execution of the composition root, but rather
|
||||||
/// an <see cref="IOperationSequence"/> has finished.
|
/// only after a preceding operation within an <see cref="IOperationSequence"/> has finished.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class DelayedInitializationOperation : IOperation
|
public class LazyInitializationOperation : IOperation
|
||||||
{
|
{
|
||||||
private Func<IOperation> initialize;
|
private Func<IOperation> initialize;
|
||||||
private IOperation operation;
|
private IOperation operation;
|
||||||
|
|
||||||
public IProgressIndicator ProgressIndicator { get; set; }
|
public IProgressIndicator ProgressIndicator { get; set; }
|
||||||
|
|
||||||
public DelayedInitializationOperation(Func<IOperation> initialize)
|
public LazyInitializationOperation(Func<IOperation> initialize)
|
||||||
{
|
{
|
||||||
this.initialize = initialize;
|
this.initialize = initialize;
|
||||||
}
|
}
|
|
@ -55,7 +55,7 @@
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Compile Include="Operations\CommunicationOperation.cs" />
|
<Compile Include="Operations\CommunicationOperation.cs" />
|
||||||
<Compile Include="Operations\DelayedInitializationOperation.cs" />
|
<Compile Include="Operations\LazyInitializationOperation.cs" />
|
||||||
<Compile Include="Operations\I18nOperation.cs" />
|
<Compile Include="Operations\I18nOperation.cs" />
|
||||||
<Compile Include="Operations\DelegateOperation.cs" />
|
<Compile Include="Operations\DelegateOperation.cs" />
|
||||||
<Compile Include="OperationModel\OperationSequence.cs" />
|
<Compile Include="OperationModel\OperationSequence.cs" />
|
||||||
|
|
|
@ -48,11 +48,11 @@ namespace SafeExamBrowser.WindowsApi
|
||||||
|
|
||||||
if (success)
|
if (success)
|
||||||
{
|
{
|
||||||
logger.Debug($"Successfully resumed thread #{thread.Id} of explorer shell process.");
|
logger.Debug($"Successfully resumed explorer shell thread with ID = {thread.Id}.");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
logger.Warn($"Failed to resume thread #{thread.Id} of explorer shell process within {MAX_ATTEMPTS} attempts!");
|
logger.Warn($"Failed to resume explorer shell thread with ID = {thread.Id} within {MAX_ATTEMPTS} attempts!");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -89,7 +89,7 @@ namespace SafeExamBrowser.WindowsApi
|
||||||
|
|
||||||
if (shellProcess != null)
|
if (shellProcess != null)
|
||||||
{
|
{
|
||||||
logger.Debug($"Found explorer shell processes with PID = {processId}.");
|
logger.Debug($"Found explorer shell processes with PID = {processId} and {shellProcess.Threads.Count} threads.");
|
||||||
|
|
||||||
foreach (ProcessThread thread in shellProcess.Threads)
|
foreach (ProcessThread thread in shellProcess.Threads)
|
||||||
{
|
{
|
||||||
|
@ -98,11 +98,11 @@ namespace SafeExamBrowser.WindowsApi
|
||||||
if (success)
|
if (success)
|
||||||
{
|
{
|
||||||
suspendedThreads.Add(thread);
|
suspendedThreads.Add(thread);
|
||||||
logger.Debug($"Successfully suspended thread #{thread.Id} of explorer shell process.");
|
logger.Debug($"Successfully suspended explorer shell thread with ID = {thread.Id}.");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
logger.Warn($"Failed to suspend thread #{thread.Id} of explorer shell process!");
|
logger.Warn($"Failed to suspend explorer shell thread with ID = {thread.Id}!");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -8,6 +8,7 @@
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Threading;
|
||||||
using SafeExamBrowser.Contracts.Monitoring;
|
using SafeExamBrowser.Contracts.Monitoring;
|
||||||
using SafeExamBrowser.WindowsApi.Constants;
|
using SafeExamBrowser.WindowsApi.Constants;
|
||||||
using SafeExamBrowser.WindowsApi.Delegates;
|
using SafeExamBrowser.WindowsApi.Delegates;
|
||||||
|
@ -27,23 +28,27 @@ namespace SafeExamBrowser.WindowsApi.Monitoring
|
||||||
private HookDelegate hookProc;
|
private HookDelegate hookProc;
|
||||||
|
|
||||||
internal IntPtr Handle { get; private set; }
|
internal IntPtr Handle { get; private set; }
|
||||||
|
internal AutoResetEvent InputEvent { get; private set; }
|
||||||
internal IKeyboardInterceptor Interceptor { get; private set; }
|
internal IKeyboardInterceptor Interceptor { get; private set; }
|
||||||
|
|
||||||
internal KeyboardHook(IKeyboardInterceptor interceptor)
|
internal KeyboardHook(IKeyboardInterceptor interceptor)
|
||||||
{
|
{
|
||||||
|
InputEvent = new AutoResetEvent(false);
|
||||||
Interceptor = interceptor;
|
Interceptor = interceptor;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal void Attach()
|
internal void Attach()
|
||||||
{
|
{
|
||||||
var module = Kernel32.GetModuleHandle(null);
|
var process = System.Diagnostics.Process.GetCurrentProcess();
|
||||||
|
var module = process.MainModule;
|
||||||
|
var moduleHandle = Kernel32.GetModuleHandle(module.ModuleName);
|
||||||
|
|
||||||
// IMORTANT:
|
// IMORTANT:
|
||||||
// Ensures that the hook delegate does not get garbage collected prematurely, as it will be passed to unmanaged code.
|
// Ensures that the hook delegate does not get garbage collected prematurely, as it will be passed to unmanaged code.
|
||||||
// Not doing so will result in a <c>CallbackOnCollectedDelegate</c> error and subsequent application crash!
|
// Not doing so will result in a <c>CallbackOnCollectedDelegate</c> error and subsequent application crash!
|
||||||
hookProc = new HookDelegate(LowLevelKeyboardProc);
|
hookProc = new HookDelegate(LowLevelKeyboardProc);
|
||||||
|
|
||||||
Handle = User32.SetWindowsHookEx(HookType.WH_KEYBOARD_LL, hookProc, module, 0);
|
Handle = User32.SetWindowsHookEx(HookType.WH_KEYBOARD_LL, hookProc, moduleHandle, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal bool Detach()
|
internal bool Detach()
|
||||||
|
@ -53,6 +58,8 @@ namespace SafeExamBrowser.WindowsApi.Monitoring
|
||||||
|
|
||||||
private IntPtr LowLevelKeyboardProc(int nCode, IntPtr wParam, IntPtr lParam)
|
private IntPtr LowLevelKeyboardProc(int nCode, IntPtr wParam, IntPtr lParam)
|
||||||
{
|
{
|
||||||
|
InputEvent.Set();
|
||||||
|
|
||||||
if (nCode >= 0)
|
if (nCode >= 0)
|
||||||
{
|
{
|
||||||
var keyData = (KBDLLHOOKSTRUCT) Marshal.PtrToStructure(lParam, typeof(KBDLLHOOKSTRUCT));
|
var keyData = (KBDLLHOOKSTRUCT) Marshal.PtrToStructure(lParam, typeof(KBDLLHOOKSTRUCT));
|
||||||
|
|
|
@ -8,6 +8,7 @@
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Threading;
|
||||||
using SafeExamBrowser.Contracts.Monitoring;
|
using SafeExamBrowser.Contracts.Monitoring;
|
||||||
using SafeExamBrowser.WindowsApi.Constants;
|
using SafeExamBrowser.WindowsApi.Constants;
|
||||||
using SafeExamBrowser.WindowsApi.Delegates;
|
using SafeExamBrowser.WindowsApi.Delegates;
|
||||||
|
@ -20,23 +21,27 @@ namespace SafeExamBrowser.WindowsApi.Monitoring
|
||||||
private HookDelegate hookProc;
|
private HookDelegate hookProc;
|
||||||
|
|
||||||
internal IntPtr Handle { get; private set; }
|
internal IntPtr Handle { get; private set; }
|
||||||
|
internal AutoResetEvent InputEvent { get; private set; }
|
||||||
internal IMouseInterceptor Interceptor { get; private set; }
|
internal IMouseInterceptor Interceptor { get; private set; }
|
||||||
|
|
||||||
internal MouseHook(IMouseInterceptor interceptor)
|
internal MouseHook(IMouseInterceptor interceptor)
|
||||||
{
|
{
|
||||||
|
InputEvent = new AutoResetEvent(false);
|
||||||
Interceptor = interceptor;
|
Interceptor = interceptor;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal void Attach()
|
internal void Attach()
|
||||||
{
|
{
|
||||||
var module = Kernel32.GetModuleHandle(null);
|
var process = System.Diagnostics.Process.GetCurrentProcess();
|
||||||
|
var module = process.MainModule;
|
||||||
|
var moduleHandle = Kernel32.GetModuleHandle(module.ModuleName);
|
||||||
|
|
||||||
// IMORTANT:
|
// IMORTANT:
|
||||||
// Ensures that the hook delegate does not get garbage collected prematurely, as it will be passed to unmanaged code.
|
// Ensures that the hook delegate does not get garbage collected prematurely, as it will be passed to unmanaged code.
|
||||||
// Not doing so will result in a <c>CallbackOnCollectedDelegate</c> error and subsequent application crash!
|
// Not doing so will result in a <c>CallbackOnCollectedDelegate</c> error and subsequent application crash!
|
||||||
hookProc = new HookDelegate(LowLevelMouseProc);
|
hookProc = new HookDelegate(LowLevelMouseProc);
|
||||||
|
|
||||||
Handle = User32.SetWindowsHookEx(HookType.WH_MOUSE_LL, hookProc, module, 0);
|
Handle = User32.SetWindowsHookEx(HookType.WH_MOUSE_LL, hookProc, moduleHandle, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal bool Detach()
|
internal bool Detach()
|
||||||
|
@ -46,6 +51,8 @@ namespace SafeExamBrowser.WindowsApi.Monitoring
|
||||||
|
|
||||||
private IntPtr LowLevelMouseProc(int nCode, IntPtr wParam, IntPtr lParam)
|
private IntPtr LowLevelMouseProc(int nCode, IntPtr wParam, IntPtr lParam)
|
||||||
{
|
{
|
||||||
|
InputEvent.Set();
|
||||||
|
|
||||||
if (nCode >= 0 && !Ignore(wParam.ToInt32()))
|
if (nCode >= 0 && !Ignore(wParam.ToInt32()))
|
||||||
{
|
{
|
||||||
var mouseData = (MSLLHOOKSTRUCT) Marshal.PtrToStructure(lParam, typeof(MSLLHOOKSTRUCT));
|
var mouseData = (MSLLHOOKSTRUCT) Marshal.PtrToStructure(lParam, typeof(MSLLHOOKSTRUCT));
|
||||||
|
|
|
@ -13,6 +13,7 @@ using System.ComponentModel;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using System.Threading;
|
||||||
using SafeExamBrowser.Contracts.Monitoring;
|
using SafeExamBrowser.Contracts.Monitoring;
|
||||||
using SafeExamBrowser.Contracts.WindowsApi;
|
using SafeExamBrowser.Contracts.WindowsApi;
|
||||||
using SafeExamBrowser.WindowsApi.Constants;
|
using SafeExamBrowser.WindowsApi.Constants;
|
||||||
|
@ -231,20 +232,50 @@ namespace SafeExamBrowser.WindowsApi
|
||||||
|
|
||||||
public void RegisterKeyboardHook(IKeyboardInterceptor interceptor)
|
public void RegisterKeyboardHook(IKeyboardInterceptor interceptor)
|
||||||
{
|
{
|
||||||
var hook = new KeyboardHook(interceptor);
|
var hookReadyEvent = new AutoResetEvent(false);
|
||||||
|
var hookThread = new Thread(() =>
|
||||||
|
{
|
||||||
|
var hook = new KeyboardHook(interceptor);
|
||||||
|
|
||||||
hook.Attach();
|
hook.Attach();
|
||||||
|
KeyboardHooks[hook.Handle] = hook;
|
||||||
|
hookReadyEvent.Set();
|
||||||
|
|
||||||
KeyboardHooks[hook.Handle] = hook;
|
while (true)
|
||||||
|
{
|
||||||
|
hook.InputEvent.WaitOne();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
hookThread.SetApartmentState(ApartmentState.STA);
|
||||||
|
hookThread.IsBackground = true;
|
||||||
|
hookThread.Start();
|
||||||
|
|
||||||
|
hookReadyEvent.WaitOne();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void RegisterMouseHook(IMouseInterceptor interceptor)
|
public void RegisterMouseHook(IMouseInterceptor interceptor)
|
||||||
{
|
{
|
||||||
var hook = new MouseHook(interceptor);
|
var hookReadyEvent = new AutoResetEvent(false);
|
||||||
|
var hookThread = new Thread(() =>
|
||||||
|
{
|
||||||
|
var hook = new MouseHook(interceptor);
|
||||||
|
|
||||||
hook.Attach();
|
hook.Attach();
|
||||||
|
MouseHooks[hook.Handle] = hook;
|
||||||
|
hookReadyEvent.Set();
|
||||||
|
|
||||||
MouseHooks[hook.Handle] = hook;
|
while (true)
|
||||||
|
{
|
||||||
|
hook.InputEvent.WaitOne();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
hookThread.SetApartmentState(ApartmentState.STA);
|
||||||
|
hookThread.IsBackground = true;
|
||||||
|
hookThread.Start();
|
||||||
|
|
||||||
|
hookReadyEvent.WaitOne();
|
||||||
}
|
}
|
||||||
|
|
||||||
public IntPtr RegisterSystemForegroundEvent(Action<IntPtr> callback)
|
public IntPtr RegisterSystemForegroundEvent(Action<IntPtr> callback)
|
||||||
|
|
|
@ -24,7 +24,7 @@ namespace SafeExamBrowser.WindowsApi
|
||||||
|
|
||||||
public bool HasTerminated
|
public bool HasTerminated
|
||||||
{
|
{
|
||||||
get { return process.HasExited; }
|
get { process.Refresh(); return process.HasExited; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public Process(int id)
|
public Process(int id)
|
||||||
|
@ -36,7 +36,12 @@ namespace SafeExamBrowser.WindowsApi
|
||||||
|
|
||||||
public void Kill()
|
public void Kill()
|
||||||
{
|
{
|
||||||
process.Kill();
|
process.Refresh();
|
||||||
|
|
||||||
|
if (!process.HasExited)
|
||||||
|
{
|
||||||
|
process.Kill();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Process_Exited(object sender, System.EventArgs e)
|
private void Process_Exited(object sender, System.EventArgs e)
|
||||||
|
|
Loading…
Reference in a new issue