マウスジェスチャーツール Crevice 3.2.184 をリリースしました

カッコいいロゴを頂いたのでテンション上がってそのまま18時間コーディングしたらメジャーバージョンが1つ上がっていました。

f:id:ruby-U:20171223133526p:plain

どうですか、超クールなのでは?? イエーイ🎉🎉

Crevice 3.2.184 リリースノート

1月にリリースしたCrevice 2.5 から Crevice 3 へメジャーバージョンが1つ上がり、いくつかの大きな変更が加わっています。

  • 新しいクールなアイコン! アプリケーションの外観をブラッシュアップしました。

  • ユーザースクリプトのキャッシュ機構を追加。Crevice 2.5 と比較して起動速度が最大で50倍高速になりました。

  • コマンドラインインターフェイスの追加。ユーザースクリプトの場所、タスクトレイアイコン非表示、プロセス優先度、詳細なログ出力などの設定を引数として指定できるようになりました。

特に頑張ったところは起動速度の高速化です。CreviceではユーザースクリプトC# Scriptを使っていて、Crevice 2.5 ではRoslynで毎回コンパイルしていたのですが、このリソースが無駄だし起動時間は遅くなるしいいことなしだったので、ユーザースクリプトコンパイルした後のアセンブリをうまいことキャッシュできないかなーと試行錯誤してたら偶然うまく行きました。雰囲気でやっている。

技術資料

以下、できたてホヤホヤのソースから抜粋して、Roslynでユルい C# Script をコンパイルして、アセンブリからそれを評価する方法を書いておきます。アセンブリに GetType して、 GetMethod して Invoke という資料はよく見かけますが、 C# Script 特有のトップレベルに変数がくるようなコードでうまくそれをやる方法が見当たらなかったので貴重な資料だと思います(ホントか?)

Crevice 2 の実装(ユルいC# Script にglobalsを与えて評価する)

ユーザースクリプトから Script を作ります。

private Script ParseScript(string userScript)
{
    var stopwatch = new Stopwatch();
    Verbose.Print("Parsing UserScript...");
    stopwatch.Start();
    var script = CSharpScript.Create(
        userScript,
        ScriptOptions.Default
            .WithSourceResolver(ScriptSourceResolver.Default.WithBaseDirectory(UserDirectory))
            .WithMetadataResolver(ScriptMetadataResolver.Default.WithBaseDirectory(UserDirectory))
            .WithReferences("microlib")                   // microlib.dll
            .WithReferences("System")                     // System.dll
            .WithReferences("System.Core")                // System.Core.dll
            .WithReferences("Microsoft.CSharp")           // Microsoft.CSharp.dll
            .WithReferences(Assembly.GetEntryAssembly()), // CreviceApp.exe
                                                          // todo dynamic type
        globalsType: typeof(Core.UserScriptExecutionContext));
    stopwatch.Stop();
    Verbose.Print("UserScript parse finished. ({0})", stopwatch.Elapsed);
    return script;
}

そしてこの Script にコンテキスト ctx を globals として与えて評価します。ctxLeftButton, MiddleButton のような設定のためのトークン、 ジェスチャー定義に関する @when 関数や、Config のようなインスタンスTooltip, Balloon のようなユーティリティ関数などをメンバーとして持ち、それを globals として与えることで、ユーザースクリプト評価時のコンテキストとしています。

public class UserScriptExecutionContext
{
    public readonly DSL.Def.LeftButton   LeftButton   = DSL.Def.Constant.LeftButton;
    public readonly DSL.Def.MiddleButton MiddleButton = DSL.Def.Constant.MiddleButton;
// 略
    public Config.UserConfig Config
    {
      get { return Global.UserConfig; }
    }

    public DSL.WhenElement @when(DSL.Def.WhenFunc func)
    {
        return root.@when(func);
    }

    public void Tooltip(string text)
    {
        Tooltip(text, Global.UserConfig.UI.TooltipPositionBinding(WinAPI.Window.Window.GetPhysicalCursorPos()));
    }

    public void Balloon(string text)
    {
        Balloon(text, Global.UserConfig.UI.BalloonTimeout);
    }
// 略
}
private IEnumerable<Core.GestureDefinition> EvaluateUserScript(Script userScript, Core.UserScriptExecutionContext ctx)
{
    var stopwatch = new Stopwatch();
    Verbose.Print("Compiling UserScript...");
    stopwatch.Start();
    var diagnotstics = userScript.Compile();
    stopwatch.Stop();
    Verbose.Print("UserScript compilation finished. ({ 0})", stopwatch.Elapsed);
    foreach (var dg in diagnotstics.Select((v, i) => new { v, i }))
    {
        Verbose.Print("Diagnotstics[{0}]: {1}", dg.i, dg.v.ToString());
    }
    Verbose.Print("Evaluating UserScript...");
    stopwatch.Restart();
    userScript.RunAsync(ctx).Wait();
    stopwatch.Stop();
    Verbose.Print("UserScript evaluation finished. ({0})", stopwatch.Elapsed);
    return ctx.GetGestureDefinition();
}

評価後 ctx にはマウスジェスチャーの設定が保存されています。

Crevice 3 の実装(ユルい C# Script から生成したアセンブリをロードして globals を与えて評価する)

ユーザースクリプトから Script を作ります。

private Script ParseScript(string userScript)
{
    var stopwatch = new Stopwatch();
    Verbose.Print("Parsing UserScript...");
    stopwatch.Start();
    var script = CSharpScript.Create(
        userScript,
        ScriptOptions.Default
            .WithSourceResolver(ScriptSourceResolver.Default.WithBaseDirectory(UserDirectory))
            .WithMetadataResolver(ScriptMetadataResolver.Default.WithBaseDirectory(UserDirectory))
            .WithReferences("microlib")                   // microlib.dll
            .WithReferences("System")                     // System.dll
            .WithReferences("System.Core")                // System.Core.dll
            .WithReferences("Microsoft.CSharp")           // Microsoft.CSharp.dll
            .WithReferences(Assembly.GetEntryAssembly()), // CreviceApp.exe
                                                          // todo dynamic type
        globalsType: typeof(Core.UserScriptExecutionContext));
    stopwatch.Stop();
    Verbose.Print("UserScript parse finished. ({0})", stopwatch.Elapsed);
    return script;
}

ここまでは以前の実装と同じです。次にアセンブリのバイナリを作ります。次回起動時にアプリケーションのバージョンやユーザースクリプトに変更がなければ、これをキャッシュとして使うことで起動時間を大幅に短縮することができます。

private UserScriptAssembly.Cache CompileUserScript(UserScriptAssembly usa, string userScriptCode, Script userScript)
{
    var stopwatch = new Stopwatch();
    Verbose.Print("Compiling UserScript...");
    stopwatch.Start();
    var compilation = userScript.GetCompilation();
    stopwatch.Stop();
    Verbose.Print("UserScript compilation finished. ({0})", stopwatch.Elapsed);

    var peStream = new MemoryStream();
    var pdbStream = new MemoryStream();
    Verbose.Print("Genarating UserScriptAssembly...");
    stopwatch.Restart();
    compilation.Emit(peStream, pdbStream);
    stopwatch.Stop();
    Verbose.Print("UserScriptAssembly generation finished. ({0})", stopwatch.Elapsed);
    return usa.CreateCache(userScriptCode, peStream.GetBuffer(), pdbStream.GetBuffer());
}

キャッシュをロードして、以前と同様にコンテキスト ctx を globals として与えて評価します。アセンブリに GetType("Submission#0") して、 GetMethod("") して得られた関数を Invoke するのですが、この時に ctxnew object[] { new object[] { ctx, null } } のような形で2番めの引数として渡すのがポイントです。

private IEnumerable<Core.GestureDefinition> EvaluateUserScriptAssembly(UserScriptAssembly.Cache cache, Core.UserScriptExecutionContext ctx)
{
    var stopwatch = new Stopwatch();
    Verbose.Print("Loading UserScriptAssembly...");
    stopwatch.Start();
    var assembly = Assembly.Load(cache.pe, cache.pdb);
    stopwatch.Stop();
    Verbose.Print("UserScriptAssembly loading finished. ({0})", stopwatch.Elapsed);
    Verbose.Print("Evaluating UserScriptAssembly...");
    stopwatch.Restart();
    var type = assembly.GetType("Submission#0");
    var factory = type.GetMethod("<Factory>");
    var parameters = new object[] { new object[] { ctx, null } };
    var result = factory.Invoke(null, parameters);
    stopwatch.Stop();
    Verbose.Print("UserScriptAssembly evaluation finished. ({0})", stopwatch.Elapsed);
    return ctx.GetGestureDefinition();
}

評価後 ctx にはマウスジェスチャーの設定が保存されています。

仕組み的に Crevice 2 と Crevice 3 でのユーザースクリプトの実行は全く同様の結果になる…はずなのですが、いまいち確信が持てないので失敗時にはフォールバックするようには実装しています。

private IEnumerable<Core.GestureDefinition> GetGestureDef(Core.UserScriptExecutionContext ctx)
{
    var userScriptCode = GetUserScriptCode();
    var userScript = ParseScript(userScriptCode);
    if (!Global.CLIOption.NoCache)
    {
        try
        {
            var cache = GetUserScriptAssemblyCache(userScriptCode, userScript);
            return EvaluateUserScriptAssembly(cache, ctx);
        }
        catch (Exception ex)
        {
            Verbose.Print("Error occured when UserScript conpilation and save and restoration; fallback to the --nocache mode and continume");
            Verbose.Print(ex.ToString());
        }
    }
    return EvaluateUserScript(userScript, ctx);
}

うまく動かないよーという方がもしいらっしゃいましたらご連絡ください。