Monday, November 19, 2012

Mobile VoIP


The Nexus 4 is on backorder. But just because I have to stick with my iPhone 4 for the time being (#FirstWorldProblems) doesn't mean I have to stick with the $100/month AT&T bill. Described below is my journey transitioning my iPhone from AT&T to a combination of Google Voice and a low-priced T-Mobile monthly/no-contract plan.

Unlocking the iPhone 4


In order to use my iPhone with a carrier other than AT&T, I'd have to get it unlocked so that it wasn't tied to AT&T's network. It doesn't matter that it's a GSM/SIM-based phone. If you don't unlock it, trying to use another carrier's SIM will do nothing but get you a "this SIM is unsupported" nasty-gram from the phone. Unlocking is easy enough, with a few caveats:
  • AT&T won't unlock the phone unless you meet several requirements such as being a current AT&T customer and being off-contract.
  • You can use an online request form, but it can take up to 7 days to process. Why the delay? Who knows.
  • After you get the request approval notification, you'll have to plug the iPhone into iTunes and do a full Restore of iOS, at the end of which will be a sort of "Congratulations" message indicating a successful unlock. If you have a nearly full 16GB, the file coping alone will take forever. <Sigh> They can't just flip a setting somewhere?
  • Although you should be able to restore the phone settings (apps and otherwise) from a backup, it's been my experience that these restores never work quite right (app setting deleted here, another app missing there), so be sure to backup all important phone data before the restore wipes the phone.

Porting from AT&T to Google Voice


This was easy enough to perform online using guided forms on the Google Voice website. It can take up to 24 hours to complete the port, and text messages may not work for the ported number for a few days. Also, porting will close your AT&T account, so make sure you unlock your iPhone before porting. There is a one-time fee of $20 to port--plus another $20 if you want to keep your existing Google Voice number.

New T-Mobile Account


I ordered a SIM-only activation package from T-Mobile. Price shipped was 99-cents. Took about a week to arrive in the mail. Once it did, I gathered all the important numbers (activation code, SIM barcode, iPhone IMEI number, etc) and used the T-Mobile online activation process to create an account and choose a Monthly 4G plan. Pretty easy.

Google Voice


I had to add my new T-Mobile line to Google Voice as a forwarding phone. Not a problem, however, prepaid T-Mobile plans do not include call forwarding, that makes it impossible to forward T-Mobile voicemail to Google Voice voicemail. The only solution for this is to call (or online chat) T-Mo customer support and get them to completely deactivate voicemail forwarding. This will prevent unanswered calls from being routed to T-Mo voicemail, which is okay because Google Voice voicemail will catch it (eventually). As long as no one calls the T-Mo directly, there's no issue with this approach.

Call2Click


The Google Voice iOS app has an option in the settings called Call2Click. When active, it means that when dialing out, GV will call one of your forward numbers first, and then after you answer it will dial the outgoing number. This is what makes all the following options possible.

Option 1: Linphone + SIP + IPKall


T-Mobile has a $30/month, no-contract plan for 100 minutes/unlimited text/unlimited data (up to 5GB at 4G) that seems to be pretty popular among the hacker crowd. If you can live with VoIP, you do all your talking over wifi or cell data, and no minutes are used. One method of accomplishing this is to get

  • a SIP account
  • a SIP client application
  • a US phone number which routes to the SIP
There are many options for all the above, plenty of them free. It works like this... First, you get an SIP account (from, say, linphone.org). Then you get a phone number which routes to that account (from, say, ipkall.com). Then you add the number as a forwarding phone in GV.

Then, install any of a number of SIP client applications (MicroSIP, linphone on iOS/Windows/Mac, etc). When your GV number is called, it routes all the way to your SIP client. To make an outgoing call, use the Google Voice callback feature (Call2Click) to ring your SIP number first and then connect to the dialed number.

Once it's all setup, it's simple to use. The downside to this setup is the call quality is highly variable, and--if poor--it can be near impossible to fix it. But you can't beat the price (free).

Option 2: Skype with Skype-In/Skype Number


If call quality with the SIP/IPKall option is subpar for your liking, consider using Skype. This ain't no mother's-basement telephony company; it's a big time player with teams of software gurus supporting it. However, that level of quality isn't free. Skype will run you $30/year for your own phone number (they say $60, but it's usually on sale) and $3/month for unlimited US calling. Hook the Skype number to GV and you've got the same solution as above.

Option 3: Cell Plan with More Minutes


If any of the above gets too funky, T-Mo has a $50/month unlimited voice/text/data plan (4G up to 2GB). No contract required, still fits nicely with GV integrations, still relatively cheap. However, also consider that overages on the $30 plan are $0.10 per minute, which still makes it a cheaper option than the $50/month plan if you can keep it under 300 minutes.

Sunday, October 7, 2012

Raspberry Pi Setup Notes

Finally took my Raspberry Pi out of the box:



Time to start messing around with it...
  • Going to start with the recommended Raspbian "Wheezy" build, though I'll probably give XBMC.
  • For OS storage, I picked-up a Microcenter-branded Adata 16 GB microSDHD class 10 for $11. (I went with microSD cause it's only a few bucks more and you get compatibility with cell phones/cameras/etc.)
  • Keyboard: check. Mouse: check. Monitor + HDMI: check. But after SSH and VNC servers are set-up, I'm booting the keyboard and mouse and reclaiming the USB ports for hard drives.
  • Probably going to skip adding a USB wifi-adapter and just keep it on the shelf next to the router.
  • I've got 3 mini-USB cables, but no micro-USB. So it looks like I'm off to Microcenter again.
  • All my legos are in a box in the bottom of a closet at my parent's house, so I guess I'll need to find my own case. Including tax and shipping from the UK: $15.47. Takes 5-12 business days, but until then...
Update:
  • The microSD card was a dud. Cheap electronics, why you so cheap? I'm going to return it to Microcenter, maybe trade it in for an actual name-brand SD card.
  • In the meantime, I had a 2GB mircoSD class 4 sitting in the bottom of my "wires & batteries" drawer just collecting dust. Turns out it works just fine with my RPi, though I'm only left with a few hundred MB of free space on the OS. Time will tell if that's enough...
  • Got a USB to micro-USB cord (3-ft., $10) that--when paired with a spare iPhone charger--works just fine to power the unit.
  • After a bit of reading, I think I'll give Raspbmc a try (as opposed to Xian or OpenELEC) because it maintains a relatively standard Debian base that will allow me to still use the RPi for other functions (NAS, iTunes server, backup scripts, etc.).

Sunday, September 16, 2012

Google Drive for iOS

Even with its latest update, Google Drive for iOS is still missing some essential features:
- I appreciate it took this long even to get one-at-a-time uploads, but it's really not a usable feature until you can select more than one at a time.
- You can share but you can't unshare? Seems like an oversight to me.
- There's still no "gallery" view of image thumbnails for folders containing images/photos.
- There is no apparent way to sort a folder by file size, date modified, etc.

Some of these features may actually be there, but the fact I can't find them still constitutes a need for improvement.

I'm optimistic Google will address these drawbacks. They always do.

Friday, August 3, 2012

Plugging the Holes in Linq-to-Entities

If you can do it in SQL, you should be able to do it with Linq-to-Entities, right? Trouble is, not everything in a Linq expression is directly translatable into SQL by Entity Framework. Well, not without some help...
Between the above techniques (especially the last one), you should be able to get entity to run just about anything.

Thursday, June 21, 2012

Custom Telerik MVC Grid Ajax Binding

Here's a code block demonstrating the manual sort/filter/paging of an ajax-bound Telerik MVC Grid request--it's handy for hook-ins of various flavors.

public ActionResult CustomBindingResult(IEnumerable items)
{
    int page = int.Parse(Request["page"]);
    int size = int.Parse(Request["size"]);
    string orderBy = Request["orderBy"];
    string groupBy = Request["groupBy"];
    string filter = Request["filter"];

    var list = items.ToList();
    var filtered = (IEnumerable)list.AsQueryable().ToGridModel(1, int.MaxValue, null, null, filter).Data;
    var results = (IEnumerable)filtered.AsQueryable().ToGridModel(page, size, orderBy, groupBy, null).Data;
    var data = results.Select(result => Activator.CreateInstance(typeof(TDestination), result));
    var model = new GridModel { Data = data, Total = filtered.Count() };
    return View(model);
}

Will need the Telerik.Web.Mvc.Extensions namespace.

Wednesday, June 13, 2012

Storing State in the Url Hash

It can be done. We have the technology.
function settingsToStorage(settings) {
    var s = [];
    $.each(settings, function (key, value) {
        s.push(key + "=" + encodeURIComponent(value));
    })
    return s.join("&");
}

function settingsFromStorage(storage) {
    var settings = {};
    storage.replace(/[#&]+([^=&]+)=([^&]*)/gi, function (m, key, value) { settings[key] = decodeURIComponent(value); });
    return settings;
}

Thursday, June 7, 2012

HTTP Error 403.14 in IIS 7.5

I get this error the first time I try to set up IIS for local development on a new 64-bit Windows install.
Turns out, ASP.NET 4 isn't yet properly registered with IIS. To do so, run the following from an administrator-level command prompt:

%windir%\Microsoft.NET\Framework64\v4.0.30319\aspnet_regiis.exe -ir

Do the same for a 32-bit Windows install except with s/Framework64/Framework/.

Wednesday, April 11, 2012

NTFS Symbolic Link

Some dude at work was all, "Move your application data to this other drive."
And I was all, but I don't want to break all my relative paths.

BOOM!

Symlinks for Windows. Problem solved.

ASP.NET MVC: Compiling Views

I'm in yer project, compilin' yer views.
http://stackoverflow.com/questions/383192/compile-views-in-asp-net-mvc

Add <MvcBuildViews>true</MvcBuildViews> to the <PropertyGroup> element.

Tuesday, April 10, 2012

UnxUtils + cURL

For when you need to do some basic unix-type scripting but don't want the overhead of installing cygwin:

  • UnxUtils: Bins that will run on Windows with no external dependencies.
  • cURL: Because wget is for little girls.

Tuesday, March 13, 2012

Open Chrome in Incognito Mode by Default

Requires modifying a couple of registry keys:
http://phasma.binarycore.org/?p=incognito

[HKEY_CLASSES_ROOT\Applications\chrome.exe\shell\open\command]
@="\"C:\\Users\\USERNAME\\AppData\\Local\\Google\\Chrome\\Application\\chrome.exe\" --incognito -- \"%1\""

[HKEY_CLASSES_ROOT\ChromeHTML\shell\open\command]
@="\"C:\\Users\\USERNAME\\AppData\\Local\\Google\\Chrome\\Application\\chrome.exe\" --incognito -- \"%1\""

Saturday, March 3, 2012

Chromium OS Live USB

I've been inspired by this lifehacker article to give Chromium OS (a non-Google version Chrome OS) a try on an out-dated Gateway.

I'm using the Chromium "Lime" build available here.

I'm using these steps to burn the image to a USB drive.

Update: While I was able to get a build working on my modern HP laptop, it was an epic fail on the ancient desktop. It's probably just as well since it's PIII, 256 RAM probably wouldn't have been able to handle Chromium OS anyway.

Thursday, March 1, 2012

Must-have Gmail Lab Features


  • Mark as Read Button: puts a popular action on the toolbar instead of in a menu
  • Send & Archive: moving towards inbox cleanliness with every email response
  • Signature tweaks: removes stupid "--" and puts sig before quoted reply text
  • Undo Send: holds a just-sent email for a small window allowing for "oh, shit, wait..."
  • Video chat enhancements: why not

Wednesday, February 29, 2012

Debugging ASP.NET MVC in IIS

Some notes:

  • Add IUSR and IIS_IUSRS for read access to project folder.
  • Setup a virtual directory in IIS pointed to project folder.
  • Change project settings to use Local IIS web server at same URL as mapped virtual directory.
  • Clean & Rebuild solution.
  • If connecting to database using integrated security, switch app pool identity to current user (Advanced Settings -> Identity).
  • Optional: open up firewall for accessing site from other systems.
  • If publishing from Visual Studio, make sure all images/css/scripts are included in the project so that they will be deployed.

Dropbox + Google Apps Engine + Google Apps = Free Web Hosting

Each Dropbox account has a Public folder with a fixed base URL from which one can make files publically available on the web. Any file can be served, including static HTML files. So why not use Dropbox to host a simple website? I'll tell you why: because the base URL is a sloppy mess which makes straight domain forwarding impossible.

This dude has a pretty cool workaround--use Google App Engine to host a URL-rewriting service specially configured to proxy requests from the app to Dropbox. He calls it DropbProx (a specific version of the more complete mirror proxy, mirrorrr). And since the GAE application gets a sweet URL in the form appname.appspot.com, it's ripe for domain forwarding.

In order to transfer the application files to your GAE account, you'll need to install the Google App Engine SDK. If you're a Windows user, I suggest the SDK for Python--which requires, of course, the Python runtime.  Here is a straight-forward tutorial (pics and vid included) on installing the SDK and deploying an application to GAE.

Once the app is up and running and forwarding requests from your app URL to your Dropbox URL, go to the GAE dashboard under Administration >> Application Settings >> Domain Setup. From here, you can "host" your application under your Google Apps domain.

Now head over to the Google Apps dashboard, and under Settings, your app should be listed in the Services column as "your-app-id (App Engine)". Follow the steps to map the www subdomain to your-app-id.appspot.com. The final step will be access your domain name provider and add a CNAME record to your DNS settings that will map www to ghs.google.com. For bonus points, have your DNS provider map your naked domain to the www subdomain so that yourdomain.com and www.yourdomain.com both send users to your website.

A final caveat: the Dropbox public folder is not an actual web server. So it won't assume a call to an open-ended URL will default to index.html, index.htm, index.php, etc. That logic would have to built into the proxy application. Still, not too shabby for free web hosting.

Tuesday, February 28, 2012

Confusing Week of Month with Nth Weekday of Month

Protip: Don't confuse computing the week of the month during which a day occurs with computing which Nth weekday of the month a day occurs (day/7 + 1 where day is the day of the month from 0-30).

Thursday, February 23, 2012

HostingEnvironment, Where Have You Been All My Life?

Son of a bitch!

Here'd I'd been schlepping around instances of the HttpContext in my MVC app so that I could use Server.MapPath() -- when all this time I could have been using the static method System.Web.Hosting.HostingEnvironment.MapPath().

Learn something new every day, I guess.

Tuesday, February 21, 2012

Tortoise SVN Sparse Directories

So I had checked out this project from SVN and with it came a dozen or so release branches I wasn't going to need. Now since these release branches were subfolders of the main project, they didn't have individual .svn folders to delete. Thus Tortoise SVN exporting wouldn't work either.  And just deleting the folders outright meant I was only one thoughtless commit away from removing them from the repository all together.

I needed some way to remove them from my local system without indicating to Tortoise SVN that I wanted to delete them from source control.  Enter the sparse directory update (src1, src2, src3).

From the Tortoise SVN context menu for a given folder, select "Update to revision". Set "Update Depth" to "Exclude", and use "Choose items" to select the folders you want to keep. Verify "Make depth sticky" is selected so you won't have to repeat this process after every code update. After a minute or two of SVN doing its thing, you'll have all of the folders you want and none that you don't.

Friday, February 10, 2012

jQuery Plugins (Non-jQuery UI Version)

I was (and still am) a fan of the jQuery UI widget template system (src1, src2, lmgtfy) for creating modular UI functionality. But if you're not a fan of the overhead of including the jQuery UI libraries, you can create the same effect with just plain 'ol jQuery plugins. You don't get all the bells and whistles of the widget factory, but as you can see, the set up is still very straightforward.

(function ($) {

    var settings = {
        // ...
    };

    function _private(options) {
    }

    var methods = { //public methods
        init: function (options) {
            $.extend(settings, options || {});
            return this..each(function () {
                // ...
                // use settings
                // ...
                // call _private
                // ...
            });
        },
        destroy: function () {
            // ...
            // unbind events, remove DOM objects, etc
            // ...
        },
        f1: function (args) {
            // ...
        },
        // ...
        fN: function (args) {
            // ...
        }
    };

    $.fn.pluginName = function (optionsOrMethod) {
        if (methods[optionsOrMethod]) {
            return methods[optionsOrMethod].apply(this, Array.prototype.slice.call(arguments, 1));
        } else if (typeof optionsOrMethod === 'object' || !optionsOrMethod) {
            return _init.apply(this, arguments);
        } else {
            $.error('Method [' + optionsOrMethod + '] does not exist');
        }
    };

})(jQuery);

Wednesday, January 25, 2012

Text File Processing With Bash

This took me way too long to figure out, and I'll never remember how to do it, so I document it here for all time. I had this list of files I needed to download using wget or curl or whatever. The trick was that each file had a unique URL and required a unique output filename. So I had this long-ish text file which looked like:

127_368_2.pdf http://localhost:52225/Cache/Embedded/127/368/2.pdf
127_368_3.pdf http://localhost:52225/Cache/Embedded/127/368/3.pdf
923_7_1.pdf http://localhost:52225/Cache/Embedded/923/7/1.pdf

where the first string was the output name and the second was it's source URL. After bouncing around scripting forums for way, way too long, I came up with the following:

cat files.txt | while read x y; do curl -b authcookie.txt -o $x $y; done

What took me so long to figure out was the parsing of each line of the file, which "while" takes care of on it's own when multiple arguments are provided. Neat.

SSH Tunneling for Fun and Profit

When you finally, finally understand how SSH tunneling works, it opens up a whole universe of possibilities...

Dynamic DNS

If you don't have a static IP address, you'll need to get yourself set up with a dynamic DNS provider so that you can find your computer no matter what IP address your ISP has given you. There are plenty of service providers to pick from. I like DynDNS because it's free and my provided domain name can be updated from my router (they also provide installable daemons for any flavor of OS).

Tunnel through NAT (with static DHCP leases)

You've got a few options for trying to tunnel through to a SSH server behind your NAT/firewall. You can set up your router to forward a certain port to your SSH server, or if your router itself has an SSH server, the ssh tunneling syntax can do the forwarding for you:

ssh -L 9999:targetserver:1234 sshuser@sshserver

This command will tunnel port 9999 on the local machine to port 1234 on the target machine through the SSH server. Keep in mind that the data flow between the SSH server and target server will not be encrypted. This issue is addressed later.

Stream Ampache (or any web application)

Simple enough--build a tunnel to the web server's http port.

ssh -L 8888:webserver:80 remoteuser@sshserver

Then point your web browser to http://localhost:8888.

VNC over SSH

Here's a handy scenario for tunneling a VNC server to local port.

ssh -L 5900:vncserver:5900 remoteuser@sshserver

Now just point your VNC client application to localhost:5900 and bam! you're connected. With this tunnel, traffic between sshserver and vncserver is exposed to the network, and since VNC is not a secure protocol by default, this could be an issue. The next section demonstrates a potential solution.

Tunnel Within a Tunnel (We Have to Go Deeper) (via1, via2)

Say you need to SSH tunnel from your localhost to host1, and then from host1 to host2. Provided that host1 and host2 both have SSH servers, you can join two tunnels in the following way:

ssh -L 9999:host2:22 host1user@host1
ssh -L 9998:localhost:5900 -p 9999 host2user@localhost

The first tunnel connects the SSH server on host2 port 22 to localhost port 9999 (using host1's SSH server). The second tunnel connects the service at port 5900 on host2 to the localhost port 9998 (through host2's SSH server available now as a result of the first tunnel).

So when whatever client application is pointed to the localhost port 9998, it's really tunneling to host2 port 5900 through host2's SSH server... where the connection to host2's SSH server is actually through another tunnel (from localhost port 9999 to host2 port 22 over host1's SSH server). And there you have it: a tunnel within a tunnel. My head hurts.

Stream iTunes (via1, via2)

This gets a little trickier because of the way iTunes advertises itself on the network. We'll need to use another application to assist with that bit, but the tunneling is the same. iTunes streams over port 3869, so:

ssh -L 9999:itunesserver:3689 sshuser@sshserver

Then, with the tunnel up and running,

dns-sd -P "Home iTunes" _daap._tcp local 3689 localhost.local. 127.0.0.1 "Arbitrary text record"

Package that up into a script, and you're good to go.

Friday, January 20, 2012

Pipe Char


Monday, January 16, 2012

jQuery Pan & Zoom for Images and Maps

This is a nice little gadget for large image viewing:
http://wayfarerweb.com/jquery/plugins/mapbox/

I'm thinking of adding a few improvements:

  1. "Smoothing out" transitions between zoom levels
  2. Support for HTML5 gestures allowing pinch-to-zoom on mobile devices
  3. Kinetic scrolling/panning
Updates to come...

Google Voice Call Screening and the iPhone

If you have Call Screening active, and you answer a call from your Google Voice forwarding number, you must press "1" in order to answer the call (or otherwise navigate a touch-tone menu system). Don't know about you, but I find this to be a minor but annoying inconvenience.

You can turn off Call Screening, but there are two sides to every Schwarz, and while the call will now connect with no confirmation required, your phone's voicemail will engage before Google voicemail--thus losing all those neat Google voicemail advantages.

But have no fear; there is a solution. Just extend your phone's voicemail timeout to be of a longer duration than the Google voicemail timeout, thereby ensuring an unanswered call is always sent to Google Voice's voicemail system before it reaches your provider's voicemail system.

In other words:
http://support.google.com/voice/bin/answer.py?hl=en&topic=19490&answer=115110

Now I can't speak for other carriers and phone models, but in order to set the voicemail timeout for an AT&T iPhone 4, the following instructions worked fine for me (source):

  1. Dial "*#61*".
  2. Copy down the number in the text "Voice Call Forwarding When Unanswered Forwards to +1XXXXXXXXXX Enabled"
  3. Dial *61*+1XXXXXXXXXX*11*tt# where +1XXXXXXXXXX is the number you copied in the previous step, and "tt" is the number of seconds (from 5 to 30 in increments of 5) for the voicemail timeout duration you wish.
As the Google Voice timeout is 25 seconds, I recommend setting your carrier timeout to 30 seconds.

Update: There's this, too.

Saturday, January 7, 2012

Entity Framework, Unit of Work, and Shared Context

Those familiar with Entity Framework, MVC, and the repository pattern are also familiar with the following run-time error:
The relationship between the two objects cannot be defined because they are attached to different ObjectContext objects.
It occurs when one attempts to associate an entity object from one data context to an entity object from another data context. With the repository pattern, there is usually one data context per repository. So if, for example, one were to query for a user

user = usersRepository.Find(userId);

and a role

role = rolesRepository.Find(roleId);

and then attempt to associate the two

user.Roles.Add(role);

one would get the aforementioned error when executing the line

usersRepository.Save()

where the Save() method wraps the SaveChanges() method of the data context.

This post describes the situation very well. It also describes some potential solutions, one of which is to share the data context across all repositories for the life of the web request. And one way to do that is to use DI--in my case, StructureMap.

Each of my repositories ultimately derives from an abstract repository base class.

public abstract class RepositoryBase
{
 protected DbContainer db;

 public RepositoryBase()
 {
  db = ObjectFactory.GetInstance<DbContainer>();
 }
}

And over in the DI configuration:

ObjectFactory.Initialize(x =>
{
 x.For<DbContainer>().HttpContextScoped().Use(() => new DbContainer());
 // ...
});

And there we have it, folks. On a cautionary note, because all repositories share the same context, a call to a repository's Save() method will persist changes to any changed entity, not just the ones from the saving repository. This can result in unexpected behavior, so, you know, be aware.

Wednesday, January 4, 2012

Using ManualResetEvent to Wait

This one time, at band camp, I was using a PDF library from third-party to generate PNG thumbnails of the pages. The library was set up in such a way that thumbnail generation was forked to a separate thread with only an event handler to notify when rendering was complete.

Since this code was going to serve the thumbnail file from a web server, I needed to file to be ready before delivering the web response. I had to wait until the event fired--a sort of "re-synchronization" or "de-asynchronization", if you will.

Enter the ManualResetEvent. With some blogger help, I was able to put together the following:

using System;
using System.Drawing;
using System.Drawing.Imaging;
using System.Threading;

// http://code.google.com/p/pdfviewer-win32
using PDFLibNet;

namespace InSite.Portal.Helpers
{
    public class PdfHelper
    {
        PDFWrapper _pdf;

        public PdfHelper(string sourceFilePath)
        {
            _pdf = new PDFWrapper();
            _pdf.LoadPDF(sourceFilePath);
        }
       
        public void SavePageAsImage(string destFilePath, int page)
        {
            SavePageAsImage(destFilePath, page, ImageFormat.Png);
        }
        
        public void SavePageAsImage(string destFilePath, int page, ImageFormat format)
        {
            ManualResetEvent _mre = new ManualResetEvent(false);
            var renderPageThumbnailFinished = new RenderNotifyFinishedHandler((i, s) => { _mre.Set(); });

            var pg = _pdf.Pages[page];
            pg.RenderThumbnailFinished += renderPageThumbnailFinished;
            var h = Convert.ToInt32(pg.Height); // get height
            var w = Convert.ToInt32(pg.Width);  // get width
            var bmp = pg.LoadThumbnail(w, h);   // call thumbnail render
            _mre.WaitOne(30 * 1000, true);      // wait until render complete (30 sec timeout)
            bmp.Save(destFilePath, format);     // save completed image
            pg.RenderThumbnailFinished -= renderPageThumbnailFinished;
        }

        public int PageCount
        {
            get
            {
                return _pdf.PageCount;
            }
        }
    }
}

Crude? Elegant? Either way, it gets the job done.