8
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

俺以外に一人くらいは欲しいかもしれないPower Automate for desktopのカスタム アクションを作ってみた

Last updated at Posted at 2025-12-12

はじめに

Power Automate for desktop(以下PAD) に無くても困るわけでないのだが、何で無いのかなぁと思っていた機能がある。
それはデータ転記の進捗を表示するプログレスバーである。(データ転記かよ・・・という話はさておき)ひと昔前と違ってGitHub Copilotも優秀だし、軽い気持ちでカスタム アクションを作ってみよと思ったのだが、ハマった。どうにか形になったので実装例とハマりどころを記録しておく。俺以外に一人でも需要があれば嬉しい。

Adobe Express - progressbar-converted.gif

環境

  • 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上で組み合わせてこの機能を実装する。

  1. プログレスバー画面を別スレッドで起動するアクション
  2. 進捗率(%)やメッセージを更新するアクション
  3. プログレスバー画面を閉じてリソースを解放するアクション

追加機能

  • 後輩から経過時間表示のリクエストがあったのでストップウォッチを組込
  • リソース解放時に経過時間をPADの変数として出力

ハマりポイント

俺にとっての最大の難関はPADに読み込ませて実行するまで途中でのプレビューや動作確認ができないことだ。ビルド、署名、アップロード、PADに読み込みまでエラーなく行なえて初めて試すことができる。トライアンドエラーを繰り返すには正直しんどい。生成AIの力を借りられるとはいえある程度、理解していないと無理ゲーなのを痛感した。そんな経緯があるので先にハマったポイントを挙げておく。

  1. プロジェクト名の付け方とタイトルの付け方に注意
    Learnのチュートリアルでも強調してかいてある。CleanShot 2025-12-12 at 20.45.55@2x.png
    プロジェクト名の命名にModules.*で付けろとは言ってはいない。がそう見える。MSさんひどいよ。このまま鵜呑みにして進むと沈没だよ。
    PADにインポートするたび呪いのように同じエラーを吐き続けることになります。
    image.png
    まずこのプロジェクト名で進むとModules.CustomModuleのようなAssenmblyTitleと名前空間が自動で出来上がるのですがこれが良くない。Learnのサンプルコードもnamespace Modules.MyCustomModuleとなっていて良さげに思ってしまう。そうかと思えばチュートリアルのサンプルコードではnamespace ModulesLogEventとなっていてシレッと「.」ドットが消されていて混乱。
    よく読むと「.」ドットはAssenmblyTitleには使えない。
    CleanShot 2025-12-12 at 20.54.25@2x.png

    生成AIは指摘もしてくれないので泥沼にハマっていき、最後には作り直したほうがいいんじゃないかと言われる始末。じゃあどうしたら良いのかは実装例まで引っぱっておく。

  2. PADのエラーを生成AIに投げてはいけない
    作成したアクションはPADに読み込むまでプレビューできないが、読み込み時や読み込んだ後のエラーをそのままGithub Copilotに解決させてはいけない。先ほどの「カスタム アクション グループが見つかりません。命名と開発が慣例に従って正しく行われていることを確認し、やり直してください。」を「このエラーを解決して」とでも投げかけたらそれっぽい謎のクラスや属性を生み出してビルドすらできず、更なる深みへとハマっていくのだ。

実装例

カスタムアクションを作成

  1. プロジェクトの新規作成
    image.png

  2. クラス ライブラリの選択
    .NET Frameworkを検索してC# クラス ライブラリ(.dll)を作成するためのプロジェクトを選択
    image.png

  3. プロジェクトを構成
    一応チュートリアルのようにプロジェクト名をModules.ProgressBarとした
    image.png

  4. SDKの追加
    ソリューション エクスプローラーを右クリックして「NuGet パッケージの管理」に進む
    image.png
    参照タブからPower Automateで検索しMicrosoft.PowerPlatform.PowerAutomate.Desktop.Actions.SDK 1.5.255.25087をクリック
    image.png
    「同意する」を選択
    image.png

  5. 参照の追加
    ソリューション エクスプローラーから参照を右クリックして「参照の追加」に進む
    image.png
    System.DrawingSystem.Windows.FormsにチェックをいれてOK
    image.png

  6. デフォルトで作られるClass1.csを削除
    image.png

  7. 新たにクラスを追加
    ソリューション エクスプローラーを右クリックして「追加」を展開し「クラス」を選択
    image.png
    名前をActions.csとして追加
    image.png

  8. 元のコードはすべて消してActions.csにコードを書き込み

    Actions.cs
    using 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();
            }
        }
    }
    
  9. ハマりどころの解決策
    ソリューション エクスプローラーを右クリックして「名前の変更」を選択し「.」ドットを削除
    この段階でAssenmblyTitleを変更するくらいなら最初から任意のプロジェクト名で良い気がする
    image.png
    image.png

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

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

  12. ソリューションのビルド
    image.png
    成功したらパスをコピーしておく
    image.png

モジュールに署名する

  1. 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
    

    cert.ps1を実行してオレオレ証明書を生成
    image.png

    保存したcertificate.pfxをダブルクリックで実行
    証明書のインポートウィザードで証明書ストアに追加する
    image.png
    image.png
    設定した任意のパスワードを入力
    image.png
    「証明書をすべてのストアに配置する」を選択し「信頼されたルート証明機関」を参照
    image.png
    image.png
    ROOT.png

    「はい」を選択するとインポートが完了
    image.png

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

  3. プロジェクトの\bin\Debugをキャビネットファイル(.cab)化するため、もう一度Visual Studio Codに戻りmakeCabFromDirectory.ps1を作成

    makeCabFromDirectory.ps1
    param(
    
        [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
    
  4. 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
    
  5. cab.ps1を実行するとprogress.cabが生成される
    image.png

  6. progress.cabに対してVisual Studioの開発者用PowerShellから再度、署名する
    Signtool sign /f "証明書の保存先パス\certificate.pfx" /p "任意のパスワード" /fd SHA256 "cabの保存先パス\progress.cab"
    image.png

Power AutomateにアップロードしてPADのカスタム アクションに読込む

  1. Power Automateにアップロード
    image.png
    image.png

  2. PADに読込む
    image.png
    ここまで紆余曲折だった
    image.png
    image.png

テストしてみる

フローにしてみる
image.png

各アクションは次のようになっている
image.png
image.png
image.png

まとめと感想

  • PADにプログレスバーって無くても困らない、でもちょっとだけ欲しくない?
  • 俺的には標準で欲しい機能です!
  • 忘れっぽい未来の俺のためにかなり長く丁寧に書いた
  • でも一人くらい誰かに役立てればうれしい

今回のハマりポイントが厳しかった。危うく誕生日のアドカレ投稿実績が途切れるところだった……!
長々と読んでくれた方がいたらありがとうございました!

参考

8
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?