はじめに
Power Automate for desktop(以下PAD) に無くても困るわけでないのだが、何で無いのかなぁと思っていた機能がある。
それはデータ転記の進捗を表示するプログレスバーである。(データ転記かよ・・・という話はさておき)ひと昔前と違ってGitHub Copilotも優秀だし、軽い気持ちでカスタム アクションを作ってみよと思ったのだが、ハマった。どうにか形になったので実装例とハマりどころを記録しておく。俺以外に一人でも需要があれば嬉しい。
環境
- Windows 11 Pro 25H2
- Power Automate for desktop Version: 2.62.161.25297 プレミアムを試用
- Visual Studio 2022 Community
- Visual Studio Code
- GitHub Copilot 課金あり
- Microsoft.PowerPlatform.PowerAutomate.Desktop.Actions.SDK 1.5.255.25087
注意事項
- プロンプトについては言及しない(できない)
- オレオレ証明書でコードサイニングしている
- Visual Studioは利用条件がある
- カスタム アクションはプレミアム機能
- 利用は自己責任で
- 2025年12月の記事
設計概要
標準のアクションでは実現できない「フローの実行中に、別ウィンドウで進捗状況をリアルタイムに表示する」機能の実装が目的。3つのカスタム アクションを用いてPAD上で組み合わせてこの機能を実装する。
- プログレスバー画面を別スレッドで起動するアクション
- 進捗率(%)やメッセージを更新するアクション
- プログレスバー画面を閉じてリソースを解放するアクション
追加機能
- 後輩から経過時間表示のリクエストがあったのでストップウォッチを組込
- リソース解放時に経過時間をPADの変数として出力
ハマりポイント
俺にとっての最大の難関はPADに読み込ませて実行するまで途中でのプレビューや動作確認ができないことだ。ビルド、署名、アップロード、PADに読み込みまでエラーなく行なえて初めて試すことができる。トライアンドエラーを繰り返すには正直しんどい。生成AIの力を借りられるとはいえある程度、理解していないと無理ゲーなのを痛感した。そんな経緯があるので先にハマったポイントを挙げておく。
-
プロジェクト名の付け方とタイトルの付け方に注意
Learnのチュートリアルでも強調してかいてある。
プロジェクト名の命名にModules.*で付けろとは言ってはいない。がそう見える。MSさんひどいよ。このまま鵜呑みにして進むと沈没だよ。
PADにインポートするたび呪いのように同じエラーを吐き続けることになります。

まずこのプロジェクト名で進むとModules.CustomModuleのようなAssenmblyTitleと名前空間が自動で出来上がるのですがこれが良くない。Learnのサンプルコードもnamespace Modules.MyCustomModuleとなっていて良さげに思ってしまう。そうかと思えばチュートリアルのサンプルコードではnamespace ModulesLogEventとなっていてシレッと「.」ドットが消されていて混乱。
よく読むと「.」ドットはAssenmblyTitleには使えない。

生成AIは指摘もしてくれないので泥沼にハマっていき、最後には作り直したほうがいいんじゃないかと言われる始末。じゃあどうしたら良いのかは実装例まで引っぱっておく。
-
PADのエラーを生成AIに投げてはいけない
作成したアクションはPADに読み込むまでプレビューできないが、読み込み時や読み込んだ後のエラーをそのままGithub Copilotに解決させてはいけない。先ほどの「カスタム アクション グループが見つかりません。命名と開発が慣例に従って正しく行われていることを確認し、やり直してください。」を「このエラーを解決して」とでも投げかけたらそれっぽい謎のクラスや属性を生み出してビルドすらできず、更なる深みへとハマっていくのだ。
実装例
カスタムアクションを作成
-
クラス ライブラリの選択
.NET Frameworkを検索してC# クラス ライブラリ(.dll)を作成するためのプロジェクトを選択

-
SDKの追加
ソリューション エクスプローラーを右クリックして「NuGet パッケージの管理」に進む

参照タブからPower Automateで検索しMicrosoft.PowerPlatform.PowerAutomate.Desktop.Actions.SDK 1.5.255.25087をクリック

「同意する」を選択

-
参照の追加
ソリューション エクスプローラーから参照を右クリックして「参照の追加」に進む

System.DrawingとSystem.Windows.FormsにチェックをいれてOK

-
新たにクラスを追加
ソリューション エクスプローラーを右クリックして「追加」を展開し「クラス」を選択

名前をActions.csとして追加

-
元のコードはすべて消してActions.csにコードを書き込み
Actions.csusing Microsoft.PowerPlatform.PowerAutomate.Desktop.Actions.SDK; using Microsoft.PowerPlatform.PowerAutomate.Desktop.Actions.SDK.Attributes; using System; using System.Diagnostics; using System.Drawing; using System.Threading; using System.Windows.Forms; namespace ModulesProgressBar { public class ProgressForm : Form { private readonly ProgressBar progressBar; private readonly Label lblMessage; private readonly Label lblElapsedTime; private readonly System.Windows.Forms.Timer timer; public Stopwatch Stopwatch { get; } public ProgressForm() { this.Text = "処理状況"; this.Size = new Size(400, 180); // 経過時間ラベルのため高さを調整 this.FormBorderStyle = FormBorderStyle.FixedDialog; this.MaximizeBox = false; this.MinimizeBox = false; this.StartPosition = FormStartPosition.CenterScreen; this.TopMost = true; this.ShowInTaskbar = false; lblMessage = new Label { Location = new Point(20, 20), Size = new Size(350, 20), Text = "準備中..." }; this.Controls.Add(lblMessage); progressBar = new ProgressBar { Location = new Point(20, 50), Size = new Size(340, 30), Style = ProgressBarStyle.Continuous, Minimum = 0, Maximum = 100, Value = 0 }; this.Controls.Add(progressBar); lblElapsedTime = new Label { Location = new Point(20, 95), Size = new Size(350, 20), Text = "経過時間: 00:00:00" }; this.Controls.Add(lblElapsedTime); // タイマーとストップウォッチの初期化 Stopwatch = new Stopwatch(); timer = new System.Windows.Forms.Timer { Interval = 1000 }; timer.Tick += Timer_Tick; Stopwatch.Start(); timer.Start(); } private void Timer_Tick(object sender, EventArgs e) { // 経過時間を hh:mm:ss 形式で表示 lblElapsedTime.Text = $"経過時間: {Stopwatch.Elapsed:hh\\:mm\\:ss}"; } public void UpdateStatus(int currentRow, int totalRows) { if (this.InvokeRequired) { this.Invoke(new Action(() => UpdateStatus(currentRow, totalRows))); return; } if (totalRows <= 0 || currentRow < 0) { progressBar.Value = 0; lblMessage.Text = "入力値が不正です。"; return; } int percent = (int)Math.Min(100.0, ((double)currentRow / totalRows * 100)); progressBar.Value = percent; lblMessage.Text = $"処理中: {currentRow} / {totalRows} 行目 ({percent}%)"; } protected override void Dispose(bool disposing) { if (disposing) { // タイマーとストップウォッチを停止・破棄 timer?.Stop(); timer?.Dispose(); Stopwatch?.Stop(); } base.Dispose(disposing); } } internal static class SharedState { private static ProgressForm _activeForm; private static readonly object StateLock = new object(); public static ProgressForm GetForm() { lock (StateLock) { return (_activeForm != null && !_activeForm.IsDisposed) ? _activeForm : null; } } public static TimeSpan Cleanup() { ProgressForm formToClose = null; TimeSpan elapsedTime = TimeSpan.Zero; lock (StateLock) { formToClose = _activeForm; _activeForm = null; } if (formToClose != null && !formToClose.IsDisposed) { try { // elapsedTime を Invoke の戻り値として受け取る elapsedTime = (TimeSpan)formToClose.Invoke(new Func<TimeSpan>(() => { formToClose.Stopwatch.Stop(); var elapsed = formToClose.Stopwatch.Elapsed; formToClose.Close(); return elapsed; })); } catch (ObjectDisposedException) { /* フォームが既に破棄されている場合は無視 */ } catch (System.ComponentModel.InvalidAsynchronousStateException) { /* フォームが既に破棄されている場合は無視 */ } } return elapsedTime; } public static void SetForm(ProgressForm form) { lock (StateLock) { _activeForm = form; } } } [Action(Id = "OpenProgressBar", Order = 1, FriendlyName = "プログレスバーを開く")] public class OpenProgressBar : ActionBase { public override void Execute(ActionContext context) { if (SharedState.GetForm() != null) { return; // 既に開いている場合は何もしない } var formReadyEvent = new AutoResetEvent(false); try { var uiThread = new Thread(() => { Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false); using (var form = new ProgressForm()) { form.Load += (s, e) => formReadyEvent.Set(); SharedState.SetForm(form); Application.Run(form); // フォームが閉じられるまでブロック } SharedState.SetForm(null); // フォームが閉じられたら参照をクリア }); uiThread.SetApartmentState(ApartmentState.STA); uiThread.IsBackground = true; uiThread.Start(); if (!formReadyEvent.WaitOne(TimeSpan.FromSeconds(5))) { SharedState.Cleanup(); throw new TimeoutException("プログレスバーの起動がタイムアウトしました。"); } } finally { formReadyEvent.Dispose(); } } } [Action(Id = "UpdateProgressBar", Order = 2, FriendlyName = "プログレスバーを更新する")] public class UpdateProgressBar : ActionBase { [InputArgument(FriendlyName = "現在の行番号")] public int CurrentRow { get; set; } [InputArgument(FriendlyName = "全行数")] public int TotalRows { get; set; } public override void Execute(ActionContext context) { if (TotalRows <= 0) throw new ArgumentOutOfRangeException(nameof(TotalRows), "全行数は1以上の値を指定してください。"); if (CurrentRow < 0) throw new ArgumentOutOfRangeException(nameof(CurrentRow), "現在の行番号に負の値は指定できません。"); var form = SharedState.GetForm(); if (form == null) { throw new InvalidOperationException("プログレスバーが開かれていません。"); } try { form.UpdateStatus(CurrentRow, TotalRows); } catch (Exception ex) { throw new InvalidOperationException("プログレスバーの更新に失敗しました。", ex); } } } [Action(Id = "CloseProgressBar", Order = 3, FriendlyName = "プログレスバーを閉じる")] public class CloseProgressBar : ActionBase { [OutputArgument(FriendlyName = "経過時間")] public TimeSpan ElapsedTime { get; set; } public override void Execute(ActionContext context) { this.ElapsedTime = SharedState.Cleanup(); } } } -
ハマりどころの解決策
ソリューション エクスプローラーを右クリックして「名前の変更」を選択し「.」ドットを削除
この段階でAssenmblyTitleを変更するくらいなら最初から任意のプロジェクト名で良い気がする


-
AssemblyInfo.csをクリックして開きAssemblyTitleとAssemblyProductからも「.」ドットを削除する

-
Propertiesを確認
ここではアセンブリ名と既定の名前空間がModules.*の形になっている必要がある
今回はプロジェクト名をModules.ProgressBarではじめたので変更なし

モジュールに署名する
-
PKCS #12(PFXファイル)の作成とインストール
Visual Studio Codeで任意のフォルダを開きcert.ps1を作成cert.ps1$certname = "任意の証明書名" $cert = New-SelfSignedCertificate -CertStoreLocation Cert:\CurrentUser\My -Type CodeSigningCert -Subject "CN=$certname" -KeyExportPolicy Exportable -KeySpec Signature -KeyLength 2048 -KeyAlgorithm RSA -HashAlgorithm SHA256 $mypwd = ConvertTo-SecureString -String "任意のパスワード" -Force -AsPlainText Export-PfxCertificate -Cert $cert -FilePath "証明書の保存先パス\certificate.pfx" -Password $mypwd保存したcertificate.pfxをダブルクリックで実行
証明書のインポートウィザードで証明書ストアに追加する


設定した任意のパスワードを入力

「証明書をすべてのストアに配置する」を選択し「信頼されたルート証明機関」を参照



-
ビルドされたModules.ProgressBar.dllに署名
Visual Studioに戻ってメニューの表示からターミナルを選択して開発者用PowerShellを起動
作成済みのオレオレ証明書とビルドしたModules.ProgressBar.dllのパスを指定してサインツールの実行
Signtool sign /f "証明書の保存先パス/certificate.pfx" /p "証明書に使ったパスワード" /fd SHA256 "プロジェクト保存先パス\Modules.ProgressBar\bin\Debug\Modules.ProgressBar.dll"

-
プロジェクトの\bin\Debugをキャビネットファイル(.cab)化するため、もう一度Visual Studio Codに戻りmakeCabFromDirectory.ps1を作成
makeCabFromDirectory.ps1param( [ValidateScript({Test-Path $_ -PathType Container})] [string] $sourceDir, [ValidateScript({Test-Path $_ -PathType Container})] [string] $cabOutputDir, [string] $cabFilename ) $ddf = ".OPTION EXPLICIT .Set CabinetName1=$cabFilename .Set DiskDirectory1=$cabOutputDir .Set CompressionType=LZX .Set Cabinet=on .Set Compress=on .Set CabinetFileCountThreshold=0 .Set FolderFileCountThreshold=0 .Set FolderSizeThreshold=0 .Set MaxCabinetSize=0 .Set MaxDiskFileCount=0 .Set MaxDiskSize=0 " $ddfpath = ($env:TEMP + "\customModule.ddf") $sourceDirLength = $sourceDir.Length; $ddf += (Get-ChildItem $sourceDir -Filter "*.dll" | Where-Object { (!$_.PSIsContainer) -and ($_.Name -ne "Microsoft.PowerPlatform.PowerAutomate.Desktop.Actions.SDK.dll") } | Select-Object -ExpandProperty FullName | ForEach-Object { '"' + $_ + '" "' + ($_.Substring($sourceDirLength)) + '"' }) -join "`r`n" $ddf | Out-File -Encoding UTF8 $ddfpath makecab.exe /F $ddfpath Remove-Item $ddfpath -
makeCabFromDirectory.ps1に\Modules.ProgressBar\bin\Debugのパスとcabファイルの出力先、出力名を引数として実行するため、cab.ps1を作成
cab.ps1# 任意の名前やパスに書き換えること .\makeCabFromDirectory.ps1 "C:\Users\sysze\source\repos\Modules.ProgressBar\bin\Debug" "C:\Users\sysze\PS" progress.cab -
progress.cabに対してVisual Studioの開発者用PowerShellから再度、署名する
Signtool sign /f "証明書の保存先パス\certificate.pfx" /p "任意のパスワード" /fd SHA256 "cabの保存先パス\progress.cab"

Power AutomateにアップロードしてPADのカスタム アクションに読込む
テストしてみる
まとめと感想
- PADにプログレスバーって無くても困らない、でもちょっとだけ欲しくない?
- 俺的には標準で欲しい機能です!
- 忘れっぽい未来の俺のためにかなり長く丁寧に書いた
- でも一人くらい誰かに役立てればうれしい
今回のハマりポイントが厳しかった。危うく誕生日のアドカレ投稿実績が途切れるところだった……!
長々と読んでくれた方がいたらありがとうございました!
参考

















