3

I'm trying to render some html content to a bitmap in a Windows Service.

I'm using System.Windows.Controls.WebBrowser to perform the render. The basic rendering setup works as a standalone process with a WPF window hosting the control, but as a service, at least I'm not getting the LoadCompleted events to fire.

I know that I at least need a Dispatcher or other message pump looping for this WPF control. Perhaps I'm doing it right and there are just additional tricks/incompatibilities necessary for the WebBrowser control. Here's what I've got:

I believe only one Dispatcher needs to be running and that it can run for the life of the service. I believe the Dispatcher.Run() is the actual loop itself and thus needs it's own thread which it can otherwise block. And that thread needs to be [STAThread] in this scenario. Therefore, in a relevant static constructor, I have the following:

var thread = new Thread(() =>
{
    dispatcher = Dispatcher.CurrentDispatcher;

    Dispatcher.Run();
});
thread.SetApartmentState(ApartmentState.STA);
thread.Start();

where dispatcher is a static field. Again, I think there can only be one but I'm not sure if I'm supposed to be able use Dispatcher.CurrentDispatcher() from anywhere instead and get the right reference.

The rendering operation is as follows. I create, navigate, and dispose of the WebBrowser on dispatcher's thread, but event handler assignments and mres.Wait I think may all happen on the render request-handling operation. I had gotten The calling thread cannot access this object because a different thread owns it but now with this setup I don't.

WebBrowser wb = null;
var mres = new ManualResetEventSlim();

try
{
    dispatcher.Invoke(() => { wb = new WebBrowser(); });

    wb.LoadCompleted += (s, e) =>
    {
        // Not firing
    };

    try
    {
        using (var ms = new MemoryStream())
        using (var sw = new StreamWriter(ms, Encoding.Unicode))
        {
            sw.Write(html);
            sw.Flush();
            ms.Seek(0, SeekOrigin.Begin);

            // GO!
            dispatcher.Invoke(() =>
            {
                try
                {
                    wb.NavigateToStream(ms);
                    Debug.Assert(Dispatcher.FromThread(Thread.CurrentThread) != null);
                }
                catch (Exception ex)
                {
                    // log
                }
            });

            if (!mres.Wait(15 * 1000)) throw new TimeoutException();
        }
    }
    catch (Exception ex)
    {
        // log
    }
}
finally
{
    dispatcher.Invoke(() => { if (wb != null) wb.Dispose(); });
}

When I run this, I get my timeout exception every time since the LoadCompleted never fires. I've tried to verify that the dispatcher is running and pumping properly. Not sure how to do that, but I hooked a few of the dispatcher's events from the static constructor and I get some printouts from that, so I think it's working.

The code does get to a wb.NavigateToStream(ms); breakpoint.

Is this bad application of Dispatcher? Is the non-firing of wb.LoadCompleted due to something else?

Thanks!

Jason Kleban
  • 20,024
  • 18
  • 75
  • 125
  • `wb.LoadCompleted` doesn't get fired because you add this event handler from a different thread the WebBrowser object was created on. Try moving it inside `dispatcher.Invoke(() => { .. })` delegate. That still might not be a nice design, but at least the even should be getting fired. – noseratio Aug 13 '13 at 15:59
  • @Noseratio - Thanks for the suggestion. I don't think that it is a requirement that the event handler needs to be added on the same thread but I just tried it but it doesn't allow the event to fire - no change. Once I get it working I'll try with and without just to see. – Jason Kleban Aug 13 '13 at 16:15

2 Answers2

1

Here's a modified version of your code which works as a console app. A few points:

  • You need a parent window for WPF WebBrowser. It may be a hidden window like below, but it has to be physically created (i.e. have a live HWND handle). Otherwise, WB never finishes loading the document (wb.Document.readyState == "interactive"), and LoadCompleted never gets fired. I was not aware of such behavior and it is different from the WinForms version of WebBrowser control. May I ask why you picked WPF for this kind of project?

  • You do need to add the wb.LoadCompleted event handler on the same thread the WB control was created (the dispatcher's thread here). Internally, WPF WebBrowser is just a wrapper around apartment-threaded WebBrowser ActiveX control, which exposes its events via IConnectionPointContainer interface. The rule is, all calls to an apartment-threaded COM object must be made on (or proxied to) the thread the object was originally created on, because that's what such kind of objects expect. In that sense, IConnectionPointContainer methods are no different to other methods of WB.

  • A minor one, StreamWriter automatically closes the stream it's initialized with (unless explicitly told to not do so in the constructor), so there is no need to for wrapping the stream with using.

The code is ready to compile and run (it requires some extra assembly references: PresentationFramework, WindowsBase, System.Windows, System.Windows.Forms, Microsoft.mshtml).

using System;
using System.Text;
using System.Threading;
using System.Diagnostics;
using System.Windows;
using System.Windows.Threading;
using System.Windows.Controls;
using System.IO;
using System.Runtime.InteropServices;
using mshtml;    

namespace ConsoleWpfApp
{
    class Program
    {
        static Dispatcher dispatcher = null;
        static ManualResetEventSlim dispatcherReady = new ManualResetEventSlim();

        static void StartUIThread()
        {
            var thread = new Thread(() =>
            {
                Debug.Print("UI Thread: {0}", Thread.CurrentThread.ManagedThreadId);
                try
                {
                    dispatcher = Dispatcher.CurrentDispatcher;
                    dispatcherReady.Set();
                    Dispatcher.Run();
                }
                catch (Exception ex)
                {
                    Debug.Print("UI Thread exception: {0}", ex.ToString());
                }
                Debug.Print("UI Thread exits");
            });
            thread.SetApartmentState(ApartmentState.STA);
            thread.Start();
        }

        static void DoWork()
        {
            Debug.Print("Worker Thread: {0}", Thread.CurrentThread.ManagedThreadId);

            dispatcherReady.Wait(); // wait for the UI tread to initialize

            var mres = new ManualResetEventSlim();
            WebBrowser wb = null;
            Window window = null; 

            try
            {    
                var ms = new MemoryStream();
                using (var sw = new StreamWriter(ms, Encoding.Unicode)) // StreamWriter automatically closes the steam
                {
                    sw.Write("<b>Hello, World!</b>");
                    sw.Flush();
                    ms.Seek(0, SeekOrigin.Begin);

                    // GO!
                    dispatcher.Invoke(() => // could do InvokeAsync here as then we wait anyway
                    {
                        Debug.Print("Invoke Thread: {0}", Thread.CurrentThread.ManagedThreadId);
                        // create a hidden window with WB
                        window = new Window()
                        {
                            Width = 0,
                            Height = 0,
                            Visibility = System.Windows.Visibility.Hidden,
                            WindowStyle = WindowStyle.None,
                            ShowInTaskbar = false,
                            ShowActivated = false
                        };
                        window.Content = wb = new WebBrowser();
                        window.Show();
                        // navigate
                        wb.LoadCompleted += (s, e) =>
                        {
                            Debug.Print("wb.LoadCompleted fired;");
                            mres.Set(); // singal to the Worker thread
                        };
                        wb.NavigateToStream(ms);
                    });

                    // wait for LoadCompleted
                    if (!mres.Wait(5 * 1000))
                        throw new TimeoutException();

                    dispatcher.Invoke(() =>
                    {   
                        // Show the HTML
                        Console.WriteLine(((HTMLDocument)wb.Document).documentElement.outerHTML);
                    });    
                }
            }
            catch (Exception ex)
            {
                Debug.Print(ex.ToString());
            }
            finally
            {
                dispatcher.Invoke(() => 
                {
                    if (window != null)
                        window.Close();
                    if (wb != null) 
                        wb.Dispose();
                });
            }
        }

        static void Main(string[] args)
        {
            StartUIThread();
            DoWork();
            dispatcher.InvokeShutdown(); // shutdown UI thread
            Console.WriteLine("Work done, hit enter to exit");
            Console.ReadLine();
        }
    }
}
noseratio
  • 59,932
  • 34
  • 208
  • 486
  • No problem. I'm sure you have your reasons to use WPF for this, but it'd probably take less resources if done with WinForms's WebBrowser. Or, perhaps, it'd be more appropriate to use [Awesomium](http://awesomium.com), which is tailored for off-screen rendering. – noseratio Aug 14 '13 at 05:35
  • 1
    Yay! It works! Thanks! Yeah, this may go to Awesomium but this is cheaper. – Jason Kleban Aug 14 '13 at 12:58
0

Maybe the Webbrowser Control needs Desktop Interaction for rendering the content:

enter image description here

My feeling say that using WPF controls and in particular particulary the Webbrowser-Control (=Wrapper around the IE ActiveX control) isn't the best idea.. There are other rendering engines that might be better suited for this task: Use chrome as browser in C#?

Community
  • 1
  • 1
doerig
  • 1,857
  • 1
  • 18
  • 26
  • Hi Martin. I'm not even running it as a service yet, really. It can be run that way but for now I'm just running it as a user process under my credentials with ServiceBase harness - so it should already have that access (for now). I'll investigate using Chrome too but it'd be nice to figure this one out first. – Jason Kleban Aug 13 '13 at 17:06