日曜日, 8月 17, 2025
日曜日, 8月 17, 2025
- Advertisment -
ホームニューステックニュースMagicOnion + WPF を使用して異なるPC間でアプリの操作内容をリアルタイム共有

MagicOnion + WPF を使用して異なるPC間でアプリの操作内容をリアルタイム共有


「WPFアプリの操作内容を異なるPC間でリアルタイムで共有したい」と考えていたところ、MagicOnionで実装するのが実装しやすそう&楽しそうと感じた為、「MagicOnion + WPF」で該当の動作を確認する簡単なアプリを作成してみました。
作成にあたり、MagicOnionのサンプルコードにあるチャットアプリを参考にさせてもらっています。また、実行環境としてDockerコンテナーを使用していますが、DockerfileはVisualStudio側で自動生成されるものを基本として、必要な部分だけ調整し使用しています。
色々と知識が不足している状態で作成しておりますので、間違っている部分などご指摘いただけると幸いです。

https://github.com/Cysharp/MagicOnion

  • Visual StudioでMagicOnionサーバー、WPFアプリを作成
  • 作成したMagicOnionサーバーをDockerDesktopで起動
  • 同一ネットワークにて、WPFアプリを複数のPCで起動
  • 各WPFアプリはMagicOnionサーバーへ自端末での操作内容を送る
  • 各WPFアプリはMagicOnionサーバーより各端末の操作内容を受け取る

(イメージ)

以下の環境で実行します。

  • .NET9
  • Visual Studio Comunity
  • Docker Desktop

もしDocker Desktopがインストールされていない場合は、先にインストールしてください。

https://www.docker.com/ja-jp/get-started/

ソリューションの構成は以下の通りです。

プロジェクト名 プロジェクトテンプレート 説明
InteractionShare.Shared クラスライブラリ Server – Clientの通信用インターフェイス、MessagePackObjects
InteractionShare.Server ASP.NET Core gRPC サービス MagicOnionServer
InteractionShare.App WPFアプリケーション Client用WPFアプリ

InteractionShare.Shared


最初にServer-Client間で使用する「InteractionShare.Shared」を作成します。新しいプロジェクトの作成にて「クラスライブラリ」を選択し作成します。

プロジェクトを作成したら、NuGetにて「MagicOnion.Abstractions」「MessagePack」をインストールします。

パッケージのインストールが完了したら、MessagePackObjectから作成していきます。

Join時のリクエスト内容

MessagePackObjects/Requests.cs

using MessagePack;

namespace InteractionShare.Shared.MessagePackObjects
{
    [MessagePackObject]
    public struct JoinRequest
    {
        [Key(0)]
        public string RoomName { get; set; }

        [Key(1)]
        public string UserName { get; set; }
    }
}

操作内容のレスポンス

MessagePackObjects/Responses.cs

using MessagePack;

namespace InteractionShare.Shared.MessagePackObjects
{
    [MessagePackObject]
    public struct StatusResponce
    {
        [Key(0)]
        public string UserName { get; set; }
        [Key(1)]
        public string WindowName { get; set; }
        [Key(2)]
        public string FunctionName { get; set; }
    }
}

続いて、APIのインターフェイスを作成します。

Client -> Server API

Hubs/IInteractionShareHub.cs

using InteractionShare.Shared.MessagePackObjects;
using MagicOnion;

namespace InteractionShare.Shared.Hubs
{
    

Server -> Client API

Hubs/IInteractionShareHubReceiver.cs

using InteractionShare.Shared.MessagePackObjects;

namespace InteractionShare.Shared.Hubs
{
    

以上でInteractionShare.Sharedの作成は完了です。

InteractionShare.Server


次に、MagicOnionServerとなる「InteractionShare.Server」を作成します。新しいプロジェクトの作成にて「ASP.NET Core gRPC サービス」を選択します。

プロジェクトを作成したら、デフォルトで「Protos/greet.proto」「Services/GreeterService.cs」が作成されますが、これらは使用しないため削除します。

NuGetにて「MagicOnion.Server」をインストールします。

プロジェクトの参照の追加で、先に作成した「InteractionShare.Shared」を追加します。

MagicOnionServer

既存のProgram.csを以下の様に書き換えます。

Program.cs

using Microsoft.AspNetCore.Server.Kestrel.Core;

var builder = WebApplication.CreateBuilder(args);
builder.WebHost.ConfigureKestrel(options =>
{
    options.ConfigureEndpointDefaults(endpointOptions =>
    {
        endpointOptions.Protocols = HttpProtocols.Http2;
    });
});
builder.Services.AddMagicOnion();

var app = builder.Build();
app.MapMagicOnionService();

app.Run();

続いて、実際のサーバーの処理を作成します。

InteractionShareHub.cs

using Cysharp.Runtime.Multicast;
using InteractionShare.Shared.Hubs;
using InteractionShare.Shared.MessagePackObjects;
using MagicOnion.Server.Hubs;

namespace InteractionShare.Server;

public class InteractionShareHub : StreamingHubBaseIInteractionShareHub, IInteractionShareHubReceiver>, IInteractionShareHub
{
    private IGroupIInteractionShareHubReceiver>? room;
    private string myName = string.Empty;
    private readonly IMulticastSyncGroupGuid, IInteractionShareHubReceiver> roomForAll;

    public InteractionShareHub(IMulticastGroupProvider groupProvider)
    {
        roomForAll = groupProvider.GetOrAddSynchronousGroupGuid, IInteractionShareHubReceiver>("All");
    }

    public async Task JoinAsync(JoinRequest request)
    {
            this.room = await this.Group.AddAsync(request.RoomName);
            this.myName = request.UserName;
            this.room.All.OnJoin(request.UserName);
    }

    public async Task LeaveAsync()
    {
        if (this.room is not null)
        {
            await this.room.RemoveAsync(this.Context);
            this.room.All.OnLeave(this.myName);
        }
    }

    public async Task SendStatusAsync(string windowName, string fucntionName)
    {
        if (this.room is not null)
        {
            var response = new StatusResponce { UserName = this.myName, WindowName = windowName, FunctionName = fucntionName };
            this.roomForAll.All.OnSendStatus(response);
        }

        await Task.CompletedTask;
    }

    protected override ValueTask OnConnecting()
    {
        Console.WriteLine($"client connected {this.Context.ContextId}");
        roomForAll.Add(ConnectionId, Client);
        return CompletedTask;
    }

    protected override ValueTask OnDisconnected()
    {
        roomForAll.Remove(ConnectionId);
        return CompletedTask;
    }
}

Docker/Docker Compose

次にDocker、docker-compose を作成します。
プロジェクト「InteractionShare.Server」を右クリックして、「追加」->「コンテナー オーケストレーターのサポート」を選択します。

「Docker Compose」を選択し「OK」を押します。

コンテナースキャンフォールディングオプションは、デフォルトのままでOKです。

プロジェクト配下に「Dockerfile」が作成されます。今回ポートは443を使用しますので、Dockerfileの内容を以下の通り修正します。

Dockerfile

Dockerfile(修正前)

EXPOSE 8080
EXPOSE 8081

Dockerfile(修正後)

EXPOSE 443

また、作成された「docker-compose」の「docker-compose.yml」を開き、以下の通り編集します。

docker-compose.yml

services:
  interactionshare.server:
    image: ${DOCKER_REGISTRY-}interactionshareserver
    build:
      context: .
      dockerfile: InteractionShare.Server/Dockerfile
    ports:
      - "5000:443"
    expose:
      - "443"
    environment:
      - ASPNETCORE_ENVIRONMENT=Development
      - ASPNETCORE_URLS=https://+
      - ASPNETCORE_HTTPS_PORT=443

以上でInteractionShare.Serverの作成は完了です。

InteractionShare.App

最後にクライアント側アプリ「InteractionShare.App」を作成します。新しいプロジェクトの作成にて「WPFアプリケーション」を選択し作成します。

プロジェクトを作成したら、NuGetにて「MagicOnion.Client」「System.Reactive」をインストールします。

プロジェクトの参照の追加で、先に作成した「InteractionShare.Shared」を追加します。

使用するViewModelを作成します。

ViewModels/ViewModelSubWindow.cs

using InteractionShare.Shared.Hubs;
using System.ComponentModel;
using System.Windows.Input;

namespace InteractionShare.App.ViewModels
{
    public class ViewModelSubWindow : INotifyPropertyChanged
    {
        private IInteractionShareHub hub;
        private string editedElsewhere = string.Empty;
        public string EditedElsewhere
        {
            get => editedElsewhere;
            set { editedElsewhere = value; OnPropertyChanged(nameof(EditedElsewhere)); }
        }
        private string button01ClickInfo = string.Empty;
        public string Button01ClickInfo
        {
            get => button01ClickInfo;
            set { button01ClickInfo = value; OnPropertyChanged(nameof(Button01ClickInfo)); }
        }
        private string button02ClickInfo = string.Empty;
        public string Button02ClickInfo
        {
            get => button02ClickInfo;
            set { button02ClickInfo = value; OnPropertyChanged(nameof(Button02ClickInfo)); }
        }
        private string button03ClickInfo = string.Empty;
        public string Button03ClickInfo
        {
            get => button03ClickInfo;
            set { button03ClickInfo = value; OnPropertyChanged(nameof(Button03ClickInfo)); }
        }
        private string button04ClickInfo = string.Empty;
        public string Button04ClickInfo
        {
            get => button04ClickInfo;
            set { button04ClickInfo = value; OnPropertyChanged(nameof(Button04ClickInfo)); }
        }


        public ICommand Button01Command { get; }
        public ICommand Button02Command { get; }
        public ICommand Button03Command { get; }
        public ICommand Button04Command { get; }
        public ViewModelSubWindow(IInteractionShareHub interactionShareHub)
        {
            hub = interactionShareHub;
            Button01Command = new RelayCommand(OnButton01Click);
            Button02Command = new RelayCommand(OnButton02Click);
            Button03Command = new RelayCommand(OnButton03Click);
            Button04Command = new RelayCommand(OnButton04Click);
        }

        private async void OnButton01Click()
        {
            await hub.SendStatusAsync($"Button", "01");
        }
        private async void OnButton02Click()
        {
            await hub.SendStatusAsync($"Button", "02");
        }
        private async void OnButton03Click()
        {
            await hub.SendStatusAsync($"Button", "03");
        }
        private async void OnButton04Click()
        {
            await hub.SendStatusAsync($"Button", "04");
        }

        public event PropertyChangedEventHandler? PropertyChanged;
        protected void OnPropertyChanged(string propertyName)
            => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));

        internal void SetButtonInfo(string userName, string functionName)
        {
            Button01ClickInfo = string.Empty;
            Button02ClickInfo = string.Empty;
            Button03ClickInfo = string.Empty;
            Button04ClickInfo = string.Empty;
            if (functionName == "01")
                Button01ClickInfo = $"{userName}がButton01をクリックしました";
            else if (functionName == "02")
                Button02ClickInfo = $"{userName}がButton02をクリックしました";
            else if (functionName == "03")
                Button03ClickInfo = $"{userName}がButton03をクリックしました";
            else if (functionName == "04")
                Button04ClickInfo = $"{userName}がButton04をクリックしました";
        }
    }
}

ViewModels/RelayCommand.cs

using System.Windows.Input;

namespace InteractionShare.App.ViewModels
{
    public class RelayCommand : ICommand
    {
        private readonly Action execute;
        private readonly Funcbool>? canExecute;

        public RelayCommand(Action execute, Funcbool>? canExecute = null)
        {
            this.execute = execute;
            this.canExecute = canExecute;
        }

        public bool CanExecute(object? parameter) => canExecute?.Invoke() ?? true;
        public void Execute(object? parameter) => execute();
        public event EventHandler? CanExecuteChanged;
    }
}

操作内容を共有するウィンドウを作成します。

Views/SubWindow.xaml

Window x:Class="InteractionShare.App.Views.SubWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:InteractionShare.App.Views"
        mc:Ignorable="d"
        Title="SubWindow" 
        WindowStartupLocation="CenterScreen"
        Height="350" 
        Width="400"
        Loaded="Window_Loaded"
        Unloaded="Window_Unloaded"
        >

    Window.Resources>
        ResourceDictionary>
            BooleanToVisibilityConverter x:Key="BooleanToVisibilityConverter" />
        ResourceDictionary>
    Window.Resources>

    Grid>
        Grid.RowDefinitions>
            RowDefinition Height="60"/>
            RowDefinition Height="*"/>
            RowDefinition Height="50"/>
        Grid.RowDefinitions>
        Label Grid.Row="0" Content="{Binding EditedElsewhere}" HorizontalAlignment="Center" VerticalAlignment="Top" FontSize="20" Margin="10" Foreground="Red"  />

        Grid Grid.Row="1">
            Grid.RowDefinitions>
                RowDefinition Height="*"/>
                RowDefinition Height="*"/>
            Grid.RowDefinitions>
            Grid.ColumnDefinitions>
                ColumnDefinition Width="50*"/>
                ColumnDefinition Width="50*"/>
            Grid.ColumnDefinitions>
            StackPanel Grid.Row="0" Grid.Column="0" Orientation="Vertical" >
                Button Name="Button01" Content="01" Command="{Binding Button01Command}" Margin="5" Width="120" Height="50" Tag="01"/>
                Label Content="{Binding Button01ClickInfo}" Height="50"/>
            StackPanel>
            StackPanel Grid.Row="0" Grid.Column="1" Orientation="Vertical" >
                Button Name="Button02" Content="02" Command="{Binding Button02Command}" Margin="5" Width="120" Height="50" Tag="02"/>
                Label Content="{Binding Button02ClickInfo}" Height="50"/>
            StackPanel>
            StackPanel Grid.Row="1" Grid.Column="0" Orientation="Vertical" >
                Button Name="Button03" Content="03" Command="{Binding Button03Command}" Margin="5" Width="120" Height="50" Tag="03"/>
                Label Content="{Binding Button03ClickInfo}" Height="50"/>
            StackPanel>
            StackPanel Grid.Row="1" Grid.Column="1" Orientation="Vertical" >
                Button Name="Button04" Content="04" Command="{Binding Button04Command}" Margin="5" Width="120" Height="50" Tag="04"/>
                Label Content="{Binding Button04ClickInfo}" Height="50"/>
            StackPanel>
        Grid>

        Button x:Name="ButtonClose" Grid.Row="2" Content="閉じる" HorizontalAlignment="Right" VerticalAlignment="Bottom" Margin="5" Width="120" Height="30" Click="ButtonClose_Click"/>
    Grid>
Window>

Views/SubWindow.xaml.cs

using InteractionShare.App.ViewModels;
using InteractionShare.Shared.Hubs;
using System.Windows;

namespace InteractionShare.App.Views
{
    

続いて、クライアント側の通信処理を作成します。

InteractionShareHubReceiver.cs

using InteractionShare.Shared.Hubs;
using InteractionShare.Shared.MessagePackObjects;
using System.Collections.ObjectModel;
using System.Reactive.Subjects;
using System.Windows;

namespace DataBridge.Client
{
    internal class InteractionShareHubReceiver : IInteractionShareHubReceiver
    {
        private readonly ObservableCollectionstring> dispMessages;
        private readonly SubjectStatusResponce> statusSubject;
        public IObservableStatusResponce> StatusObservable => statusSubject;

        public InteractionShareHubReceiver(ObservableCollectionstring> messages)
        {
            dispMessages = messages;
            statusSubject = new SubjectStatusResponce>();
        }

        public void OnJoin(string name)
        {
            Application.Current.Dispatcher.Invoke(() =>
            {
                dispMessages.Add($"[{name}]がJoinしました");
            });
        }

        public void OnLeave(string name)
        {
            Application.Current.Dispatcher.Invoke(() =>
            {
                dispMessages.Add($"[{name}]がLeaveしました");
            });
        }

        public void OnSendStatus(StatusResponce responce)
        {
            Application.Current.Dispatcher.Invoke(() =>
            {
                dispMessages.Add($"[{responce.UserName}]が{responce.WindowName}{responce.FunctionName}しました");
                statusSubject.OnNext(responce);
            });
        }
    }
}

最後にMainWindowを作成します。

MainWindow.xaml

Window x:Class="InteractionShare.App.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:InteractionShare.App"
        mc:Ignorable="d"
        Title="MainWindow" Height="400" Width="800"
        WindowStartupLocation="CenterScreen">
    Grid Margin="10">
        Grid.ColumnDefinitions>
            ColumnDefinition Width="300"/>
            ColumnDefinition Width="500"/>
        Grid.ColumnDefinitions>

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

        Grid Grid.Column="0">
            Grid.RowDefinitions>
                RowDefinition Height="40"/>
                RowDefinition Height="40"/>
                RowDefinition Height="40"/>
                RowDefinition Height="40"/>
            Grid.RowDefinitions>
            Grid.ColumnDefinitions>
                ColumnDefinition Width="50*"/>
                ColumnDefinition Width="50*"/>
            Grid.ColumnDefinitions>

            StackPanel Orientation="Horizontal" Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="2" >
                Label Content="User Name:" VerticalAlignment="Center" Margin="5"/>
                TextBox Name="TextBoxUserName" Text="UserA"  Width="200" Margin="5" Background="LightYellow" VerticalContentAlignment="Center"  />
            StackPanel>
            StackPanel Orientation="Horizontal" Grid.Row="1" Grid.Column="0" Grid.ColumnSpan="2" >
                Label Content="User Name:" VerticalAlignment="Center" Margin="5"/>
                TextBox Name="TextBoxServerName" Text="https://localhost:5000"  Width="200" Margin="5" Background="LightYellow" VerticalContentAlignment="Center"  />
            StackPanel>
            Button Name="ButtonJoin" Grid.Row="2" Grid.Column="0" Content="Join" Click="ButtonJoin_Click" Margin="5" />
            Button Name="ButtonLeave" Grid.Row="3" Grid.Column="0" Content="Leave" Click="ButtonLeave_Click" Margin="5" />

            Button Name="ButtonSubWindow" Grid.Row="2" Grid.Column="1" Content="Open SubWindow" Click="ButtonSubWindow_Click" Margin="5"  />
        Grid>


        ListBox Name="ListBoxMessages" Grid.Column="1"   />

    Grid>
Window>

MainWindow.xaml.cs

using DataBridge.Client;
using Grpc.Net.Client;
using InteractionShare.App.Views;
using InteractionShare.Shared.Hubs;
using InteractionShare.Shared.MessagePackObjects;
using MagicOnion.Client;
using System.Collections.ObjectModel;
using System.Windows;

namespace InteractionShare.App
{
    

以上でコーディングは完了です。
続いて、単一PCで動作確認を行っていきます。

接続先を「https://localhost:5000」にして、単一PC内で動作確認を行います。
サーバー、クライアント共に同じPC上で実行します。サーバー(InteractionShare.Server)はVisualStudioからデバッグ実行し、クライアントのWPFアプリ(InteractionShare.App)は事前にビルドしたexeを実行します。

Serverのデバッグ実行

先ほど作成した「Docker Compose」を右クリック->デバッグ->新しいインスタンス で実行します。

問題なく実行できれば、Docker DesktopのContainersに以下の様に表示されます。

この状態でビルド済みのWPFアプリ(InteractionShare.App)を実行すると、動作確認することができます。

単一PCで問題なく動作する事が確認出来たら、デバッグを終了します。
その後、Docker Desktop上から該当のコンテナを削除します。

コンテナの削除が完了したら、複数PCで動作確認するための準備へ進みます。

複数のPC上からの動作確認を行うため、自己署名証明書を作成して環境を構築します。

自己署名証明書の作成

Power Shellにて以下のコマンドを実行し、自己署名証明書を作成します。
以下のコマンドの{ホスト名}{パスワード}を任意の値に修正して実行すると、デスクトップ上に「ホスト名.pfx」が作成されます。

$certname = "{ホスト名}"
$cert = New-SelfSignedCertificate `
-CertStoreLocation "Cert:\CurrentUser\My" `
-Subject "CN=$certname" `
-DnsName $certname `
-KeyExportPolicy Exportable `
-KeyLength 2048 
$mypwd = ConvertTo-SecureString -String "{パスワード}" -Force -AsPlainText 
$outputPath = $Env:HOMEDRIVE + $Env:HOMEPATH + "\Desktop\" + $certname + ".pfx"
Export-PfxCertificate -Cert $cert -FilePath $outputPath -Password $mypwd

(参考)

https://learn.microsoft.com/ja-jp/entra/identity-platform/howto-create-self-signed-certificate

自己署名証明書の設定(サーバー側)

サーバー側のPCでは、作成した自己署名証明書を以下の通り設定します。

  1. ユーザー証明書の管理を開き、作成した証明書を「個人」-「証明書」から「信頼されたルート証明機関」-「証明書」へ移動します。

  2. コンテナ側からpfxファイルを読み込むため、以下のファイルパスを作成しpfxファイルをコピーします。
C:\Users\{ユーザー}\.aspnet\https\{ホスト名}.pfx

Docker Composeの修正

Docker Composeにて以下の処理を追加します。

(参考)

https://learn.microsoft.com/ja-jp/aspnet/core/security/docker-compose-https?view=aspnetcore-9.0

まず「docker-compose.yml」をコピーして「docker-compose.debug.yml」を作成します。ここからは、こちらを使う様にします。これはテスト起動を目的としてファイル内にパスワードを書き込んでしまうためです。

  1. pfxを読み込むため、証明書保管先をコンテナ上でマウントします

docker-compose.debug.yml

    volumes:
      - ~/.aspnet/https:/https:ro
  1. 証明書ファイル名、パスワードを記載します

docker-compose.debug.yml

    environment:
      - ASPNETCORE_Kestrel__Certificates__Default__Password={パスワード}
      - ASPNETCORE_Kestrel__Certificates__Default__Path=/https/{ホスト名}.pfx

まとめると、以下の様になります。

docker-compose.debug.yml

services:
  interactionshare.server:
    image: ${DOCKER_REGISTRY-}interactionshareserver
    build:
      context: .
      dockerfile: InteractionShare.Server/Dockerfile
    ports:
      - "5000:443"
    expose:
      - "443"
    volumes:
      - ~/.aspnet/https:/https:ro
    environment:
      - ASPNETCORE_ENVIRONMENT=Development
      - ASPNETCORE_URLS=https://+
      - ASPNETCORE_HTTPS_PORT=443
      - ASPNETCORE_Kestrel__Certificates__Default__Password={パスワード}
      - ASPNETCORE_Kestrel__Certificates__Default__Path=/https/{ホスト名}.pfx

完了したら、PowerShellにてDockerComposeを実行します。

cd {docker-compose.debug.ymlのパス}
docker-compose -f "docker-compose.debug.yml" up

自己署名証明書の設定(クライアント側)

サーバ側にて作成したpfxファイルを、クライアント側PCへもインストールします。
インストール時、保管場所を「信頼されたルート証明機関」-「証明書」に指定します。

動作確認

異なるPCでInteractionShare.App(WPFアプリ)を起動します。
接続先に「https://{サーバー名}:5000」を指定してJoinします。MagicOnion経由で操作内容がお互いの画面に表示される事を確認します。

サンプルコードを参考にMagicOnion + WPFで構築しましたが、かなり作りやすかったです。C#だけで完結できるのも、学習コストが低くて非常に助かります。
凄く楽しかったので、今後もいろいろやってみようと思います。



Source link

Views: 0

RELATED ARTICLES

返事を書く

あなたのコメントを入力してください。
ここにあなたの名前を入力してください

- Advertisment -