cockscomblog?

cockscomb on hatena blog

MCP時代における認可サーバーの要件

これははてなエンジニア Advent Calendar 2025、8日目の記事です。


Model Context Protocol (MCP)の登場で、AIアプリケーションが外部システムを呼び出し、情報を参照する手続きの標準化が進んでいる。

MCPサーバーのトランスポート層として、ローカル利用を想定したstdio(標準入出力)に加え、HTTPを利用するStreamable HTTPが定義されている。このStreamable HTTPにおいて、ユーザーに紐づくプライベートな情報を扱うために避けて通れないのが、認可(Authorization)の仕組みである。

本記事は2025年11月25日に公開されたMCPの最新仕様(2025-11-25)に基づく。

MCPの認可基盤としての「OAuth 2.1」

MCPの認可仕様(Authorization)は、IETFの標準技術(とそのドラフト)に立脚している。

その中心となるのがOAuth 2.1だ。

OAuth 2.1は、広く普及しているOAuth 2.0のベストプラクティスを取り込み、セキュリティを強化した改訂版である。

MCPアーキテクチャとOAuth 2.1を対応づけると、MCPリモートサーバーOAuth 2のリソースサーバーアカウントシステムOAuth 2の認可サーバーとなる。これらは単一のシステムでもよいし、別々でもよい。

OAuth 2は、認可プロトコルとしては、おそらく最もひろく利用されており、十分に枯れている。MCPに限らず、およそ認可という仕組みが必要なHTTPベースのシステムはすべて、OAuth 2を採用するのが自然だ。

メタデータによるディスカバリ

MCPクライアントとしてのAIアプリケーションは、数多のMCPサーバーと接続する可能性があり、それぞれについて予め詳細を知っておくことは不可能だ。これを解決するために、認可サーバーは「OAuth 2.0 Authorization Server Metadata」を、リソースサーバーは「OAuth 2.0 Protected Resource Metadata」に準拠している必要がある。

OAuth 2.0 Authorization Server Metadataは、認可サーバーの仕様をJSONで提供するもので、ふつう認可サーバーの /.well-known/oauth-authorization-server で配信する。このメタデータからは、認可フローで利用する各エンドポイントのURLはもちろん、認可サーバーが対応している様々なパラメータが把握できる。

これに対してOAuth 2.0 Protected Resource Metadataは、リソースサーバーの仕様をJSONで提供する。MCPの文脈では特に、認可サーバーや対応するスコープを示すのに使われる。リソースサーバーの /.well-known/oauth-protected-resource で配信する。スコープの選択に関しては難しい問題があるが、それは後述する。

これによって、MCPクライアントは動的に認可の仕様を取得し、適切に認可フローを開始できる。

クライアントの登録

ここまでの対応は、通常のOAuth 2の対応でも普通に行われることが多い。通常、OAuth 2に対応するサーバー・クライアントでは、事前にクライアントを認可サーバーに登録し、Client IDを取得する。これはクライアントが予め認可サーバーのことを知っておけるから成立している。

MCPでは、MCPクライアントは事前にMCPサーバーの詳細を知らないという前提がある。MCPという標準があることで、予め詳細を共有しておく必要がない。このために、クライアントを認可サーバーに登録するのも動的に行える方がよい。

動的クライアント登録

OAuth 2.0 Dynamic Client Registration Protocolは、動的クライアント登録(DCR)と呼ばれる仕様で、まさにこの問題を解決するためにある。そもそもDCRはOpenID Connect(OpenID Connect Dynamic Client Registration 1.0 incorporating errata set 2)に由来している。OpenID ConnectもMCPと同様、標準があることで、サーバーとクライアントの関係を疎にできることで、DCRを行うモチベーションがあるのだろう。

ただしDCRは、一般にはあまり実装されていない。任意のクライアントを動的に登録できるのは、認可サーバーから見れば、管理上の負荷が増える可能性もあり、好まれない。これを受けて、MCPの最新版で加わったのが、次に説明するOAuth Client ID Metadata Documentsである。

Client ID Metadata Documents

OAuth Client ID Metadata Documents(CIMD)のRFCはまだドラフト段階だが、MCPの最新の仕様で採用された。CIMDでは、クライアントを登録するのではなく、クライアントの情報を記したJSONのURLをClient IDとして使用する。認可サーバーは受け取ったClient IDがURLであれば、これをCIMDに準拠したクライアントだと認識し、URLからクライアント情報を取得する。

CIMDは突飛な発想のようにも思えるが、意外なほどうまく機能する。予めクライアントを登録する必要もなければ、クライアント情報を無制限に保存する必要もない。Client IDとリダイレクトURIのオリジンを比較することで、無関係なクライアント情報で誤認させるような攻撃も防ぎやすい。

クライアント登録の順序

仕様では次の順序でクライアント登録する方法を定めている(§5 Client Registration Approaches)。

  1. もし予め用意したクライアント情報があればそれを利用する
  2. 認可サーバーがCIMDに対応していればそれを利用する
  3. DCRにフォールバックする
  4. クライアント情報がないかユーザーに確認する

CIMDは最新の仕様で追加されたのもあり、現在のところ対応はあまり広がっていない。よく使われているところではVS Codeでの対応が知られており、Client IDは https://vscode.dev/oauth/client-metadata.json となっているようだ。今後は採用が広がるだろうと思われるので、認可サーバーでの対応を検討しておきたい。

DCRもCIMDも、事前に登録されたクライアントではないクライアントからの利用を受け付けることになるため、セキュリティやポリシーなどをよく検討しなければならない。

スコープの選択

ところで、MCPと同様にサーバーとクライアント間のやり取りが標準化されているOpenID Connectでは、使用するOAuth 2スコープが openidprofile のように標準化されている。このことで、クライアントはリソースサーバーのスコープの詳細に立ち入らずに済む。

一方MCPでは、リソースサーバーがどのようなOAuth 2スコープを受け付けているか知る必要がある。仕様ではMCPクライアントがどのようにスコープを選択すべきか定められており(§6 Scope Selection Strategy)、次の優先順位で採用することになっている。

  1. リソースサーバーが401を返した際の WWW-Authenticate ヘッダの scope パラメータを参照する
  2. OAuth 2.0 Protected Resource Metadataで表明された、リソースサーバーがサポートするスコープを参照する

これで済めば話は簡単だが、実際の運用ではもう少しややこしい。OAuth 2.1では明確に規定されていない(実装に委ねられている)が、多くの認可サーバーの実装では、クライアントに予めスコープを設定し、認可フローで要求できるスコープはそのサブセットでなくてはならないという制約を課す。DCRでもそういったケースが考慮されたのか、動的に登録できるクライアント情報に scope パラメータが含まれている。ClaudeのMCPクライアントとしての実装では、実際に、DCRの際にリソースサーバーに合わせたスコープを要求するのを確認した。一方CIMDではその仕様上、リソースサーバーに合わせてスコープを要求するのは困難だ。

従って、認可サーバーではクライアントのスコープが空のとき、MCPで利用する可能性のあるスコープ全てをデフォルトスコープとして設定する、というような実装をすることになるかもしれない。

まとめ

MCPのように、標準化されたプロトコルに則って認可の必要なHTTPベースのAPIアクセスを提供する場合、APIを提供するリソースサーバーや認可サーバーは、OAuth 2やそれに関連する認可プロトコルを実装しなければならない。MCPの仕様はbest current practice的なものになっている。

もし皆さんがアカウントシステムを開発・運用しているなら、OAuth 2.1を筆頭に、これらの標準に則った実装を行う必要があるかもしれない。

普通自動車運転免許取得

この度、普通自動車第一種運転免許(AT車限定)を取得したことを、皆さんにご報告いたします。

Nano Banana Proで作成


春から自動車学校に通って、夏に忙しい時期があって少し足が遠のいたりしつつ、半年かけての取得となった。

36歳になって運転免許を取得するのだから、何か目的があると格好よいのだが、特に具体的なものはない。

家庭があるから、自動車を運転できると便利な場面があるだろうとは思う。運転が明確に苦手だと分かれば、残りの人生も都会の駅近に住み続けることになるし、そうでなければもう少し自由がある、くらいのことは考えた。何にしても、やってみないと分からない。

以前から身近な人には、事あるごとに免許を持っていないことを自己申告していたほど、免許がないことをアイデンティティの一部にしてきた。しかしそうやって、自分で自分を定めてしまうのは不自由だ。我が身に変化があるほどよいし、好きな言葉は「君子豹変」だ。

そういうことを考えて、免許を取ることにした。


子供の頃から、車そのものや、レースゲームは嫌いじゃなかった。父の運転する自動車の助手席に乗るのはおもしろかったし、グランツーリスモもかなり楽しんだ。

グランツーリスモにブックレットが付属していたのか、あるいは別売のガイドブックだったのか覚えていないが、速く走るために、自動車の走行を物理的に説明してくれる読み物があった。ABSの仕組みのようなことも書いてあったような記憶があるが、そういう知識は免許の取得に際しても役に立つ。一方、コーナーの手前で急減速して荷重を前輪に移動させてコーナリング・フォースを高める、というようなテクニックは、当然ながらお蔵入りになった。

何にせよ、自動車自体にはわずかながらも親しんでいたことは、存外に役に立ったかもしれない。過去に父が運転しながら話していたことが蘇ってくるような場面も多かった。


ところで、36歳が急に自動車学校に行くと、ちょっと変な感じになるのではないかと少しだけ心配したが、これは全くの杞憂だった。そもそも、今時、30代や40代で自動車学校に通う人はそれほど珍しくないのだろうし、年齢に関連して「2回目じゃないよね」という意味のことを言われたのは一度だけだった。自動車学校の指導員もそういうことを言わないように気をつけているのではないか。

一方で学ぶ側としては、36歳であることで、見えてくることもある。


教え方の巧拙は現実に差があるのだと思う。もちろん教官の皆さんは全員プロフェッショナルで、とてもありがたかった。

その中でも、まずはなるべく野放しにしてくれて、然るべきタイミングでフィードバックをもらえる、そういう風にしてくださると、自分で気づくところもあって、よく身になった。

少しやりづらかったのは、例えば運転中に「このまま進むとどうなると思うか」のように問われるような場合で、コミュニケーション上の正解を探すことに一生懸命になってしまった。

翻って我が身を振り返ると、36歳ともなれば日常的に後進を育成するようなこともあると思うのだが、果たしてうまくやれているのか、考えてしまう。


見えてくることと言えば、自分自身の傾向、あるいは癖について、少し自覚した。

例えば「路肩に寄せて停止」する場面で、路肩に対してちょうど適切な距離まで寄って停止できると100点だと認識して、100点を取ろうと考えてしまう。しかし本当のところ、寄りすぎて路肩に擦ってしまえば事故だ。だから100点を取ろうと寄せることにこだわるべきではなく、安全を最重要に考えなければいけないと思う。

そういう、悪く言えばゲーム感覚とでも表現されるような、考え方の癖が自分にはあると知った。恐ろしいことだ。恐ろしいが、知らなければどうしようもなかったところ、幸い知ることができたのだから、気をつけようもあろうと思う。


近頃の自動車教習所は、自宅でオンデマンド学科教習を利用できることもあって、仕事をしながらでも通いやすいようだ。実際、仕事を休むこともほぼなく、週末のどちらかで技能教習を受けるようなペースで進められた。

免許を取得しても、これからさらに安全運転に努め、乗車時間を増やして経験を積んでいかなければならないのだと思うが、とにかく一区切りである。今まで、免許を持っていないけどなんらかの事情で自動車を運転させられる(ダメに決まっている)悪夢を見ることが度々あったのだけど、これからはそういう悪夢を見なくて済む。

免許がないことがアイデンティティだった自分はもういない。

パスキーの安全性について

パスキーによる認証を開発したとき、パスキーの安全性をどう評価するのが妥当なのか検討していた。もちろんフィッシング耐性が高いというような特性については把握していて、サービス利用者にとって便益の多い認証であることはわかっている。ただそれが、例えばパスワードとTOTPを組み合わせた多要素認証に対して、どちらがより安全と言えるのか。これを一言に表すのはあまり簡単ではない。

パスキーは多要素認証なのか

多要素認証というのは、something you knowsomething you havesomething you are の3種類の要素のうち複数を組み合わせる認証を言う語だ。

多要素認証は単一種類の要素による認証と較べて飛躍的に安全である。例えば、物理的な鍵は something you have であるが、それが盗まれてしまえば安全ではない。鍵が複数あっても、一度に盗まれてしまうかもしれない。もしここに something you are である生体認証が加われば、鍵だけが盗まれても問題ないから、安全性が大きく向上する。

パスワードは something you know、TOTPは something you have であるから、このふたつを組み合わせると多要素認証になる。

ではパスキーは単一要素なのか多要素なのか。

スマートフォンでパスキーを利用したことがある人ならご存じのとおり、パスキーを利用する際にはスマートフォンの生体認証が要求される。このことで、基本的には多要素認証として扱ってよいのだろう。パスキーが保存されているスマートフォンを持っており(something you have)、生体認証(something you are)でアクティベートされている。

ところが、生体認証のための機能を備えていないパソコンでは、パスキーの利用時に、何も問われないか、あるいはパソコンのログインパスワードが要求されるかする。特に前者の場合、something you have しか満たさないのではないか。このようなケースがある以上、パスキー全体を多要素認証とは見做せないのではないか。

ユーザーの確認

このような問題のために、パスキー(WebAuthn)には仕組みがある。詳細はweb.devの次の記事が詳しい。

RP(認証するサービス側)はuserVerificationの値によって、パスキーの挙動を制御できる。そして送られてくるユーザーの確認(UV)フラグの値によって、実際のユーザーがデバイスを操作していることを検証できる。

要するに、UVフラグが真のとき、実質的に多要素認証の要件が満たされたと見做せるはずだ。

NIST SP 800-63B

ここまでで、UVフラグが真の場合のパスキーが多要素認証と同等であると見做せるだろうということを説明した。このことを改めて確認するために、米国国立標準技術研究所(NIST)が発行するNIST SP 800-63 Digital Identity Guidelinesを参照する。この文書は、アメリカ政府を対象としたガイドラインであるから、普遍的に適用できるものではないが、網羅的に整理されており、一定の頻度でアップデートが重ねられていることから、これを基準として用いるのが普通だ。特に、いままさに第4版への改訂作業が進んでおり、そのPublic Draftではパスキーに相当する概念が扱われている。

Syncable Authenticatorsというのは、パスキーが複数のデバイスで同期できることからくるネーミングだ。

この文書のImplementation Requirementsに、以下の記述がある。

User Verified (UV)

The User Verified flag indicates that the authenticator has locally authenticated the user using one of the available “user verification” methods. Verifiers SHALL indicate that UV is preferred and SHALL inspect responses to confirm the value of the UV flag. This indicates whether the authenticator can be treated as a multi-factor cryptographic authenticator. If the user is not verified, agencies SHALL treat the authenticator as a single-factor cryptographic authenticator. A further extension to the WebAuthn Level 3 specification (see Sec. 10.3 of WebAuthn) provides additional data on verification methods if agencies seek to gain context on the local authentication event.

要するに、UVフラグが真のとき「multi-factor cryptographic authenticator」として扱えるが、そうでなければ「single-factor cryptographic authenticator」として扱わなければならない、ということになっている。

NIST SP 800-63Bでは、認証のレベルを3つのAuthentication Assurance Level (AAL)に分けており、AAL3が最も信用でき、AAL1が基本的なレベルという風に設定されている。AALを満たすための条件の一つに、「Permitted Authenticator Types」がある。

AAL3で許可されているauthenticatorは、「multi-factor cryptographic authentication」か「single-factor cryptographic authentication used in conjunction with a password」のどちらかである。つまりパスキーは、UVフラグが真ならそれ単体でAAL3の(authenticatorの)要件を満たし、UVフラグが偽であったとしてもパスワードとの組み合わせで要件を満たす。

パスワードとTOTPの組み合わせではAAL2までの要件しか満たさない。この差は、cryptographic authenticationがフィッシング耐性を持つ「single- あるいは multi-factor cryptographic authentication」ではない、手動入力を必要とするauthenticatorはフィッシング耐性を持たない*1という性質からきているものと思われる。

パスキーの安全性について

パスキーはUVフラグの値が真なら実質的に多要素認証であり、フィッシング耐性の面から、パスワードとTOTPの組み合わせよりもセキュアであると考えられている、ということを説明した。

もちろん、パスキーに限ったものではないが、実装の詳細や運用によって問題が起きる場合も考えられ、間違いなくいつでも安全ということはない。パスキー自体も進歩しており、これからさらに使いやすく便利になっていく。しかし現時点でも、安全かつ便利な有力な選択肢と思う。

はてなはパスキーに対応しています。

SwiftUIにおけるWebViewの実装

来週のWWDC25で発表されるSwiftUIの新機能に、WebViewがあるだろう、ということが話題になった。OSSであるWebKitリポジトリから、それが容易に伺える。このSwiftUIのWebViewのコードを読むと、よくできている。これがなぜよくできているのか、宣言的UIフレームワークとしてのSwiftUIという観点から、説明を試みたい。

説明にあたって、回り道ではあるが、まずはSwiftUIでWebViewを作ることを考える。WebViewの実態は、WebKitフレームワークWKWebViewを使えば良い。これをどうやってSwiftUIのViewにするのか。

WebViewのAPI

どうやってと言いながら、いったん実装のことを忘れて、WebViewのインターフェースを考える。最初に思いつくのは、最も単純なパターンで、URLを与えてページを読み込むものだ。

WebView(url: URL(string: "https://example.com")!)

これはよさそうだ。次に、このWebViewを使ったUIを考える。

アドレスバー

簡単なWebブラウザを作るとして、まずはアドレスバーから考えてみる。

struct BrowserView: View {
    @State var url: URL = URL(string: "about:blank")!
    var body: some View {
        VStack {
            WebView(url: url)
            TextField("Enter URL", text: Binding(
                get: { url.absoluteString },
                set: { url = URL(string: $0) ?? url }
            ))
        }
    }

WebViewの外部に置かれたTextFieldでURLを入力させる。WebViewにはそれを与えることで、ユーザーの入力したページを開ける。

ところが、少し考えてみると、これはうまく機能しない。ユーザーがWebViewを操作して別のページへ移動したとき、TextFieldにそれが反映されない。反映させるために、例えばWebViewはBinding<URL>を受け取るとよいだろうか。

struct WebView: View {
    @Binding var url: URL
    ...
}

struct BrowserView: View {
    @State var url: URL = URL(string: "about:blank")!
    var body: some View {
        VStack {
            WebView(url: $url)
            ...
        }
    }
}

ではページのタイトルを参照したいときはどうか。実装上はWKWebViewtitle propertyを読み取ればよいが、これをBinding<String>として扱うのはおかしい。ページのタイトルは外から与えられない。SwiftUIのデータフローでは、Preferencesを使うこともできるが、間接的なアプローチになる。

「戻る」ボタン

Webブラウザには必ずある「戻る」ボタンはどうやって作るとよいのだろう。WebViewにgoBack()メソッドを作ったとして、Viewをどう参照すればよいのか。また、「戻る」ボタンは、back-forward list上に戻ることのできる要素がある時だけ有効であるべきだ。戻ることができるかどうか、どう判別するとよいのか。

戻ることができるかどうかは、やはりPreferencesを使うこともできるだろうが、別なアイデアとして、ScrollViewReaderのように、プロキシを介してWebViewの情報を読み取らせることも考えられる。この手段は、これまで出てきた中で最もユニバーサルな解決策になり得る。プロキシにgoBack()メソッドを持たせることで、実際に「戻る」ボタンを機能させられる。

このアプローチは実際にcybozu/WebUIで用いられている。

Single Source of Truth

ここで基本に立ち返って、SwiftUIの(あるいは宣言的UIフレームワークの)重要な考え方である、Source of Truthとしてのステートがあり、ビューはそれの写像である、ということを考える。ビューから状態を得るのではなく、ビューの外から状態を与える。

これに従って「戻る」機能を考え直すと、BackForwardListがWebViewの外にあって、それをWebViewに与えるのがよいはずだ。

@Observable
class BackForwardList {
    var canGoBack: Bool = false
    func goBack() {
        ...
    }
}

struct WebView: View {
    @Binding var url: URL
    @Binding var backForwardList: BackForwardList
    ...
}

struct BrowserView: View {
    @State var url: URL = URL(string: "about:blank")!
    @State var backForwardList = BackForwardList()
    var body: some View {
        VStack {
            WebView(url: $url, backForwardList: $backForwardList)
            ...
        }
    }
}

これはまさに、正しいAPIじゃないだろうか。

ステートとしてのWebView

ここでようやく冒頭の話に戻って、SwiftUIの新しいWebViewがどのようなものか見てみる。

SwiftUIのWebViewには、ふたつのイニシャライザがある。ひとつはinit(_ page: WebPage)で、もうひとつはinit(url: URL?)となっている。URLを受け取る方も、内部的にはWebPageを作っているので、このWebPageこそが重要だとわかる。

WebPageのコードを見てみると、まさにvar url: URL?var title: Stringのようなcomputed propertyがある。もちろんvar backForwardList: BackForwardList = BackForwardList()というstored propertyもある。なるほど、WebViewに与えるステートの塊がWebPageということだ。

さらにWebPageをよくみると、lazy var backingWebView: WebPageWebViewというのがある。このWebPageWebViewというのはWKWebViewのサブクラスだ。そう、何を隠そう、WebPageの側がWKWebViewというWebViewの本体を保持しているのだ。この意味において、WKWebViewはビューではなくステートになっている。

実際のところ、WebViewRepresentableを見ると、WebPageが保持するWKWebViewがそのまま表示されていることもわかる。

実際にこの新しいWebViewを使ったコードがリポジトリに置かれている。WebPageをView Modelに持たせているが、基本的な発想はここまで書いた通りのものと思う。

まとめます

ここまで、新しいWebViewのAPIを説明した。SwiftUIの観点からみて、とてもよく設計されているのがわかったと思う。

SwiftUIにWebViewが追加されるのは、WebViewの使われる頻度からしても、ようやくか、という見方もある。しかし時間を要しただけのことはあるのではないか。

Androidアプリ「Font List」を公開しました

Font List

この記事の終わりには、いろいろあってAndroidアプリを公開しました、というオチになるわけだけど、そこまで長いので、いったんアプリをお知らせします。

システムフォントを一覧するアプリです。無料。どうぞご利用ください。

Font List

前日譚

2024年11月10日、Google Play Supportからメールが届いた。件名はこうだ。

[ご対応のお願い] デベロッパー アカウント(cockscomb)に問題があります

筆者のGoogle Play Consoleデベロッパーアカウントが使用されていないため、利用者の安全性を維持するため、閉鎖の警告を受けた。

筆者がGoogle Play Consoleのデベロッパーアカウントを開設したのは、このメールからおよそ1年前、2023年11月10日のことだ。というのも、2023年11月13日以降に個人としてデベロッパーアカウントを登録すると、一定の基準をクリアしない限りアプリを公開できなくなる。それより前に登録しておけばこの制約を逃れられるというので、25ドル払ってデベロッパーアカウントを開設しておいたのだった。

ところがその後放置状態にあったため、「休眠アカウント」という扱いになり、閉鎖の危機を迎えた。一度閉鎖されるとそのデベロッパーアカウントは削除されてしまい、新たに登録すると、やはり前述の制約下に置かれることになる。

最初のメールの時点で解決の期限は59日後。1ヶ月後に2度目のメールがあり、そして元日に3度目のメールが届く。残り8日。

残り1週間を切ったところで、解決のために活動を始める。解決するには、まだ何もないデベロッパーアカウントなので、何かアプリを作成して公開するしかない。慌ててAndroid Studioを起動する。

開発動機

そもそも普段使っているのはiPhoneだから、Androidで作りたいアプリとかないのだけど、ないなりにしばらく考えたところ、以前欲しかったアプリのことを思い出した。

Android OSをアップデートした時か何かに、ブラウザで日本語フォントのウェイトの感じが変わったような気がした。もともと端末に搭載されている日本語フォントはウェイトが少なかったのだけど、見知らぬウェイトが表示された、とかだったように思う。それで端末に搭載されているフォントの様子を知りたい。ところが標準アプリにそういった機能はなく、Google Playでもいいアプリを見つけられなかった。

ということで端末に搭載されているフォントを一覧するだけのアプリが欲しい。ちょっとAPIを調べたところ、SystemFonts.getAvailableFonts()というのを見つけた。

これじゃんということで、Android Studioでプロジェクトを作る。さすがにAndroidの最近の開発事情をうっすらとは把握しているので、Jetpack Composeのテンプレートを選ぶ。

フォント名

UIとかは置いておいて、SystemFonts.getAvailableFonts()を呼び出して、エミュレータで様子を見てみる。Fontの集合がちゃんと返ってくる。それじゃあフォントの名前のリストでも表示するかと思ったが、まったくこれがどうにもならない。

このFontというクラスには、フォントの名前にあたる情報を取得するメソッドが生えていない。フォントファイルそのものにはアクセスできる。なるほど……。

ということで、フォントファイルをちゃんと読んで、フォントのファミリー名などを取得していくことにする。

手元のAndroid端末では、OpenType、TrueType、TrueType Collectionの3つの形式のフォントが使われているようであった。現実的にもこれくらいに対応しておれば十分だろうということで、これらをパースするコードを書いていく。3つというと多く感じられるが、メタデータだけ読むくらいなら3つはほとんど共通している。

フォントファイルはいろいろなテーブルが並んでいるような構造で、冒頭にそれぞれのテーブルへのオフセットが記録されている。TrueType Collectionの場合は、ひとつのファイルの中にそれが複数並んでいて、冒頭にそれぞれへのオフセットが記録されているような形だ。これを順にパースしていって、目的のテーブルを参照する。

フォント名はnameテーブルに入っているので、これを読み出す。

nameテーブルにはNameRecordが並んでおり、フォントファミリーの名前だけでなく、いろいろな文字列メタデータが格納されている。NameID1のレコードを探すと、フォントファミリーの名前がわかる。

ちまちまnameテーブルを読んでいくことで、フォントの一覧ができてくる。

SystemFonts.getAvailableFonts()

ここで改めてSystemFonts.getAvailableFonts()の返り値Set<Font>について見ていく。ここで返ってくるFontはフォントファイルひとつひとつに対応するのかと思っていたが、実際には違った。

まずはTrueType Collectionで、フォントファイルひとつに複数のフォントが格納されている。これが個別のエントリとして返る。TrueType Collection内部のどれかということについては、Font.getTtcIndex()で示される。

フォントファイル ttcIndex axes 備考
/system/fonts/NotoSerifCJK-Regular.ttc 0 N/A Noto Serif CJK JP
/system/fonts/NotoSerifCJK-Regular.ttc 1 N/A Noto Serif CJK KR
/system/fonts/NotoSerifCJK-Regular.ttc 2 N/A Noto Serif CJK SC
/system/fonts/NotoSerifCJK-Regular.ttc 3 N/A Noto Serif CJK TC

Noto Serif CJKは、CJKの各言語のフォントをまとめたTrueType Collectionになっている

もうひとつ、Variable Fontがある。フォントファイルは単一のVariable Fontで、その可変なパラメータ違いが個別のエントリとして返る。パラメータはFont.getAxes()で取得できる。

フォントファイル ttcIndex axes 備考
/system/fonts/NotoSansKhmer-VF.ttf 0 wdth: 100, wght: 26 Thin
/system/fonts/NotoSansKhmer-VF.ttf 0 wdth: 100, wght: 39 ExtraLight
/system/fonts/NotoSansKhmer-VF.ttf 0 wdth: 100, wght: 58 Light
/system/fonts/NotoSansKhmer-VF.ttf 0 wdth: 100, wght: 90 Regular
/system/fonts/NotoSansKhmer-VF.ttf 0 wdth: 100, wght: 108 Medium
/system/fonts/NotoSansKhmer-VF.ttf 0 wdth: 100, wght: 128 SemiBold
/system/fonts/NotoSansKhmer-VF.ttf 0 wdth: 100, wght: 151 Bold
/system/fonts/NotoSansKhmer-VF.ttf 0 wdth: 100, wght: 169 ExtraBold
/system/fonts/NotoSansKhmer-VF.ttf 0 wdth: 100, wght: 190 Black

Noto Sans Khmerは、ウェイトの異なるVariable Font

Variable Font

アプリではフォントファミリー名に加えて、フォントのウェイトなどを表示したい。「Regular」や「Bold」、あるいは「Italic」のようなラベルになる。これは通常、フォントのサブファミリーとしてnameテーブルから取得できる。しかしVariable Fontの場合はそうはいかない。

Variable Fontにとってのウェイトは、wghtというaxisの値で表現される。値はフォントにもよるが、100から900までの任意の整数で表せる。これをどうラベルにするといいのか。

ここでVariable Fontのfvarテーブルを使う。fvarテーブルには、Variable Fontのパラメータの軸(axis)の定義と、インスタンスと呼ばれる典型的な設定が格納されている。例えばwghtが100、200、300、400……、の各インスタンスとそれに対応する名前のIDが記録されている。名前はnameテーブルの該当のIDを取ってきて、それぞれ「Thin」「ExtraLight」「Light」「Regular」……、と表示できる。

これを逆引きして、axisの値からインスタンスを探すと、いい具合にウェイトの名前などを表示できた。

UI

データが取れたということでJetpack ComposeでUIを作る。単純にリストを作るだけなら簡単。特にこだわりもないので、シンプルに済ませた。

Material 3というやつがみんな好きなんだろうと思うので、なるべくそこからはみ出さないようにする。といってもTop App Barをそれらしくするくらい。同僚の記事を参考にした。

ナビゲーション時のトランジションがちょっと違う感じがするけど、いろいろ調べても容易にはMaterial 3のトランジションを再現できないので妥協した。

審査

2日間の開発でアプリがなんとなくできた。まだいくらでもやったらよさそうなことが思いつくが、そうしている間にデベロッパーアカウントが閉鎖されると本末転倒。ということで、Google Play Consoleでリリースを作成し、アプリをアップロードした。アプリ名もこだわりないので、フォントの一覧なら「Font List」だろうと決めた。アイコンもなんかそれらしく15分で作成。諸々のメタデータを埋めて、審査に提出。最近はGoogle Playもしっかり審査してくれる。

この時点で、Google Play Consoleのデベロッパーアカウントの閉鎖の危機は去った。

数営業日で審査が終わり、アプリを無事公開。

いかがでしたか

システムフォントが知りたい皆さまに大変おすすめの製品となっております。

もうちょっとよくできる気がするので、暇なときに改善するかもしれない。

Development Containersのfeatureを作る

OSによって作られるメタデータファイル(.DS_StoreとかThumbs.dbとか)をgitignoreするとき、プロジェクトじゃなくてグローバルの設定にしたい。それで長年 ~/.config/git/ignore にファイルを置いていた。内容はgithub/gitignoreから取ってくる。giboを使っているなら、gibo dump macOS > ~/.config/git/ignore するだけだ。

Development Container

最近Development Containersを使ってみていて、おおよそ気に入っているのだけど、このグローバルなgitignoreの扱いに悩んだ。手元のファイルシステムからマウントされるので、.DS_StoreファイルがDevelopment Containerの中から見えてしまう。しかしグローバルなgitignoreは(あえてマウントしなければ)設定されていないから、gitの差分に出てきてしまう。

もちろんプロジェクトの .gitignore ファイルに書いたらいいのだけど、どうも気乗りしない。ということで、Development Containersのfeatureとして作ってみる。

Featureとは

Development Containersについて何年前かに使ったときは、このfeatureという概念がなかったように思う。コンテナに何か追加したければDockerfileを書くような感じだった。ところが最近では、Development Containerにfeatureを適用することで、必要な機能を追加する。例えばコンテナにNode.jsを入れたければ、.devcontainer/devcontainer.jsonfeaturesghcr.io/devcontainers/features/nodeを書き加える。

  "features": {
    "ghcr.io/devcontainers/features/node:1": {}
  },

このように、featureはOCI Imageとしてパッケージングされ、配布される。Node.jsのfeatureはfeatures/src/node at main · devcontainers/features · GitHubでその実態を見られる。

Featureを作る

自分でfeatureを作るのは、テンプレートリポジトリから始めるのがいちばん良さそうだ。GitHub Actionsもよく整備されている。サンプルとなるfeatureとして、colorhelloが入っている。これを真似していく。

まずテンプレートリポジトリから自分のリポジトリを作る。ひとつのリポジトリで複数のfeatureを提供するのが普通なようだ。このリポジトリ自体がDevelopment Containerで開発するようになっているので、VS Codeからコンテナで開く。

src/以下にディレクトリを作って、devcontainer-feature.jsoninstall.shを置く。サンプルではREADME.mdもあるが、これは後から自動生成されるので、自分で作る必要がない。

src
└── gitignoreglobal
    ├── README.md
    ├── devcontainer-feature.json
    └── install.sh

devcontainer-feature.jsonの仕様に合わせて書けばよい。optionsを定義しておくと、featureへの入力として文字列か真偽値を得られる。

install.shの方が本体で、ここにシェルスクリプトを書く。これはrootとして実行される。Development Containerとして実行する際は、例えばvscodeユーザーなどで実行されるので、その差に注意が要る。実際、gitignoreglobal featureではsystemのgit configを書き換えることにした。あまり上品ではないが、後から作られるユーザーのことを知る由もないので、諦めた。

#!/bin/sh
set -e

GITIGNORE_PATH="$(git config --system --get core.excludesfile || true)"
if [ -z "${GITIGNORE_PATH}" ]; then
  GITIGNORE_PATH=/etc/gitignore
  git config --system --add core.excludesfile $GITIGNORE_PATH
fi
echo "Using global gitignore file: ${GITIGNORE_PATH}"

mkdir -p "$(dirname "${GITIGNORE_PATH}")"
curl -sS "https://raw.githubusercontent.com/github/gitignore/main/${GITIGNORE}.gitignore" >> "${GITIGNORE_PATH}"

optionsで設定した入力値は環境変数として渡されるので、$GITIGNOREとしてこれを使っている。

curlgitdevcontainer-feature.jsondependsOnを設定していることで使えている。

    "dependsOn": {
        "ghcr.io/devcontainers/features/common-utils": {}
    }

テスト

テンプレートリポジトリから始めると、test/にテストが入っている。scenarios.jsonにテストシナリオを書いて、キー名と一致するkeyname.shに実際のテストを書く。test.shはデフォルトのテストということに決まっている。

test/gitignoreglobal
├── macos.sh
├── scenarios.json
└── test.sh

今回はscenarios.jsonで、macOS用のテストを定義する。

{
    "macos": {
        "image": "mcr.microsoft.com/devcontainers/base:ubuntu",
        "features": {
            "gitignoreglobal": {
                "gitignore": "Global/macOS"
            }
        }
    }
}

macos.shは次のように書いた。source dev-container-features-test-libすると、checkreportResultsが使えるようになって、非常に便利。

#!/bin/bash

set -e

source dev-container-features-test-lib

# check <LABEL> <cmd> [args...]
mkdir -p /tmp/test_1
cd /tmp/test_1
git init

check "no difference at start" [ -z "$(git status --porcelain)" ]

touch .DS_Store
check "no difference after adding .DS_Store" [ -z "$(git status --porcelain)" ]

touch NOT_IGNORED
check "difference after adding NOT_IGNORED" [ -n "$(git status --porcelain)" ]

reportResults

実行するにはdevcontainer CLIを使う。devcontainer features test --features gitignoreglobalのようにすると、特定のfeatureのテストを実行できる。Docker in Dockerで、新しいコンテナの中で実行されるので、やろうと思えば複数のベースイメージに対してテストを実行させられる。

またGitHub Actionsでも実行されるようになっている。

デプロイ

デプロイもdevcontainer CLIでできるが、事前に設定されたGitHub Actionsでやるのが簡単だった。GitHub Packagesにデプロイできる。README.mddevcontainer-feature.jsonの内容から自動的に生成され、Pull Requestが作られるので、便利だ。

  "features": {
    "ghcr.io/cockscomb/devcontainer-features/gitignoreglobal:1": {
      "gitignore": "Global/macOS"
    }
  },

書いていて気がついたけど、macOSWindowsが混在している環境だったら複数指定したいと思う。options環境変数マッピングされる都合からか、文字列と真偽値しか受け付けないので、スペース区切りで"Global/macOS Global/Windows"のようにできるとよさそう。

ひとまずDevelopment Containersのfeatureを作ってみた。テンプレートリポジトリやCLIが整備されているおかげで、普通にやるとテストも書けるし、CI/CDも用意できる。OCI Imageとして配布されるのもハイテクな感じがする。全体的によくできたエコシステムと思う。

Apple Vision Pro故障録

お盆前、Apple Vision Proを着けると、左目の視野の下方に黄色い横線が見えた。図示すると次のようになる。

左目の視野下方に黄色い横線

もちろん現実に黄色い線があるのではなく、Apple Vision Proを介した視野にだけ黄色い線がある。嫌な汗が出てくるのを感じつつ、問題を切り分けていく。

  • 右目を閉じたり左目を閉じたりして、左目の視野にだけ表示されることを再確認
  • 再起動を試してみるが、改善しない
  • 環境」を使うと表示されない
  • 空間写真を撮ると、写真の下方にも写り込んだ

この時点で、ビデオパススルー用の左のカメラモジュールに発生した不具合である蓋然性が高い。ディスプレイの問題なら「環境」でも表示されるだろう。ということでGenius Barの予約を取った。

Genius Bar

土曜日にGenius Barへ行った。担当してくださるジーニアスの他に、「後学のため」ということで別なジーニアスも見学することになった。

症状を説明すると、すぐにビデオパススルーカメラの不具合だろうということになる。撮っておいた環境写真が役立った。その後、謎の操作によって診断プログラムモードが起動され、ワイヤレスで診断プログラムが実行される。

Apple Vision Proは店内では修理できないので、修理センターに送る旨を伝えられ、手続きをする。本体とバッテリーが目の前で専用の梱包資材に収納される。この時点で、基本的に無償であることも説明され、一安心。お盆を挟むこともあって、修理には1〜2週間かかるということだった。

ジーニアスの対応は終始丁寧で、ありがたかった。

電話での確認

3日後の火曜日、Appleのカスタマーサポートから電話が来る。エンジニアから質問があるということで、答えていく。

Apple Vision Proの修理についての経験が十分蓄積していないのかもしれないし、あるいは診断プログラムにカメラモジュールからの入力を検査する仕組みがないのかもしれない。人間の視覚に関する部分だから、人間側の方に問題がある場合もあるだろう。

改めて症状を説明し、撮っておいた写真を専用の仕組みでアップロードすると、状況をわかってもらえたようだった。

もちろんこの電話も丁寧だった。

修理

お盆なのか少し間が空いて、翌月曜日の午前に「製品の修理を開始いたします。」というメールが届く。そして夕方に「発送のご案内」メール。

ヤマト運輸の追跡サービスによると「ADSC支店」から「羽田クロノゲートベース」を経由している。これはApple Storeオンラインと同じ経路だが、修理センターも物流拠点と同じあたりにあるのだろう。

翌日、修理されたApple Vision Proが届く。Genius Barで見たのと同じ専用の梱包資材に入っていた。同封の書面によるとシリアル番号が変わっており、本体ごと交換になっていた。動作を確認したところ、当然ながら何の問題もなかった。

金曜日に再びAppleのカスタマーサポートから電話があり、様子を聞かれたので、問題ない旨を伝えた。

所感

まとめると、次のような経過を辿って修理が完了した。修理に出してから戻ってくるまで10日ほどだ。お盆を挟まなければもう少し早いかもしれない。この間Apple Vision Proを使用できなかったが、必需品というわけでもないし、仕方ない。ちょうど忙しいタイミングだったし、まあちょうどいい。

経過
0日目 不具合を発見
1日目 Genius Barで修理に出す
4日目 カスタマーサポートから電話
10日目 修理開始・発送
11日目 修理された製品の受け取り
14日目 カスタマーサポートから電話

また製品保証の範囲内ということで無償だった。Appleのハードウェア保証は原則的に国を跨いで有効なので、アメリカで買ったような場合でも安心だ。

プロセス全体について、Appleの皆さんが極めて丁寧であったことは何度書いても強調し足りないほどである。純粋に親切だったのもあるだろうし、それに加えてApple Vision Proのハードウェア的な問題への対応が現状まだ稀な症例だったということも想像できる。

ということで、またApple Vision Proを活用できるようになりました。