Introduction
I recently was faced with the challenge of automating a reporting system at work. The problem I ran into was that the database which holds the business data (MAS 90, a popular accounting software package) prompts for logon credentials each time an ODBC connection is opened.
In addition to user name and password, a three character company code is expected at logon (our system is configured to handle payroll, accounts payable, accounts receivable, and job costing for several separate companies), and I couldn't find a way to make it work by simply altering the connection string. Therefore, I had to resort to something slightly more complicated.
That something turned out to be interacting with the native Windows controls on the MAS 90 database login dialog using Platform Invoke.
I want to add that there is most likely one or more other (and quite possibly better) ways to go about this. To be honest, my solution sort of feels like a kludge. It's kind of like picking the lock on your front door every day instead of having a key made. But, you'll end up inside the house either way.
Using the code
Since I was targeting a single known window, my first step was to use the excellent Spy++ tool that ships with Visual Studio .NET to find out how the controls on the logon screen fit together.
The logon screen contains ten controls: two ComboBox
es, one Edit control (TextBox
), four Static controls (three Label
s and one icon), and three Button
s (only two are recognizable as Button
s).
The four controls we are concerned with are the two ComboBox
es, the TextBox
, and the first Button
. The order of the controls on the form is important as we'll find out a little later.
In order to get a hold of the main window, we use the FindWindow
API, which is defined in user32.dll. We use the DllImportAttribute
to declare it in our C# code. We'll also need FindWindow
's companion function, FindWindowEx
, to locate the controls within the main window, so we'll declare that as well.
using System.Runtime.InteropServices;
[DllImport("user32.dll")]
static extern IntPtr FindWindow(string lpClassName, string lpWindowName);
[DllImport("user32.dll")]
static extern IntPtr FindWindowEx(IntPtr hwndParent, IntPtr hwndChildAfter,
string lpszClass, string lpszWindow);
I've wrapped both (among others we'll come to later) functions in an overloaded function. Wrapping Windows API functions when using them from managed code is generally the recommended practice. It also allows me to use a stateful object to deal with the windows, rather than keeping track of a bunch of pointers and calling functions in a completely procedural (read non-OO) way.
public class WindowFinder
{
private IntPtr _handle;
public WindowFinder(){}
public static IntPtr Find(string className, string text)
{
return FindWindow(className, text);
}
public static IntPtr Find(IntPtr parent,
IntPtr lookAfter, string className, string text)
{
return FindWindowEx(parent, lookAfter, className, text);
}
public IntPtr Handle
{
get{ return _handle; }
}
}
If a window with the given class name and text is found, its handle is returned. If no window matching the description is found, the return value is equal to IntPtr.Zero
. After obtaining the handle to the main window, we can use FindWindowEx
to locate the actual controls that we'll be manipulating.
WindowFinder mainWindow = new WindowFinder();
WindowFinder companyWindow = new WindowFinder();
WindowFinder userWindow = new WindowFinder();
WindowFinder passwordWindow = new WindowFinder();
WindowFinder okButtonWindow = new WindowFinder();
mainWindow.Find("#32770", "MAS 90 and MAS 200 Database Signon");
companyWindow.Find(mainWindow.Handle,
IntPtr.Zero, "ComboBox", "");
userWindow.Find(mainWindow.Handle,
companyWindow.Handle, "ComboBox", "");
passwordWindow.Find(mainWindow.Handle,
userWindow.Handle, "Edit", "");
okButtonWindow.Find(mainWindow.Handle,
passwordWindow.Handle, "Button", "&OK");
Now that we have the handles to all our controls wrapped up, all that's left to do is input the proper values into the ComboBox
es and Textbox
and "click" the Button
.
We need to declare a couple more functions (and some constants) from our Windows API grab bag in order to accomplish this. Here they are:
private const int SEARCH_ALL = -1;
private const int CB_SELECTSTRING = 0x014D;
private const int WM_SETTEXT = 0x000C;
private const int BM_CLICK = 0x00F5;
[DllImport("user32.dll")]
private static extern bool SetWindowText(IntPtr hWnd, string lpString);
[DllImport("user32.dll", CharSet=CharSet.Auto)]
static extern IntPtr SendMessage(IntPtr hWnd,
uint Msg, int wParam, string lParam);
[DllImport("user32.dll", CharSet=CharSet.Auto)]
static extern IntPtr SendMessage(IntPtr hWnd,
uint Msg, int wParam, int lParam);
public void SelectComboItem(string text)
{
SendMessage(this.Window, CB_SELECTSTRING, SEARCH_ALL, text);
}
public void SetEditText(string text)
{
SendMessage(this.Window, WM_SETTEXT, 0, text);
}
public void ClickButton()
{
SendMessage(this.Window, BM_CLICK, 0, 0);
}
That last bit of code makes one of the possible improvements to this solution quite obvious. The WindowFinder
class contains methods to operate on ComboBox
es, TextBox
es, and Button
s. Obviously, a window can't be all three of these things at once, so two of them are always going to be invalid. I did it this way because it was quick, but a better solution might be to design a base class that encapsulates common functionality, and derive from it for specific controls.
Anyway, it's pretty trivial from here to finish the job. Here's the rest of the code for dealing with the logon screen:
companyWindow.SelectComboboxItem(company);
userWindow.SelectComboItem(user);
passwordWindow.SetTextBoxText(password);
okButtonWindow.ClickButton();
With that, the logon is accepted and the connection to the database is opened.
One small hurdle still remains before the code can function properly: The call to open the connection is what triggers the logon screen to appear, but it's a blocking call, so it doesn't return until the credentials have been accepted. To get around this, I simply create a separate thread for the code I've just shown to run on and then call OdbcConnection.Open
from the main thread.
Thread t = new Thread(new ThreadStart(RunCredentialAutomator));
t.Start();
_connection.Open();
...
private void RunCredentialAutomator()
{
...
IntPtr invalidHandle = IntPtr.Zero;
do
{
mainWindow.Find("#32770", "MAS 90 and MAS 200 Database Signon");
}while(mainWindow == invalidHandle);
...
}
Points of Interest
The most difficult part of .NET Interop is simply knowing the function definitions and what messages to send. For the function definitions, I highly recommend http://www.pinvoke.net/. There you can find hundreds of PInvoke signatures that you can simply copy and paste into your app, as well as tips on using them. The site is a work-in-progress and new information is being added constantly. The site is a wiki, so anyone can contribute.
For what messages are sent and received by individual controls, there is no better source of information than MSDN. For example, to find out how to control ComboBox
es, this is where you'd look. You will likely also have to spelunk through the header files on your machine to find the values for constants used.
This code is used with MAS 90 version 3.71. It may not work with other versions if the logon screen is modified.
The attached source code is for the DLL in which this code resides, but the reporting application that uses it it not included.
This code will not be directly reusable to most people because you are most likely not using MAS 90. I hope somebody will be able to get some use out of it as an example.
History
This is the first version. I'll gladly make changes if (when) someone points where I went wrong.
This member has not yet provided a Biography. Assume it's interesting and varied, and probably something to do with programming.