<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>it-lake.com</title><description>it-lake.com</description><link>https://it-lake.com/</link><item><title>【Xamarin.Forms】C#でQRコードを読み取り、ブラウザを起動させる</title><description>Xamarin.Formsで、QRコード読み取り機能を実装したのでメモしておきます。Zxingというライブラリでパフォーマンスの高いQRコード読み取りを実現できます。</description><content:encoded>&lt;p&gt;Xamarin.FormsでQR読み取り機能を実装したので、メモしておきます。&lt;/p&gt;
&lt;p&gt;当方、現時点でiOS実機でのビルドが出来ておりませんが、エミュレータでURL取得後のブラウザ起動を確認しているので問題なく動作するはずです。環境が整い次第、追記します。&lt;/p&gt;
&lt;p&gt;&amp;lt;TOC headings={getHeadings()} /&amp;gt;&lt;/p&gt;
&lt;h2&gt;環境・前提条件&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Windows10&lt;/li&gt;
&lt;li&gt;Visual Studio 2019 V16.6.3（C#）&lt;/li&gt;
&lt;li&gt;Xamarin.Forms V4.5.0.617&lt;/li&gt;
&lt;li&gt;HUAWEI Nova 2 Android 7.0&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;準備するもの&lt;/h2&gt;
&lt;p&gt;ZXing（Zebra Crossing ゼブラクロッシングと読むらしい） というバーコード読み取りのライブラリがあるみたいなので、これを使っていきます。
事前準備として、Nugetで下記の3つをインストール。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;ZXing.Net.Mobile&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;ZXing.Net.Mobile.Forms&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Xamarin.Essentials&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Zxingパッケージ&lt;/h3&gt;
&lt;p&gt;①と②は、先述したバーコード読み取り。片方だけだと動いてくれないので、両方必要です。
名前の似たようなものがたくさんあるので注意してください。&lt;/p&gt;
&lt;p&gt;たぶん、上の方に出てくる。はず...&lt;/p&gt;
&lt;h3&gt;Xamarin.Essentials&lt;/h3&gt;
&lt;p&gt;3つ目はAndroid、iOS、UWP各プラットフォームの固有APIを利用できるライブラリになります。
プラットフォーム依存のAPI（位置情報とか各種センサー等）を呼び出すコードを共通化してくれる優れものです。この中に、アプリケーション内からWebリンクを開ける&amp;lt;span class=&quot;marker-halfRed&quot; style=&quot;font-size: 12pt;&quot;&amp;gt;&lt;strong&gt;Browserクラス&lt;/strong&gt;&amp;lt;/span&amp;gt;が入ってます。&lt;/p&gt;
&lt;p&gt;サポートは下記参照&lt;br /&gt;
Android 4.4（API 19）以上&lt;br /&gt;
iOS 10.0 以上&lt;br /&gt;
UWP 10.0.16299.0 以上\&lt;/p&gt;
&lt;p&gt;Visual Studio 2019からはプロジェクト作成時にインストールされているみたいなのですが、
僕のところには何故か入ってなかったので一応。&lt;/p&gt;
&lt;h2&gt;作り方&lt;/h2&gt;
&lt;p&gt;以下の手順で作成していきます。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Manifest設定&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Appクラスの修正&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;初期画面を作る&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;コードビハインドに、ZXingのイベントハンドラを作成&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;QRコードからの取得データをBrowserクラスで開く&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;Manifest設定&lt;/h3&gt;
&lt;p&gt;プロジェクトのプロパティの[Android Manifest]から [Required permissions]で&quot;CAMERA&quot;と&quot;FLASHLIGHT&quot;の権限を取っておきます。&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;または、AndroidManifest.xmlに直接追加することもできます。以下を manifestタグの中に入れてやってください。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;!--AndroidManifest.xml--&amp;gt;
&amp;lt;uses-permission android:name = &quot;android.permission.CAMERA&quot; &amp;gt;
&amp;lt;uses-permission android:name = &quot;android.permission.FLASHLIGHT&quot; &amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Appクラスの修正&lt;/h3&gt;
&lt;p&gt;NavigationPageをMainPageに設定します。
これを行うことで、ナビゲーションスタックに、ルートページとなる初期画面がプッシュされます。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// App.xaml.cs
using System;
using Xamarin.Forms;
using Xamarin.Forms.Xaml;

namespace XamarinApp
{
    public partial class App : Application
    {
        public App()
        {
            InitializeComponent();
            MainPage = new NavigationPage(new MainPage() { });
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;iOSの初期設定&lt;/h3&gt;
&lt;p&gt;Xamarin.Formsの初期化処理であるglobal::Xamarin.Forms.Forms.Init();の後ろに、&lt;/p&gt;
&lt;p&gt;global::ZXing.Net.Mobile.Forms.iOS.Platform.Init();
という、ZXingの初期化処理を入れてあげます。&lt;/p&gt;
&lt;p&gt; &lt;/p&gt;
&lt;pre&gt;&lt;code&gt;//AppDelegate.cs
using System;
using System.Collections.Generic;
using System.Linq; using Foundation;
using UIKit;

namespace XamarinApp.iOS
{
    [Register(&quot;AppDelegate&quot;)]
    public partial class AppDelegate : global::Xamarin.Forms.Platform.iOS.FormsApplicationDelegate
    {
        public override bool FinishedLaunching(UIApplication app, NSDictionary options)
        {
            global::Xamarin.Forms.Forms.Init();
            global::ZXing.Net.Mobile.Forms.iOS.Platform.Init();
            LoadApplication(new App());
            return base.FinishedLaunching(app, options);
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;初期画面を作る&lt;/h3&gt;
&lt;p&gt;アプリが起動して、最初に表示される画面を作成していきます。
今回は、あくまでQRコードの動作確認用、サンプルアプリですので、簡単に作っていきます。&lt;/p&gt;
&lt;p&gt;以下のコードをMainPage.xamlに入力。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;!--MainPage.xaml--&amp;gt;
&amp;lt;?xml version=&quot;1.0&quot; encoding=&quot;utf-8&quot;?&amp;gt;
&amp;lt;ContentPage
xmlns=&quot;http://xamarin.com/schemas/2014/forms&quot;
xmlns:x=&quot;http://schemas.microsoft.com/winfx/2009/xaml&quot;
xmlns:d=&quot;http://xamarin.com/schemas/2014/forms/design&quot;
xmlns:mc=&quot;http://schemas.openxmlformats.org/markup-compatibility/2006&quot;
mc:Ignorable=&quot;d&quot;
x:Class=&quot;XamarinApp.MainPage&quot;&amp;gt;

    &amp;lt;ContentPage.Padding&amp;gt;
        &amp;lt;OnPlatform x:TypeArguments=&quot;Thickness&quot;&amp;gt;
            &amp;lt;On Platform=&quot;iOS&quot;&amp;gt;0,20,0,0&amp;lt;/On&amp;gt;
        &amp;lt;/OnPlatform&amp;gt;
    &amp;lt;/ContentPage.Padding&amp;gt;

    &amp;lt;ContentPage.ToolbarItems&amp;gt;
        &amp;lt;ToolbarItem x:Name=&quot;menuDate&quot; Order=&quot;Primary&quot;/&amp;gt;
        &amp;lt;ToolbarItem x:Name=&quot;cameraItem&quot; Text=&quot;カメラを起動&quot; Order=&quot;Secondary&quot; Clicked=&quot;Camera_Button&quot;/&amp;gt;
    &amp;lt;/ContentPage.ToolbarItems&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;今回は、Toolbarに「カメラを起動」という名前のアイテムを設置しました。 もちろん、ButtonにしてもOKです。 Clickedイベントに、Camera_Buttonを割り当てておきます。&lt;/p&gt;
&lt;p&gt;途中のContantPage.Paddingタグですが、iOSのみ上部に20pxの余白を作っています。 これをしておかないと、iOSでは設置したコントロールが上部に隠れてしまうので、Xamarin.Formsでのアプリ開発ではこのPaddingの定義が必要です。&lt;/p&gt;
&lt;h3&gt;コードビハインドに、ZXingのイベントハンドラを作成&lt;/h3&gt;
&lt;p&gt;先ほど作成したToolbarItemに処理を書いていきます。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/// &amp;lt;summary&amp;gt;
/// カメラを起動
/// &amp;lt;/summary&amp;gt;
/// &amp;lt;param name=&quot;sender&quot;&amp;gt;&amp;lt;/param&amp;gt;
/// &amp;lt;param name=&quot;e&quot;&amp;gt;&amp;lt;/param&amp;gt;
private async void Camera_Button(object sender, EventArgs e)
{
    var scanner = new ZXingScannerPage()
    {
        DefaultOverlayTopText = &quot;バーコードを読み取ります&quot;,
        DefaultOverlayBottomText = &quot;読み取り開始&quot;
    };
    await Navigation.PushAsync(scanner);

    scanner.OnScanResult += (result) =&amp;gt;
    {
        scanner.IsScanning = false;
        Device.BeginInvokeOnMainThread(async () =&amp;gt;
        {
            await Navigation.PopAsync();

            await Browser.OpenAsync(result.Text, new BrowserLaunchOptions
            {
                LaunchMode = BrowserLaunchMode.SystemPreferred,
                TitleMode = BrowserTitleMode.Default
            });
        });
    };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;実装方法はこんな感じ。&lt;/p&gt;
&lt;p&gt;ZXingScannerPageというスキャナーページを生成し、OnScanResulでスキャン完了のイベントハンドラを記述しています。&lt;/p&gt;
&lt;p&gt;そして、スキャンページ遷移後のバックグラウンドスレッドから。UIスレッドを操作するためにDevice.BeginInvokeOnMainThreadで、UIを操作する処理を囲んであげます。ここでは、初期画面に戻るためのポップ処理ですね。&lt;/p&gt;
&lt;p&gt;プラットフォームごとに、様々な機能をカスタマイズできるDeviceクラスのメソッドです。&lt;/p&gt;
&lt;h3&gt;QRコードからの取得データをBrowserクラスで開く&lt;/h3&gt;
&lt;p&gt;前項で、スキャンが出来るようになったのはいいけど、...&lt;/p&gt;
&lt;p&gt;この状態では、スキャン完了したら、初期画面に戻るだけです。ここで、戻る前にブラウザを起動させる処理を追加します。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;//MainPage.xaml.cs
Device.BeginInvokeOnMainThread(async () =&amp;gt; {
    await Browser.OpenAsync(result.Text, new BrowserLaunchOptions
    {
        LaunchMode = BrowserLaunchMode.SystemPreferred,
        TitleMode = BrowserTitleMode.Default
    });

    await Navigation.PopAsync();
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Device.BeginInvokeOnMainThreadの中に、BrowserクラスのOpenAsyncメソッドを追加します。
OpenAsyncメソッドにスキャン結果(URLが入ったresult)とLaunchOptionsを設定することで、アプリケーション内からWEBリンクを開くことができます。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;LaunchMode = BrowserLaunchMode.SystemPreferred&lt;/strong&gt;
&lt;strong&gt;LaunchMode = BrowserLaunchMode.External&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;TitleMode = BrowserTitleMode.Default&lt;/strong&gt;
&lt;strong&gt;TitleMode = BrowserTitleMode.Hide&lt;/strong&gt;
&lt;strong&gt;TitleMode = BrowserTitleMode.Show&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;両者とも、起動方法に関するプロパティです。&lt;/p&gt;
&lt;p&gt;LaunchModeですが、前者がブラウザを選択してアプリ内からリンクを開くのに対し、後者がブラウザの選択後、アプリ外でリンクを開くという違いがあります。&lt;/p&gt;
&lt;p&gt;TitleModeは、リンクを開いている間、Webページのタイトルを上部に表示するかどうか設定できます。Defaultにしておくことで、表示になります。&lt;/p&gt;
&lt;h2&gt;動作確認&lt;/h2&gt;
&lt;p&gt;Androidで動かします。iOSは実機が使えないので出来ましぇん。UWPもカメラが手元にないので割愛。&lt;/p&gt;
&lt;p&gt;何でXamarin.Formsにしたしw&lt;/p&gt;
&lt;h3&gt;Android&lt;/h3&gt;
&lt;p&gt;サンプルソースと、ちょっとUIが違うのは勘弁してください。&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;h2&gt;さいごに&lt;/h2&gt;
&lt;p&gt;読み取りメッチャ速い(・∀・)&lt;/p&gt;
&lt;p&gt;今Prismを勉強中なので、今回のPrism版もまた今度ね。&lt;/p&gt;
&lt;p&gt;その前にiOSビルドのための実機が必要だよね。ipod touch 7とかでいいんでしょうか。&lt;/p&gt;
&lt;h2&gt;参考&lt;/h2&gt;
&lt;p&gt;https://devblogs.microsoft.com/xamarin/barcode-scanning-made-easy-with-zxing-net-for-xamarin-forms/&lt;/p&gt;
&lt;p&gt;https://qiita.com/ytabuchi/items/3e14dcb5b9a11aaf14a9&lt;/p&gt;
&lt;p&gt;https://github.com/xamarin/Essentials/tree/main/Xamarin.Essentials/Browser&lt;/p&gt;
&lt;p&gt;https://docs.microsoft.com/ja-jp/dotnet/api/xamarin.essentials.browser&lt;/p&gt;
</content:encoded></item><item><title>SDカードリーダーだけでRaspberry PiのインストールとSSH接続をする</title><description>外出先からアクセスできるファイルサーバーが追加で欲しくなったので、小型かつ安価で導入できるRaspberry Pi 4（以下ラズパイ）を購入しました。今回は、SDカードにWifiやSSH接続を可能にする設定ファイルを作成して、起動後にSSH接続ができる状態にしていこうと思います。</description><content:encoded>&lt;p&gt;外出先からアクセスできるファイルサーバーが追加で欲しくなったので、小型かつ安価で導入できるRaspberry Pi 4（以下ラズパイ）を購入しました。通常、ラズパイのOSインストールおよび各種設定はGUIかターミナル操作で行うため、USB接続のマウスキーボード・ディスプレイが必要です。公式ドキュメントでも、外部機器を使用したセットアップの方法が解説してあり、この通りに進めていけばまず問題はありません。&lt;/p&gt;
&lt;p&gt;しかし、手元に有線のマウスキーボードが無く、セットアップのためだけにディスプレイを購入するのも気が引けます。今回は、SDカードにWifiやSSH接続を可能にする設定ファイルを作成して、起動後にSSH接続ができる状態にしていこうと思います。&lt;/p&gt;
&lt;p&gt;&amp;lt;TOC headings={getHeadings()} /&amp;gt;&lt;/p&gt;
&lt;h2&gt;準備したもの&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;ラズパイ4&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;USB TypeC対応 ACアダプター（5V/3.0A）&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;micro SDカード（速度規格Class10推奨）&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;micro SDカードリーダー（PCにSDカードスロットがあれば不要）&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;上記4つは最低限必要です。
今回はラズパイ4を使用していますが、2や3、zeroなどの別のモデルでも同じやり方でセットアップ可能です。&lt;/p&gt;
&lt;p&gt;現在流通しているmicroSDカードは、Class10規格対応のものがほとんどですが、購入する際は念のためチェックしておきましょう。また、ドライブレコーダーや監視カメラ用の高耐久モデルを選択するのもありです。MLC設計やTBW値の高いフラッシュメモリは、寿命が使い方に依存するとは言え、比較的壊れにくいです。&lt;/p&gt;
&lt;p&gt;SDカードリーダーは、microSDとUSB端子（TypeAやTypeC）に対応しているものであれば何でもいいです。SDカードにはOSの設定ファイルを書き込むだけなので、USB3.0以上の規格は必要なく、USB2.0の転送速度で十分です。&lt;/p&gt;
&lt;p&gt;今回セットアップに使うものは、ラズパイ4 model Bの2GB、SanDiskのmicroSDカード（32GB）、バッファローのSDカードリーダー（USB2.0）になります。&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;h2&gt;SDカードにRaspberry Pi OSをインストールする&lt;/h2&gt;
&lt;p&gt;公式から、&lt;a href=&quot;https://www.raspberrypi.com/software/&quot;&gt;Raspberry Pi Imager&lt;/a&gt;をダウンロードします。SDカードのフォーマットから、OSの書き込みまで行ってくれる便利なツールです。&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;インストーラーを起動し、Raspberry Pi Imagerのインストールが完了したら起動させ、インストールするOSの種類と対象ストレージを選択していきます。&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;OSの選択画面には、ラズパイOSだけでなく、Ubuntuやメディアサーバー用OSなど複数用意されています。今回は、GUIなしの「Raspberry Pi OS Lite (64-bit)　」をインストールします。グラフィック出力が不要になる分、発熱が抑えられてより省電力になるので、サーバー用途であればLite版をおすすめします。&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;ストレージ選択画面では、SDカードリーダーに接続してあるSDカードが表示されるはずなのでそちらを選択。僕の環境ではPCのRAMディスクも表示されました。このように複数機器表示されていると、間違いやすいのでご注意を。&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;h2&gt;Bootフォルダ内にWifiとSSH接続の設定ファイルを書き込む&lt;/h2&gt;
&lt;p&gt;起動後にWifiとSSH接続を可能にするには、Imagerのオプションを入力する方法と、SDカードに直接設定ファイルを書き込む方法の２つがあります。新規でインストールする場合はImagerのオプションを用いる方法が簡単で確実ですが、後者の方法もインストール後に再設定を行いたい時に使えそうです。&lt;/p&gt;
&lt;p&gt;※ もし、有線LANで運用する場合はWifiの設定は行う必要はありません。&lt;/p&gt;
&lt;h3&gt;Imagerのオプションから設定する（推奨）&lt;/h3&gt;
&lt;p&gt;Imagerの最初の画面から、Ctrl＋Shift＋Xを押すことで詳細オプションの画面に飛びます。ここから、WifiおよびSSH接続の設定が可能です。&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;設定項目&lt;/th&gt;
&lt;th&gt;説明&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;カスタマイズオプション&lt;/td&gt;
&lt;td&gt;ImagerでのOSインストール時に、このオプションの設定を常に反映するかどうか&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ホスト名&lt;/td&gt;
&lt;td&gt;ラズパイのホスト名。デフォルトは「raspberrypi.local」だが、ラズパイが複数ある場合は変更推奨。&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SSHを有効化する&lt;/td&gt;
&lt;td&gt;外部からのSSH接続を可能にする設定。パスワード認証と公開鍵認証が選択でき、後者の場合は公開鍵を入力する&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ユーザー名とパスワードを設定する&lt;/td&gt;
&lt;td&gt;インストール時に作成されるrootユーザーの情報。デフォルト設定は危険性が高いため、ユーザー名とパスワード共に設定することを推奨。&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wifiを設定する&lt;/td&gt;
&lt;td&gt;Wifi接続の情報。（SSIDとパスワード）無線LANの周波数を指定する、Wifiを使う国の項目は「JP」を設定する&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ロケール設定をする&lt;/td&gt;
&lt;td&gt;タイムゾーンとキーボードレイアウトの設定。チェックを入れることを推奨。JISキーボードを使用する場合は「jp」を設定する。&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;永続的な設定&lt;/td&gt;
&lt;td&gt;このアプリケーション自体の設定。テレメトリーは、統計情報収集の許可設定なのでON/OFFどちらでも問題なし。&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;SSH接続では、パスワード認証と公開鍵認証を選択できます。秘密鍵の管理さえ徹底していれば安全性が非常に高く、パスワード入力の手間も省けるため、公開鍵認証がよく用いられます。ラジオボタンを押して「ユーザー〇〇のためのauthorized_kes」に作成済みの公開鍵を入力することで設定可能です。もし、LAN内にLAN外からアクセスされるデバイスが無い場合は、パスワード認証でも問題ないでしょう。&lt;/p&gt;
&lt;p&gt;ユーザー名とパスワードは、デフォルト設定から変更することを強くおすすめします。デフォルト設定ではパスワードがないため、実質外部から誰でもログインできる状態となります。&lt;/p&gt;
&lt;p&gt;インストール後のbootフォルダを確認してみると、オプションで設定した内容が記述されたfirstrun.shファイルが生成されます。この状態でSDカードをラズパイに入れてbootすれば、キーボードやディスプレイ無しでSSH接続が可能になります。Imagerを使えば簡単ですね。&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# firstrun.sh
#!/bin/bash

set +e

CURRENT_HOSTNAME=`cat /etc/hostname | tr -d &quot; \t\n\r&quot;`
if [ -f /usr/lib/++++++-sys-mods/imager_custom ]; then
   /usr/lib/++++++-sys-mods/imager_custom set_hostname ++++++
else
   echo ++++++ &amp;gt;/etc/hostname
   sed -i &quot;s/++++++.*$CURRENT_HOSTNAME/++++++\++++++/g&quot; /etc/hosts
fi
FIRSTUSER=`getent passwd 1000 | cut -d: -f1`
FIRSTUSERHOME=`getent passwd 1000 | cut -d: -f6`
if [ -f /usr/lib/++++++-sys-mods/imager_custom ]; then
   /usr/lib/++++++-sys-mods/imager_custom enable_ssh
else
   systemctl enable ssh
fi
if [ -f /usr/lib/userconf-pi/userconf ]; then
   /usr/lib/userconf-pi/userconf &apos;++++++&apos; &apos;++++++&apos;
else
   echo &quot;$FIRSTUSER:&quot;&apos;++++++&apos; | chpasswd -e
   if [ &quot;$FIRSTUSER&quot; != &quot;++++++&quot; ]; then
      usermod -l &quot;++++++&quot; &quot;$FIRSTUSER&quot;
      usermod -m -d &quot;/home/++++++&quot; &quot;++++++&quot;
      groupmod -n &quot;++++++&quot; &quot;$FIRSTUSER&quot;
      if grep -q &quot;^autologin-user=&quot; /etc/lightdm/lightdm.conf ; then
         sed /etc/lightdm/lightdm.conf -i -e &quot;s/^autologin-user=.*/autologin-user=++++++/&quot;
      fi
      if [ -f /etc/systemd/system/getty@tty1.service.d/autologin.conf ]; then
         sed /etc/systemd/system/getty@tty1.service.d/autologin.conf -i -e &quot;s/$FIRSTUSER/++++++/&quot;
      fi
      if [ -f /etc/sudoers.d/010_pi-nopasswd ]; then
         sed -i &quot;s/^$FIRSTUSER /++++++ /&quot; /etc/sudoers.d/010_pi-nopasswd
      fi
   fi
fi
if [ -f /usr/lib/++++++-sys-mods/imager_custom ]; then
   /usr/lib/++++++-sys-mods/imager_custom set_wlan &apos;++++++&apos; &apos;++++++&apos; &apos;JP&apos;
else
cat &amp;gt;/etc/wpa_supplicant/wpa_supplicant.conf &amp;lt;&amp;lt; &apos;WPAEOF&apos;
country=JP
ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev
ap_scan=1

update_config=1
network={
	ssid=&quot;++++++&quot;
	psk=++++++
}
# 続く
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;直接設定ファイルを作成する&lt;/h3&gt;
&lt;p&gt;Imagerにより作成されたbootフォルダ直下に、直接設定ファイルを作成する方法です。新規インストールする場合は、ファイル作成時に記述ミスをする可能性があるのでImagerのオプションを使うことが望ましいです。しかし、誤ってsshを無効化してしまい、VNCでもログインする手立てがないといった状況にはこの手法が使えます。&lt;/p&gt;
&lt;p&gt;OSインストール後は、 SDカードにファイルがいくつか作成されていると思います。この場所に設定ファイルを追加していきます。&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;h4&gt;SSHを有効化する&lt;/h4&gt;
&lt;blockquote&gt;
&lt;p&gt;When this file is present, SSH will be enabled on boot. The contents don’t matter, it can be empty. SSH is otherwise disabled by default.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://www.raspberrypi.com/documentation/computers/configuration.html#setting-up-a-headless-raspberry-pi&quot;&gt;Raspberry Pi Documentation&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;boot時に「ssh」という名前のファイルが存在すれば、SSHを有効化して起動してくれます。ファイルの中身も空っぽで、拡張子も不要です。&lt;/p&gt;
&lt;p&gt;これは新規インストール時のみの仕様ではなく、bootする度に処理が走るので、SSHを有効化する方法の1つとして覚えておいて損はありません。既に運用中のマシンでSSHが有効化されることを確認しています。&lt;/p&gt;
&lt;h4&gt;Wifiを有効化する&lt;/h4&gt;
&lt;blockquote&gt;
&lt;p&gt;You will need to define a &lt;code&gt;wpa_supplicant.conf&lt;/code&gt; file for your particular wireless network. Put this file onto the boot folder of the SD card. When the Raspberry Pi boots for the first time, it will copy that file into the correct location in the Linux root file system and use those settings to start up wireless networking.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://www.raspberrypi.com/documentation/computers/configuration.html#setting-up-a-headless-raspberry-pi&quot;&gt;Raspberry Pi Documentation&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;「wpa_supplicant.conf」という名前のファイルを作成し、下記のようにWifiの接続情報を記述します。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev
country=JP
update_config=1

network={
        ssid=&quot;○○○○&quot;  # アクセスポイントの名前
        psk=&quot;○○○○&quot;   # パスワード
        # ↑上記ssid,pskは必須項目

        # proto=RSN        暗号化プロトコル
        # key_mgmt=WPA-PSK 暗号化方式
        # pairwise=CCMP    ユニキャスト用暗号化方式
        # auth_alg=OPEN    認証アルゴリズム
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;最低限、SSID（ssid）とパスワード（psk）が設定されていれば起動後にWifiに接続することができます。&lt;/p&gt;
&lt;p&gt;このファイルが、boot時に/etc/wpa_supplicant/wpa_supplicant.confを上書きし、以降、Wifi接続情報は上書きされたファイルを参照するようになります。なお、bootフォルダ直下に作成されたwpa_supplicant.confは起動後に破棄されます。&lt;/p&gt;
&lt;p&gt;平文のパスワードを記入するのに抵抗がある場合は、代わりに下記のツールで取得した256bitのハッシュ値を使用できます。SSIDとパスワードの組をハッシュ関数に通し、そこで得られた256bitのキーをpskに入力します。なお、ハッシュ値を用いる場合は二重引用符（&quot;&quot;）は必要ありません。下記のツールで入力したSSIDとパスワードが外部に送信されることはなく、コンピュータ上でJavaScript関数により計算、出力されるので安心です。開発者ツールで確認しても、HTMLページの取得以外はネットワーク通信は行われていません。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;http://jorisvr.nl/wpapsk.html&quot;&gt;WPA key calculation&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;h2&gt;外部からSSHで接続してみる&lt;/h2&gt;
&lt;p&gt;今回はImagerのオプションで設定した方法を試してみます。ホスト名はデフォルトのraspberrypi.localとし、SSHは公開鍵認証での接続です。また、Wifiの設定が適用されているかをチェックするため、LANケーブルは接続しません。&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;公式ドキュメントでは、Wifiに接続されるまで起動してから最大5分掛かると明記されていたので少し待機します。僕の環境では1分くらい待ってルーターのワイヤレスデバイス一覧に出現しました。では、PowerShellでホスト名を指定して下記のようにpingを飛ばしてみます。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;PS C:\Users\+++&amp;gt; ping -4 raspberrypi.local

raspberrypi.local [192.168.0.127]に ping を送信しています 32 バイトのデータ:
192.168.0.127 からの応答: バイト数 =32 時間 =99ms TTL=64
192.168.0.127 からの応答: バイト数 =32 時間 =23ms TTL=64
192.168.0.127 からの応答: バイト数 =32 時間 =3ms TTL=64
192.168.0.127 からの応答: バイト数 =32 時間 =26ms TTL=64

192.168.0.127 の ping 統計:
    パケット数: 送信 = 4、受信 = 4、損失 = 0 (0% の損失)、
ラウンド トリップの概算時間 (ミリ秒):
    最小 = 3ms、最大 = 99ms、平均 = 37ms
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;ホスト名でpingが返ってこない場合
ちゃんと返ってきました。続いてssh接続を試してみます。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;PS C:\Users\+++&amp;gt; ssh raspberrypi.local
Linux raspberrypi 5.15.61-v8+ #1579 SMP PREEMPT Fri Aug 26 11:16:44 BST 2022 aarch64

The programs included with the Debian GNU/Linux system are free software;
the exact distribution terms for each program are described in the
individual files in /usr/share/doc/*/copyright.

Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent
permitted by applicable law.
Last login: Sat Nov 12 17:44:39 2022 from 192.168.0.177
+++@raspberrypi:~ $
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;問題なく接続できました。もし、秘密鍵にパスフレーズを設定している場合は、追加の認証で秘密鍵のパスフレーズが求められます。&lt;/p&gt;
&lt;p&gt;今回の例では公開鍵認証でインストールしたため、sshd_configファイルのパスワード認証は自動的に無効化されました。また、下記のように鍵を保存する既定のパスにはインストール時に入力した公開鍵のファイルが生成されています。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;+++@raspberrypi:~ $ cd /
+++@raspberrypi:/ $ vim etc/ssh/sshd_config
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;#       $OpenBSD: sshd_config,v 1.103 2018/04/09 20:41:22 tj Exp $

# This is the sshd server systeem-wide configuration file. See
# sshd_config(5) for more information.
～

# To disable tunneled clear text password, change to no here!
PasswordAuthentication no
#PermitEmptyPasswords no

# Change to yes to enable challenge-response passwords (beware issues with
# some PAM modules and threads)
ChallengeResponseAuthentication no
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;+++@raspberrypi:~ $ cd .ssh
+++@raspberrypi:~/.ssh $ tree
.
└── authorized_keys

0 directories, 1 file
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;ホスト名でpingが返ってこない（SSH接続できない）場合&lt;/h3&gt;
&lt;p&gt;LAN内でホスト名からipアドレスへ名前解決する際、mDNS（LinuxではAvahi）というプロトコルが用いられますが、たまに不具合で機能しないことがあります。この場合は、ラズパイに割り当てられたIPアドレスを直接指定してSSH接続を試みます。ラズパイのIPアドレスの確認方法ですが、以下の２つの方法が簡単です。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;ルーターの管理画面から、ネットワークに接続されているデバイスの一覧を確認する&lt;/li&gt;
&lt;li&gt;arpコマンドでネットワーク内に割り当てられているIPアドレスの一覧を取得する&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;ルーターによっては、LANに接続されているデバイスの一覧を確認できる機能が搭載されています。また、デバイス名も表示されるため、ラズパイのIPアドレスを簡単に特定できます。ルーターの管理画面へのアクセス方法は、機種によって異なるためここでは省略します。&lt;/p&gt;
&lt;p&gt;
&amp;lt;p style=&quot;text-align: center&quot;&amp;gt;&lt;a href=&quot;https://www.buffalo.jp/support/faq/detail/15741.html&quot;&gt;BUFFALOルーターの例&lt;/a&gt;&amp;lt;/p&amp;gt;&lt;/p&gt;
&lt;p&gt;続いてarpコマンド（arp -a）による確認方法です。arpコマンドは、LAN内のIPアドレスとMACアドレスの対応表（arpテーブル）を取得できます。ラズパイがLANに接続されており、IPアドレスが割り振られている場合は、arpコマンドを使用することで、ラズパイのIPアドレスを推測できるわけです。&lt;/p&gt;
&lt;p&gt;しかし、仕様上arpテーブルは定期的にクリアされ、一度通信を行ったアドレスしかテーブルに表示されません。タイミングによっては目的のアドレスが表示されないことがあるので、arp -aでラズパイっぽいIPアドレスが見つからなければ、一度テーブルを更新してから再度実行します。&lt;/p&gt;
&lt;p&gt;arpテーブルはarp応答を受けた時に更新されるため、まずはLAN内のIPアドレスにpingを送ります。この時ルーターやONUを介していたり、DHCPの割当範囲を指定している場合があると思うので、ネットワーク部とホスト部の値は環境に応じて変更してください。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;for ($i=0; $i -lt 255; $i++){ping -n 1 192.168.1.$i}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;for ((i = 0 ; i &amp;lt;= 255 ; i++)); do ping -c 1 192.168.1.${i}; done
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;192.168.0.0 に ping を送信しています 32 バイトのデータ:
192.168.0.177 からの応答: 宛先ホストに到達できません。

192.168.0.0 の ping 統計:
    パケット数: 送信 = 1、受信 = 1、損失 = 0 (0% の損失)、

192.168.0.1 に ping を送信しています 32 バイトのデータ:
192.168.0.1 からの応答: バイト数 =32 時間 =16ms TTL=64

192.168.0.1 の ping 統計:
    パケット数: 送信 = 1、受信 = 1、損失 = 0 (0% の損失)、
ラウンド トリップの概算時間 (ミリ秒):
    最小 = 16ms、最大 = 16ms、平均 = 16ms

192.168.0.2 に ping を送信しています 32 バイトのデータ:
192.168.0.177 からの応答: 宛先ホストに到達できません。

192.168.0.2 の ping 統計:
    パケット数: 送信 = 1、受信 = 1、損失 = 0 (0% の損失)、

192.168.0.3 に ping を送信しています 32 バイトのデータ:
192.168.0.177 からの応答: 宛先ホストに到達できません。

192.168.0.3 の ping 統計:
    パケット数: 送信 = 1、受信 = 1、損失 = 0 (0% の損失)、
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;指定した範囲のIPアドレス全てにpingが送られ、その都度arp要求も行うため、arp応答があった時にテーブルが更新されます。ping要求が全て終了したら、arp -aコマンドを実行してみます。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;PS C:\Users\+++&amp;gt; arp -a

インターフェイス: 192.168.0.177 --- 0x11
  インターネット アドレス 物理アドレス           種類
  192.168.0.1           ++-++-++-7e-7e-47     動的
  192.168.0.100         ++-++-++-16-06-e0     動的
  192.168.0.104         ++-++-++-03-7c-84     動的
  192.168.0.127         ++-++-++-ad-9a-ef     動的
  192.168.0.150         ++-++-++-57-84-e4     動的
  192.168.0.238         ++-++-++-d4-b6-0e     動的
  192.168.0.255         ff-ff-ff-ff-ff-ff     静的
以下略
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;LANに接続されている稼働中のデバイスが表示されました。僕の環境では、PC等のいくつかのデバイスはipを固定しているので、見覚えのない192.168.0.127が怪しいですね。このアドレスでラズパイのSSH接続を試みます。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;PS C:\Users\+++&amp;gt; ssh 192.168.0.127
Linux raspberrypi 5.15.61-v8+ #1579 SMP PREEMPT Fri Aug 26 11:16:44 BST 2022 aarch64
The programs included with the Debian GNU/Linux system are free software;
the exact distribution terms for each program are described in the
individual files in /usr/share/doc/+/copyright.

Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent
permitted by applicable law.
Last login: Sun Nov 13 16:45:31 2022 from 192.168.0.177
+++@raspberrypi:~ $
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;ビンゴ！&lt;/p&gt;
&lt;h2&gt;さいごに&lt;/h2&gt;
&lt;p&gt;24時間稼働を想定しているサーバー用途でPCを組むとなると、場所も取るし電気代も掛かるので複数台の導入は敷居が高いです。その点、小型で消費電力の小さいラズパイであれば気軽にサーバーを増設できるので便利ですね。&lt;/p&gt;
&lt;p&gt;今回のセットアップ方法のおかげで、マウス・キーボードやディスプレイを買わなくて済みました。
浮いた金で食う飯はウマい！&lt;/p&gt;
&lt;h2&gt;参考&lt;/h2&gt;
&lt;p&gt;https://www.raspberrypi.com/documentation/computers/configuration.html#setting-up-a-headless-raspberry-pi&lt;/p&gt;
&lt;p&gt;https://www.raspberrypi.com/documentation/computers/configuration.html#the-boot-folder&lt;/p&gt;
&lt;p&gt;https://www.raspberrypi.com/news/a-security-update-for-raspbian-pixel/&lt;/p&gt;
</content:encoded></item><item><title>MAUIでネイティブビューをカスタマイズする色々な方法</title><description>クロスプラットフォーム開発なのであれば、極力プラットフォームコードは書かずに済ませたいものですが、発展途上な上にネイティブUIをラップしているMAUIの場合、 クロスプラットフォームAPIのみで複雑な要件を満たすことは難しいです。ここで、ネイティブビューをカスタマイズする方法のうち有用なものをまとめてみます。</description><content:encoded>&lt;p&gt;クロスプラットフォーム開発なのであれば、極力プラットフォームコードは書かずに済ませたいものですが、発展途上な上にネイティブUIをラップしているMAUIの場合、クロスプラットフォームAPIのみで複雑な要件を満たすことは難しいです。ここで、ネイティブビューをカスタマイズする方法のうち有用なものをまとめてみます。&lt;/p&gt;
&lt;p&gt;&amp;lt;TOC headings={getHeadings()} /&amp;gt;&lt;/p&gt;
&lt;h2&gt;ビューから直接ネイティブビューにアクセスする&lt;/h2&gt;
&lt;p&gt;クロスプラットフォームビューからview.Handler.PlatformView、もしくはToPlatformでネイティブビューに直接アクセスしてカスタマイズする方法です。カスタムハンドラーやマッパー追加がクソ面倒な時に使えます。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;    public static void Open(this DatePicker datePicker)
    {
        // コードで日付選択画面を表示するメソッドが提供されていないので、ネイティブビューでタップさせる。
#if ANDROID
        var handler = datePicker.Handler as IDatePickerHandler;
        handler.PlatformView?.PerformClick();
        //var platformView = datePicker.ToPlatform(datePicker.Handler.MauiContext);
        //platformView?.PerformClick();
#elif IOS

#endif
    }

    public static void Open(this TimePicker timePicker)
    {
#if ANDROID
        var handler = timePicker.Handler as ITimePickerHandler;
        handler.PlatformView?.PerformClick();
#elif IOS

#endif
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;br /&gt;
手軽に特定のビューに対してカスタマイズを施せるので、直感的であることが利点でしょうか。高度なカスタマイズが必要なく、カスタムコントロールとして扱う必要もないのであればこれで十分です。再利用性が低い場合は上記のようにプリプロセッサディレクティブで殴り書きし、再利用性が高い場合にはヘルパーやユーティリティ、拡張メソッド等でまとめるか、プラットフォーム別の実装を抽象化したインターフェースをクロスプラットフォーム側で呼ぶ形にするといいです。&lt;/p&gt;
&lt;p&gt;ちなみに、この方法ではビューのレンダリング後（画面に表示された後）でないと動かないので注意が必要です。view.Handlerはレンダリング後でないとnull参照でアクセスできませんし、ToPlatformで取得したネイティブビューに変更を加えてもレンダリング時にMAUIのデフォルトハンドラーやバインディングの影響を受けてしまうので、うまく動作しません。あくまでレンダリング後のビューに対して簡単なカスタマイズを施すためのものと割り切り、多用は避けるべきです。&lt;/p&gt;
&lt;h2&gt;エフェクトを使用する&lt;/h2&gt;
&lt;p&gt;カスタムハンドラーを作成せずに、簡易的にネイティブビューをカスタマイズする方法です。下記ではTimePickerで0時0分の時のみ、テキストを非表示にしています。&lt;/p&gt;
&lt;h4&gt;実装クラス&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;//PlatformEffectから取得できるControlはネイティブのUI要素、
//Containerはそれを含むコンテナ要素（親ビューであり、AndroidではFrameLayoutやViewGroup等、iOSではUIView等が該当）を表す。

public class TimePickerNotVisibleTimeTextRoutingEffect : RoutingEffect
{
    
}

#if ANDROID
public partial class TimePickerNotVisibleTimeTextPlatformEffect : PlatformEffect
{
    EditText editText;
    Microsoft.Maui.Controls.TimePicker timePicker;
    Android.Graphics.Color originalTextColor;

    protected override void OnAttached()
    {
        // TimePickerのAndroidネイティブビューはEditTextらしい。タップイベントでTimePickerDialogを表示し、結果を
        // EditTextに入力している。
        editText = Control as Android.Widget.EditText;
        originalTextColor = new Android.Graphics.Color(editText.CurrentTextColor);
        timePicker = Element as Microsoft.Maui.Controls.TimePicker;
        if(editText is null || timePicker is null)
        {
            return;
        }

        UpdateTimeTextVisibility();
        editText.AfterTextChanged += EditText_AfterTextChanged;
    }

    protected override void OnDetached()
    {
        if(editText != null)
        {
            editText.AfterTextChanged -= EditText_AfterTextChanged;
            editText.Visibility = ViewStates.Visible;
        }
    }

    void EditText_AfterTextChanged(object? sender, Android.Text.AfterTextChangedEventArgs e)
    {
        UpdateTimeTextVisibility();
    }

    void UpdateTimeTextVisibility()
    {
        if(timePicker.Time.TotalHours == 0 &amp;amp;&amp;amp; timePicker.Time.TotalMinutes == 0)
        {
            editText.SetTextColor(Android.Graphics.Color.Transparent);
        }
        else
        {
            editText.SetTextColor(originalTextColor);
        }
    }
}

#elif IOS
public partial class TimePickerNotVisibleTimeTextPlatformEffect : PlatformEffect
{
    protected override void OnAttached()
    {

    }

    protected override void OnDetached()
    {

    }
}

#endif
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;エフェクトの登録&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;// MauiProgram.cs
var builder = MauiApp.CreateBuilder();
builder
    .UseMauiApp&amp;lt;App&amp;gt;()
    // 他なんか色々設定する
    .ConfigureEffects(effects =&amp;gt;
    {
        effects.Add&amp;lt;TimePickerNotVisibleTimeTextRoutingEffect&amp;gt;();
    });
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;エフェクトの適用&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;var timePicker = new TimePicker().Effects(new TimePickerNotVisibleTimeTextRoutingEffect());
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;br /&gt;
OnDetachedメソッドはビューが破棄されるタイミングで実行されるので、明示的にview.Effects.Clear()を呼ぶ必要はありません。エフェクトの登録と適用が若干面倒なのは否めませんが、デフォルトハンドラーの上書きや完全な新しいカスタムコントロールの作成で複雑化するリスクが無く、コードを疎結合に保てます。&lt;/p&gt;
&lt;p&gt;エフェクトの実装において、プラットフォーム別にファイルを分けたい場合は、フォルダ名やファイル名で区別することでターゲット別にコンパイル対象を制御できます。（参考：&lt;a href=&quot;https://learn.microsoft.com/ja-jp/dotnet/maui/platform-integration/configure-multi-targeting?view=net-maui-9.0&quot;&gt;マルチターゲットの構成&lt;/a&gt;）
私は保守性が損なわれないのであれば、あちこちファイル移動するほうが嫌いなので1ファイルにまとめます。&lt;/p&gt;
&lt;p&gt;冒頭で簡易的なカスタマイズと言いましたが、クラスメンバーやメソッドを追加して色々できる柔軟性のおかげで割と積極的に使っていけます。カスタムコントロールを作成するまでもなく、デフォルトのハンドラーをいじりたくない場合に有用です。&lt;/p&gt;
&lt;h2&gt;デフォルトハンドラーにマッパーをあてる&lt;/h2&gt;
&lt;p&gt;デフォルトハンドラーのマッパーにカスタムロジックを追加する方法です。&lt;/p&gt;
&lt;p&gt;&amp;lt;details open&amp;gt;
&amp;lt;summary&amp;gt;ハンドラーとマッパーについて&amp;lt;/summary&amp;gt;
MAUIでは、ButtonやEntryをAndroidやiOS等のプラットフォームを意識せずに使用することができますが、Flutterのような独自コントロールを用いているわけではなく、IButtonやIEntryといったインタフェース（仮想ビュー）を介して各プラットフォーム固有のネイティブビューにアクセスしているんですよね。この仮想ビューとネイティブビューとのマッピングを行っているのがハンドラーです。ハンドラーでは、ネイティブビューのインスタンスを生成して仮想ビューに割り当てたり、MAUIのコントロールAPIをネイティブビューAPIにマッピングしたりなど、MAUIコントロールとネイティブビューとの橋渡しを担っています。&lt;/p&gt;
&lt;p&gt;MAUIコントロールの変更を、実際のネイティブビューに反映させる処理を保持しているのがマッパーです。ハンドラーはマッパーを保持しており、マッパーをカスタマイズすることで、MAUIコントロールで独自の機能や振る舞いを追加することができます。ネイティブビューのプロパティ変更はプロパティマッパー、ネイティブビューのコマンド変更はコマンドマッパーで設定可能です。&lt;/p&gt;
&lt;p&gt;ちなみに、Xamarin時代のカスタムレンダラーはMAUIに移植可能ですが、ハンドラーを差し置いて使う理由が無いのでここでは取り上げません。&lt;/p&gt;
&lt;p&gt;&lt;br /&gt;
ハンドラーやマッパーのより詳しい解説は下記のドキュメントと記事がとても参考になります。&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://learn.microsoft.com/en-us/dotnet/maui/user-interface/handlers/?view=net-maui-9.0&quot;&gt;.NET MAUI ハンドラー&lt;/a&gt;&lt;br /&gt;
&lt;a href=&quot;https://zenn.dev/microsoft/articles/maui-custom-control?redirected=1&quot;&gt;MAUI で独自のコントロールを作る方法のメモ&lt;/a&gt;&lt;br /&gt;
&lt;a href=&quot;https://zenn.dev/muak/articles/876fa1d62a3f40&quot;&gt;MAUIのカスタムコントロールの所感&lt;/a&gt;&lt;br /&gt;
&lt;a href=&quot;https://qiita.com/kokada420/items/13e398dd98eeb3f1684e&quot;&gt;MAUIのHandlerってどう使うの&lt;/a&gt;&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&amp;lt;/details&amp;gt;&lt;/p&gt;
&lt;h3&gt;プロパティマッパー&lt;/h3&gt;
&lt;p&gt;（&lt;a href=&quot;https://learn.microsoft.com/ja-jp/dotnet/maui/user-interface/handlers/customize?view=net-maui-8.0&quot;&gt;公式のサンプルコード&lt;/a&gt;）&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;    Microsoft.Maui.Handlers.EntryHandler.Mapper.AppendToMapping(&quot;MyCustomization&quot;, (handler, view) =&amp;gt;
    {
#if ANDROID
        handler.PlatformView.SetSelectAllOnFocus(true);
#elif IOS || MACCATALYST
        handler.PlatformView.EditingDidBegin += (s, e) =&amp;gt;
        {
            handler.PlatformView.PerformSelector(new ObjCRuntime.Selector(&quot;selectAll&quot;), null, 0.0f);
        };
#elif WINDOWS
        handler.PlatformView.GotFocus += (s, e) =&amp;gt;
        {
            handler.PlatformView.SelectAll();
        };
#endif
    });
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;コマンドマッパー&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;EntryHandler.CommandMapper.AppendToMapping(&quot;Focus&quot;, (handler, view, args) =&amp;gt;
{
    Log.Debug(&quot;Entry&quot;, &quot;Entryにフォーカスが!!!&quot;);
});
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;添付プロパティで制御したい場合&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;EntryHandler.Mapper.AppendToMapping(&quot;NoUnderline&quot;, (handler, view) =&amp;gt;
{
    // フラグが設定されていたら、Entryの下線を非表示にする
    if(handler?.PlatformView != null &amp;amp;&amp;amp; !EntryAttached.GetIsVisibleUnderLine(view as Entry))
    {
        handler.PlatformView.BackgroundTintList = Android.Content.Res.ColorStateList.ValueOf(Android.Graphics.Color.Transparent);
    }
});

public static partial class EntryAttached
{
    public static readonly BindableProperty IsVisibleUnderLineProperty = BindableProperty.CreateAttached(&quot;IsVisibleUnderLine&quot;, typeof(bool), typeof(EntryAttached), false);

    public static bool GetIsVisibleUnderLine(BindableObject view)
    {
        return (bool)view.GetValue(IsVisibleUnderLineProperty);
    }

    public static void SetIsVisibleUnderLine(BindableObject view, bool isVisibleUnderline)
    {
        view.SetValue(IsVisibleUnderLineProperty, isVisibleUnderline);
    }
}

// 初期化時にフラグを設定する
var entry = new Entry();
entry.SetValue(EntryAttached.IsVisibleUnderLineProperty, true);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;br /&gt;
カスタムハンドラーを作成せずに特定のプロパティやコマンドを拡張できます。カスタマイズをアプリケーション全体で適用する場合に実装が楽になるので有用ですが、適用する条件が必要になったり等、複雑化するようであれば、素直にエフェクトかカスタムハンドラーを作成したほうがいいです。コードで示したように、staticメンバーや&lt;a href=&quot;https://learn.microsoft.com/ja-jp/dotnet/maui/fundamentals/attached-properties?view=net-maui-9.0&quot;&gt;添付プロパティ&lt;/a&gt;（レンダリング以前の添付プロパティの変更が反映されているため、制御可能）で特定の条件に基づいてカスタマイズを適用できますが、条件定義とロジックの適用が分離してしまいます。&lt;/p&gt;
&lt;p&gt;ここでは、デフォルトのマッピングの後にカスタムロジックを追加するAppendToMappingを使用しましたが、デフォルトのマッピング前に追加したり、デフォルトのマッピングを上書きすることも可能です。↓&lt;br /&gt;
&lt;a href=&quot;https://learn.microsoft.com/ja-jp/dotnet/maui/user-interface/handlers/customize?view=net-maui-9.0&quot;&gt;ハンドラーを使用してコントロールをカスタマイズする&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;カスタムハンドラーを作成する&lt;/h2&gt;
&lt;p&gt;カスタムハンドラーを作成して、デフォルトのビューもしくは完全に新しいカスタムコントロールに適用する方法です。ここでは、Entryの下線を非表示にするカスタムハンドラーでデフォルトハンドラーを上書きするアプローチと、長押しイベントとアニメーション表示に対応したLongPressBoxViewの作成を例に挙げます。&lt;/p&gt;
&lt;h3&gt;デフォルトハンドラーを継承して作成する&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;public partial class BaseEntryHandler : EntryHandler
{
    static readonly string IsVisibleUnderLine = &quot;IsVisibleUnderLine&quot;;
    public static readonly BindableProperty IsVisibleUnderLineProperty = BindableProperty.CreateAttached(IsVisibleUnderLine, typeof(bool), typeof(BaseEntryHandler), false);
    public static bool GetIsVisibleUnderLine(BindableObject view)
    {
        return (bool)view.GetValue(IsVisibleUnderLineProperty);
    }

    public static void SetIsVisibleUnderLine(BindableObject view, bool isVisibleUnderline)
    {
        view.SetValue(IsVisibleUnderLineProperty, isVisibleUnderline);
    }
}

#if ANDROID
public partial class BaseEntryHandler : EntryHandler
{
    protected override void ConnectHandler(AppCompatEditText platformView)
    {
        base.ConnectHandler(platformView);
        // フラグが設定されていたら、Entryの下線を非表示にする
        if(!GetIsVisibleUnderLine(VirtualView as BindableObject))
        {
            platformView.BackgroundTintList = Android.Content.Res.ColorStateList.ValueOf(Android.Graphics.Color.Transparent);
        }
    }
}

#elif IOS
public partial class BaseEntryHandler : EntryHandler
{

}
#endif

var builder = MauiApp.CreateBuilder();
builder
    .UseMauiApp&amp;lt;App&amp;gt;()
    // 他なんか色々設定する
    .ConfigureMauiHandlers(handlers =&amp;gt;
    {
        // Entryのデフォルトハンドラーを上書きする
        handlers.AddHandler&amp;lt;Entry, BaseEntryHandler&amp;gt;();
    });
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;新しいハンドラーを作成してカスタムコントロールに適用する&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;// 長押しイベントとアニメーションに対応したBoxView
public class LongPressBoxView : View
{
    public static readonly BindableProperty BoxColorProperty = BindableProperty.Create(nameof(BoxColor), typeof(Color), typeof(LongPressBoxView), Colors.Blue);

    public Color BoxColor
    {
        get =&amp;gt; (Color)GetValue(BoxColorProperty);
        set =&amp;gt; SetValue(BoxColorProperty, value);
    }

    public event EventHandler? LongPressed;

    internal void RaiseLongPressed()
    {
        LongPressed?.Invoke(this, EventArgs.Empty);
    }
}

public partial class LongPressBoxViewHandler : ViewHandler&amp;lt;LongPressBoxView, Android.Views.View&amp;gt;
{
    public LongPressBoxViewHandler() : base(Mapper)
    {
        
    }

    // Viewのデフォルトマッパーを継承して、BoxColorプロパティのマッピングを追加
    readonly static PropertyMapper&amp;lt;LongPressBoxView, LongPressBoxViewHandler&amp;gt; Mapper =
        new PropertyMapper&amp;lt;LongPressBoxView, LongPressBoxViewHandler&amp;gt;(ViewHandler.ViewMapper)
        {
            [nameof(LongPressBoxView.BoxColor)] = MapBoxColor
        };

    public static void MapBoxColor(LongPressBoxViewHandler handler, LongPressBoxView view)
    {
        handler.UpdateBoxColor(view.BoxColor);
    }

    partial void UpdateBoxColor(Color color);
}

#if ANDROID
public partial class LongPressBoxViewHandler
{
    Android.Views.View? nativeView;

    protected override Android.Views.View CreatePlatformView()
    {
        nativeView = new Android.Views.View(Context);
        nativeView.SetBackgroundColor(this.VirtualView.BoxColor.ToPlatform());
        nativeView.SetOnLongClickListener(new LongClickListener(this.VirtualView, nativeView));
        return nativeView;
    }

    partial void UpdateBoxColor(Color color)
    {
        nativeView?.SetBackgroundColor(color.ToPlatform());
    }

    class LongClickListener(LongPressBoxView virtualView, Android.Views.View nativeView) : Java.Lang.Object, Android.Views.View.IOnLongClickListener
    {
        readonly LongPressBoxView virtualView = virtualView;
        readonly Android.Views.View nativeView = nativeView;

        public bool OnLongClick(Android.Views.View? v)
        {
            // アニメーション（拡大→元に戻す）
            var scaleUp = ObjectAnimator.OfPropertyValuesHolder(nativeView, PropertyValuesHolder.OfFloat(&quot;scaleX&quot;, 1.0f, 1.2f), PropertyValuesHolder.OfFloat(&quot;scaleY&quot;, 1.0f, 1.2f));
            scaleUp.SetDuration(100);

            var scaleDown = ObjectAnimator.OfPropertyValuesHolder(nativeView,PropertyValuesHolder.OfFloat(&quot;scaleX&quot;, 1.2f, 1.0f), PropertyValuesHolder.OfFloat(&quot;scaleY&quot;, 1.2f, 1.0f));
            scaleDown.SetDuration(100);

            var animatorSet = new AnimatorSet();
            animatorSet.PlaySequentially(scaleUp, scaleDown);
            animatorSet.Start();
            virtualView.RaiseLongPressed();
            return true;
        }
    }
}

#elif IOS

#endif

// MauiProgram.cs
var builder = MauiApp.CreateBuilder();
builder
    .UseMauiApp&amp;lt;App&amp;gt;()
    // 他なんか色々設定する
    .ConfigureMauiHandlers(handlers =&amp;gt;
    {
        // カスタムハンドラーをあてたカスタムコントロールを追加
        handlers.AddHandler&amp;lt;LongPressBoxView, LongPressBoxViewHandler&amp;gt;();
    });

// カスタムコントロールを使用する
new LongPressBoxView().HorizontalFill().Height(400).Invoke(x =&amp;gt;
{
    x.BoxColor = Colors.Red;
    x.LongPressed += (s,e) =&amp;gt; System.Diagnostics.Debug.WriteLine(&quot;長押し!!!&quot;);
}),

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;br /&gt;
MAUIにおけるネイティブビューカスタマイズの中では実装が複雑ですが、その分柔軟で深いカスタマイズが可能になります。初期化や破棄処理はConnectHandlerやDissconnectHandlerをオーバーライドしてください。ハンドラーを自作する場合でも、マッピングの全てが既存のビューと異なるようなケースは少ないと思います。確実に既存のビューのマッピング設定を継承し、必要に応じて特定のマッピングを追加するなり変更するなりすれば問題は発生しづらいです。&lt;/p&gt;
&lt;p&gt;既存のビューを拡張する形でカスタムロジックを追加したい&lt;br /&gt;
→ デフォルトハンドラーを継承したカスタムハンドラーで上書きする。&lt;/p&gt;
&lt;p&gt;既存のビューと仕様が大きく異なる独自のカスタムコントロールを追加したい&lt;br /&gt;
→ カスタムハンドラーを作成してカスタムコントロールに適用する&lt;/p&gt;
&lt;h2&gt;さいごに&lt;/h2&gt;
&lt;p&gt;そもそもコードを共通化したいからクロスプラットフォームフレームワークを選ぶのに、プラットフォームコードの実装に時間を取られていては本末転倒ですよね。プラットフォームを意識しない実装で複雑さを軽減するためにも、ライフサイクルイベントはMicrosoft.Maui.Controls.Windowクラスのクロスプラットフォームイベントを利用したり、プラットフォームの差異をインタフェースで吸収させる等の工夫が求められます。&lt;/p&gt;
&lt;p&gt;痒い所に手が届かないもどかしさは多少ありますが、C#という強力な言語で大部分のコードを共通化できるのはやっぱり魅力的です。.NET 9まで来て、致命的過ぎるバグも減ってきた※ので少しずつ採用例が増えるといいな..!&lt;/p&gt;
&lt;p&gt;&lt;br /&gt;
&lt;br /&gt;
※ 致命的過ぎるバグが無いとは言っていない...（絶望）&lt;/p&gt;
</content:encoded></item><item><title>CommunityToolkit.MauiのPopupV2に移行する時のメモ</title><description>MAUI標準で提供されているDisplayAlertではなく、コミュニティ主導で開発されているCommunityToolkitの方のポップアップ。 カスタムUIでポップアップを簡単に実装できる利便性の高いもので、最近書き直しされてV2に。</description><content:encoded>&lt;p&gt;MAUI標準で提供されているDisplayAlertではなく、コミュニティ主導で開発されているCommunityToolkitの方のポップアップ。カスタムUIでポップアップを簡単に実装できる利便性の高いもので、最近書き直しされてV2に。&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/CommunityToolkit/Maui/releases/tag/12.0.0&quot;&gt;リリースノート&lt;/a&gt;&lt;br /&gt;
&lt;a href=&quot;https://github.com/CommunityToolkit/Maui/wiki/Migrating-to-Popup-v2&quot;&gt;移行ガイド&lt;/a&gt;&lt;br /&gt;
&lt;a href=&quot;https://learn.microsoft.com/ja-jp/dotnet/communitytoolkit/maui/views/popup&quot;&gt;ドキュメント&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;V2の主な変更点&lt;/h2&gt;
&lt;p&gt;2025/06/18時点のメモなので注意。最新の情報はドキュメントおよびリポジトリを参照。&lt;/p&gt;
&lt;h3&gt;同一インスタンスで複数回表示できるように&lt;/h3&gt;
&lt;p&gt;一度作成したPopupインスタンスを使い回せるようになった。この変更に伴い、SingletonやScoped等のライフサイクルでDI登録して、IPopupServiceから必要なタイミングで呼び出せるように。またIPopupServiceの名前空間はCommunityToolkit.Maui.CoreからCommunityToolkit.Mauiに移動。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;builder.Services.AddTransientPopup&amp;lt;SimplePopup, SimplePopupViewModel&amp;gt;();
builder.Services.AddScopedPopup&amp;lt;AppPopup, AppPopupViewModel&amp;gt;();
builder.Services.AddSingletonPopup&amp;lt;SettingPopup&amp;gt;();

popupService.ShowPopupAsync&amp;lt;SimplePopupViewModel&amp;gt;(Shell.Current,options: null, queryAttributes: null);
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Popupを継承せずに、コンテンツとなるViewを直接渡して表示できるように&lt;/h3&gt;
&lt;p&gt;ポップアップ終了時の結果が不要な場合、CommunityToolkit.Maui.Views.Popupを継承せずに直接Viewを渡して表示することが可能になった。Popup自体はただのContentViewなので、従来通りPopupを継承して渡すことも可能。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public static async Task&amp;lt;IPopupResult&amp;lt;TResult&amp;gt;&amp;gt; ShowPopupAsync&amp;lt;TResult&amp;gt;(this Page page, View view, IPopupOptions? options = null, CancellationToken token = default);
public static async Task&amp;lt;IPopupResult&amp;lt;TResult&amp;gt;&amp;gt; ShowPopupAsync&amp;lt;TResult&amp;gt;(this INavigation navigation, View view, IPopupOptions? options = null, CancellationToken token = default);
public static async Task&amp;lt;IPopupResult&amp;lt;TResult&amp;gt;&amp;gt; ShowPopupAsync&amp;lt;TResult&amp;gt;(this Shell shell, View view, IPopupOptions? options = null, IDictionary&amp;lt;string, object&amp;gt;? shellParameters = null, CancellationToken token = default);
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;IQueryAttributableでパラメータを渡すように&lt;/h3&gt;
&lt;p&gt;ポップアップのビューモデルにパラメータを渡したい場合、従来はonPresenting引数のオーバーロードで設定していた。V2からはShellナビゲーション間での受け渡しと同様に、IQueryAttributableを実装して辞書で渡せるようになった。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// https://github.com/CommunityToolkit/Maui/wiki/Migrating-to-Popup-v2#passing-data-to-a-popup-has-changed
public class NamePopupViewModel : ObservableObject, IQueryAttributable
{
    [ObservableProperty]
    public partial string Name { get; set; } = &quot;&quot;;

    public void ApplyQueryAttributes(IDictionary&amp;lt;string, object&amp;gt; query)
    {
        Name = query[nameof(NamePopupViewModel.Name)] as string;
    }
}

public class MyViewModel : INotifyPropertyChanged
{
    private readonly IPopupService popupService;

    public MyViewModel(IPopupService popupService)
    {
        this.popupService = popupService;
    }

    public async Task DisplayPopup()
    {
        var queryAttributes = new Dictionary&amp;lt;string, object&amp;gt;
        {
            [nameof(NamePopupViewModel.Name)] = &quot;Shaun&quot;
        };

        await this.popupService.ShowPopupAsync&amp;lt;NamePopupViewModel&amp;gt;(
            Shell.Current,
            options: null,
            queryAttributes);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;結果をIPopupResult,IPopupResult&amp;lt;T&amp;gt;で受け取るように&lt;/h3&gt;
&lt;p&gt;object?からIPopupResultに変更されて型安全に。また、従来ではポップアップの外をタップして閉じられたかどうかを判別するにはOnClosedもしくはOnDismissedByTappingOutsideOfPopupメソッドをオーバーライドする必要があったが、V2からはIPopupResultに含まれるようになった。これにより、V2のPopupではOnClosedメソッドがOnClosedイベントに置き換わるなど、APIの変更がある。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public interface IPopupResult&amp;lt;out TResult&amp;gt; : IPopupResult
{
	TResult? Result { get; }
}

public interface IPopupResult
{
	bool WasDismissedByTappingOutsideOfPopup { get; }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;ここで注意が必要なのが、Null非許容型を結果にしている時の挙動である。結果がboolのポップアップを外側タップで終了させた場合、ResultにアクセスするとCommunityToolkit.Maui.PopupResultExceptionが発生する。そのため、結果無しで終了すると想定される場合、先にWasDismissedByTappingOutsideOfPopupをチェックする必要がある。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# タプルが結果のポップアップの場合の例
CommunityToolkit.Maui.PopupResultException:
&apos;Result is null, but PopupResult type, System.ValueTuple`2[System.Boolean,System.String], cannot be converted to null. 
Every time WasDismissedByTappingOutsideOfPopup is True, Result is always null. When using a non-nullable type (e.g. bool) be sure to first check if 
WasDismissedByTappingOutsideOfPopup is False before getting the value of Result&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;https://github.com/CommunityToolkit/Maui/blob/main/src/CommunityToolkit.Maui/Primitives/PopupResult.shared.cs&lt;/p&gt;
&lt;h3&gt;ポップアップの挙動や外観のオプション&lt;/h3&gt;
&lt;p&gt;V1にもあった、CanBeDismissedByTappingOutsideOfPopup（ポップアップの外をタップすることで閉じるようにするフラグ）はPopupクラスのメンバーからPopupOptionsで呼び出し時に設定するように変更。また、このオプションから背景オーバーレイや影、境界線のスタイルを指定可能になった。オプションは呼び出しごとに適用され、ポップアップ終了後は維持されないため、共通の設定を適用したい場合はキャッシュする必要がある。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;this.ShowPopupAsync(new SimplePopup(), new PopupOptions()
{
    CanBeDismissedByTappingOutsideOfPopup = true,
    Shape = new RoundRectangle()
    {
        CornerRadius = new CornerRadius(4),
        Stroke = Colors.Blue,
        StrokeThickness = 4
    },
    PageOverlayColor = Colors.White,
    Shadow = new Shadow()
    {
        Offset = new Point(20,20),
        Radius = 20f,
        Opacity = 0.3f,
    }
});
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;プロパティの置き換え&lt;/h3&gt;
&lt;p&gt;SizeとColorが置き換えられた。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;toolkit:Popup 
    xmlns=&quot;http://schemas.microsoft.com/dotnet/2021/maui&quot;
    xmlns:x=&quot;http://schemas.microsoft.com/winfx/2009/xaml&quot;
    xmlns:toolkit=&quot;http://schemas.microsoft.com/dotnet/2022/maui/toolkit&quot;
    x:Class=&quot;MyProject.SimplePopup&quot;
-   Size=&quot;100,200&quot;
+   WidthRequest=&quot;100&quot;
+   HeightRequest=&quot;200&quot;&amp;gt;
    
&amp;lt;/toolkit:Popup&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;toolkit:Popup 
    xmlns=&quot;http://schemas.microsoft.com/dotnet/2021/maui&quot;
    xmlns:x=&quot;http://schemas.microsoft.com/winfx/2009/xaml&quot;
    xmlns:toolkit=&quot;http://schemas.microsoft.com/dotnet/2022/maui/toolkit&quot;
    x:Class=&quot;MyProject.SimplePopup&quot;
-   Color=&quot;Green&quot;
+   BackgroundColor=&quot;Green&quot;&amp;gt;
    
&amp;lt;/toolkit:Popup&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;方針&lt;/h3&gt;
&lt;p&gt;DI登録とIPopupServiceでのポップアップ表示、IQueryAttributableによるパラメータ受け渡しは密結合を回避するには有用だが、抽象度の高さゆえの認知負荷・管理負荷増大のリスクも生じる。私の環境では、従来通り依存関係の多いポップアップのみDI登録し、パラメータの受け渡しに関してはできる限りインスタンス生成時にコンストラクタで渡す実装を継続する。&lt;/p&gt;
</content:encoded></item><item><title>【C#】ソースジェネレーターでリフレクションを回避する</title><description>MAUIアプリで項目の多い設定画面を実装したのですが、リフレクションでの読み書きが精神衛生上悪かったので、ソースジェネレーターを使って静的コードを生やすことにしました。ソースジェネレーターの環境構築や機能の解説は、先人の方々の記事をご覧ください。</description><content:encoded>&lt;p&gt;MAUIアプリで項目の多い設定画面を実装したのですが、リフレクションでの読み書きが精神衛生上悪かったので、ソースジェネレーターを使って静的コードを生やすことにしました。&lt;/p&gt;
&lt;p&gt;ソースジェネレーターの環境構築や機能の解説は、先人の方々の記事をご覧ください。熟読推奨です。
&lt;br /&gt;
&lt;a href=&quot;https://neue.cc/2022/12/16_IncrementalSourceGenerator.html&quot;&gt;2022年(2024年)のC# Incremental Source Generator開発手法&lt;/a&gt;&lt;br /&gt;
&lt;a href=&quot;https://zenn.dev/nuskey/articles/introduction-to-incremental-source-generator&quot;&gt;【C#】Incremental Source Generator入門&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&amp;lt;TOC headings={getHeadings()} /&amp;gt;&lt;/p&gt;
&lt;h2&gt;背景&lt;/h2&gt;
&lt;p&gt;下記のような設定項目を定義したレコードがあるとします。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public partial record QuickUploadSettings
{
    /// &amp;lt;summary&amp;gt;
    /// アップロード完了後の動作
    /// &amp;lt;para&amp;gt;Return to the list screen after uploading&amp;lt;/para&amp;gt;
    /// &amp;lt;/summary&amp;gt;
    [EnhancedDisplay(&quot;Action after uploading&quot;, &quot;Sets the action after the upload button is pressed.&quot;)]
    [SettingPageItemType(SettingPageItemType.Picker)]
    public required AfterUploadOperation AfterUploadOperation { get; set; }


 // テキスト共有
    const string TextSharing = &quot;Text Sharing&quot;;
    /// &amp;lt;summary&amp;gt;
    /// テキスト共有時にタイトルプロパティに値を設定する
    /// &amp;lt;/summary&amp;gt;
    [EnhancedDisplay(&quot;Set to Title property&quot;, &quot;Title property is set when sharing text.&quot;, GroupName = TextSharing)]
    [SettingPageItemType(SettingPageItemType.Checkbox)]
    public required bool TextToTitle { get; set; }

    /// &amp;lt;summary&amp;gt;
    /// テキストプロパティに値を設定する
    /// &amp;lt;/summary&amp;gt;
    [EnhancedDisplay(&quot;Set to Text property&quot;, /*&quot;Text property is set when sharing text.&quot;,*/ GroupName = TextSharing)]
    [SettingPageItemType(SettingPageItemType.Checkbox)]
    public required bool TextToText { get; set; }

    /// &amp;lt;summary&amp;gt;
    /// URLプロパティに値を設定する
    /// &amp;lt;/summary&amp;gt;
    [EnhancedDisplay(&quot;Set to URL property&quot;, /*&quot;URK property is set when sharing text.&quot;*/ GroupName = TextSharing)]
    [SettingPageItemType(SettingPageItemType.Checkbox)]
    public required bool TextToUrl { get; set; }

// 以下省略。設定項目となるメンバーがたくさんある想定。
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;br /&gt;
&lt;br /&gt;
これをUIに起こすためにバインディング用のモデルクラスに変換し、ObservableCollection&amp;lt;T&amp;gt;に追加します↓&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// UIバインディング用のモデルクラスに変換するために、設定値や属性で定義したメタデータをリフレクションで取得することになる。
internal static void LoadSettings(ObservableCollection&amp;lt;SettingItemGroup&amp;gt; settings, object settingsClass)
{
    var propertyGroups = ReflectionCache.GetProperties(settingsClass.GetType())
        .Select(x =&amp;gt; (property: x, display: x.GetEnhancedDisplay()))
        .Where(x =&amp;gt; x.display.IsVisible)
        .GroupBy(x =&amp;gt; x.display.GroupName);

    bool firstGroup = true;
    foreach(var propertyGroup in propertyGroups)
    {
        var settingItems = propertyGroup.Select(settingsClass, static (x, instance) =&amp;gt; SettingPageItemFactory.Create(x.property, x.display, x.property.GetValue(instance))).ToArray();
        var group = new SettingItemGroup(propertyGroup.Key, settingItems, firstGroup);
        settings.Add(group);
        firstGroup = false;
    }
}

file static class SettingPageItemFactory
{
    public static SettingPageItem Create(PropertyInfo property, EnhancedDisplayAttribute display, object value)
    {
        return property.GetSettingPageItemType() switch
        {
            SettingPageItemType.Checkbox =&amp;gt; new SettingCheckbox(property,display, (bool)value),
            SettingPageItemType.Switch =&amp;gt; new SettingSwitch(property, display, (bool)value),
            SettingPageItemType.Picker =&amp;gt; new SettingPicker(property, display, value),
            SettingPageItemType.Action =&amp;gt; new SettingAction(property, display),
            _ =&amp;gt; throw new NotImplementedException(),
        };
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;br /&gt;
&lt;br /&gt;
設定値を保存するときも読み込み時と同様、リフレクションで設定項目に値をセットします。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;internal static bool SaveSettings(ObservableCollection&amp;lt;SettingItemGroup&amp;gt; settings, object settingsClass)
{
    var allItems = settings.SelectMany(x =&amp;gt; x).ToDictionary(x =&amp;gt; x.SettingPropertyName, x =&amp;gt; x);
    bool isChanged = false;
    foreach(var items in allItems.Values)
    {
        if(!items.EqualsBeforeValue())
        {
            isChanged = true;
        }
    }

    foreach(var property in ReflectionCache.GetProperties(settingsClass.GetType()).Where(x =&amp;gt; x.GetEnhancedDisplay().IsVisible))
    {
        var currentValue = allItems[property.Name].CurentValue;
        property.SetValue(settingsClass, currentValue);
    }

    return isChanged;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;問題点&lt;/h3&gt;
&lt;p&gt;設定項目のメタデータは簡潔に設定したいので属性を使いたいのですが、このような設計では項目数が多くなると必然的にリフレクションで読み書きすることになります。また、設定項目とその設定値をMemberInfoやobjectで扱うことになるので、バインディング用のモデルクラスへ渡す時にダウンキャストが必要になるか、モデルクラスに渡すデータをobjectで保有することになってしまいます。型安全性が失われ、objectを値型でやり取りしようものなら、各所でボックス化しまくってパフォーマンスも悪化します。設定項目が少ないのであれば、この程度のリフレクション・ボックス化コストは無視できますが、規模が大きくなってくるとこの影響が顕著に現れます。&lt;/p&gt;
&lt;h2&gt;改善&lt;/h2&gt;
&lt;p&gt;設定項目の読み込みと保存処理にリフレクションを使用せず、ソースジェネレーターを活用して全てのメンバーの読み書き処理をコンパイル時に生成します。流れとしてはSettingsStoreという属性が付与されたrecordを検出し、リフレクションで設定項目となるメンバーを走査して各メタデータと設定値を取得します。そして、取得した情報をソース生成のためのコンテキスト(GenerationContext)に整形してからGenerateメソッドでコード生成を行う感じです。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[SettingsStore] // コード生成のトリガーとなる属性を付与
public partial record QuickUploadSettings
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;[Generator(LanguageNames.CSharp)]
public sealed class SettingsStoreGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        // ソース生成先の名前空間を取得
        var namespaceProvider = context.AnalyzerConfigOptionsProvider.Select((options, _) =&amp;gt;
        {
            options.GlobalOptions.TryGetValue(&quot;build_property.RootNamespace&quot;, out var rootNamespace);
            return rootNamespace ?? string.Empty;
        });

        // SettingsStoreAttributeが付与されたrecordを取得してGenerationContextに整形
        var settingsSyntax = context.SyntaxProvider.ForAttributeWithMetadataName(&quot;SampleApp.Core.SettingsStoreAttribute&quot;,
            predicate: static (node, _) =&amp;gt; node is RecordDeclarationSyntax,
            transform: static (ctx, _) =&amp;gt; Transform(ctx));

        var combined = namespaceProvider.Combine(settingsSyntax.Collect());
        context.RegisterSourceOutput(combined, (context, source) =&amp;gt; Generate(context, source));
    }

    static GenerationContext Transform(GeneratorAttributeSyntaxContext ctx)
    {
        // recordのメンバーを走査して、設定項目一覧のシンボルを取得
        var symbol = (INamedTypeSymbol)ctx.TargetSymbol;
        var members = symbol.GetMembers()
            .Where(x =&amp;gt; 
                x.DeclaredAccessibility is Accessibility.Public &amp;amp;&amp;amp;
                x.Kind is SymbolKind.Property &amp;amp;&amp;amp; 
                !x.IsStatic &amp;amp;&amp;amp; 
                !x.IsImplicitlyDeclared) // recordで暗黙的に生成されるEqualityContractプロパティを除外
            .ToList();

        // 各メンバーの属性情報から必要なメタデータを取得
        var settingItemContexts = new List&amp;lt;SettingItem&amp;gt;(members.Count);
        foreach(var member in members)
        {
            var propertySymbol = member as IPropertySymbol;
            var propertyType = propertySymbol.Type;
            var enhancedDisplayAttribute = member.GetAttributes().FirstOrDefault(x =&amp;gt; x.AttributeClass?.ToDisplayString() == &quot;SampleApp.Core.EnhancedDisplayAttribute&quot;);
            var itemTypeAttribute = member.GetAttributes().FirstOrDefault(x =&amp;gt; x.AttributeClass?.ToDisplayString() == &quot;SampleApp.Core.SettingPageItemTypeAttribute&quot;);
            var itemTypeArg = itemTypeAttribute.ConstructorArguments[0];
            var itemTypeName = itemTypeArg.Type.GetMembers().OfType&amp;lt;IFieldSymbol&amp;gt;().FirstOrDefault(f =&amp;gt; f.HasConstantValue &amp;amp;&amp;amp; Equals(f.ConstantValue, itemTypeArg.Value))?.Name;
            var displayConstructorArgs = enhancedDisplayAttribute.ConstructorArguments;
            var displayNamedArgs = enhancedDisplayAttribute.NamedArguments;
            const int KnownColorBlack = 35; // System.Drawing.KnownColor.Black
            bool isVisible = displayNamedArgs.FirstOrDefault(x =&amp;gt; x.Key == &quot;IsVisible&quot;).Value.Value is bool visible ? visible : true;
            if(!isVisible)
                continue;

            settingItemContexts.Add(new SettingItem()
            {
                PropertyName = member.Name,
                SettingPageItemClass = IsEnumType(propertyType) &amp;amp;&amp;amp; itemTypeName == &quot;Picker&quot; ? $&quot;Setting{itemTypeName}&amp;lt;{propertyType.ToDisplayString()}&amp;gt;&quot; : $&quot;Setting{itemTypeName}&quot;,
                IsAction = itemTypeName == &quot;Action&quot;,
                Title = displayConstructorArgs[0].Value?.ToString(),
                Description = displayConstructorArgs[1].Value?.ToString(),
                GroupName = displayNamedArgs.FirstOrDefault(x =&amp;gt; x.Key == &quot;GroupName&quot;).Value.Value?.ToString(),
                DescriptionKnownColorInt = Convert.ToInt32(displayNamedArgs.FirstOrDefault(x =&amp;gt; x.Key == &quot;DescriptionKnownColor&quot;).Value.Value ?? KnownColorBlack),
                IsDescriptionBold = displayNamedArgs.FirstOrDefault(x =&amp;gt; x.Key == &quot;IsDescriptionBold&quot;).Value.Value is bool visible2 ? visible2 : false,
            });
        }

        // 設定画面表示用に、GroupNameでグループ化
        var settingItemGroups = settingItemContexts
            .GroupBy(x =&amp;gt; x.GroupName)
            .Select(g =&amp;gt; new SettingItemGroup()
            {
                GroupName = g.Key,
                SettingItems = new EquatableArray&amp;lt;SettingItem&amp;gt;(g.ToArray()),
            })
            .ToArray();

        // GenerationContextに整形して返す
        return new GenerationContext()
        {
            ClassName = symbol.Name,
            SettingItemGroups = settingItemGroups,
        };
    }

    static void Generate(SourceProductionContext context, (string namespaceName, ImmutableArray&amp;lt;GenerationContext&amp;gt; generationContexts) source)
    {
        (string namespaceName, ImmutableArray&amp;lt;GenerationContext&amp;gt; generationContexts) = source;
        // 各グループ・設定項目からコード生成
        foreach(var generationContext in generationContexts)
        {
            var className = generationContext.ClassName;
            var classVariableName = char.ToLowerInvariant(className[0]) + className.Substring(1);

            var sb = new StringBuilder();
            sb.AppendLine($$&quot;&quot;&quot;
// &amp;lt;auto-generated /&amp;gt;
using System.Collections.ObjectModel;

namespace {{namespaceName}};

public static partial class {{className}}Store
{
    public static void Load(ObservableCollection&amp;lt;SettingItemGroup&amp;gt; settings, {{className}} {{classVariableName}})
    {
&quot;&quot;&quot;);
            bool firstItemGroup = true;
            foreach(var group in generationContext.SettingItemGroups)
            {
                sb.AppendLine($$&quot;&quot;&quot;
        settings.Add(new SettingItemGroup(&quot;{{group.GroupName}}&quot;, new SettingPageItem[]
        {
&quot;&quot;&quot;);
                foreach(var item in group.SettingItems)
                {
                    var constructorArg = item.IsAction ? string.Empty : $&quot;{classVariableName}.{item.PropertyName}&quot;;
                    sb.AppendLine($$&quot;&quot;&quot;
            new {{item.SettingPageItemClass}}({{constructorArg}})
            {
                SettingPropertyName = nameof({{className}}.{{item.PropertyName}}),
                Title = &quot;{{item.Title}}&quot;,
                Description = &quot;{{item.Description}}&quot;,
                DescriptionKnownColor = (global::System.Drawing.KnownColor){{item.DescriptionKnownColorInt}},
                IsDescriptionBold = {{item.IsDescriptionBold.ToString().ToLower()}},
            },
&quot;&quot;&quot;);
                }

                sb.AppendLine($$&quot;&quot;&quot;
        },firstItemGroup: {{(firstItemGroup ? &quot;true&quot; : &quot;false&quot;)}}));
&quot;&quot;&quot;);
                firstItemGroup = false;
            }

            sb.AppendLine($$&quot;&quot;&quot;
    }

    public static bool Save(ObservableCollection&amp;lt;SettingItemGroup&amp;gt; settings, {{className}} {{classVariableName}})
    {
        var allItems = settings.SelectMany(x =&amp;gt; x).ToDictionary(x =&amp;gt; x.SettingPropertyName, x =&amp;gt; x);
        bool isChanged = false;
        foreach(var item in allItems.Values)
        {
            if(!item.EqualsBeforeValue())
            {
                isChanged = true;
            }
        }
&quot;&quot;&quot;);
            var allSettings = generationContext.SettingItemGroups.SelectMany(x =&amp;gt; x.SettingItems).ToList();
            foreach(var item in allSettings.Where(x =&amp;gt; !x.IsAction))
            {
                sb.AppendLine($$&quot;&quot;&quot;
        {{classVariableName}}.{{item.PropertyName}} = (allItems[nameof({{className}}.{{item.PropertyName}})] as {{item.SettingPageItemClass}}).GetCurrentValue();
&quot;&quot;&quot;);
            }

            sb.AppendLine($$&quot;&quot;&quot;
        return isChanged;
    }

    public static void EnterValue(ObservableCollection&amp;lt;SettingItemGroup&amp;gt; settings, {{className}} {{classVariableName}})
    {
        var allItems = settings.SelectMany(x =&amp;gt; x).ToDictionary(x =&amp;gt; x.SettingPropertyName, x =&amp;gt; x);
&quot;&quot;&quot;);
            foreach(var item in allSettings.Where(x =&amp;gt; !x.IsAction))
            {
                sb.AppendLine($$&quot;&quot;&quot;
        (allItems[nameof({{className}}.{{item.PropertyName}})] as {{item.SettingPageItemClass}}).EnterValue({{classVariableName}}.{{item.PropertyName}});
&quot;&quot;&quot;);
            }

            sb.AppendLine($$&quot;&quot;&quot;
    }
}
&quot;&quot;&quot;);
            context.AddSource($&quot;{generationContext.ClassName}.g.cs&quot;, SourceText.From(sb.ToString(), Encoding.UTF8));
        }
    }

    static bool IsEnumType(ITypeSymbol typeSymbol)
    {
        if(typeSymbol.TypeKind == TypeKind.Enum)
            return true;

        // Nullable&amp;lt;Enum&amp;gt; 対応
        if(typeSymbol.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T &amp;amp;&amp;amp;
            typeSymbol is INamedTypeSymbol named &amp;amp;&amp;amp;
            named.TypeArguments.Length == 1 &amp;amp;&amp;amp;
            named.TypeArguments[0].TypeKind == TypeKind.Enum)
            return true;

        return false;
    }

    record GenerationContext
    {
        public string ClassName { get; set; }
        public EquatableArray&amp;lt;SettingItemGroup&amp;gt; SettingItemGroups { get; set; }
    }

    record SettingItemGroup
    {
        public string GroupName { get; set; }
        public EquatableArray&amp;lt;SettingItem&amp;gt; SettingItems { get; set; }
    }

    record SettingItem
    {
        public bool IsAction { get; set; }
        public string PropertyName { get; set; }
        public string SettingPageItemClass { get; set; }
        public string Title { get; set; }
        public string Description { get; set; }
        public string GroupName { get; set; }

        public int DescriptionKnownColorInt { get; set; }
        public bool IsDescriptionBold { get; set; }
    }

    readonly struct EquatableArray&amp;lt;T&amp;gt; : IEquatable&amp;lt;EquatableArray&amp;lt;T&amp;gt;&amp;gt;, IEnumerable&amp;lt;T&amp;gt; where T : IEquatable&amp;lt;T&amp;gt;
    {
        readonly T[]? array;
        public EquatableArray() { array = []; }
        public EquatableArray(T[] array) { this.array = array; }
        public static implicit operator EquatableArray&amp;lt;T&amp;gt;(T[] array) =&amp;gt; new EquatableArray&amp;lt;T&amp;gt;(array);
        public int Length =&amp;gt; array?.Length ?? 0;
        public ReadOnlySpan&amp;lt;T&amp;gt; AsSpan() =&amp;gt; array is null ? ReadOnlySpan&amp;lt;T&amp;gt;.Empty : array.AsSpan();
        public ReadOnlySpan&amp;lt;T&amp;gt;.Enumerator GetEnumerator() =&amp;gt; AsSpan().GetEnumerator();
        IEnumerator&amp;lt;T&amp;gt; IEnumerable&amp;lt;T&amp;gt;.GetEnumerator() =&amp;gt; (array ?? []).AsEnumerable().GetEnumerator();
        IEnumerator IEnumerable.GetEnumerator() =&amp;gt; ((IEnumerable)(array ?? [])).GetEnumerator();

        public bool Equals(EquatableArray&amp;lt;T&amp;gt; other)
        {
            var thisArray = array ?? [];
            var otherArray = other.array ?? [];
            if(thisArray.Length != otherArray.Length)
                return false;
            for(int i = 0; i &amp;lt; thisArray.Length; i++)
            {
                if(!thisArray[i].Equals(otherArray[i]))
                    return false;
            }
            return true;
        }

        public override bool Equals(object? obj)
        {
            return obj is EquatableArray&amp;lt;T&amp;gt; other &amp;amp;&amp;amp; Equals(other);
        }

        public override int GetHashCode()
        {
            var currentArray = array ?? [];
            unchecked
            {
                int hashCode = 17;
                foreach(var value in currentArray)
                {
                    hashCode = hashCode * 31 + value.GetHashCode();
                }
                return hashCode;
            }
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;実稼働アプリで動いているコードの抜粋なのでアプリ固有のロジックが含まれていますが、今回のようなユースケースでの実装例として、使えそうなので紹介します。SyntaxProviderを用いて構文木から取得したレコードのメンバー情報と、それに付随するシンボル情報を使って属性にアクセスし、コード生成に必要な値を収集、整形しています。ソースジェネレーターからは出力先のプロジェクトを参照できないので、トリガーにしている属性や特定のクラス、構造体といったユーザー定義型を指したい場合は、SampleApp.Core.EnhancedDisplayAttributeのように完全修飾名で記述する必要があります。&lt;/p&gt;
&lt;p&gt;ここで割と重要なのが、IncrementalValuesProvider&amp;lt;T&amp;gt;およびIncrementalValueProvider&amp;lt;T&amp;gt;として渡すオブジェクト（今回の場合、namespaceProviderの名前空間文字列とGenerationContext）のEqualsが値比較されるようにしておくことです。Incremental Generatorでは、Incremental Generatorのパイプラインに流れるオブジェクトが前回のものと等価であるかをチェックしており、等価の場合に後続のソース生成処理をスキップする仕様になっています。そのため、Equals内で参照比較されていると、当然パイプラインに流れるオブジェクトは毎回異なるものとして判定されるので常にソース生成が走ってしまい、パフォーマンスが悪化します。このことから、生成処理に渡すオブジェクトは全メンバーが値ベースで比較されるrecordがおすすめです。メンバーで持つコレクションもT[]やList&amp;lt;T&amp;gt;とかは当然アウトなので、EquatableArray&amp;lt;T&amp;gt;のような、コレクションを値オブジェクトとして扱える自作ラッパーで包むといいです。また、各オブジェクトが値比較される前提かつ数が少ないのであればValueTuple&amp;lt;T&amp;gt;にしてもいいでしょう。&lt;/p&gt;
&lt;p&gt;属性をソース生成のトリガーにしない場合は、CreateSyntaxProviderで得られるSyntaxTreeをこねくり回すことで対象の構文を取得することが可能ですが、属性でマークされた構文ノードが欲しい場合は、今回のようにForAttributeWithMetadataNameメソッドを使うと楽に取得できます。他にも特定のユースケースで使えそうなプロバイダーがいくつかあるので調べてみてください。&lt;/p&gt;
&lt;p&gt;コード生成部分ではグループ化された設定項目を必要な箇所でループし、StringBuilderでコードを組み立てています。デバッグ時のテキストビジュアライザーや生成結果を確認しながら実装を進めていくことなるので、生文字リテラルを活用してインデントを揃えておくと可読性が向上して良い感じになります。また、ライブラリ開発者以外は必須ではないですが、global::SampleApp.Generators.SampleAttributesのように、globalエイリアスを用いることで予期しない名前空間の衝突を避けられます。&lt;/p&gt;
&lt;h2&gt;生成結果&lt;/h2&gt;
&lt;p&gt;このSettingsStoreGeneratorによって生成されたコードが下記になります。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// &amp;lt;auto-generated /&amp;gt;
using System.Collections.ObjectModel;

namespace SampleApp.Core;

public static partial class QuickUploadSettingsStore
{
    public static void Load(ObservableCollection&amp;lt;SettingItemGroup&amp;gt; settings, QuickUploadSettings quickUploadSettings)
    {
        settings.Add(new SettingItemGroup(&quot;&quot;, new SettingPageItem[]
        {
            new SettingPicker&amp;lt;SampleApp.Core.AfterUploadOperation&amp;gt;(quickUploadSettings.AfterUploadOperation)
            {
                SettingPropertyName = nameof(QuickUploadSettings.AfterUploadOperation),
                Title = &quot;Action after uploading&quot;,
                Description = &quot;Sets the action after the upload button is pressed.&quot;,
                DescriptionKnownColor = (global::System.Drawing.KnownColor)35,
                IsDescriptionBold = false,
            },
        },firstItemGroup: true));
        settings.Add(new SettingItemGroup(&quot;Text Sharing&quot;, new SettingPageItem[]
        {
            new SettingCheckbox(quickUploadSettings.TextToTitle)
            {
                SettingPropertyName = nameof(QuickUploadSettings.TextToTitle),
                Title = &quot;Set to Title property&quot;,
                Description = &quot;Title property is set when sharing text.&quot;,
                DescriptionKnownColor = (global::System.Drawing.KnownColor)35,
                IsDescriptionBold = false,
            },
            new SettingCheckbox(quickUploadSettings.TextToText)
            {
                SettingPropertyName = nameof(QuickUploadSettings.TextToText),
                Title = &quot;Set to Text property&quot;,
                Description = &quot;&quot;,
                DescriptionKnownColor = (global::System.Drawing.KnownColor)35,
                IsDescriptionBold = false,
            },
            // 以下省略
        },firstItemGroup: false));

        // 以下省略
    }

    public static bool Save(ObservableCollection&amp;lt;SettingItemGroup&amp;gt; settings, QuickUploadSettings quickUploadSettings)
    {
        var allItems = settings.SelectMany(x =&amp;gt; x).ToDictionary(x =&amp;gt; x.SettingPropertyName, x =&amp;gt; x);
        bool isChanged = false;
        foreach(var item in allItems.Values)
        {
            if(!item.EqualsBeforeValue())
            {
                isChanged = true;
            }
        }

        // 一部省略
        quickUploadSettings.AfterUploadOperation = (allItems[nameof(QuickUploadSettings.AfterUploadOperation)] as SettingPicker&amp;lt;SampleApp.Core.AfterUploadOperation&amp;gt;).GetCurrentValue();
        quickUploadSettings.TextToTitle = (allItems[nameof(QuickUploadSettings.TextToTitle)] as SettingCheckbox).GetCurrentValue();
        quickUploadSettings.TextToText = (allItems[nameof(QuickUploadSettings.TextToText)] as SettingCheckbox).GetCurrentValue();
        quickUploadSettings.UrlOrWebToTitle = (allItems[nameof(QuickUploadSettings.UrlOrWebToTitle)] as SettingCheckbox).GetCurrentValue();
        quickUploadSettings.AddUrlOrWebToBlock = (allItems[nameof(QuickUploadSettings.AddUrlOrWebToBlock)] as SettingPicker&amp;lt;SampleApp.Core.WebPageBlockType&amp;gt;).GetCurrentValue();
        quickUploadSettings.ImageToTitle = (allItems[nameof(QuickUploadSettings.ImageToTitle)] as SettingCheckbox).GetCurrentValue();
        quickUploadSettings.AddImageToBlock = (allItems[nameof(QuickUploadSettings.AddImageToBlock)] as SettingCheckbox).GetCurrentValue();
        quickUploadSettings.KindleToBlock = (allItems[nameof(QuickUploadSettings.KindleToBlock)] as SettingPicker&amp;lt;SampleApp.Core.KindleBlockType&amp;gt;).GetCurrentValue();
        return isChanged;
    }

    public static void EnterValue(ObservableCollection&amp;lt;SettingItemGroup&amp;gt; settings, QuickUploadSettings quickUploadSettings)
    {
        var allItems = settings.SelectMany(x =&amp;gt; x).ToDictionary(x =&amp;gt; x.SettingPropertyName, x =&amp;gt; x);
        // 一部省略
        (allItems[nameof(QuickUploadSettings.AfterUploadOperation)] as SettingPicker&amp;lt;SampleApp.Core.AfterUploadOperation&amp;gt;).EnterValue(quickUploadSettings.AfterUploadOperation);
        (allItems[nameof(QuickUploadSettings.TextToTitle)] as SettingCheckbox).EnterValue(quickUploadSettings.TextToTitle);
        (allItems[nameof(QuickUploadSettings.TextToText)] as SettingCheckbox).EnterValue(quickUploadSettings.TextToText);
        (allItems[nameof(QuickUploadSettings.UrlOrWebToTitle)] as SettingCheckbox).EnterValue(quickUploadSettings.UrlOrWebToTitle);
        (allItems[nameof(QuickUploadSettings.AddUrlOrWebToBlock)] as SettingPicker&amp;lt;SampleApp.Core.WebPageBlockType&amp;gt;).EnterValue(quickUploadSettings.AddUrlOrWebToBlock);
        (allItems[nameof(QuickUploadSettings.ImageToTitle)] as SettingCheckbox).EnterValue(quickUploadSettings.ImageToTitle);
        (allItems[nameof(QuickUploadSettings.AddImageToBlock)] as SettingCheckbox).EnterValue(quickUploadSettings.AddImageToBlock);
        (allItems[nameof(QuickUploadSettings.KindleToBlock)] as SettingPicker&amp;lt;SampleApp.Core.KindleBlockType&amp;gt;).EnterValue(quickUploadSettings.KindleToBlock);
    }
}

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;設定クラス（ここではQuickUploadSettings）のメンバーが増減する度にIncremental GeneratorによってQuickUploadSettingsStoreが自動的に更新され、リフレクションを一切使用せずにLoad, Save, EnterValueメソッドで設定項目の読み込みと保存、UIバインディング用モデルクラスへの値設定が行えるようになりました。特にボックス化も発生していたEnterValueの速度が改善したのが大きく、JIT最適化により読み込みと保存も改善（特に2回目以降）しています。このあたりのパフォーマンス差に関しては、可能ならBenchmarkDotNetで正確に検証してみるとより良いと思います。&lt;/p&gt;
&lt;p&gt;全ての設定項目のメンバーの読み書きを静的コード生成できるようになったので、下記のようにUIバインディング用のモデルクラスもobjectを排除して型安全に実装できるようになりました。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public abstract class SettingPageItem : NotifyPropertyChangedBase
{
    public abstract SettingPageItemType ItemType { get; }
    public required string SettingPropertyName { get; init; }
    public required string Title { get; init; }
    public required string Description
    {
        get =&amp;gt; field;
        set =&amp;gt; OnPropertyChanged(ref field, value);
    }
    public abstract bool EqualsBeforeValue();

    // 以下省略
}

public class SettingCheckbox(bool isChecked) : SettingPageItem
{
    public override SettingPageItemType ItemType =&amp;gt; SettingPageItemType.Checkbox;
    bool _isChecked = isChecked;
    public bool IsChecked
    {
        get =&amp;gt; _isChecked;
        set =&amp;gt; OnPropertyChanged(ref _isChecked, value);
    }
    // 以下省略
}

public class SettingPicker&amp;lt;T&amp;gt; : SettingPageItem where T : struct, Enum
{
    public SettingPicker(T selectedItem) 
    {
        var selectedItemName = selectedItem.GetEnhancedDisplayName();
        this.PickerItems = FastEnum.GetValues&amp;lt;T&amp;gt;().Select(x =&amp;gt; x.GetEnhancedDisplayName()).ToArray();
        this.SelectedItem = selectedItemName;
        this.BeforeValue = selectedItemName;
    }

    public override SettingPageItemType ItemType =&amp;gt; SettingPageItemType.Picker;

    public IEnumerable&amp;lt;string&amp;gt; PickerItems
    {
        get =&amp;gt; field;
        set =&amp;gt; OnPropertyChanged(ref field, value);
    }

    public string SelectedItem
    {
        get =&amp;gt; field;
        set =&amp;gt; OnPropertyChanged(ref field, value);
    }
    // 以下省略
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;br /&gt;
&lt;br /&gt;
最終的に、下記のように設定値をモデルクラスとバインディングさせてUIコントロールを生成しました。XAML嫌いなので100% C#コードで作ってます。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;static View CreateSettingValueControl(SettingPageItem settingItem, Grid parentGrid)
{
    return settingItem.ItemType switch
    {
        SettingPageItemType.Switch =&amp;gt; new Switch().Margin(0, 0, 18, 10).IsToggled(nameof(SettingSwitch.IsToggled), BindingMode.TwoWay, settingItem, fallbackValue: false)/*.Invoke(parentGrid, (x, grid) =&amp;gt; grid.TapGesture(x, x =&amp;gt; x.IsToggled = !x.IsToggled))*/,
        SettingPageItemType.Checkbox =&amp;gt; new CheckBox().Margin(0, 0, OperatingSystem.IsAndroid() ? 18 : 24, 10).IsChecked(nameof(SettingCheckbox.IsChecked), BindingMode.TwoWay, settingItem, fallbackValue: false)/*.Invoke(parentGrid, (x, grid) =&amp;gt; grid.TapGesture(x, x =&amp;gt; x.IsChecked = !x.IsChecked))*/,
        SettingPageItemType.Picker =&amp;gt; new Picker().Margin(0, 0, 5, 10).ItemsSource(nameof(SettingPicker&amp;lt;&amp;gt;.PickerItems), settingItem).SelectedItem(nameof(SettingPicker&amp;lt;&amp;gt;.SelectedItem), BindingMode.TwoWay, settingItem)/*.Invoke(parentGrid, (x, grid) =&amp;gt; grid.TapGesture(x, x =&amp;gt; x.Open()))*/,
        SettingPageItemType.Action =&amp;gt; new Label().Margin(0, 0, 23, 0).Icon(MaterialOutlinedIcons.NavigateNext).IconSize(MauiFontSizeHelper.GetSize&amp;lt;Label&amp;gt;(MauiFontSize.Subtitle)).FontBold(),
        _ =&amp;gt; null,
    };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;さいごに&lt;/h2&gt;
&lt;p&gt;一般的なC#開発者が明示的にRoslynに触れる機会はあまり無いので、ソースジェネレーターに忌避感ありそうですが、Incremental Source Generatorに進化してコード生成のパフォーマンスが改善し、便利なAPIが使えるようになっています。生文字リテラル($$&quot;&quot;&quot;...&quot;&quot;&quot;)もC#11から導入されて生成コードのインデントを揃えやすくなり、可読性が向上したので実装の敷居は割と下がってきていると思います。属性ベースで静的コードを生やせるようになると、今回のようなパフォーマンス改善や機能拡張、NativeAOT対応など、幅が広がるので慣れておくと良さそうです。&lt;/p&gt;
&lt;p&gt;ちなみに少し気になって、リフレクションのパフォーマンスをベンチマークしてみたら、.NET7以降かなり速くなっていて、最新の.NET10でも改善されていました。最新の.NETを使っているなら、リフレクションコストは過度に心配しなくていいかも？言語機能より、MAUIのボトルネックの方が大きそう..。&lt;/p&gt;
&lt;p&gt;https://github.com/kosaku-hayashi/BenchmarkTest&lt;/p&gt;
</content:encoded></item><item><title>SSRのAstroをv4からv6にアップグレードした時にやったこと</title><description>Astro v4で構築してから今まで放置していたので、最近リリースされたv6まで一気にアップグレードしました。ただAstroバージョンを上げて変更箇所を対応するだけだと思っていたら、v6に上がったことでCloudflareアダプターの更新が必要になり、Vite 7でAstro公式のTailwind統合も使えなくなったので</description><content:encoded>&lt;p&gt;Astro v4で構築してから今まで放置していたので、最近リリースされたv6まで一気にアップグレードしました。ただAstroバージョンを上げて変更箇所を対応するだけだと思っていたら、v6に上がったことでCloudflareアダプターの更新が必要になり、Vite 7でAstro公式のTailwind統合も使えなくなったのでTailwindのViteプラグインに移行し、さらにCloudflare PagesからWorkersへの移行も必要になったりと、思いのほか面倒でした。
アップグレードの手順はドキュメントを見てくださいと言ったらそれまでなので、ここでは私が管理しているプロジェクトで対応が必要だったものをまとめてみます。&lt;/p&gt;
&lt;p&gt;&amp;lt;TOC headings={getHeadings()} /&amp;gt;&lt;/p&gt;
&lt;h2&gt;環境&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Astro v4.16.18（一部SSRページ）&lt;/li&gt;
&lt;li&gt;Tailwind CSS v3.4.17（@astrojs/tailwind）&lt;/li&gt;
&lt;li&gt;@astrojs/cloudflare v11.2.0&lt;/li&gt;
&lt;li&gt;Cloudflare Pages&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Astro v5への更新&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://docs.astro.build/ja/guides/upgrade-to/v5/&quot;&gt;Upgrade to Astro v5&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;大きな変更が無く修正が容易なので、v5への更新はすんなり終わるはずです。&lt;/p&gt;
&lt;h3&gt;hybridレンダリングモードの廃止&lt;/h3&gt;
&lt;p&gt;v4まではSSGとSSRを混在させるためにhybridモードを指定する必要がありましたが、v5からは明示的な指定が不要に。各ページの修正は必要なく、従来通りexport &lt;code&gt;const prerender = false;&lt;/code&gt;を宣言するだけでいいです。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// astro.config.mjs
export default defineConfig({
  site: &quot;https://example.com&quot;,
  output: &apos;hybrid&apos;, // [!code --]
  adapter: cloudflare(),
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;https://docs.astro.build/ja/guides/upgrade-to/v5/#removed-hybrid-rendering-mode&lt;/p&gt;
&lt;h3&gt;コンテンツコレクションをglobローダーで読み込むように&lt;/h3&gt;
&lt;p&gt;コレクション設定ファイルの置き場所が&lt;code&gt;src/content/config.ts&lt;/code&gt;から&lt;code&gt;src/content.config.ts&lt;/code&gt;（src直下）に移動し、&lt;code&gt;type: &apos;content&apos;&lt;/code&gt;の代わりにglobローダーでコレクションを読み込む形に。&lt;code&gt;zod&lt;/code&gt;のインポート元も&lt;code&gt;astro:content&lt;/code&gt;から&lt;code&gt;astro/zod&lt;/code&gt;に変わっています。ファイルベースのローダーになったのでコンテンツを管理するディレクトリ構造の制限が緩和されました。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// src/content/config.ts → src/content.config.ts に移動
import { z, defineCollection } from &apos;astro:content&apos;; // [!code --]
import { defineCollection } from &apos;astro:content&apos;; // [!code ++]
import { glob } from &apos;astro/loaders&apos;; // [!code ++]
import { z } from &apos;astro/zod&apos;; // [!code ++]

const posts = defineCollection({
  type: &apos;content&apos;, // [!code --]
  loader: glob({ pattern: &quot;**/*.mdx&quot;, base: &quot;./src/content/posts&quot; }), // [!code ++]
  schema: ({ image }) =&amp;gt;
    z.object({
      title: z.string(),
      description: z.string(),
      cover: z.union([image(), z.string()]).optional(),
      tags: z.array(z.string()),
      updateDate: z.string().optional(),
      showSideToc: z.boolean(),
      noIndex: z.boolean(),
    }),
});

export const collections = { posts };
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;https://docs.astro.build/ja/guides/upgrade-to/v5/#updating-existing-collections&lt;/p&gt;
&lt;h3&gt;コレクションエントリのslugフィールド消滅 ＆ idフィールドの形式変更&lt;/h3&gt;
&lt;p&gt;Content Layer APIへの移行でエントリのslugフィールドが廃止され、新しい形式のidフィールドに置き換わりました。動的ルーティングの構築だけに使用しているなら単にidに変更するだけでいいのですが、このidはglobローダーbaseからの相対パス（拡張子なし）であり、拡張子ありだった元のidと形式が異なるのでファイルパスとして扱っていた場合は注意が必要です。私のプロジェクトではエントリidを拡張子ありのファイルパスとして処理している部分で影響が出たので、同じく新しく追加されたfilePathフィールドに変更しました。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;export async function getStaticPaths() {
  const posts = await getCollection(&apos;blog&apos;);
  return posts.map((post) =&amp;gt; ({
    params: { slug: post.slug }, // [!code --]
    params: { slug: post.id },   // [!code ++]
    props: post,
  }));
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;render()関数の変更&lt;/h3&gt;
&lt;p&gt;エントリの&lt;code&gt;.render()&lt;/code&gt;メソッドが廃止され、&lt;code&gt;astro:content&lt;/code&gt;から&lt;code&gt;render()&lt;/code&gt;をインポートして呼び出す形になりました。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import { getCollection, type CollectionEntry } from &apos;astro:content&apos;; // [!code --]
import { getCollection, render, type CollectionEntry } from &apos;astro:content&apos;; // [!code ++]

// ...
const { Content } = await note.render(); // [!code --]
const { Content } = await render(note); // [!code ++]
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;tsconfig.jsonの更新&lt;/h3&gt;
&lt;p&gt;型定義の参照先が&lt;code&gt;src/env.d.ts&lt;/code&gt;から、tsconfig.jsonの&lt;code&gt;include&lt;/code&gt;で読む形に変わりました。これが今後のAstro推奨設定となり、後述するような手動での型定義を必要としない場合はもとの&lt;code&gt;src/env.d.ts&lt;/code&gt;は削除しても問題ありません。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// tsconfig.json
{
  &quot;extends&quot;: &quot;astro/tsconfigs/strict&quot;,
  &quot;include&quot;: [&quot;.astro/types.d.ts&quot;, &quot;**/*&quot;], // [!code ++]
  &quot;exclude&quot;: [&quot;dist&quot;], // [!code ++]
  &quot;compilerOptions&quot;: {
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;https://docs.astro.build/ja/guides/upgrade-to/v5/#changed-typescript-configuration &lt;br /&gt;
https://docs.astro.build/ja/guides/typescript/#tsconfig-templates&lt;/p&gt;
&lt;h3&gt;フォントパッケージの型定義追加&lt;/h3&gt;
&lt;p&gt;私のプロジェクトでは型定義を持たないフォントパッケージを使用しており、インポートしている部分で型エラーが出るようになったので明示的に&lt;code&gt;src/env.d.ts&lt;/code&gt;に追加しました。たぶんtsconfig.jsonのincludeに追加した&lt;code&gt;**/*&lt;/code&gt;が犯人。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// src/env.d.ts
declare module &apos;@fontsource-variable/inter&apos;;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Astro v6への更新&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://docs.astro.build/ja/guides/upgrade-to/v6/&quot;&gt;Upgrade to Astro v6&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;実はv6への更新自体は非常に簡単で、ドキュメントを見る限り、v5のコンテンツコレクションの変更をサボらずにやっていれば対応箇所は多くないです。現に私のプロジェクトではv6で対応が必要なものは1つもなく、Astroと付随するアダプターのバージョンを上げるだけで済みました。曲者なのはVite 7への更新に伴う@tailwindcss/viteへの移行と、astrojs/cloudflare v13への更新に伴うCloudflare Pagesの廃止です。つまりはTailwindをv3からv4に、デプロイ先をPagesからWorkersに移行する必要があったということですね。&lt;/p&gt;
&lt;h3&gt;react-dom/serverエイリアスの削除&lt;/h3&gt;
&lt;p&gt;React 19+を使用しているプロジェクトがCloudflareにデプロイできない問題があり、下記の回避策を講じていたのですがv6で修正されていたのでこれを削除しました。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;vite: {
  resolve: { // [!code --]
    // React+19をCFにデプロイ出来ない問題の回避 // [!code --]
    alias: import.meta.env.PROD ? { // [!code --]
      &quot;react-dom/server&quot;: &quot;react-dom/server.edge&quot;, // [!code --]
    } : undefined, // [!code --]
  }, // [!code --]
},
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;https://github.com/withastro/astro/issues/12824&lt;/p&gt;
&lt;h2&gt;@astrojs/tailwindから@tailwindcss/viteへの移行&lt;/h2&gt;
&lt;p&gt;Tailwind CSS v4でライブラリ自体が再設計され、Tailwind公式からVite用プラグイン（@tailwindcss/vite）が提供されるようになったのでAstroの公式統合（@astrojs/tailwind）はAstro v5時点で非推奨に。そしてAstro公式統合がvite 6依存だったので、7に上がったAstro v6でビルドが通らなくなりました。必然的にTailwind v4へ更新することになるので、tailwind.config.jsonや一部ユーティリティクラスの修正が必要です。更新にはマイグレーションツール（&lt;code&gt;npx @tailwindcss/upgrade&lt;/code&gt;）もあるみたいです。&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/withastro/astro/pull/13511&quot;&gt;@astrojs/tailwind廃止&lt;/a&gt; &lt;br /&gt;
&lt;a href=&quot;https://tailwindcss.com/docs/installation/using-vite&quot;&gt;Tailwind CSS v4 Vite用プラグイン&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;@tailwindcss/viteへの移行&lt;/h3&gt;
&lt;p&gt;AstroのintegrationsからTailwindを取り除き、Viteのpluginsに追加します。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import tailwind from &apos;@astrojs/tailwind&apos;; // [!code --]
import tailwindcss from &quot;@tailwindcss/vite&quot;; // [!code ++]

export default defineConfig({
  integrations: [tailwind(), mdx(), react()], // [!code --]
  integrations: [mdx(), react()], // [!code ++]
  vite: {
    plugins: [
      tailwindcss(), // [!code ++]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;ここで下記のエラーが発生。何やらトップレベルのViteとAstroが依存しているViteが競合しており、前者は@astrojs/cloudflareが要求している8.0.9のVite、後者はAstroの7.3.2のViteでした。cloudflareアダプターはViteの6,7,8を許容しているみたいなのでViteを7.xに上書きすることで解決。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;型 &apos;Plugin&amp;lt;any&amp;gt;[]&apos; を型 &apos;PluginOption&apos; に割り当てることはできません。
プロパティ &apos;hotUpdate&apos; の型に互換性がありません。
 型 &apos;import(&quot;.../node_modules/vite/...&quot;)&apos; を
 型 &apos;import(&quot;.../node_modules/astro/node_modules/vite/...&quot;)&apos; に割り当てることはできません
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;&quot;pnpm&quot;: {
  &quot;overrides&quot;: {
    &quot;vite&quot;: &quot;^7.3.1&quot;
  },
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;tailwind.config.jsの廃止とCSSへの移行&lt;/h3&gt;
&lt;p&gt;v4では&lt;code&gt;tailwind.config.js&lt;/code&gt;が不要になり、設定をCSSファイルに直接記述するようになりました。&lt;code&gt;@import &quot;tailwindcss&quot;&lt;/code&gt;でTailwindを取り込んだあと、各設定を&lt;code&gt;@&lt;/code&gt;で始まるディレクティブで追加していきます。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/* src/styles/global.css */
@import &quot;tailwindcss&quot;;

@plugin &quot;@tailwindcss/typography&quot;;

@custom-variant dark (.dark &amp;amp;);

@theme {
  --font-sans: &quot;Inter Variable&quot;, ui-sans-serif, system-ui, sans-serif,
    &quot;Apple Color Emoji&quot;, &quot;Segoe UI Emoji&quot;, &quot;Segoe UI Symbol&quot;, &quot;Noto Color Emoji&quot;;
}

@custom-variant mobile (@media (max-width: 800px));
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;スコープドCSSでの@applyの使用&lt;/h3&gt;
&lt;p&gt;Tailwind v4では、現時点でフレームワーク固有のスタイルブロックやCSSモジュール内での&lt;code&gt;@apply&lt;/code&gt;、その他ユーティリティやバリアントがそのままでは機能しません。これらにアクセスする場合は@referenceで明示的にCSSを参照する必要があります。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;style&amp;gt;
  @reference &quot;../styles/global.css&quot;;
  img {
    @apply max-h-150 object-contain border border-black mobile:max-h-100;
  }
&amp;lt;/style&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;https://tailwindcss.com/docs/upgrade-guide#using-apply-with-vue-svelte-or-css-modules&lt;/p&gt;
&lt;h3&gt;ユーティリティクラスの変更&lt;/h3&gt;
&lt;p&gt;詳しい変更箇所は割愛します。該当箇所で警告が出るはずなので、VSCodeならクイックフィックスでポチポチ変換していくか、AIに適当に変換してもらってください。&lt;/p&gt;
&lt;h2&gt;@astrojs/cloudflare v13への更新&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://docs.astro.build/ja/guides/integrations-guide/cloudflare/#upgrading-to-v13-and-astro-6&quot;&gt;アップグレードガイド&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;CloudflareはAstroのホスティング先として有力な選択肢でしたが、Astroのローカル開発サーバーがNode.jsで動いているのでCloudflareの機能（ダッシュボードの設定やWorkersバインディング等）との相性が悪く、wrangler設定ファイルや.dev.varsファイルが使えないので開発用と本番用でコードを分ける必要があったりと、お世辞にも開発体験が良いものではありませんでした。一応、npx wrangler devコマンドでCloudflare用の設定ファイルを適用してビルド済みの成果物をプレビューすることは出来ましたが、Astroの開発サーバーで動いていないのでこれでテストしたものをそのままデプロイしたら本番で動かないみたいなことが容易に起こり得ました。v13ではAstroの開発サーバーがCloudflareのworkerdランタイム互換になったので、ローカル開発環境と本番環境で同じコードが動くようになり、開発体験が改善されました。&lt;/p&gt;
&lt;h3&gt;Astro.locals.runtime APIの廃止&lt;/h3&gt;
&lt;p&gt;CloudflareのAPIにアクセスする場合はAstro.locals.runtimeを使用していたのですが、v13でCloudflareのAPIがグローバルにアクセスできるようになったので、これを使用する必要がなくなりました。実行コンテキストはAstro.locals.cfContextで取得出来るようになっています。.dev.varsやwrangler.jsoncのvarsに定義した環境変数はwrangler typesコマンドで生成されるworker-configuration.d.tsにEnvインタフェースとして登録されるようになったので、envにインテリセンスが効いて型安全にアクセスできるようになったのが嬉しいところです。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import { env } from &apos;cloudflare:workers&apos;; // [!code ++]

// ...
const customScheme = (Astro.locals as any).runtime.env.AUTHORIZATION_CALLBACK_CUSTOME_SCHEME; // [!code --]
const customScheme = env.AUTHORIZATION_CALLBACK_CUSTOME_SCHEME; // [!code ++]
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Cloudflare PagesからCloudflare Workersへの移行&lt;/h2&gt;
&lt;p&gt;astrojs/cloudflare v13のドキュメントをスクロールしていったら、見過ごせない一文が目に入り、どうやらv13からはCloudflare Pagesのサポートを切るみたいで、Workersへの移行が必要になりました。近年、Workersへの統合を進めていて、今後のPagesのサポートは限定的とのことなので早めの移行が推奨されます。Workersでの開発やビルド、デプロイに関する操作にはwrangler CLIを使用します。Githubと連携してCI/CDを実行してくれるWorkers Buildsを使えばwrangler CLIは必須ではないのですが、CI/CDワークフローが整うまではローカルで操作できたほうが何かと便利なので入れておいたほうがいいです。CLIで認証した後、後述するwrangler設定ファイルを準備してローカルでnpx wrangler deployコマンドを叩けば簡単にデプロイできます。&lt;/p&gt;
&lt;p&gt;https://docs.astro.build/ja/guides/integrations-guide/cloudflare/#removed-cloudflare-pages-support&lt;/p&gt;
&lt;h3&gt;ビルド出力先の変更に伴うコードの修正&lt;/h3&gt;
&lt;p&gt;静的アセットのビルド出力先がdist/clientに変わるので、この変更に影響する部分は網羅的にチェックします。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;writeBundle() {
  // ビルド後にブランチ名付きファイルを削除
  const distWellKnown = path.join(process.cwd(), &apos;dist&apos;, &apos;.well-known&apos;); // [!code --]
  const distWellKnown = path.join(process.cwd(), &apos;dist&apos;, &apos;client&apos;, &apos;.well-known&apos;);// [!code ++]
  if (fs.existsSync(distWellKnown)) {
    const files = fs.readdirSync(distWellKnown);
    files.forEach(/** @param {string} file */ (file) =&amp;gt; {
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;wrangler.jsoncの作成&lt;/h3&gt;
&lt;p&gt;Workersにデプロイするなら必須の設定ファイルです。厳密には、Astroではmain,compatibility_date,assetsの基本構成のみであれば不要でオプションみたいですが、私のプロジェクトでは環境ごとにWorkersプロジェクトを分ける必要があり、カスタム設定を記述するので作成します。アダプターを入れるかどうか、またアダプターによって変わると思いますが、Cloudflareの場合はdist/clientがビルド出力先になるのでdirectoryキーを指定します。not_found_handlingキーは、アセットが見つからなかった時の挙動を指定するもので、404-pageだと404ページを返し、single-page-applicationだとSec-Fetch-Mode: navigateのリクエストに対してはindex.htmlを、それ以外はWorkerに渡されるような挙動になります。今回のSSRのプロジェクトではプリレンダリングされたアセット以外はWorkerで処理する必要があるので&quot;none&quot;を指定します。（キー自体を設定しなくてもいい）&lt;/p&gt;
&lt;p&gt;開発環境、本番環境で構成を分ける場合はenvセクションにそれぞれの環境の設定を記述しますが、一部の設定はトップレベル（envの外）に定義したものが各環境に継承されないので注意が必要です。継承されない設定に関しては各環境で個別に定義してください。また、このwrangler.jsoncはgit管理するものなので、ここに設定する環境変数は非機密なものに限られます。機密情報を設定する場合はwrangler secret putコマンドを使い、毎回手打ちするのも面倒なはずなのでGithub Secretsに保存しておき、デプロイ時にGithub Actionsを実行させるのが確実かつ楽だと思います。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// wrangler.jsonc
{
  &quot;$schema&quot;: &quot;node_modules/wrangler/config-schema.json&quot;,
  &quot;name&quot;: &quot;sampleApp-development&quot;,
  &quot;main&quot;: &quot;@astrojs/cloudflare/entrypoints/server&quot;,
  &quot;compatibility_date&quot;: &quot;2026-04-22&quot;,
  &quot;assets&quot;: {
    &quot;directory&quot;: &quot;./dist/client/&quot;,
    &quot;not_found_handling&quot;: &quot;none&quot;
  },

  &quot;env&quot;: {
    &quot;development&quot;: {
      &quot;name&quot;: &quot;sampleApp-development&quot;,
      &quot;vars&quot;: {
        &quot;ENVIRONMENT&quot;: &quot;development&quot;,
      }
    },
    &quot;production&quot;: {
      &quot;name&quot;: &quot;sampleApp&quot;,
      &quot;vars&quot;: {
        &quot;ENVIRONMENT&quot;: &quot;production&quot;,
      },
      &quot;routes&quot;: [{
        &quot;pattern&quot;: &quot;sampleApp.com&quot;,
        &quot;custom_domain&quot;: true
      }],
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;a href=&quot;https://developers.cloudflare.com/workers/wrangler/configuration/&quot;&gt;Wrangler Configuration&lt;/a&gt; &lt;br /&gt;
&lt;a href=&quot;https://developers.cloudflare.com/workers/wrangler/configuration/#top-level-only-keys&quot;&gt;継承されるキー・されないキー&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;ビルドスクリプトの変更&lt;/h3&gt;
&lt;h4&gt;環境の指定方法の変更&lt;/h4&gt;
&lt;p&gt;ここまでの対応でWorkersにデプロイできるかテストしてみたのですが、npx wrangler deploy --env production（まだ本番のカスタムドメインには接続していない状態）が動作しておらず、sample-appのWorkerが作られるのではなく、sample-app-developmentのワーカーに再デプロイするような挙動になっていました。.astroやdistを削除して再ビルドしてからデプロイしても同じで少しハマることに。&lt;/p&gt;
&lt;p&gt;ドキュメントをよく見たらAstro 6.0から環境の指定方法が変わっていました。これまではビルドとデプロイが分かれていて、環境はデプロイ時に指定する形でしたが、Viteプラグインに移行したことで環境はビルド時に決まるようになったとのこと。完全に見落としていました！&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Changed: Deploy to Cloudflare Environment&lt;br /&gt;
&lt;br /&gt;
In Astro 5.x, you could build your Astro project once and deploy it to a specific Cloudflare environment with .wrangler deploy --env some-env &lt;br /&gt;
&lt;br /&gt;
Since Astro 6.0, the integration relies on the Cloudflare Vite plugin and this behavior has changed. The environment is now determined during the build phase. Therefore, you must build your project separately for each environment.&lt;br /&gt;
&lt;br /&gt;
To deploy to a specific Cloudflare environment, prefix your command with the variable. For example, the command will build your Astro project and deploy it with Wrangler using the environment.CLOUDFLARE_ENVCLOUDFLARE_ENV=some-env astro build &amp;amp;&amp;amp; wrangler deploysome-env&lt;br /&gt;
&lt;br /&gt;
Learn how to update your Cloudflare environments in the Migrate from wrangler dev guide.
&lt;br /&gt;
&lt;br /&gt;
&lt;a href=&quot;https://docs.astro.build/ja/guides/integrations-guide/cloudflare/#changed-deploy-to-cloudflare-environment&quot;&gt;変更:Cloudflare環境へのデプロイ&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;ということで、ビルド時に&lt;code&gt;CLOUDFLARE_ENV={環境}&lt;/code&gt;を指定することで解決。また、ローカル（Windows）でもCI/CDでも同じコマンドで動くようにcross-envを付け、コマンドが長くなるのでpushコマンドにまとめます。&lt;/p&gt;
&lt;h4&gt;型定義ファイルの自動生成&lt;/h4&gt;
&lt;p&gt;wrangler typesコマンドを使うと、現状の構成に合わせてAPIの型定義ファイル（worker-configuration.d.ts）を自動生成してくれます。wrangler.jsoncに設定した環境変数などもこの型定義ファイルに反映されるようになるので、これをビルドスクリプトに組み込んでおくと便利です。&lt;/p&gt;
&lt;p&gt;&lt;br /&gt;
&lt;br /&gt;
最終的にこうなりました↓&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// package.json
&quot;scripts&quot;: {
  &quot;dev&quot;: &quot;wrangler types &amp;amp;&amp;amp; pnpm copy-well-known &amp;amp;&amp;amp; cross-env CLOUDFLARE_ENV=development astro dev&quot;,
  &quot;start&quot;: &quot;wrangler types &amp;amp;&amp;amp; astro dev&quot;,
  &quot;build&quot;: &quot;wrangler types &amp;amp;&amp;amp; pnpm copy-well-known &amp;amp;&amp;amp; astro check &amp;amp;&amp;amp; astro build&quot;,
  &quot;push&quot;: &quot;pnpm push:development&quot;,
  &quot;push:development&quot;: &quot;cross-env CLOUDFLARE_ENV=development pnpm build &amp;amp;&amp;amp; pnpm exec wrangler deploy --env development&quot;,
  &quot;push:production&quot;: &quot;cross-env CLOUDFLARE_ENV=production pnpm build &amp;amp;&amp;amp; pnpm exec wrangler deploy --env production&quot;,
  &quot;astro&quot;: &quot;astro&quot;,
  &quot;copy-well-known&quot;: &quot;node scripts/copy-well-known.js&quot;
},
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;GitHub Actionsでのデプロイ&lt;/h3&gt;
&lt;p&gt;機密情報はnpx wrangler secret putコマンドで設定し、デプロイと同時に登録することが出来ません。また、代わりにダッシュボードのシークレットで設定することは出来ますが、これは次回デプロイした時にwrangler.jsoncのvarsで上書きされてしまうので、デプロイのワークフロー内でwrangler secret putコマンドを実行するのが確実です。Cloudflareも各種設定はダッシュボードではなくWranglerやCI/CDパイプラインでの管理を推奨としているので、これがベストプラクティスのようです。&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://developers.cloudflare.com/workers/wrangler/configuration/#source-of-truth&quot;&gt;Source of Truth&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;公式の&lt;a href=&quot;https://github.com/cloudflare/wrangler-action&quot;&gt;wrangler/action&lt;/a&gt;というものがあるみたいですが、ワークフロー自体シンプルですし、シークレットの設定をサポートしていないので自前で書きました。事前にCloudflareのAPIトークンとアカウントIDをGithub Secretsに保存し、各ステップで定義しておけば認証代わりになります。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;name: Deploy
on:
  push:
    branches:
      - main
      - deploy

concurrency:
  group: ${{ github.workflow }}-${{ github.ref_name }}
  cancel-in-progress: true

jobs:
  deploy:
    runs-on: ubuntu-latest
    name: Deploy
    env:
      DEPLOY_ENV: ${{ github.ref_name == &apos;deploy&apos; &amp;amp;&amp;amp; &apos;production&apos; || &apos;development&apos; }}
    steps:
      - name: チェックアウト
        uses: actions/checkout@v6

      - name: pnpmセットアップ
        uses: pnpm/action-setup@v6
        with:
          version: latest

      - name: Node.jsセットアップ
        uses: actions/setup-node@v6
        with:
          node-version: 24
          cache: &quot;pnpm&quot;
          cache-dependency-path: &quot;sample-app.com/pnpm-lock.yaml&quot;

      - name: 依存関係のインストール
        run: pnpm install --frozen-lockfile
        working-directory: sample-app.com

      - name: Deploy
        run: pnpm push:${{ env.DEPLOY_ENV }}
        working-directory: sample-app.com
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}

      - name: シークレットの設定
        run: echo &quot;$TURNSTILE_SECRET&quot; | pnpm exec wrangler secret put TURNSTILE_SECRET --env ${{ env.DEPLOY_ENV }}
        working-directory: sample-app.com
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          TURNSTILE_SECRET: ${{ secrets.TURNSTILE_SECRET }}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Cloudflare AccessやTurnstile等の設定変更&lt;/h3&gt;
&lt;p&gt;pages.devドメインでこれらのサービスを使用している場合、当然ながら変更が必要です。&lt;/p&gt;
&lt;h3&gt;元のPagesプロジェクトの削除&lt;/h3&gt;
&lt;p&gt;デプロイされたサイトが問題なく動いていることと、カスタムドメインの繋ぎ直し等を実施したら元のCloudflare Pagesのプロジェクトは不要になるので消します。しかし、Pagesプロジェクトでのデプロイメント数が100を超えているとプロジェクトを削除できない問題があるらしく、公式ドキュメントに回避策が提示されています。Windowsでは下記のコマンドを叩いて全消しに成功。正確には一発で全部消えたわけではなく、途中で止まったので数回続けて叩いたら全部消せました。ページネーションの仕様なのかリクエスト制限なのか知りませんが。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;npx wrangler pages deployment list --project-name sample-app --json | jq -r &apos;.[].Id&apos; | ForEach-Object { npx wrangler pages deployment delete $_ --project-name sample-app --force }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;https://developers.cloudflare.com/pages/platform/known-issues/#delete-a-project-with-a-high-number-of-deployments&lt;/p&gt;
&lt;h2&gt;さいごに&lt;/h2&gt;
&lt;p&gt;型を管理するためにTSを使っているわけじゃ無いっつーの！&lt;/p&gt;
</content:encoded></item></channel></rss>