✌️

[C#] タスクバーでPowerShellを実行したい!

2024/11/04に公開

やりたいこと

タスクバーから直接PowerShellコマンド実行!

はじめに

PowerShellっていいですよね。
私は普段、常にPowerShellプロンプトを画面の端っこに立ち上げてます。

https://zenn.dev/onakamanpuku/articles/7f8217ae8e4842

ちょっとした単純作業をスクリプト化して、コマンド一発でできるようにしてます。
自分の職場では、(みんなLinuxのコマンドとか使えるにもかかわらず)なぜか敬遠してる人が多いのですが、
bashとかより、Tab補完が強力で使いやすくてお気に入りです。

なのですが、ずっと使ってて、やっぱりウィンドウが常に開いてあるのはちょっと邪魔というか、
もっといい感じに実行できる環境ないのかなーと思い始めてきまして、
理想はタスクバーの検索欄みたいな感じでPowerShellを実行したい。
でもそんな機能なさそう。
そうだ自分で作ってみよう!
という感じで今回作ってみました。

PowerShell愛好家としてはなかなか便利な機能なんじゃないかと思います。

環境

・Windows10/11
・pwsh7
・C#5

完成系

さっそくですが、完成系、いろいろ紆余曲折ありながらも、結局C#で実装しました。
そして、そんなつもりはなかったんですが、作ってくうちにどんどん長くなってしまい、
ここで載せるには、って感じになったので、githubに上げました。

https://github.com/onakamanpuku/pexe/tree/main

https://github.com/onakamanpuku/pexe/blob/main/pexe.ini

https://github.com/onakamanpuku/pexe/blob/main/pexe.cs

使い方

使い方は簡単。
上のファイルを一式、どっかのフォルダにおいて、

PS > C:\Windows\Microsoft.NET\Framework\v4.0.30319\csc.exe /target:winexe /win32icon:pexe.ico /out:pexe.exe pexe.cs

として、出来上がったpexe.exeをクリックして実行するだけ。
csc.exepwshをインストールしてるならどこかにいるはず。自分は↑でした。)

設定ファイル(pexe.ini)で、表示位置とかフォントとかは設定してください。
等幅フォントを強くお勧めします。

表示位置は、Windows11のように、左側のスペースが空いてる前提で、PosLeft=12とかにしておけば多分OK。
Windows10のように右側が空いてる場合は、画面の解像度にもよりますが、500とか600とか
そんな感じですかね。

あと結果表示領域をトグルするボタンがあるんですが、その色は2種類用意してます。(ExpandButtonDarkで設定)
基本的には目立たないようにしたいので、Win11のような明るいタスクバーなら明るい色、
Win10のように暗いタスクバーなら暗い色 っていう使いわけを想定してます。

実装

すべて書くと大変なので、作るにあたって苦労した点を残しておきます。

タスクバーに入力欄を作るには

結論から言うとタスクバーの上にフォームウィンドウを乗っけてるだけです。

ここが一番、難しい というか無理だと思ってて、
今までこんな感じのものを作ろうと思ったことはあっても、実行に移したことはありませんでした。

ですが別のちょっとしたPowerShellスクリプトを書いてて、
特定のウィンドウをアクティブにする処理を試してたところ、
最前面フラグをONにすれば、その瞬間はタスクバーより手前に表示することができるということに気付き、
じゃあ、タスクバーの位置に、入力フォームをずっと最前面に表示しておけば、それっぽいものを作れるやん!
というわけで、今回の実装に至りました。

Formの表示時にTopMostフラグをONに、そして、定期的な監視処理で、
最前面からいなくなってれば、もう一回TopMostフラグをONにして無理やり最前面に表示させてます。

それがこの処理ですね。

pexe.cs
    private int retryDelay = 0;
    private void FocusCheckTimer_Tick(object sender, EventArgs e) {
        if (0 < retryDelay) {
            retryDelay--;
        }
        else {
            try {
                IntPtr activeWindow = GetForegroundWindow();
                if (activeWindow != this.Handle && activeWindow != outputForm.Handle) {
                    PopupForm.HideAll();
                }

                graphics.CopyFromScreen(POS_LEFT + 20, POS_TOP + 20, 0, 0, bmp.Size);
                Color pixel = bmp.GetPixel(0, 0);

                if ((pixel.R < 255) && (pixel.G < 255) && (pixel.B < 255)) {
                    if ((Location.X != POS_LEFT) || (Location.Y != POS_TOP)) {
                        Location = new Point(POS_LEFT, POS_TOP);
                    }

                    TopMost = true;
                }
            }
            catch {
                retryDelay = 10;
            }
        }
    }

入力欄を白色#FFFで作ってるので、入力欄があるであろう座標を監視して、
#FFFじゃなければ最前面に出すという、かなりの力技です。
こんなことして困ることないだろうか、と思いつつですが、今のところちゃんと動いてます。

pwshの実行手段

さて、一番の難関がクリアできたところで、
あとは、入力したコマンドをどうpwshに実行し、結果をどう表示するか というところで、
ここは、まあ簡単にできるだろうと思ってたのですが、意外と大変でした。

方法としては少なくとも3つありまして、
まあ環境によってお好きにしてもらったらいいかとは思うんですが、
すべて試した結果③にしました。

① PowerShellで実装し、FormのコールバックでPowerShellの処理を実行する
② C#でManagement.Automationを使い、PowerShellプロセスとお喋りする
③ C#でProcessクラスを使い、標準入出力を叩く

方法①

もともと、このアプリはPowerShellスクリプトで実装し始めてて、
FormのところはC#ソースを埋め込んで、AddTypeする予定でした。
なので、①で実装して、一旦は完成したんです。

参考までにこういう感じ

PowerShellでやるなら
Add-Type @"
(略)
public delegate string[] PowerShellCallback(string message);
public class PsForm : Form {
    public PsForm(PowerShellCallback callback) {
        pwshCallback = callback;
(略)
    }
    private void InputBox_KeyDown(object sender, KeyEventArgs e) {
        if (e.KeyCode == Keys.Enter) {
            string[] result = pwshCallback(command);
(略)
}
"@

$callback = [PowerShellCallback] {
    param ($cmd)

    try {
        $res = invoke-expression $cmd 2>&1 | Out-String -w 80
    }
    catch {
        $res = $_.Exception.Message
    }

    return $res -split "`n"
}

$form = New-Object PsForm($callback)

$form.ShowDialog() | Out-Null

しかし使ってて、少しずつ不満が出てきました。
何かというと、
・出力の色がつかない
・コマンド実行完了まで待たされる(途中の出力を出せない)

正直、贅沢言わなければ全然これでも使えます。
ソースの量も少なくて済みます。
でも折角だから色を付けてみたくなってしまい、
続いて②の方法に挑戦してみました。

方法②

②の方法だと、pwshプロセスの標準出力を受け取れるので、
色の情報とかもキャッチでき、あとは、RichTextBoxにその色を反映させれば、
良い感じの出力になります。

コンソールの色情報をパースする処理
public class ColorHandle {
    private struct COLOR {
        public Color normal;
        public Color light;
    }

    static private readonly COLOR[] COLOR_TABLE = {
        new COLOR { normal = ColorTranslator.FromHtml("#0C0C0C"), light = ColorTranslator.FromHtml("#767676") }, // black
        new COLOR { normal = ColorTranslator.FromHtml("#C50F1F"), light = ColorTranslator.FromHtml("#E74856") }, // red
        new COLOR { normal = ColorTranslator.FromHtml("#13A10E"), light = ColorTranslator.FromHtml("#16C60C") }, // green
        new COLOR { normal = ColorTranslator.FromHtml("#C19C00"), light = ColorTranslator.FromHtml("#F9F1A5") }, // yellow
        new COLOR { normal = ColorTranslator.FromHtml("#0037DA"), light = ColorTranslator.FromHtml("#3B78FF") }, // blue
        new COLOR { normal = ColorTranslator.FromHtml("#881798"), light = ColorTranslator.FromHtml("#B4009E") }, // magenta
        new COLOR { normal = ColorTranslator.FromHtml("#3A96DD"), light = ColorTranslator.FromHtml("#61D6D6") }, // cyan
        new COLOR { normal = ColorTranslator.FromHtml("#CCCCCC"), light = ColorTranslator.FromHtml("#F2F2F2") }, // white
    };

    static public Color ReverseColor(Color color) {
        return Color.FromArgb(color.ToArgb() ^ 0xffffff);
    }

    static public bool IsAnsiColorCode(string[] codes) {
        if (0 < codes.Length) {
            int colorCode;
            if (int.TryParse(codes[0], out colorCode)) {
                switch ((colorCode / 10) * 10) {
                    case  30:
                    case  40:
                    case  90:
                    case 100:
                        return true;
                    default:
                        break;
                }

            }
        }

        return false;
    }

    static private string ansi8BitToRgb(int c) {
        c = c % 256;
        return "#" + (
            (c < 16)  ? new[]{"000000","800000","008000","808000","000080","800080","008080","C0C0C0","808080","FF0000","00FF00","FFFF00","0000FF","FF00FF","00FFFF","FFFFFF"}[c] :
            (c < 232) ? string.Format("{0:X2}{1:X2}{2:X2}", ((c - 16) / 36 % 6) * 51, ((c - 16) / 6 % 6) * 51, (c - 16) % 6 * 51) :
            string.Format("{0:X2}{0:X2}{0:X2}", (c - 232) * 10 + 8)
        );
    }

    static public bool ParseColorCode(string[] codes, ref Color color, ref Color backColor) {
        bool  colorSpecify = false;
        bool  isBack       = false;
        Color res          = Color.Empty;

        foreach (string code in codes) {
            int colorCode;
            if (int.TryParse(code, out colorCode)) {
                if (colorSpecify) {
                    if ((colorCode == 5) && (3 <= codes.Length)) {
                        if (int.TryParse(codes[2], out colorCode)) {
                            res = ColorTranslator.FromHtml(ColorHandle.ansi8BitToRgb(colorCode));
                            break;
                        }
                    }
                    else if ((colorCode == 2) && (5 <= codes.Length)) {
                        int r,g,b;
                        if (int.TryParse(codes[2], out r)) {
                            if (int.TryParse(codes[3], out g)) {
                                if (int.TryParse(codes[4], out b)) {
                                    res = ColorTranslator.FromHtml(string.Format("#{0:X2}{1:X2}{2:X2}", r, g, b));
                                    break;
                                }
                            }
                        }
                    }
                    else {
                        Console.WriteLine("1other: " + colorCode);
                        return false;
                    }
                }

                bool light = false;
                switch ((colorCode / 10) * 10) {
                    case 30:
                        break;
                    case 90:
                        light = true;
                        break;
                    case 40:
                        isBack = true;
                        break;
                    case 100:
                        isBack = true;
                        light = true;
                        break;
                    default:
                        Console.WriteLine("other: " + colorCode);
                        return false;
                }

                int idx = colorCode % 10;
                if (idx < COLOR_TABLE.Length) {
                    res = light ? COLOR_TABLE[idx].light : COLOR_TABLE[idx].normal;
                    break;
                }
                else if (idx == 8) {
                    colorSpecify = true;
                }
                else {
                    Console.WriteLine("other: " + colorCode);
                    return false;
                }
            }
        }

        if (res != Color.Empty) {
            if (isBack) {
                backColor = res;
            }
            else {
                color = res;
            }
            return true;
        }

        return false;
    }
}
色つけて出力する処理

    public void AppendColoredLine(string line) {
        line = line + Environment.NewLine;
        string[] parts = Regex.Split(line, @"(\x1b\[[0-9;]*m)");

        Color color     = textBox.ForeColor;
        Color backColor = textBox.BackColor;

        bool bold      = false;
        bool italic    = false;
        bool underline = false;
        bool negative  = false;
        int  start     = -1;

        foreach (string part in parts) {
            if (Regex.IsMatch(part, @"\x1b\[[0-9;]*m")) {
                Match match = Regex.Match(part, @"\x1b\[(?<codes>[0-9;]*)m");
                if (match.Success) {
                    string[] codes = match.Groups["codes"].Value.Split(';');
                    if (ColorHandle.IsAnsiColorCode(codes)) {
                        ColorHandle.ParseColorCode(codes, ref color, ref backColor);
                    }
                    else {
                        foreach (string code in codes) {
                            int colorCode;
                            if (int.TryParse(code, out colorCode)) {
                                switch (colorCode) {
                                    case 0:
                                        color     = textBox.ForeColor;
                                        backColor = textBox.BackColor;
                                        bold      = false;
                                        italic    = false;
                                        underline = false;
                                        negative  = false;
                                        break;
                                    case 1:
                                        bold = true;
                                        break;
                                    case 3:
                                        italic = true;
                                        break;
                                    case 4:
                                        underline = true;
                                        break;
                                    case 7:
                                        negative = true;
                                        break;
                                    default:
                                        Console.WriteLine("other: " + colorCode);
                                        break;
                                }
                                break;
                            }
                        }
                    }

                }
            }
            else {
                start = textBox.TextLength;
                textBox.AppendText(part);
                if (0 <= start) {
                    textBox.SelectionStart     = start;
                    textBox.SelectionLength    = textBox.TextLength - start;
                    textBox.SelectionColor     = color;
                    textBox.SelectionBackColor = backColor;

                    FontStyle style = FontStyle.Regular;
                    if (bold)      style |= FontStyle.Bold;
                    if (italic)    style |= FontStyle.Italic;
                    if (underline) style |= FontStyle.Underline;

                    textBox.SelectionFont = new Font(textBox.Font, style);

                    if (negative) {
                        textBox.SelectionBackColor = color;
                        textBox.SelectionColor = backColor;
                    }
                }

                start = -1;
            }
        }
    }


とってもいい感じ

よしよし、これで完成だ!
と思って使ってみたのですが、
あれ、何かおかしい、、どうやらこいつPowerShell5で動いとるやないかい!
ということで、PowerShell7で動かす方法を調べました。
でもよくわかりませんでした、、

そもそも自分はC#アプリの開発経験もなく、
趣味のPowerShellスクリプトの延長線上でやってるだけなので、
VisualStudioなんか入れてないし、
csc.exeコマンドを叩いて、ビルドして実行 ってしてただけなんですよね。
それで、なんか.NET Coreが、、とか.NET Frameworkのバージョンが、、とか言われて
一応バージョン指定とか、試してみたんですが、うまくいかず、
ここの勉強は、その時が来たらということで、、と早々に諦めました。

というわけで、結局泥臭く③でやることにしました。

方法③

③になったからと言って②とそんなにやることは変わらず、
基本的にはコマンドを標準入力から流し込めばOKです。

ただし、標準出力を見ることしか出来ないので、
コマンドが完了したかどうかがわからないのは問題。
コマンドの切れ目を取得するためにどうするか、結構悩みましたが、
ここはCopilotさんにも相談しつつ、コマンドの後ろに、毎回一意の文字を出力してもらうことにして、
切れ目を判断しています。

    public async Task<string[]> ExecuteCmdAsync(string cmd) {

        if (executing) {
            return null;
        }
        executing = true;

        outputString.Clear();
        Result.Clear();

        string endMarker = Guid.NewGuid().ToString();
        await StandardInput.WriteLineAsync(string.Format("{0}; Write-Output {1}", cmd, endMarker));
        await StandardInput.FlushAsync();

        string[] lines;

        while (true) {
            int idx;
            lines = outputString.ToString().Split(new[] { Environment.NewLine }, StringSplitOptions.None);
            if (-1 < (idx = Array.IndexOf(lines, endMarker))) {
                lines[idx] = lines[idx].Replace(endMarker, "");

                Result = new List<string>(new ArraySegment<string>(lines, 1, lines.Length - 2));
                executing = false;

                return Result.ToArray();
            }
            else {
                if (2 < lines.Length) {
                    Result = new List<string>(new ArraySegment<string>(lines, 1, lines.Length - 2));
                }
            }

            await Task.Delay(100);
        }
    }

問題点としては、historyを見たときに何やらわけのわからん文字列がいちいち入ってて
よくわからないことになってしまうことですが、
どうせUpキーのコールバックとして、Historyの処理は自分で実装することになるので、
Historyを自分で管理すれば問題なしです。

あと、もう一つ問題があって、(結局解決はしてないんですが、)
pwshプロセスが認識してる、ウィンドウ幅の認識がどうもおかしい。
起動時に以下のコマンドで指定するようにしてるんですが、

    public async void Run() {
        Start();

        await StandardInput.WriteLineAsync(
            "$newsize = $(get-host).ui.rawui.buffersize; "
            + "$newsize.width =" + IniFile.Read("OutputColumns", "80") + ";"
            + "$(get-host).ui.rawui.buffersize = $newsize"
        );

        BeginOutputReadLine();
        BeginErrorReadLine();
        OutputDataReceived += (sender, e) => { if (e.Data != null) outputString.AppendLine(e.Data); };
        ErrorDataReceived += (sender, e) => { if (e.Data != null) outputString.AppendLine(e.Data); };
    }

自由に設定できないんですよねー
いける設定といけない設定があって、conhostでPowerShellを立ち上げたときの既定の設定が影響してることはわかったんですが、だからって設定が効かないのは納得いかない。
まあ、このアプリを作ったことによって、conhostでPowerShell立ち上げる必要もなくなったし、
既定の設定も幅80にして、このアプリでも幅80指定して使ってます。
幅指定がうまく効かない方はPowerShellの既定の設定をいじってみてください。

フォームの見た目

あとは細かい話になりますが、
WindowsのFormって見た目の融通が効かない。
どうりでよく見るWindowsのFormアプリって見た目がダサいわけだ、、
と自分で作ってみて初めて分かる次第ですが、
実行結果の欄はともかく、常にタスクバーに見えてる入力欄はせめて良い感じにしたいなーとなり、
普通のTextBoxではなく、Panelの中に、Padding付き枠線なしRichTextBoxを入れることで
マシな見た目にしてます。

    public CmdInputPanel(int height, PwshProcess process) : base() {
        BackColor   = Color.White;
        Padding     = new Padding(Window.DpiScaledInt(4), Window.DpiScaledCeil(6),1,1);
        BorderStyle = BorderStyle.None;
        bordercolor = Color.LightGray;
        Height      = height;

        pwshProcess = process;

        inputBox = new RichTextBox() {
            Font           = new Font(IniFile.Read("Font", "Arial"), (int)Math.Ceiling(12 / Window.DPI_SCALING)),
            Dock           = DockStyle.Fill,
            BorderStyle    = BorderStyle.None,
            ScrollBars     = RichTextBoxScrollBars.None,
            LanguageOption = RichTextBoxLanguageOptions.UIFonts,
            Multiline      = false,
            WordWrap       = false,
        };
        Controls.Add(inputBox);

        history   = new History(100);
        compForm  = new PopupForm(foreColor:"#111", backColor:"#eee", borderColor:"#ddd");
        borderPen = new Pen(bordercolor, 1);

        inputBox.PreviewKeyDown += (s, e) => { if (e.KeyCode == Keys.Tab) {e.IsInputKey = true;} };
        inputBox.KeyDown += OnKeyDown;

        inputBox.TextChanged += OnTextChanged;
        inputBox.SelectionChanged += OnSelectionChanged;
        inputBox.MouseUp += OnMouseUp;
    }

便利機能

あとは、historyの機能とか、補完機能とか、
ちまちま実装してます。
やっぱりPowerShellの最大の特長は補完だと思ってるので、
ここの機能は結構こだわりました。
具体的には、
タブキーを押したときに、入力候補を表示。
TabExpansion2という関数を呼べば簡単に一覧を取得できます。すごい)

この状態から↑↓キーで選択もできるように(これは本家にはない便利さ!)

もちろん引数も補完できます。(TabExpansion2関数の力)


あともう一つ、
自分の大好きな機能にコマンドを途中まで入力すると、
うっすらと直近のコマンド候補が表示され、「→」キーで補完できるやつがあります。

これ

もちろんこれも再現しました。
RichTextBoxのコールバックで、historyから前方一致するコマンドを探してきて、
うっすら表示させてます。RichTextBox`も意外とやりおる。

    private void OnTextChanged(object sender, EventArgs e)
    {
        if (isProcessing)
            return;

        isProcessing = true;

        string actualText = inputText;
        int caretPosition = inputBox.SelectionStart;

        inputBox.Clear();
        inputBox.SelectionStart = 0;
        inputBox.SelectionColor = Color.Black;
        inputBox.AppendText(actualText);

        try {
            string hint = history.LastMatch(actualText);
            appendText = hint.Substring(actualText.Length, hint.Length - actualText.Length);
        }
        catch {
            appendText = "";
        }

        inputBox.SelectionStart = actualText.Length;
        inputBox.SelectionColor = Color.Silver;
        inputBox.AppendText(appendText);

        inputBox.SelectionStart = caretPosition;
        inputBox.SelectionLength = 0;
        inputBox.SelectionColor = Color.Black;

        isProcessing = false;
    }

ばっちり再現!!

おわりに

というわけで、最初想定していたよりも、若干大がかりなプログラムにはなってしまいましたが、
無事目的は達成できました。

やりたくても出来なかったこととして、
Ctrl+Cで実行中のコマンドを止めたいってのがあったんですが、
ちょっと無理でしたね。
一応termコマンドで、Ctrl+Cイベント送るようにしてみたんですが、
自分自身が死んでしまうのでお手上げでした。
Automationの方を使ったらできるんかなーとか思いつつ、
このアプリをタスクバーに固定さえしておけば、
termコマンドで終了して、もう一度Win+1などで起動すれば、使用感は問題なし。
不満はないです。

あとは実行結果を毎回消しちゃってるんで、
上スクロールで前回のも見れても良いなーと思ったんですが、
めんどくさかったのでやってないです。

ともあれ、自分でアプリを作るメリットとして、いつでも好きなようにカスタマイズできるってのがあるんで、今後も何か良い機能を思いついたら追加していこうと思います。

以上!
PowerShell大好きな方、一度使ってみてはいかが~

Discussion