0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

WinUI 3 にコマンドプロンプト (DOS 窓・ターミナル) を埋め込んで、ボタンからコマンドを送る。ConPTY と xterm.js

0
Posted at

アプリの中にコマンドプロンプトのような黒い画面を出して、ボタンを押すと決まったコマンドが送られるようにします。
WinUI 3 のアプリで、Windows の ConPTY (擬似コンソール)と xterm.js を使って作ります。

Windows フォームのアプリで作る方法は、こちらの記事に書いています。
ConPTY の仕組みの解説は、その記事にあります。
https://qiita.com/Mimumumimu/items/faf040f7aacfbe57c966

やること

  1. 下のリンクの Terminal フォルダ (3 ファイル)と、WinUI3/TerminalView.cs を、プロジェクトに追加する。名前空間は付けていないので、そのまま使えます。
  2. ウィンドウの XAML に、黒い画面を置く Grid を足す。コードでそこに TerminalView を入れて、部品をつなぐ。足す内容は、「部品をつなぐ」の節にあります。
  3. ボタンから送る: _writer?.SendLine("dir /b");

知っておくこと

次のようなことで探している人向けの記事です。

  • WinUI 3 のウィンドウに、コマンドプロンプトの画面を出したい
  • WinUI 3 のアプリに、DOS 窓のようなターミナルを埋め込みたい
  • 確認を聞いてくる対話型のコマンドを、アプリの中で動かしたい
  • ボタンを押したら、決まったコマンドを送りたい

向かない場面

コマンドを 1 回動かして結果の文字だけが欲しいときは、Process.Start で標準出力を読めば足ります。
こちらのほうが簡単です。
端末かどうかで動きを変えるコマンドを動かしたいときに、ConPTY を使います。

前提

  • Windows 11 の WinUI 3 のアプリです
  • パッケージ化しない形の Windows App SDK 2.5.1 と .NET 10 で動かして確かめました
  • インターネットへの接続。xterm.js をインターネットから読み込みます
  • パッケージ化する形は、試していません

Windows フォーム版と違うところ

部品は 4 つで、Windows フォーム版と同じ構成です。
共通の 3 ファイルは、WinUI 3 でも、そのまま使えました。
変わるのは、画面を描く TerminalView と、それをつなぐウィンドウの側です。

項目 Windows フォーム版 WinUI 3 版
共通の 3 ファイル そのまま使う そのまま使う
画面の部品 Windows フォームの UserControl WinUI 3 の UserControl。XAML を持たない
準備を始める所 Load Loaded。1 回だけにする
画面のスレッドへ戻す BeginInvoke DispatcherQueue.TryEnqueue
閉じるとき FormClosing Window.Closed
WebView2 のパッケージ Microsoft.Web.WebView2 を入れる 足さなくてよい
Main [STAThread] を書く XAML のビルドが作るので、書かない

部品をつなぐ

ウィンドウが、4 つの部品をつなぎます。
部品がお互いを知らないので、つなぎ方はウィンドウの中にまとまります。

先に、XAML に、黒い画面を置く枠を足します。
TerminalView は名前空間を付けていないので、XAML から指定しにくく、コードでこの Grid に入れます。

<!-- 黒い画面 (TerminalView)は、コードでこの Grid に入れる -->
<Grid x:Name="TerminalHost" Grid.Row="0" />

次に、フィールドと、イベントの結びつけです。

private readonly TerminalView _view = new();

private PseudoConsole? _console;
private ShellReader? _reader;
private ShellWriter? _writer;

public MainWindow()
{
    InitializeComponent();
    Title = "ターミナル";
    AppWindow.Resize(new Windows.Graphics.SizeInt32(1000, 640));

    // 名前空間のない部品は XAML から指定しにくいので、Grid にコードで入れる
    TerminalHost.Children.Add(_view);

    _view.Ready += OnViewReady;
    _view.InputTyped += text => _writer?.Send(text);          // 画面で打ったキーを、シェルへ
    _view.Resized += (columns, rows) => _console?.Resize(columns, rows);

    Closed += (_, _) => StopShell();
}

画面の準備ができたら、シェルを起動して、読み込みと送信の部品をつなぎます。

private void OnViewReady(int columns, int rows)
{
    _console = PseudoConsole.Start(Shell, columns, rows);

    _reader = new ShellReader(_console.Output);
    // 読み込みは別のスレッドで動くので、画面に描くときは、画面のスレッドへ戻す
    _reader.OutputReceived += text => DispatcherQueue.TryEnqueue(() => _view.Write(text));
    _reader.Start();

    _writer = new ShellWriter(_console.Input);
}

Windows フォーム版の BeginInvoke を、DispatcherQueue.TryEnqueue に置き換えただけです。
Shell は、起動するシェルの名前です。見本では "cmd.exe" にしています。
"powershell.exe" に変えると、PowerShell を起動できます。

ウィンドウを閉じるときは、シェルを終わらせて、後片付けをします。

private void StopShell()
{
    _console?.Close();
    _reader?.Wait(TimeSpan.FromSeconds(1));
    _console?.Dispose();
    _console = null;
}

ボタンからコマンドを送る

ボタンの Click の中に、1 行書くだけです。

private void OnSendClick(object sender, RoutedEventArgs e)
{
    _writer?.SendLine("dir /b");
}

SendLine は文字を送って Enter を押します。
Enter を押したくないときは Send を使います。

アプリを起動すると、ウィンドウに黒い画面が出て cmd.exe が動きます。
入力欄と送信ボタンまで付いた完成形の見本は、リポジトリの WinUI3/MainWindow.xaml と WinUI3/MainWindow.xaml.cs にあります。

TerminalView の WinUI 3 用の部分

TerminalView の中身は、Windows フォーム版とほぼ同じです。
変わるのは、WebView2 の準備を始める所と、WebView2 から届くメッセージを受ける関数の引数です。

private async void OnLoaded(object sender, RoutedEventArgs e)
{
    // Loaded は何度も来ることがあるので、準備は 1 回だけにする
    if (_started)
    {
        return;
    }
    _started = true;

    await _webView.EnsureCoreWebView2Async();
    _webView.CoreWebView2.WebMessageReceived += OnWebMessageReceived;
    _webView.NavigateToString(Html);
}
private void OnWebMessageReceived(CoreWebView2 sender, CoreWebView2WebMessageReceivedEventArgs e)

Windows フォーム版では、1 つ目の引数が object? でした。
WinUI 3 では CoreWebView2 になります。

Loaded は、部品が画面に載るたびに来ます。
別の場所へ動かされて載り直すと、もう一度来て、準備が 2 回走ります。
そのため、1 回だけにする印 _started を付けています。

つまずきやすいところ

日本語入力が英字になる

入力欄で日本語を打ってから、黒い画面をクリックして日本語を打つと、日本語入力はオンのままなのに英字が入ります。
この見本には、回避策を入れていません。
同じ症状の原因と、直し方の 1 つは、こちらの記事に書いています。
https://qiita.com/Mimumumimu/items/525090b30f267262b1b9

その記事のコードは、WebView2 を直接置いた画面のものです。
この見本の TerminalView は WebView2 を中に持っているので、そのまま当てはまるかは、試していません。

設計の判断

TerminalView は、XAML を持たない UserControl

TerminalView は、.xaml ファイルを持たない、コードだけの UserControl です。
XAML を持つと、.xaml と .xaml.cs の 2 ファイルになり、x:Class に名前空間が要ります。
コードだけにすると、1 ファイルで、名前空間なしでコピーできます。
そのかわり、XAML から指定しにくいので、Grid にコードで入れます。

つなぎ方は、ウィンドウのコードビハインド

部品をつなぐ処理は、ウィンドウのコードビハインドに書いています。
この記事で見せたいのは、「自分のウィンドウで部品をつなぐ形」です。
読む人のアプリが MVVM だとは限らないので、ViewModel には分けていません。

全部のコード

Terminal フォルダの 3 ファイルは、Windows フォーム版の記事の「全部のコード」と同じです。
フォルダへの直接のリンクは、上の「やること」の枠にあります。
ここには、WinUI 3 用のファイルを載せます。
先頭に必要な using と #nullable enable を書いてあるので、プロジェクトの設定に関わらずそのままビルドできます。
名前空間は付けていません。付けたいときは、4 つとも同じ名前空間にしてください。

WinUI3/TerminalView.cs
#nullable enable
using System;
using System.Text.Json;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.Web.WebView2.Core;

/// <summary>
/// xterm.js で、ターミナルの画面を描く部品。
/// シェルのことは知らない。文字を渡されたら描き、キーが打たれたら知らせるだけ。
/// </summary>
public sealed class TerminalView : UserControl
{
    private readonly WebView2 _webView = new();
    private bool _started;

    /// <summary>画面の準備ができた (桁数, 行数)</summary>
    public event Action<int, int>? Ready;

    /// <summary>画面の中で、キーが打たれた (打たれた文字)</summary>
    public event Action<string>? InputTyped;

    /// <summary>画面の大きさが変わった (桁数, 行数)</summary>
    public event Action<int, int>? Resized;

    public TerminalView()
    {
        Content = _webView;
        Loaded += OnLoaded;
    }

    private async void OnLoaded(object sender, RoutedEventArgs e)
    {
        // Loaded は何度も来ることがあるので、準備は 1 回だけにする
        if (_started)
        {
            return;
        }
        _started = true;

        await _webView.EnsureCoreWebView2Async();
        _webView.CoreWebView2.WebMessageReceived += OnWebMessageReceived;
        _webView.NavigateToString(Html);
    }

    /// <summary>画面に文字を描く。画面を動かすスレッドから呼ぶ</summary>
    public void Write(string text)
    {
        var json = JsonSerializer.Serialize(new { type = "output", data = text });
        _webView.CoreWebView2?.PostWebMessageAsJson(json);
    }

    private void OnWebMessageReceived(CoreWebView2 sender, CoreWebView2WebMessageReceivedEventArgs e)
    {
        using var message = JsonDocument.Parse(e.WebMessageAsJson);
        var root = message.RootElement;

        switch (root.GetProperty("type").GetString())
        {
            case "ready":
                Ready?.Invoke(root.GetProperty("cols").GetInt32(), root.GetProperty("rows").GetInt32());
                break;
            case "input":
                InputTyped?.Invoke(root.GetProperty("data").GetString() ?? "");
                break;
            case "resize":
                Resized?.Invoke(root.GetProperty("cols").GetInt32(), root.GetProperty("rows").GetInt32());
                break;
        }
    }

    /// <summary>画面の HTML。xterm.js を、CDN から読み込む</summary>
    private const string Html = """
        <!DOCTYPE html>
        <html>
        <head>
          <meta charset="utf-8">
          <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.css"
                integrity="sha384-8Xk9wy/gzEDUKrXtrmCFa2bBuK3BpjpDuL/p0SeKQX19Khl/M+lHOgD/CyYf7efP" crossorigin="anonymous">
          <style>
            html, body { margin: 0; height: 100%; background: #000; overflow: hidden; }
            #terminal { height: 100%; }
          </style>
        </head>
        <body>
          <div id="terminal"></div>
          <script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.js"
                  integrity="sha384-M169f14mRZOXm3hD/v2Ti0ThIT/RnAQagXA9nlE15yHAtrW19gdePJh/HaTzUOe/" crossorigin="anonymous"></script>
          <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.js"
                  integrity="sha384-iF+jqbuti4XlB64clWgFWYEscb+UnSRv3VgVikGYZu+otNFnSHr7y7NcKfBnGizn" crossorigin="anonymous"></script>
          <script>
            const host = window.chrome.webview;
            const term = new Terminal({ fontSize: 14 });
            const fit = new FitAddon.FitAddon();
            term.loadAddon(fit);
            term.open(document.getElementById("terminal"));
            fit.fit();

            // 打ったキーを、C# へ送る
            term.onData(data => host.postMessage({ type: "input", data }));
            // 大きさが変わったら、C# へ伝える
            term.onResize(size => host.postMessage({ type: "resize", cols: size.cols, rows: size.rows }));
            window.addEventListener("resize", () => fit.fit());

            // C# から届いた文字を、画面に描く
            host.addEventListener("message", e => {
              if (e.data.type === "output") term.write(e.data.data);
            });

            // 準備ができたことを、C# へ伝える
            host.postMessage({ type: "ready", cols: term.cols, rows: term.rows });
          </script>
        </body>
        </html>
        """;
}
WinUI3/MainWindow.xaml
<?xml version="1.0" encoding="utf-8"?>
<Window
    x:Class="ConPtyTerminalWinUI3.MainWindow"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">

    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="*" />
            <RowDefinition Height="Auto" />
        </Grid.RowDefinitions>

        <!-- 黒い画面 (TerminalView)は、コードでこの Grid に入れる -->
        <Grid x:Name="TerminalHost" Grid.Row="0" />

        <Grid Grid.Row="1" Padding="12" ColumnSpacing="8">
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="*" />
                <ColumnDefinition Width="Auto" />
            </Grid.ColumnDefinitions>

            <TextBox x:Name="InputBox"
                     Grid.Column="0"
                     PlaceholderText="コマンドを入力"
                     KeyDown="OnInputKeyDown" />

            <Button Grid.Column="1"
                    Content="送信"
                    Style="{StaticResource AccentButtonStyle}"
                    Click="OnSendClick" />
        </Grid>
    </Grid>
</Window>
WinUI3/MainWindow.xaml.cs
#nullable enable
using System;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Input;
using Windows.System;

namespace ConPtyTerminalWinUI3;

/// <summary>
/// 部品をつなぐだけのウィンドウ。
/// 画面 (TerminalView)・出力の読み込み (ShellReader)・入力の送信 (ShellWriter) は、お互いを知らない。
/// </summary>
public sealed partial class MainWindow : Window
{
    /// <summary>起動するシェル。PowerShell にしたいときは "powershell.exe" にする</summary>
    private const string Shell = "cmd.exe";

    private readonly TerminalView _view = new();

    private PseudoConsole? _console;
    private ShellReader? _reader;
    private ShellWriter? _writer;

    public MainWindow()
    {
        InitializeComponent();
        Title = "ターミナル";
        AppWindow.Resize(new Windows.Graphics.SizeInt32(1000, 640));

        // 名前空間のない部品は XAML から指定しにくいので、Grid にコードで入れる
        TerminalHost.Children.Add(_view);

        _view.Ready += OnViewReady;
        _view.InputTyped += text => _writer?.Send(text);          // 画面で打ったキーを、シェルへ
        _view.Resized += (columns, rows) => _console?.Resize(columns, rows);

        Closed += (_, _) => StopShell();
    }

    /// <summary>画面の準備ができたら、シェルを起動して、読み込みと送信の部品をつなぐ</summary>
    private void OnViewReady(int columns, int rows)
    {
        _console = PseudoConsole.Start(Shell, columns, rows);

        _reader = new ShellReader(_console.Output);
        // 読み込みは別のスレッドで動くので、画面に描くときは、画面のスレッドへ戻す
        _reader.OutputReceived += text => DispatcherQueue.TryEnqueue(() => _view.Write(text));
        _reader.Start();

        _writer = new ShellWriter(_console.Input);
    }

    private void OnSendClick(object sender, RoutedEventArgs e) => SendFromInputBox();

    /// <summary>入力欄で Enter を押しても、送信できる</summary>
    private void OnInputKeyDown(object sender, KeyRoutedEventArgs e)
    {
        if (e.Key == VirtualKey.Enter)
        {
            SendFromInputBox();
            e.Handled = true;
        }
    }

    /// <summary>入力欄の文字を、シェルへ送る</summary>
    private void SendFromInputBox()
    {
        _writer?.SendLine(InputBox.Text);
        InputBox.Text = "";
    }

    /// <summary>シェルを終わらせて、後片付けをする</summary>
    private void StopShell()
    {
        _console?.Close();
        _reader?.Wait(TimeSpan.FromSeconds(1));
        _console?.Dispose();
        _console = null;
    }
}

この仕組みが動いているところ

MmmTool は Windows 11 のタスクトレイに常駐する WinUI 3 のツールで、Claude Code や Kiro を動かす画面に、この仕組みを使っています。

AI と一緒に作りました。どう動くかは自分で決めて、気に入らないところは指示して直しています。

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?