Paging Michael Chiklis (part 2)
Can the steps from last time be improved? You bet! First off, I absolutely hate that I had to convert the hex value of 0x00800000 just to appease int.Parse. To wit:
<Target Name="PostBuild">
<Select MsiFileName="$(BuiltOutputPath)"
TableName="Control"
Columns="Control;Type;Attributes"
Where="`Dialog_`='ConfirmInstallForm' AND `Control`='NextButton'">
<Output TaskParameter="Records" ItemName="NextButton" />
</Select>
<AssignValue Items="@(NextButton)"
Metadata="Attributes"
Operator="Or"
Value="0x0080000">
<Output TaskParameter="ModifiedItems" ItemName="UpdatedNextButton" />
</AssignValue>
<ModifyTableData MsiFileName="$(BuiltOutputPath)"
TableName="Control"
ColumnName="Attributes"
Value="%(UpdatedNextButton.Attributes)"
Where="`Dialog_`='ConfirmInstallForm' AND `Control`='NextButton'" />
</Target>
Building results in a build failure:
PostBuild.proj(12,9): error MSB4018: The "AssignValue" task failed unexpectedly.
PostBuild.proj(12,9): error MSB4018: System.FormatException: Input string was not in a correct format.
PostBuild.proj(12,9): error MSB4018: at System.Number.StringToNumber(String str, NumberStyles options, NumberBuffer& number, NumberFormatInfo info, Boolean parseDecimal)
PostBuild.proj(12,9): error MSB4018: at System.Number.ParseInt32(String s, NumberStyles style, NumberFormatInfo info)
PostBuild.proj(12,9): error MSB4018: at System.Int32.Parse(String s)
PostBuild.proj(12,9): error MSB4018: at SetupProjects.Tasks.Operators.OrOperator.Operate(String left, String right)
PostBuild.proj(12,9): error MSB4018: at SetupProjects.Tasks.AssignValue.Execute()
PostBuild.proj(12,9): error MSB4018: at Microsoft.Build.BuildEngine.TaskEngine.ExecuteTask(ExecutionMode howToExecuteTask, Hashtable projectItemsAvailableToTask, BuildPropertyGroup projectPropertiesAvailableToTask, Boolean& taskClassWasFound)
This should be easy to fix. Here are the series of tests I wrote for this scenario (added to the UtilitiesTest class):
[TestMethod]
public void ParseInt_ValidDecimalTest()
{
Assert.AreEqual(15, Utilities.ParseInt("15"));
}
[TestMethod]
public void ParseInt_ValidHexTest()
{
Assert.AreEqual(175, Utilities.ParseInt("0xAf"));
}
[TestMethod]
public void ParseInt_LeadingZeroesDecimalTest()
{
Assert.AreEqual(15, Utilities.ParseInt("0015"));
}
[TestMethod]
public void ParseInt_LeadingZeroesHexTest()
{
Assert.AreEqual(175, Utilities.ParseInt("0x00Af"));
}
[TestMethod]
[ExpectedException(typeof(FormatException))]
public void ParseInt_InvalidDecimalTest()
{
Utilities.ParseInt("Af");
}
[TestMethod]
[ExpectedException(typeof(FormatException))]
public void ParseInt_InvalidHexTest()
{
Utilities.ParseInt("0xGf");
}
There they are: 6 tests of basic parsing functionality. Source code to make these tests pass looks like:
public static int ParseInt(string text)
{
System.Globalization.NumberStyles style = System.Globalization.NumberStyles.Integer;
if (text.StartsWith("0x"))
{
text = text.Substring(2);
style = System.Globalization.NumberStyles.HexNumber;
}
return int.Parse(text, style);
}
This allows for the update of OrOperator
and BitwiseAnd
filters:
class OrOperator : IOperator
{
public string Operate(string left, string right)
{
int result = Utilities.ParseInt(left) | Utilities.ParseInt(right);
return result.ToString();
}
}
public class BitwiseAnd : IFilter
{
// ...
private int GetIntegerMetadata(ITaskItem item, string metadataName)
{
string data = item.GetMetadata(metadataName);
if (string.IsNullOrEmpty(data))
{
throw new ArgumentException("Failed to specify {0} metadata", metadataName);
}
return Utilities.ParseInt(data);
}
}
With these changes, the postbuild step runs fine.
The last item for this entry will focus on a better way to update the rows within the database. While using the ExecuteSql
task works for this scenario, I would still like to abstract a lot of that SQL away. Furthermore, every time that task is run, the database is opened and closed, which isn't terribly efficient. So, let's create a new task, called Update
that will modify rows within a database but only open the database once. The task will be used to correct the that there is more than one dialog that should have the shield button: theMaintenanceForm needs it as well (and I wouldn't be shocked if others were missed as well). This task would have also come in handy when updating deferred custom actions with the NoImpersonate
attribute.
In addition to the usual task input of the name of the msi to modify, this task will only take one additional parameter: an ITaskItem
array featuring all of the table entries that need to be updated. An item in the array will need to have the necessary information within it, including the name of the table that will be modified, metadata containing the new values, and metadata that can be used to uniquely identify the row within the table. Fortunately, the TaskItems
coming out of the Select
task are already of this form: the table name is stored as the ItemSpec
of the item, and the metadata is individual column values within that row. All that remains is a way to uniquely identify the rows within the table.
Actually, those are likely already there: if the item includes all of the primary keys of the table, that should be enough to identify those entries: the SQL statement can be structured to query against those primary keys. The statement used will look something like this:
UPDATE `{table}` SET {non-key column name/value pairs} WHERE {key column name/value pairs}
The first thing required to accomplish this is a means to identify the primary keys of a table in a database. I foresee multiple uses for a function such as this, so let's add it to the Utilities
class. Sticking with the theme of this post, let's have the unit test check the keys of the Control table:
[TestMethod]
public void GetPrimaryKeysTest()
{
List<string> keys = SetupProjects.Tasks.Utilities.GetPrimaryKeys(msi, "Control");
Assert.AreEqual(2, keys.Count);
Assert.AreEqual("Dialog_", keys[0]);
Assert.AreEqual("Control", keys[1]);
}
A simple enough test: a function in Utilities
takes 2 parameters: a Database
and the name of a table. The function returns the primary keys of the table as a list of strings. Here is the first attempt to get this test passing:
public static List<string> GetPrimaryKeys(Database msi, string tableName)
{
Record keysRecord = msi.get_PrimaryKeys(tableName);
List<string> keys = new List<string>();
for (int i = 1; i < keysRecord.FieldCount; i++)
{
keys.Add(keysRecord.get_StringData(i));
}
return keys;
}
Fortunately, the Database
object already has a get_PrimaryKeys
operation defined for it. Let's see what happens when the test is run:
Failed GetPrimaryKeysTest Test method SetupProjects.Tasks.UnitTests.UtilitiesTest.GetPrimaryKeysTest threw exception: System.Runtime.InteropServices.COMException: Member not found. (Exception from HRESULT: 0x80020003 (DISP_E_MEMBERNOTFOUND)).
Well, that is a little surprising: a COMException
with Member not found. Its tempting to think try accessing PrimaryKeys
as a property instead of as a function call, but it turns out that won't work, because of compiler errors.
After doing a little searching on the internet, I found out that there is a small bug in how the automation objects are exported; it turns out that code using get_PrimaryKeys
needs to do a special Invoke
to access the PrimaryKeys
, which needs to be treated as both a method and a property:
public static List<string> GetPrimaryKeys(Database msi, string tableName)
{
object[] args = new object[] { tableName }; Record keysRecord = (Record)typeof(Database).InvokeMember("PrimaryKeys", BindingFlags.InvokeMethod | BindingFlags.GetProperty, null, msi, args);
List<string> keys = new List<string>();
for (int i = 1; i < keysRecord.FieldCount; i++)
{
keys.Add(keysRecord.get_StringData(i));
}
System.Runtime.InteropServices.Marshal.FinalReleaseComObject(keysRecord);
return keys;
}
This change eliminates the exception, but the test still fails:
Failed GetPrimaryKeysTest Assert.AreEqual failed. Expected:<2>, Actual:<1>.
Oops: I remembered that records in MSI are 1-based, but I forgot to fix my loop counter to adjust or this. Let's fix that now:
public static List<string> GetPrimaryKeys(Database msi, string tableName)
{
object[] args = new object[] { tableName };
Record keysRecord = (Record)typeof(Database).InvokeMember("PrimaryKeys",
BindingFlags.InvokeMethod | BindingFlags.GetProperty,
null, msi, args);
List<string> keys = new List<string>();
for (int i = 1; i <= keysRecord.FieldCount; i++)
{
keys.Add(keysRecord.get_StringData(i));
}
System.Runtime.InteropServices.Marshal.FinalReleaseComObject(keysRecord);
return keys;
}
Now the test passes.
With GetPrimaryKeys
in hand, let's get started on the Update
task. Laying this out according to the task definition from before produces this skeleton:
public sealed class Update : SetupProjectTask
{
protected ITaskItem[] records;
[Required]
public ITaskItem[] Records
{
get { return records; }
set { records = value; }
}
protected override bool ExecuteTask()
{
return true;
}
}
The tests I want to write for this task will feature a table with 4 columns (2 of strings and 2 of ints), 2 primary key columns (1 string and 1 int). The tests will modify the values in only one of these column.
Here is t table displaying the original sample data in my table:
| |||
StringKey | IntKey | StringValue | IntValue |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
One test will modify all Value1 strings to new values:
| |||
StringKey | IntKey | StringValue | IntValue |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Another test will modify the ints and strings where IntKey=2:
| |||
StringKey | IntKey | StringValue | IntValue |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
The final test will modify multiple values by looking at multiple keys:
| |||
StringKey | IntKey | StringValue | IntValue |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Below, you'll note the 3 coded testcasaes. Trust me, they're a bit buried, but they're in there. There is a little bit of support infrastructure as well: I created a new class to represent an entry in the "_TestTable" table in an msi. The Update
task works against this table. Some day, I'll probably update my other tests to work against this as well.
[TestClass()]
public class UpdateTest
{
private string databaseFileName;
#region Additional test attributes
[TestInitialize()]
public void MyTestInitialize()
{
databaseFileName = Path.GetTempFileName();
FileStream fout = new FileStream(databaseFileName, FileMode.Open);
fout.Write(SetupProjects.Tasks.UnitTests.Properties.Resources.schema, 0, SetupProjects.Tasks.UnitTests.Properties.Resources.schema.Length);
fout.Close();
Database msi = OpenDatabase(MsiOpenDatabaseMode.msiOpenDatabaseModeTransact);
Utilities.ExecuteSql(msi, TestTableRow.CreateStatement);
Utilities.ExecuteSql(msi, new TestTableRow("Key1", 1, "Value1", 1).InsertStatement);
Utilities.ExecuteSql(msi, new TestTableRow("Key1", 2, "Value1", 2).InsertStatement);
Utilities.ExecuteSql(msi, new TestTableRow("Key2", 1, "Value2", 1).InsertStatement);
Utilities.ExecuteSql(msi, new TestTableRow("Key2", 2, "Value2", 2).InsertStatement);
msi.Commit();
System.Runtime.InteropServices.Marshal.FinalReleaseComObject(msi);
}
[TestCleanup()]
public void MyTestCleanup()
{
if (File.Exists(databaseFileName))
{
File.Delete(databaseFileName);
}
}
#endregion
/// <summary>
/// Updates a string value
/// Specifies a single key
/// </summary>
[TestMethod()]
public void UpdateSingleValue()
{
List<ITaskItem> records = new List<ITaskItem>();
records.Add(new TestTableRow("Key2", 0, "New Value", 0).GetTaskItem(new string[] { TestTableRow.STRINGKEY, TestTableRow.STRINGVALUE }));
RunTask(records);
List<TestTableRow> expectedRecords = new List<TestTableRow>();
expectedRecords.Add(new TestTableRow("Key1", 1, "Value1", 1));
expectedRecords.Add(new TestTableRow("Key1", 2, "Value1", 2));
expectedRecords.Add(new TestTableRow("Key2", 1, "New Value", 1));
expectedRecords.Add(new TestTableRow("Key2", 2, "New Value", 2));
VerifyRecords(expectedRecords);
}
/// <summary>
/// Updates string and int values
/// Specifies a single key
/// </summary>
[TestMethod()]
public void UpdateMultipleValues()
{
List<ITaskItem> records = new List<ITaskItem>();
records.Add(new TestTableRow("N/A", 1, "New Value", 15).GetTaskItem(new string[] { TestTableRow.INTKEY, TestTableRow.STRINGVALUE, TestTableRow.INTVALUE }));
RunTask(records);
List<TestTableRow> expectedRecords = new List<TestTableRow>();
expectedRecords.Add(new TestTableRow("Key1", 1, "New Value", 15));
expectedRecords.Add(new TestTableRow("Key1", 2, "Value1", 2));
expectedRecords.Add(new TestTableRow("Key2", 1, "New Value", 15));
expectedRecords.Add(new TestTableRow("Key2", 2, "Value2", 2));
VerifyRecords(expectedRecords);
}
/// <summary>
/// Updates multiple values
/// Specifies multiple keys
/// Specifies values out of order
/// </summary>
[TestMethod]
public void UpdateMulitpleValuesWithMultipleKeys()
{
List<ITaskItem> records = new List<ITaskItem>();
records.Add(new TestTableRow("Key2", 1, "New Value", 10).TaskItem);
records.Add(new TestTableRow("Key1", 2, "New Value", 20).TaskItem);
RunTask(records);
List<TestTableRow> expectedRecords = new List<TestTableRow>();
expectedRecords.Add(new TestTableRow("Key1", 1, "Value1", 1));
expectedRecords.Add(new TestTableRow("Key1", 2, "New Value", 20));
expectedRecords.Add(new TestTableRow("Key2", 1, "New Value", 10));
expectedRecords.Add(new TestTableRow("Key2", 2, "Value2", 2));
VerifyRecords(expectedRecords);
}
private Database OpenDatabase(MsiOpenDatabaseMode mode)
{
Type classType = Type.GetTypeFromProgID("WindowsInstaller.Installer");
Object installerClassObject = Activator.CreateInstance(classType);
Installer installer = (Installer)installerClassObject;
return installer.OpenDatabase(databaseFileName, mode);
}
private void RunTask(List<ITaskItem> records)
{
Update target = new Update();
target.MsiFileName = databaseFileName;
target.Records = records.ToArray();
Assert.IsTrue(target.Execute(), "Update task failed");
}
private void VerifyRecords(List<TestTableRow> expectedRecords)
{
List<TestTableRow> actualRecords = new List<TestTableRow>();
Database msi = OpenDatabase(MsiOpenDatabaseMode.msiOpenDatabaseModeReadOnly);
View view = msi.OpenView("SELECT * FROM `_TestTable`");
view.Execute(null);
Record record = view.Fetch();
while (record != null)
{
actualRecords.Add(new TestTableRow(record));
record = view.Fetch();
}
System.Runtime.InteropServices.Marshal.FinalReleaseComObject(view);
System.Runtime.InteropServices.Marshal.FinalReleaseComObject(msi);
Assert.AreEqual(expectedRecords.Count, actualRecords.Count);
for (int i = 0; i < expectedRecords.Count; i++)
{
Assert.AreEqual(expectedRecords[i], actualRecords[i]);
}
}
}
class TestTableRow
{
public const string TABLENAME = "_TestTable";
public const string STRINGKEY = "StringKey";
public const string INTKEY = "IntKey";
public const string STRINGVALUE = "StringValue";
public const string INTVALUE = "IntValue";
private string stringKey;
private int intKey;
private string stringValue;
private int intValue;
public TestTableRow(string stringKey, int intKey, string stringValue, int intValue)
{
this.stringKey = stringKey;
this.intKey = intKey;
this.stringValue = stringValue;
this.intValue = intValue;
}
public TestTableRow(Record record) : this(record.get_StringData(1), record.get_IntegerData(2), record.get_StringData(3), record.get_IntegerData(4))
{
}
public string InsertStatement
{
get
{
return string.Format("INSERT INTO `{0}` (`{1}`, `{2}`, `{3}`, `{4}`) " +
"VALUES ('{5}', {6}, '{7}', {8})",
TABLENAME, STRINGKEY, INTKEY, STRINGVALUE, INTVALUE,
stringKey, intKey, stringValue, intValue);
}
}
public ITaskItem TaskItem
{
get { return GetTaskItem(new string[] { STRINGKEY, INTKEY, STRINGVALUE, INTVALUE }); }
}
public ITaskItem GetTaskItem(string[] columns)
{
TaskItem item = new TaskItem(TABLENAME);
foreach(string column in columns)
{
if (column.Equals(STRINGKEY))
item.SetMetadata(column, stringKey);
else if (column.Equals(INTKEY))
item.SetMetadata(column, intKey.ToString());
else if (column.Equals(STRINGVALUE))
item.SetMetadata(column, stringValue);
else if (column.Equals(INTVALUE))
item.SetMetadata(column, intValue.ToString());
}
return item;
}
public static string CreateStatement
{
get { return string.Format("CREATE TABLE `{0}` " +
"(`{1}` CHAR(72) NOT NULL, `{2}` LONG NOT NULL, `{3}` CHAR(255), `{4}` LONG " +
"PRIMARY KEY `{5}`, `{6}`)",
TABLENAME, COLUMNNAMES[0], COLUMNNAMES[1], COLUMNNAMES[2], COLUMNNAMES[3], COLUMNNAMES[0], COLUMNNAMES[1]);
}
}
public static string SelectStatement
{
get { return string.Format("SELECT * FROM `{0}`", TABLENAME); }
}
public override bool Equals(object obj)
{
if (!(obj is TestTableRow))
{
return false;
}
TestTableRow other = (TestTableRow)obj;
return stringKey.Equals(other.stringKey) && intKey == other.intKey &&
stringValue == other.stringValue && intValue == other.intValue;
}
public override int GetHashCode()
{
return stringKey.GetHashCode() ^ intKey ^ stringValue.GetHashCode() ^ intValue;
}
}
For once, I'll spare a lot of the baby steps that went into producing the code to get these tests pass, and cut straight to the punchline:
public sealed class Update : SetupProjectTask
{
private ITaskItem[] records;
[Required]
public ITaskItem[] Records
{
get { return records; }
set { records = value; }
}
protected override bool ExecuteTask()
{
foreach (ITaskItem record in Records) <br> { string sql = CreateUpdateStatement(record); Utilities.ExecuteSql(Msi, sql); <br> } <br> Msi.Commit();
return true;
}
private string CreateUpdateStatement(ITaskItem record) <br> { // A typical update statement looks something like this: // UPDATE `Feature` SET `Title`='Performances' WHERE `Feature`='Arts' // Or, more generically: // UPDATE `{Table}` SET `StringColumn1`='StringValue1', `IntColumn2`=Value2, ... WHERE `StringKey1`='StringKeyValue1' AND `IntKey2`=2 ... // The function creates the update statement by creating the value segments and key segments // by matching the available columns in the msi with the values specified in the record. View columnView = Msi.OpenView(string.Format("SELECT * FROM `{0}`", record.ItemSpec)); List<ColumnInfo> columns = ColumnInfo.GetColumnInfo(columnView); <br> System.Runtime.InteropServices.Marshal.FinalReleaseComObject(columnView); List<string> keys = Utilities.GetPrimaryKeys(Msi, record.ItemSpec); StringBuilder values = new StringBuilder(); StringBuilder keyValues = new StringBuilder(); foreach (ColumnInfo column in columns) <br> { if (TaskItemContainsMetadata(record, column.Name)) <br> { if (keys.Contains(column.Name)) <br> { <br> AppendValue(keyValues, " AND ", column, record.GetMetadata(column.Name)); <br> } else { <br> AppendValue(values, ", ", column, record.GetMetadata(column.Name)); <br> } <br> } <br> } return string.Format("UPDATE `{0}` SET {1} WHERE {2}", record.ItemSpec, values.ToString(), keyValues.ToString()); <br> } private void AppendValue(StringBuilder builder, string separator, ColumnInfo column, string value) <br> { // If the string builder isn't empty, that means there is at least one clause in there. // Multiple clauses need to include the appropriate separator if (builder.Length > 0) <br> { <br> builder.Append(separator); <br> } if (column.IsInteger) <br> { <br> builder.Append(string.Format("`{0}` = {1}", column.Name, value)); <br> } else { <br> builder.Append(string.Format("`{0}` = '{1}'", column.Name, value)); <br> } <br> } private bool TaskItemContainsMetadata(ITaskItem item, string name) <br> { foreach (string metadataName in item.MetadataNames) <br> { if (name.Equals(metadataName)) <br> { return true; <br> } <br> } return false; <br> } <br>} class ColumnInfo { private bool isInteger; private string name; public bool IsInteger <br> { get { return isInteger; } <br> } public string Name <br> { get { return name; } <br> } public ColumnInfo(string name, bool isInteger) <br> { this.name = name; this.isInteger = isInteger; <br> } public static List<ColumnInfo> GetColumnInfo(View view) <br> { List<ColumnInfo> infoList = new List<ColumnInfo>(); Record columnInfoNames = view.get_ColumnInfo(MsiColumnInfo.msiColumnInfoNames); for (int i = 1; i <= columnInfoNames.FieldCount; i++) <br> { <br> infoList.Add(new ColumnInfo(columnInfoNames.get_StringData(i), Utilities.IsIntegerData(view, i))); <br> } return infoList; <br> }
}
The structure is similar to the Select
task: a helper function is used to create a SQL statement, which is then run via ExecuteSql
.
Something that is new is my first attempt at using objects in the code. Most of the ColumnInfo
concept was simply lifted from the Select
class, except this time I chose to encapsulate it into a class. Still on the agenda is to get the Select
task to use ColumnInfo
; but that won't get done until the unit tests for Select
are also beefed up.
Now that the task is completed, let's see it in action:
<ItemGroup>
<_Dialog Include="ConfirmInstallForm">
<Control>NextButton</Control>
</_Dialog>
<_Dialog Include="MaintenanceForm">
<Control>FinishButton</Control>
</_Dialog>
</ItemGroup>
<ItemGroup>
<_ControlColumns Include="Attributes" />
</ItemGroup>
<Target Name="PostBuild">
<GetPrimaryKeys MsiFileName="$(BuiltOutputPath)"
TableName="Control">
<Output TaskParameter="PrimaryKeys" ItemName="_ControlColumns" />
</GetPrimaryKeys>
<Select MsiFileName="$(BuiltOutputPath)"
TableName="Control"
Columns="@(_ControlColumns)"
Where="`Dialog_`='%(_Dialog.Identity)' AND `Control`='%(_Dialog.Control)'">
<Output TaskParameter="Records" ItemName="Buttons" />
</Select>
<AssignValue Items="@(Buttons)"
Metadata="Attributes"
Operator="Or"
Value="0x00800000">
<Output TaskParameter="ModifiedItems" ItemName="UpdatedButtons" />
</AssignValue>
<Update MsiFileName="$(BuiltOutputPath)"
Records="@(UpdatedButtons)" />
</Target>
The new post-build project includes 2 ItemGroup
definitions. The first is a group containing the entries in the Control table that will be modified. The identifier for the dialog is the name of the Dialog that will be modified, and the metadata is the name of the control that will be modified. The next group defines the columns that will be returned from the Select
statement. There is only one entry in there, but it needs to be an ItemGroup
because the Select
task takes a vector of column names. Of course, the Update
statement will also need the primary keys of the table. So, why aren't tey listed as well? Actually, they are appended to this group as the output from the GetPrimaryKeys
task. Ah, yes: the GetPrimaryKeys
task, which goes a little something like this:
public sealed class GetPrimaryKeys : SetupProjectTask
{
protected string tableName;
protected string[] primaryKeys;
[Required]
public string TableName
{
get { return tableName; }
set { tableName = value; }
}
[Output]
public string[] PrimaryKeys
{
get { return primaryKeys; }
set { primaryKeys = value; }
}
protected MsiOpenDatabaseMode Mode
{
get { return MsiOpenDatabaseMode.msiOpenDatabaseModeReadOnly; }
}
protected override bool ExecuteTask()
{
primaryKeys = Utilities.GetPrimaryKeys(Msi, TableName).ToArray();
return true;
}
}
A very easy task that uses the GetPrimaryKeys
helper method defined earlier in the Utilities
(see, I knew that I would find multiple uses for this function). The output values are appended onto the _ControlColumns group (and yes, I seem to recall not being happy with this whole "output is appended to existing group" idea; obviously I'm over that now). Most of the remaining items in the target are similar to what was shown before. So there you have it: a slightly improved (I hope) way to add the shield button to a Windows Installer package.
The next post will feature the next Vista improvement, I promise.
Comments
Anonymous
August 07, 2007
If I had any blog readership and a place to send questions to, I anticipate one would look somethingAnonymous
June 07, 2010
hi mister, thanks !! where is teh Utilities class and all code source ?? any updates about this post in 2010 ??? greetings