2011年11月4日金曜日

wpf : グローバルフックdllをC#から呼び出す

以前VC++で作った「グローバルフックでランダムな文字列を挿入するプログラム」をwpfでもやってみました。 目的は、

  • 64bit版のdll(VC++で作ったやつを流用)のロードを試す。
  • wpfでタスクトレイアプリの作成を試す。

の2点です。

そういえば、最初の投稿で「C#でのグローバルフックは色々と面倒くさそう」と書きましたが、ここを見るとそうでもないようですね。

dllを作るのとC#だけでやるのに手間の差はさほど無いようです。 けど、まぁいいや。

アプリケーションを起動するとタスクトレイにアイコンが表示されます。 その状態で別のアプリケーションを操作中にショートカットキー(デフォルトでCtrl+Alt+F)を押すとランダムな文字列が挿入されます。 ランダムな文字列は大文字小文字のアルファベットと数字です。 タスクトレイを右クリックすると設定、終了のメニューが出ます。 設定ダイアログはxamlで作成。 ショートカットキーと文字列の長さを変更可能。 「自分でも使うかどうか?」っていう機能を適当に実装しただけなので色々不親切になっています。

まずはdllのロードのしかたから。 とりあえず普通にDllImportしたけどダメでした。 「なんでかなぁ?」と思いながら適当に検索してたらこんなページを発見。

そこに「C#からアンマネージドDLLクラスを呼び出すにはエントリポイント(関数名)をエンコードした名前で指定しないといけない。」とあります。 もしかして64bit版dllのグローバル関数を呼ぶときもそうなのか? と思ってやってみたら上手くいきました。

using System.Runtime.InteropServices;

namespace InsertRandomString
{
 class Dll
 {
  [DllImport("CreateRandomString_KeyHook.dll", EntryPoint = "?AddHook@@YA_NXZ")]
  public extern static bool AddHook();

  [DllImport("CreateRandomString_KeyHook.dll", EntryPoint = "?RemoveHook@@YA_NXZ")]
  public extern static bool RemoveHook();

  [DllImport("CreateRandomString_KeyHook.dll", EntryPoint = "?SetShortcutKey@@YA_NH@Z")]
  public extern static bool SetShortcutKey(int key);

  [DllImport("CreateRandomString_KeyHook.dll", EntryPoint = "?SetHashLength@@YA_NH@Z")]
  public extern static bool SetHashLength(int len);
 }
}

EntryPoint属性にある関数名前後に記号が付いてるのが「エンコードした名前」です。 コマンドラインツールのdumpbinで調べられます。 開発環境のコマンドラインツールはスタートメニューにある「Visual Studioコマンドプロンプト」を使うと便利です。 開発ツールのパスが通った状態のコマンドプロンプトが出てきます。 dllがあるディレクトリに移動して、

dumpbin /exports なんたら.dll

で関数名が分かります。

どうでもいいことですが、プログラムの名称がCreateRandomStringからInsertRandomStringに変わっています。 気分の問題だけど、こういう変更はちょっとカッコ悪いかも。 dllの名前はそのままですしね。

DllImportを使うと、最初に関数呼び出しがあったときにdllのロードが行われるようですね。 「オーバーヘッドが気になる場合」や「アプリケーション起動時にdllがロードできたか確認したい場合」はLoadLibraryした方が良いかも?

dllが無事呼べるようになったので、アプリケーション本体を作っていきます。 作るプロジェクトはwpfですが、wpfにはタスクトレイコントロールがありません。 参照を追加してSystem.Windows.Forms.NotifyIconを使います。

このプログラムはタスクトレイにアイコンを出すだけの常駐プログラムなので、にApp.xamlを書き換えます。

<Application x:Class="InsertRandomString.App"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             ShutdownMode="OnExplicitShutdown"
             Startup="OnStartup"
             Exit="OnExit">
    <Application.Resources>
    </Application.Resources>
</Application>

書き換えるのは、

  • 起動時にMainWindowを表示しないようにStartupUriを消去。
  • Startupイベントを追加してグローバルフック開始。設定ファイル読み込み。タスクトレイアイコン表示。
  • MainWindowを閉じても終了しないようにShutdownModeを変更。
  • Exitイベントを追加してフックの終了。タスクトレイアイコンの消去。

App.xaml.csはこうなりました。

using System;
using System.Windows;
using DnetForm = System.Windows.Forms;

namespace InsertRandomString
{
    public partial class App : Application
    {
        private DnetForm.NotifyIcon _notifyIcon;

        private void OnStartup(object sender, StartupEventArgs e)
        {
            try
            {
                if (!Dll.AddHook())
                {
                    MessageBox.Show("キーボードのフックに失敗しました。", "InsertRandomString");
                    Shutdown();
                    return;
                }
            }
            catch
            {
                MessageBox.Show("dllの読み込みに失敗しました。", "InsertRandomString");
                Shutdown();
                return;
            }

            try
            {
                AppConfig.Load();

                AppConfig conf = AppConfig.Current;
                Dll.SetHashLength(conf.HashLength);
                Dll.SetShortcutKey(conf.ShortcutKeyCode);
            }
            catch
            {
                MessageBox.Show("設定ファイルの読み込みに失敗しました。", "InsertRandomString");
            }

            InitializeTaskTray();
        }

        private void OnExit(object sender, ExitEventArgs e)
        {
            try
            {
                Dll.RemoveHook();
            }
            catch { }

            if(_notifyIcon != null)
                _notifyIcon.Dispose();
        }

        private void InitializeTaskTray()
        {
            _notifyIcon = new DnetForm.NotifyIcon();
            _notifyIcon.Text = "ランダム文字列(0-9a-zA-Z)挿入";
            _notifyIcon.Icon = InsertRandomString.Properties.Resources.TASKTRAY_ICON;
            _notifyIcon.Visible = true;

            DnetForm.ContextMenuStrip contextMenu = new DnetForm.ContextMenuStrip();

            DnetForm.ToolStripMenuItem menuItemConfig = new DnetForm.ToolStripMenuItem();
            menuItemConfig.Text = "設定";
            menuItemConfig.Click += new EventHandler(OnConfigMenuClick);
            contextMenu.Items.Add(menuItemConfig);

            DnetForm.ToolStripMenuItem menuItemExit = new DnetForm.ToolStripMenuItem();
            menuItemExit.Text = "終了";
            menuItemExit.Click += new EventHandler(OnExitMenuClick);
            contextMenu.Items.Add(menuItemExit);

            _notifyIcon.ContextMenuStrip = contextMenu;
        }

        private void OnExitMenuClick(object sender, EventArgs e)
        {
            Shutdown();
        }

        private void OnConfigMenuClick(object sender, EventArgs e)
        {
            MainWindow wnd = new MainWindow();
            if (wnd.ShowDialog() == true)
            {
                AppConfig conf = AppConfig.Current;
                Dll.SetHashLength(conf.HashLength);
                Dll.SetShortcutKey(conf.ShortcutKeyCode);

                try
                {
                    AppConfig.Save();
                }
                catch
                {
                    MessageBox.Show("設定ファイルの保存に失敗しました。", "InsertRandomString", MessageBoxButton.OK, MessageBoxImage.Information);
                }
            }
        }
    }
}

このコード、App.Startupイベント中にShutdownを呼んでもExitイベントが発生しないことがありました。 デバッグ構成で確認。 リリース構成だと正常にExitイベントが発生しました。 原因は良く分からず、不気味です。

MainWindowは設定ダイアログとして使っています。 設定ダイアログと設定項目を保持するAppConfigクラスについては以前の投稿「wpf 設定ダイアログの作り方」を見てください。

で、このプログラムのMainWindow.xamlのコードはこんな感じです。

<Window x:Class="InsertRandomString.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:app="clr-namespace:InsertRandomString"
        Title="InsertRandomString"
        Topmost="True"
        SizeToContent="WidthAndHeight"
        ResizeMode="NoResize"
        Loaded="OnLoaded"
        Icon="/InsertRandomString;component/CreateRandomString.ico">
    <Window.Resources>
        ...前と同じ
    </Window.Resources>
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition/>
            <RowDefinition/>
            <RowDefinition/>
        </Grid.RowDefinitions>
        <Grid.ColumnDefinitions>
            <ColumnDefinition/>
            <ColumnDefinition/>
        </Grid.ColumnDefinitions>
        <Label Grid.Row="0" Grid.Column="0" ToolTip="Ctrl+Shift+?">ショートカットキー</Label>
        <TextBox Name="textBoxShortcutKey" Grid.Row="0" Grid.Column="1" Margin="2" Width="48" InputMethod.IsInputMethodEnabled="False">
            <TextBox.Text>
                <Binding
                        Path="ShortcutKey"
                        UpdateSourceTrigger="PropertyChanged"
                        ValidatesOnExceptions="True"
                        ValidatesOnDataErrors="True"
                />
            </TextBox.Text>
        </TextBox>
        <Label Grid.Row="1" Grid.Column="0">桁</Label>
        <TextBox Name="textBoxHashLength" Grid.Row="1" Grid.Column="1" Margin="2" Width="48" InputMethod.IsInputMethodEnabled="False">
            <TextBox.Text>
                <Binding
                        Path="HashLength"
                        UpdateSourceTrigger="PropertyChanged"
                        ValidatesOnExceptions="True"
                        ValidatesOnDataErrors="True"
                >
                    <Binding.ValidationRules>
                        <app:HashLengthValidationRule/>
                    </Binding.ValidationRules>
                </Binding>
            </TextBox.Text>
        </TextBox>
        <StackPanel Grid.Row="2" Grid.Column="0" Grid.ColumnSpan="2" Orientation="Horizontal" HorizontalAlignment="Right">
            <Button Content="設定" Click="OnConfigButtonClick" IsDefault="True" Margin="10,0"/>
            <Button Content="キャンセル" Click="OnCancelButtonClick" IsCancel="True" />
        </StackPanel>
    </Grid>
</Window>

ボタンのIsDefaultプロパティとIsCancelプロパティ、前の投稿では見逃してたっけ? まぁ、けっこう使用感に影響有りますよってことで。

文字列の桁数を検証するHashLengthValidationRuleクラスのコードです。

using System.Windows.Controls;
using System.Globalization;

namespace InsertRandomString
{
    class HashLengthValidationRule : ValidationRule
    {
        private static string _errMsg = "" + MIN + "~" + MAX + "の整数を入力してください。";

        public const int MIN = 3;
        public const int MAX = 1024;

        public override ValidationResult Validate(object value, CultureInfo cultureInfo)
        {
            int res;

            if (!int.TryParse(value as string, out res))
            {
                return new ValidationResult(false, _errMsg);
            }

            if (res < MIN || MAX < res)
            {
                return new ValidationResult(false, _errMsg);
            }

            return new ValidationResult(true, null);
        }
    }
}

MINやMAXなどの固定値はホントはdllに持たせてそこから取ってくるべきです。 dllのコードとこちらのコードの両方に定数を書くのはバグの元になってしまいます。 でも今回は手抜きで。

MainWindow.xaml.csはこうなりました。

using System.Windows;
using System.Windows.Controls;
using DnetDrawing = System.Drawing;
using DnetForm = System.Windows.Forms;

namespace InsertRandomString
{
    public partial class MainWindow : Window
    {
        PixelScaleTransformer _trans;

        public MainWindow()
        {
            InitializeComponent();
            Left = -int.MaxValue;

            this.DataContext = AppConfig.Current.Copy();
        }

        private void OnLoaded(object sender, RoutedEventArgs e)
        {
            SetWindowPosition();
        }

        private void OnConfigButtonClick(object sender, RoutedEventArgs e)
        {
            if (!IsValid(this))
            {
                MessageBox.Show(this, "不正な設定項目があります。", "InsertRandomString", MessageBoxButton.OK, MessageBoxImage.Information);
                return;
            }

            AppConfig.Current = (AppConfig)this.DataContext;
            DialogResult = true;
        }

        private void OnCancelButtonClick(object sender, RoutedEventArgs e)
        {
            DialogResult = false;
        }

        private void SetWindowPosition()
        {
            if (_trans == null)
                _trans = new PixelScaleTransformer(this);

            DnetDrawing.Point dp = DnetForm.Cursor.Position;
            Point sp = new Point(dp.X, dp.Y);
            sp = _trans.DeviceToWpf(sp);

            DnetForm.Screen currentScreen = DnetForm.Screen.FromPoint(dp);
            Point screenSize = new Point(currentScreen.Bounds.Width, currentScreen.Bounds.Height);
            screenSize = _trans.DeviceToWpf(screenSize);

            double x = sp.X - this.ActualWidth / 2.0;
            if (x < 0)
                x = 0;
            else if (screenSize.X - this.ActualWidth < x)
                x = screenSize.X - this.ActualWidth;

            double y = sp.Y - this.ActualHeight / 2.0;
            if (y < 0)
                y = 0;
            else if (screenSize.Y - this.ActualHeight < y)
                y = screenSize.Y - this.ActualHeight;

            this.Left = x;
            this.Top = y;
        }

        private bool IsValid(DependencyObject node)
        {
            ...前と同じ
        }
    }
}

ウィンドウをマウスの近くに表示したかったのですが、それが意外と面倒でした。 説明も面倒なので箇条書きすると、

  1. wpfでモニタ解像度を得る方法が分からなかったのでSystem.Windows.Forms.Screenを使用。
  2. Screenはデバイス座標。wpfの論理座標で使用するには変換が必要。
  3. 変換するためのコードはSourceInitiazileイベント以降にしか使えない。
  4. SourceInitiazileイベントはウィンドウ表示後にしか発生しない。
  5. とりあえずウィンドウを画面外に「表示」して座標変換できるようになってから移動することに。

LoadedイベントはSourceInitiazileイベントの後に発行されます。 今回はどっちのイベントでやってもよかったんですが、とりあえずLoadedイベントで移動させています。

「デバイス座標」⇔「wpfの論理座標」の変換には以前投稿したPixelScaleTransformerクラスを使いました。 マルチモニタのときどうなるかは未確認です。

アプリケーションの設定情報を保持するAppConfigクラスはこんなに長くなりました。

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Xml.Serialization;

namespace InsertRandomString
{
    public class AppConfig : INotifyPropertyChanged, IDataErrorInfo
    {
        // ホントはdllにデフォルト値を持たせてそこから取ってくるべきだけど、手抜き
        public const int DEFAULT_HASH_LENGTH = 10;
        public const char DEFAULT_SHORTCUT_KEY = 'F';

        public static AppConfig Current { get; set; }

        static AppConfig()
        {
            Current = new AppConfig();
        }

        public AppConfig()
        {
            _hashLength = DEFAULT_HASH_LENGTH;
            _shortcutKey = DEFAULT_SHORTCUT_KEY.ToString();
            _errors = new Dictionary<string, string>();
        }

        private static string _FileName
        {
            get
            {
                string path = Environment.GetCommandLineArgs()[0];
                int extensionIndex = path.LastIndexOf('.') + 1;
                return path.Substring(0, extensionIndex) + "conf";
            }
        }

        public static void Save()
        {
            try
            {
                XmlSerializer serializer = new XmlSerializer(typeof(AppConfig));

                using (StreamWriter sout = new StreamWriter(AppConfig._FileName))
                {
                    serializer.Serialize(sout, AppConfig.Current);
                }
            }
            catch
            {
                throw;
            }
        }

        public static void Load()
        {
            try
            {
                XmlSerializer serializer = new XmlSerializer(typeof(AppConfig));

                using (StreamReader sin = new StreamReader(AppConfig._FileName))
                {
                    AppConfig.Current = serializer.Deserialize(sin) as AppConfig;
                }
            }
            catch
            {
                throw;
            }
        }

        private int _hashLength;
        public int HashLength
        {
            get { return _hashLength; }
            set
            {
                _hashLength = value;
                NotifyPropertyChanged("HashLength");
            }
        }

        private string _shortcutKey;
        public string ShortcutKey
        {
            get { return _shortcutKey; }
            set
            {
                if (value == null || value.Trim().Length == 0)
                {
                    _errors["ShortcutKey"] = "キー設定が空です。";
                }
                else if (value.Length == 1)
                {
                    char newKey = char.ToUpper(value[0]);
                    _shortcutKey = newKey.ToString(); // 小文字の場合を考えて

                    if (Misc.IsUpperAlphabet(newKey))
                    {
                        _errors.Remove("ShortcutKey");
                    }
                    else
                    {
                        _errors["ShortcutKey"] = "未対応のキーです。";
                    }
                }
                else
                {
                    // 1文字入力されるたびに呼ばれる
                    // ペーストでの3文字目以降は無視
                    // 設定ファイルを直に書き換えられた場合の対処は割愛
                    char oldKey = _shortcutKey[0];
                    char newKey = (value[0] == oldKey ? value[1] : value[0]);
                    newKey = char.ToUpper(newKey);
                    _shortcutKey = newKey.ToString();

                    if (Misc.IsUpperAlphabet(newKey))
                    {
                        _errors.Remove("ShortcutKey");
                    }
                    else
                    {
                        _errors["ShortcutKey"] = "未対応のキーです。";
                    }
                }
                NotifyPropertyChanged("ShortcutKey");
            }
        }
        public int ShortcutKeyCode
        {
            get
            {
                Debug.Assert(_shortcutKey != null && _shortcutKey.Length == 1);

                char key = _shortcutKey[0];
                if (Misc.IsUpperAlphabet(key))
                    return key;

                Debug.Fail("ShortcutKeyCodeが不正");
                return DEFAULT_SHORTCUT_KEY;
            }
        }

        public AppConfig Copy()
        {
            AppConfig res = new AppConfig();
            res._hashLength = this._hashLength;
            res._shortcutKey = string.Copy(this._shortcutKey);

            res._errors = new Dictionary<string, string>();
            foreach (string key in this._errors.Keys)
            {
                res._errors.Add(string.Copy(key), string.Copy(this._errors[key]));
            }
            
            return res;
        }

        ...INotifyPropertyChanged, IDataErrorInfoの実装は前と同じ
    }
}

設定項目が2つしかないのにこの長さは大変です。 まぁ仕方がないんですけどね。

SaveとLoadにはXmlSerializerを使っています。 XmlSerializerはお手軽ではありますが、ユーザーが設定ファイルを直接書き換えてタイプミスした場合色々不具合が出ることが予想されます。 あと、アプリケーションのバージョンアップで設定項目の増減があるときも気を使わなければなりませんよね。 そういう欠点があるので、ちゃんとしたアプリケーションには使わない方がいいかもしれません。

ShortcutKeyプロパティをstring型にしているのは手抜きです。 ショートカットキーはアルファベット大文字1文字なのでchar型の方が理にかなっています。 しかし、お手軽にバインドできるのでstring型にしました。 TextBoxの入力制限は一通りやるとかなりの手間になりますからね。 こんなサンプルプログラムでやることじゃない。

ショートカットキーが大文字のアルファベット1文字という仕様なのは面倒だからです。 dllの方でそう作っちゃいました。 で、C#でcharが大文字のアルファベットかどうかを調べるコードはこれです。

namespace InsertRandomString
{
    public class Misc
    {
        public static bool IsUpperAlphabet(char c)
        {
            return 'A' <= c && c <= 'Z';
        }
    }
}

「char.IsLetterがあるじゃないか」というのは間違いでした。 char.IsLetterではアルファベット以外の文字でも反応してしまいます。 このページに書いてました。

msdnライブラリの記述を信用してはいけないってことですね。

コードはこれで全部です。 結局全部のコードをあげてしまいました。 それぞれのコードで気付いたことを上げていったらまとまりのない文章になってしまった。 「ダラダラ長い文章なんて誰も読まね~よ」という言葉をいろんな人に言われたのが思い出されます。 まぁ、このブログはこれでいいか?