アーカイブ

【Stream Deck】C#でStream Deck プラグイン開発をやってみたよ

Stream Deckのプラグインを自作してみました。言語はC#で開発しています。

目次
  1. Stream Deckは自作プラグインが開発できる
  2. C#でStreamDeckのプラグインが開発できるテンプレート「StreamDeckToolKit」
  3. StreamDeckToolKitを使った開発手順
    1. 事前準備
    2. StreamDeckToolKitのインストール
    3. プロジェクトの作成
    4. マニフェストファイルの編集
    5. プロパティインスペクターの実装
    6. プラグイン側処理の実装
    7. プラグインのデバッグ
    8. パッケージング
  4. おわりに
本記事で紹介している「StreamDeckToolKit」のターゲットフレームワークは .NET Core 3.1です。.NET Core3.1 のサポートは2022年12月13日に終了しています。

Stream Deckは自作プラグインが開発できる

「Stream Deck」は名前にStreamと入っている通り、配信者向けのデバイスとして販売されています。

ただ機能としては高度なキーマクロが設定可能なLCDキーボードという感じなので、配信者に限らずPCを使う人なら誰にでも便利なデバイスです。

さらにさらに、SDKが用意されていてプラグインが自作できるというのだから、我々プログラマーにとっては最高のおもちゃというわけですね!

C#でStreamDeckのプラグインが開発できるテンプレート「StreamDeckToolKit」

一応、公式で開発言語して記載が見られるのは「Javascript」「C++」「Objective-C」。

サンプルプログラムもこの言語で書かれたものが用意されています。

.NETがいいなぁ・・・と思ってGitHubを漁っていると、なにやらC#がStreamDeckのプラグイン開発ができるテンプレートを発見。これを使って開発してみることに。

○公式ドキュメントはこちら

StreamDeckToolkit

○Githubのページはこちら

GitHub – FritzAndFriends/StreamDeckToolkit

StreamDeckToolKitを使った開発手順

今回は、任意のテキストをペースト(貼り付け)するプラグインを例に作成手順をみてみたいと思います。

と、いうのも標準の「テキスト」プラグインがどうもGoogle 日本語入力でIMEオンの状態だとうまく動作しないのですよね…なので代わりのものを作ります。

「StreamDeckToolKit」のドキュメントに手順が記載されていますので、こちらを参考に進めていきます。(サイトは英語ですが、Google翻訳で日本語に直せば十分読めるレベルです)

事前準備

開発環境として以下を準備します。

  • Visual Studio
  • .NET Core SDK (Ver2.2.100~)

.NET Core SDKのバージョンは、こだわりがなければテンプレートやライブラリのターゲットに合わせれば良いと思います。(記事作成時の場合、3.1)

単一バイナリが生成できるようになった3.0以降はマストかもしれない。

.NET Coreはサポート終了していますのでご注意ください。

StreamDeckToolKitのインストール

NuGetを使ってプロジェクトのテンプレートをインストールします。

コマンドプロンプトを開いて以下コマンドを実行

dotnet new -i StreamDeckPluginTemplate

プロジェクトの作成

Visual Studio を開き、新規プロジェクトを作成します。

前項までで追加したテンプレートを選択します。

プロジェクトの種類:「StreamDeckPlugin」

テンプレート名:「Stream Deck Plugin (csharofritz)」


通常のプロジェクトの設定を入力します。

「プロジェクト名(J)」や「場所(L)」と「ソリューション名(M)」に任意の名前を入力して「次へ(N)」


Stream Deck Plugin固有の設定を入力します。

○plugin-name

プラグインの名前を入力します。ここの入力を元にクラスファイルなどが自動生成されるようです。

後から変更するのは面倒そうなので、バシッと決めておいちゃいましょう

○uuid

以下のような逆ドメイン形式で入力します

com.yourcompany.plugin.action

正直わからないですけど、↓のような感じかな?

セクション内容
com「com」固定?
yourcompany作成者の識別情報
pluginプラグインの名前
actionアクションの名前
※アクションで細分化しないプラグインの場合はなしでOK?


作成直後のソリューションは以下のような構成

プラグイン個別の実装は空の状態ではなく、公式のサンプルプラグイン「Counter」が実装された状態のようでした。

準備が整ったので、コーディングに移ります。

マニフェストファイルの編集

最初にプラグインの情報が書かれたファイル「manifest.json」を編集します。

ex.)実装例

{
  "Actions": [
    {
      "Icon": "images/actionIcon",
      "Name": "\u30c6\u30ad\u30b9\u30c8\u8cbc\u308a\u4ed8\u3051",
      "States": [
        {
          "Image": "images/actionDefaultImage",
          "TitleAlignment": "middle",
          "FontSize": "16"
        }
      ],
      "SupportedInMultiActions": false,
      "Tooltip": "\u4e8b\u524d\u306b\u767b\u9332\u3057\u305f\u30c6\u30ad\u30b9\u30c8\u3092\u8cbc\u308a\u4ed8\u3051",
      "UUID": "com.gennull.pastetext"
    }
  ],
  "Category": "\u30ab\u30b9\u30bf\u30e0 \u30c6\u30ad\u30b9\u30c8",
  "CategoryIcon": "images/category/categoryIcon",
  "Disabled": false, //※※※StreamDeckのバージョンによってはこの記載があるとインストール時にエラーになります。※※※
  "Author": "UMA\u30a4\u30ab",
  "CodePathWin": "PasteText.exe",
  "CodePathMac": "PasteText",
  "PropertyInspectorPath": "property_inspector/property_inspector.html",
  "Description": "\u4e8b\u524d\u306b\u767b\u9332\u3057\u305f\u30c6\u30ad\u30b9\u30c8\u3092\u8cbc\u308a\u4ed8\u3051",
  "Name": "PasteText",
  "Icon": "images/pluginIcon",
  "URL": "https://gennull.com/blog/",
  "Version": "1.0",
  "SDKVersion": 2,
  "Software": {
    "MinimumVersion": "4.1"
  },
  "OS": [
    {
      "Platform": "windows",
      "MinimumVersion": "10"
    }
  ]
}

各項目の説明はElgato公式の開発者向けドキュメントを参照してください。

日本語はそのままだとビルド時にPowerShellスクリプトでエラーが出てしまったので、エスケープして入力しています。

プロパティインスペクターの実装

プロパティインスペクターを実装します。

アプリでプラグインを配置したときの設定画面ですね。

「タイトル」部分は勝手に表示されるので、個別で実装する必要はありません。

今回は、貼り付け対象のテキストだけ設定できればよいので、設定項目としてテキストエリア1つを作成してみることにします。

設定項目を表すクラスを定義

C#側で設定項目を扱うクラスを定義します。

テンプレートの場合、「models\CounterSettingModel.cs」が該当します。

「Counter」プラグイン用の実装になっていますので、作成するプラグインに合わせて適切な名前にリネームし、設定項目をプロパティとして宣言しましょう。

Ex.)文字列項目「Text」1つの場合

namespace PasteText.Models
{
    public class PasteTextSettingModel
    {
        public string Text { get; set; } = "";
    }
}

デフォルト値を割り当てることが推奨されていますので、忘れず割り当てるようにしましょう。

HTMLで画面を構築

プロパティインスペクターの画面はHTMLで構成します。

テンプレートの場合、対象のHTMLファイルは「property_inspector/property_inspector.html」です。

ファイルは「manifest.json」の「PropertyInspectorPath」キーで指定したパスのファイルなのでリネームも可能。

Ex.)テキストエリア1つの場合

<!DOCTYPE html>
<html>
<head>
	<meta charset="utf-8" />
	<title>PasteText Property Inspector</title>
	<link rel="stylesheet" href="css/sdpi.css">
	<link rel="stylesheet" href="css/property-inspector.css">
</head>
<body>
	<div class="sdpi-wrapper">
		<!-- for more examples of the types of fields supported in property inspector visit:
			Elgato Github PiSamples -> https://github.com/elgatosf/streamdeck-pisamples
			and
			Elgato SDK Documentation -> https://developer.elgato.com/documentation/stream-deck/sdk/property-inspector/-->
		
		<div type="textarea"class="sdpi-item" id="text-container">
			<div class="sdpi-item-label">テキスト</div>
			<span class="sdpi-item-value textarea">
				<textarea type="textarea" oninput="setSettings(event.target.value, 'Text')" id="text-input"></textarea>
			</span>
		</div>

	</div>
	<script src="js/property-inspector.js"></script>
</body>
</html>

基本的には、sdpi-wrapperクラスの中身だけいじればOKのハズです。

こちらも、Elgato公式の開発者向けドキュメントに詳しい内容や実装例があるので参考にするとよいでしょう。

<textArea>要素の記述については、一部「StreamDeckToolKit」向けのお作法に則った記述になっています。

jsでプロパティ値の受け渡し処理を実装

プロパティインスペクター側からプラグイン個別のモジュールへの設定値の受け渡し処理を実装します。

といっても、テンプレートのベースクラス側でほとんどやってくれていますので、大した実装はありません。

Ex.)文字列項目「Text」1つの場合

// global websocket, used to communicate from/to Stream Deck software
// as well as some info about our plugin, as sent by Stream Deck software 
var websocket = null,
  uuid = null,
  inInfo = null,
  actionInfo = {},
  settingsModel = {
	Text: "" //TODO① C#側のModelとプロパティ名が一致している必要がある
  };

function connectElgatoStreamDeckSocket(inPort, inUUID, inRegisterEvent, inInfo, inActionInfo) {
  uuid = inUUID;
  actionInfo = JSON.parse(inActionInfo);
  inInfo = JSON.parse(inInfo);
  websocket = new WebSocket('ws://localhost:' + inPort);

  //initialize values
  if (actionInfo.payload.settings.settingsModel) {
        //TODO② 
	settingsModel.Text = actionInfo.payload.settings.settingsModel.Text;
  }

  //TODO③ HTML構築時に指定したidで要素を特定して値を取得する
  document.getElementById('text-input').value = settingsModel.Text;

  websocket.onopen = function () {
	var json = { event: inRegisterEvent, uuid: inUUID };
	// register property inspector to Stream Deck
	websocket.send(JSON.stringify(json));

  };

  websocket.onmessage = function (evt) {
	// Received message from Stream Deck
	var jsonObj = JSON.parse(evt.data);
	var sdEvent = jsonObj['event'];
	switch (sdEvent) {
	  case "didReceiveSettings":
		if (jsonObj.payload.settings.settingsModel.Text) {
          //TODO④
		  settingsModel.Text = jsonObj.payload.settings.settingsModel.Text;
		  document.getElementById('text-input').value = settingsModel.Text;
		}
		break;
	  default:
		break;
	}
  };
}

const setSettings = (value, param) => {
  if (websocket) {
	settingsModel[param] = value;
	var json = {
	  "event": "setSettings",
	  "context": uuid,
	  "payload": {
		"settingsModel": settingsModel
	  }
	};
	websocket.send(JSON.stringify(json));
  }
};

プラグイン側処理の実装

前項までで、プロパティインスペクターで設定した値をC#側のコードで参照できるようになりました。

あとは、実装したい処理を自動生成された「BaseStreamDeckActionWithSettingsModel」を継承したクラスを中心に、ガリガリ書いていくだけです。

設定値の参照

「BaseStreamDeckActionWithSettingsModel」を継承したクラスの場合、設定値は「SettingsModel」プロパティから参照可能です。

アクションの実装

ベースクラスに、各アクション用の継承可能なメソッドが用意されているので、これを継承して実装します。

Ex.)テキストのペースト処理

using Microsoft.Extensions.Logging;
using StreamDeckLib;
using StreamDeckLib.Messages;
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;

namespace PasteText
{
    [ActionUuid(Uuid = "com.gennull.pastetext.action")]
    public class PasteTextAction : BaseStreamDeckActionWithSettingsModel<Models.PasteTextSettingModel>
    {
        private readonly HashSet<string> DATAFORMATS_WHITELIST = new HashSet<string>
        {
            DataFormats.Bitmap,
            DataFormats.CommaSeparatedValue,
            DataFormats.Dib,
            DataFormats.Dif,
            //DataFormats.EnhancedMetafile,
            DataFormats.FileDrop,
            DataFormats.Html,
            DataFormats.Locale,
            //DataFormats.MetafilePict,
            DataFormats.OemText,
            DataFormats.Palette,
            DataFormats.PenData,
            DataFormats.Riff,
            DataFormats.Rtf,
            DataFormats.Serializable,
            DataFormats.StringFormat,
            DataFormats.SymbolicLink,
            DataFormats.Text,
            DataFormats.Tiff,
            DataFormats.UnicodeText,
            DataFormats.WaveAudio
        };
        public override async Task OnKeyUp(StreamDeckEventPayload args)
        {
            try
            {
                await base.OnKeyUp(args);
                Thread thread = new Thread(() =>
                {
                    try { 
                        IDataObject data = new DataObject();
                        IDataObject prev = Clipboard.GetDataObject();
                        if (prev != null)
                        {
                            string[] formats = prev.GetFormats();
                            foreach(string format in formats)
                            {
                                if (DATAFORMATS_WHITELIST.Contains(format))
                                {
                                    data.SetData(format, prev.GetData(format));
                                }
                            }
                        }
                        Clipboard.SetData(DataFormats.Text, SettingsModel.Text);
                        SendKeys.SendWait("^v");
                        Clipboard.SetDataObject(data, true);
                    } catch(Exception e)
                    {
                        Logger.LogError(e.Message);
                    }
                });
                thread.SetApartmentState(ApartmentState.STA);
                thread.Start();
                thread.Join();
            }
            catch (Exception e)
            {
                Logger.LogError(e.Message);
            }
        }

    }
}

プラグインのデバッグ

プラグインのデバッグ方法は以下です。

①Debugビルド
 ※このとき自動でStream Deckアプリが立ち上がります。

②Visual Studioからプラグインのプロセスにアタッチ
 ※プラグインは、Stream Deckアプリ起動時に個別プロセスで起動されます。

プラグイン名のプロセスを見つけてアタッチしましょう。

パッケージング

アイコンの作成

各アイコンファイルを作成してプロジェクト内のファイルを差し替えましょう。

詳しくは公式を参照。

プラグインのビルド

.NET Coreのアプリケーションになっているので、何も設定しないと参照モジュールやらフレームワークのファイルがブリブリブリっと大量に掃かれてしまいます。また容量もクソでかいです。

単一バイナリ かつ ランタイムを含まない形でビルドします。

プロジェクトファイルを編集して制御します。

	<PropertyGroup>
		<OutputType>Exe</OutputType>
		<TargetFramework>netcoreapp3.1</TargetFramework>
		<PublishSingleFile>true</PublishSingleFile>
		<SelfContained>false</SelfContained>
		<PublishReadyToRun>true</PublishReadyToRun>
		<UseWindowsForms>true</UseWindowsForms>
		<LangVersion>latest</LangVersion>
		<!-- When building/running on Windows -->
		<RuntimeIdentifier>win-x64</RuntimeIdentifier>
		<!-- When on non-Windows environment, assume macOS for now -->
		<!-- At this time, the only platforms we are really targetting, and supported by the Stream Deck SDK are Windows and macOS  -->
		<!--<RuntimeIdentifiers Condition="'$(Configuration)'=='Release' ">win-x64;osx-x64</RuntimeIdentifiers> -->
		<RunPostBuildEvent>OnBuildSuccess</RunPostBuildEvent>
		<RollForward>Major</RollForward>
	</PropertyGroup>

4~6行目を追加して、13行目をコメントアウトしています。

2023/11/27

.NET Core 3.1がとっくにサポート終了していたので、苦肉の策としてサポート内の.NETランタイムでも動作するよう、15行目にロールフォワードの記述を追加しました。
※互換性のある新しいバージョンの.NETランタイムでも動作可能とするための記載
※環境変数を設定している場合そっちが優先されてしまうので環境により動作しない場合あり


編集できたら「発行」してバイナリを生成します。

インストーラーの作成

必要ファイルをかき集めます。

  • プラグインのバイナリとモジュール
    ※「発行」の成果物
  • アイコン
    ※「images」フォルダ
  • マニフェストファイル
    ※「manifest.json」
  • アプリケーション設定ファイル
    ※「appsetting.json」

上記を「{uuid}.sdPlugin」フォルダにぶちこみます。

以下のような構成になりました。

あとは上記フォルダとツール(※)を同階層などに配置してツールをコマンド実行

※配布用ファイルを作るツール「DistributionTool」が公式で配布されているので、それを使用します。

Packaging – Developer Documentation

DistributionTool.exe -b -i com.gennull.pastetext.sdPlugin -o .\ Release

上記の例ではカレントフォルダに出力しています。

エラーが出たらエラー内容に従って対処してあげましょう。マニフェストのDiscriptionとか空だと怒られました。

無事完成。インストール&動作することが確認できました!

この辺手動ってマジ・・!?って感じがするのでなにか間違えてる気がする

おわりに

色々作りたいね~

プロフィール
筆者:UMAイカ

IT企業に勤務しています。
当ブログは商品レビューや生活の知恵、プログラミング、PCTipsなどについてお役立ち情報を発信します。趣味などの雑記も少し。
【マイブーム】:ウイスキー/自炊
【最近のひとこと】:転職したい・・・

- SNS -
アーカイブ