macOS 26 Tahoe における hdiutil/DMG の既知バグ

Compose Multiplatform で macOS 向けデスクトップアプリを開発していたとき、配布パッケージのビルドが失敗しました。
./gradlew :desktop-app:packageDistributionForCurrentOS を実行すると、jpackage が DMG 生成の途中でエラーを返して終了します。

原因はコードではなく、OS でした。macOS 26 Tahoeにおける hdiutil のバグです。

症状

hdiutil attach は終了コード 0 を返してもマウントされたボリュームが強制的に read-only になります。

$ mount | grep tabconv
/dev/disk34s1 on /private/tmp/tabconv_mount (hfs, local, nodev, nosuid, read-only, ...)

-readwrite フラグを明示しても挙動は変わりません。その結果、マウント後のファイルコピーが失敗します。

cp: /private/tmp/tabconv_mount/TabConv.app: Read-only file system

jpackage 経由では次のエラーとして表面化します。

java.io.IOException: Command [/usr/bin/hdiutil, attach, ...-tmp.dmg,
  -quiet, -mountroot, .../images] exited with 1 code
java.nio.file.FileSystemException: .../images/TabConv/TabConv.app: Read-only file system

根本原因(調査中)

Apple は macOS 26.4 Beta 1 のリリースノートで、HFS+ 外部メディアが自動マウントに失敗する場合がある旨を「known issue」(識別子: 168672160)として公式に認め、diskutil mount コマンドによるワークアラウンドを案内しました。

バグの詳細な原因については Apple から公式な説明はありませんが、コミュニティの調査では fsck_hfs(HFS+ ボリュームの整合性チェックツール)の異常が有力視されています。実際に fsck_hfs を手動実行すると次のような dyld エラーが発生します。

dyld[...]: Library not loaded: @rpath/libclang_rt.ubsan_osx_dynamic.dylib
Referenced from: .../fsck_hfs
Reason: tried: '...' (no such file)

これは UndefinedBehaviorSanitizer(UBSan)用のデバッグライブラリへの依存を示しており、内部デバッグビルドが誤って同梱されたことが原因ではないかと指摘されています。macOS はマウント時の整合性チェックが正常に完了しないと、書き込みを許可せずに read-only でマウントします。

Apple は Beta 2 のリリースに際し、フィードバックへの返答でこのバグの修正を確認しています。

影響を受ける主なツール

ツール 使用箇所
jpackage (JDK 21) .dmg / .pkg 生成(Compose Desktop の packageDistributionForCurrentOS
electron-builder macOS 向け DMG 生成
create-dmg HFS+ ベースの DMG 作成

背景:ASIF と HFS+ の位置づけ

macOS 26 では Apple Sparse Image Format(ASIF)が新たに導入されました。 ASIF はスパース構造を持ち、ほぼネイティブ速度での読み書きを実現します。 Apple は VM のバッキングストアを RAW(UDIF read-write)から ASIF へ移行するよう推奨しており、HFS+ ベースのディスクイメージは事実上レガシーとして扱われる方向にあります。

参考

Laravelのルートモデルバインディング

概要

ルートモデルバインディングとは、URLパラメータからEloquentモデルのインスタンスを自動的に解決する仕組みです。
コントローラで手動に find() を呼び出す冗長なコードをフレームワーク側に委譲できます。

暗黙的バインディング

最もシンプルな形式です。
コントローラのメソッド引数の型ヒントとルートパラメータ名が一致するだけで、Laravelが自動的にモデルを解決します。

// routes/web.php
Route::get('/posts/{post}', [PostController::class, 'show']);

// PostController.php
public function show(Post $post): Response
{
    return response()->json($post);
}

デフォルトでは主キー(id)を使ってモデルを検索します。
レコードが見つからない場合は自動的に404レスポンスを返します。

解決するキーのカスタマイズ

スラッグやUUIDなど、id 以外のカラムで解決したい場合は2通りの方法があります。

ルート定義での指定

Route::get('/posts/{post:slug}', [PostController::class, 'show']);

モデルでのオーバーライド

// Post.php
public function getRouteKeyName(): string
{
    return 'slug';
}

明示的バインディング

RouteServiceProvider または AppServiceProviderboot() メソッドでバインディングを明示的に定義します。
カスタムクエリや複雑なロジックが必要な場合に有効です。

// AppServiceProvider.php
public function boot(): void
{
    Route::model('post', Post::class);

    // または任意のロジックでモデルを解決する
    Route::bind('post', function (string $value): Post {
        return Post::where('slug', $value)->firstOrFail();
    });
}

スコープ付きバインディング

ネストしたルートで親子関係を強制したい場合に使います。

Route::get('/users/{user}/posts/{post}', [PostController::class, 'show'])
    ->scopeBindings();

これにより、{post}{user} に属するものだけが解決対象になります。
別ユーザーのリソースへのアクセスは404になるため、認可ロジックの一部を宣言的に表現できます。

ルートグループ全体に適用することも可能です。

Route::scopeBindings()->group(function () {
    Route::get('/users/{user}/posts/{post}', [PostController::class, 'show']);
});

ソフトデリートされたモデルの解決

デフォルトでは、ソフトデリートされたモデルは解決されません。
withTrashed() をルートに付与することで解決対象に含められます。

Route::get('/posts/{post}', [PostController::class, 'show'])
    ->withTrashed();

カスタム解決ロジック(resolveRouteBinding

モデル側でバインディングロジックを完全に制御したい場合は、resolveRouteBinding をオーバーライドします。

// Post.php
public function resolveRouteBinding(mixed $value, ?string $field = null): ?Model
{
    return $this->where($field ?? $this->getRouteKeyName(), $value)
        ->where('is_published', true)
        ->firstOrFail();
}

この方法はモデルにドメインロジックを閉じ込められる一方、解決ロジックがルート定義から見えなくなるため、チームの規模や可読性のトレードオフを考慮してください。

まとめ

方法 ユースケース
暗黙的バインディング 標準的なCRUD操作
{param:column} 記法 スラッグ・UUIDなど主キー以外での解決
明示的バインディング 複雑なクエリやカスタムロジック
スコープ付きバインディング 親子関係の強制・認可の宣言的表現
resolveRouteBinding モデル側にロジックを閉じ込めたい場合

ルートモデルバインディングを適切に活用することで、コントローラはモデルの取得処理から解放され、ビジネスロジックに集中できます。

LaravelにRepositoryパターンは不要か

Laravelにおいて、Repositoryパターンは多くの場合不要です。その理由は、Eloquentが採用するActive Recordパターンにあります。

Active Recordはデータとロジックを統合したパターンであり、モデル自身がデータアクセスの責務を担っています。その上にさらにRepositoryレイヤーを重ねるのは責務の重複であり、本質的な価値をもたらしません。Repositoryパターンが自然に機能するのは、ドメインモデルとデータアクセスが分離されたData Mapperパターン(Doctrineなど)との組み合わせでしょう。

また、Laravelの多くの機能はEloquentに深く統合されています。リレーション、Eager Loading、イベント、Observerといった機能群は、Eloquentを前提として設計されています。Eloquentを使わないなら、Laravelを選択する理由そのものが希薄になります。

Laravelの標準的なアーキテクチャは、ControllerからService層やActionクラスを呼び出し、その中でEloquent Modelを利用する形を基本とします。クエリロジックはクエリスコープやモデルメソッドでカプセル化します。より複雑なクエリが必要なら、Query ObjectsやQuery Buildersで専用クラスに分離すればよいでしょう。単一責任のビジネスロジックには、ActionsやCommandsといったパターンが有効です。

ただし例外もあります。複数のデータソース(データベース、API、キャッシュ等)を統一インターフェースで扱う必要がある場合や、チーム規約として厳密なレイヤー分離が求められる場合には、Repositoryパターンが有用な選択肢となりえます。重要なのは、パターンを盲目的に適用するのではなく、実際の要件に基づいて判断することです。

Macの開発環境のメンテナンス用シェルスクリプト

開発マシンのメンテナンスは、やるべきだと分かっていながら後回しにしがちな作業の一つです。

Homebrewのアップデート、Dockerの肥大化したキャッシュ、積み上がったDerivedData——個別には些細でも、放置すれば確実にディスクとパフォーマンスを蝕みます。

本記事では、私が普段使用しているシェルスクリプトを紹介します。

設計方針

メンテナンススクリプトには独特の要件があります。
一部のコマンドが失敗しても、残りは実行を続けてほしい。set -e を使わない理由はここにあります。

set -uo pipefail

-u で未定義変数の参照を防ぎ、pipefail でパイプ中の失敗を検知します。
-e を外すことで、例えば brew doctor が警告を返しても後続の処理は止まりません。

構成

スクリプトは5つのセクションから成ります。

Homebrewの更新とクリーンアップ

brew update && brew upgrade && brew upgrade --cask --greedy
brew cleanup && brew autoremove
brew doctor

--cask --greedy は、自動更新機能を持つアプリ(Chrome、Firefox等)も含めて強制的にアップグレードします。
brew cleanupbrew autoremove で古いバージョンと不要な依存を除去し、brew doctor で環境の整合性を確認します。

Mac App Store

mas upgrade

mas を使い、App Store経由でインストールしたアプリもCLIから更新します。

Xcode関連

xcrun simctl delete unavailable

使われなくなったシミュレータランタイムを削除します。
これだけで数十GBの空きが生まれることもあります。

DerivedDataについては、Xcodeの起動状態を確認してから削除します。

if ! pgrep -q Xcode; then
  rm -rf ~/Library/Developer/Xcode/DerivedData/*
fi

ビルドキャッシュを消すだけなので、次回ビルド時に再生成されます。
起動中のXcodeを壊す心配を避けるためのガードです。

パッケージマネージャのキャッシュ整理

npm と pnpm のキャッシュを整理します。
いずれも command -v で存在確認してから実行するため、インストールしていない環境でもエラーにはなりません。

npm cache verify    # 整合性チェック。全削除はしない
pnpm store prune    # 未参照パッケージの削除

npm cache clean --force ではなく verify を選んでいるのは、壊れたエントリだけを除去し、有効なキャッシュは維持するためです。

Docker クリーンアップ

if command -v docker &>/dev/null && docker info &>/dev/null; then
  docker system prune -f --volumes --filter "until=168h"
  docker builder prune -f --filter "until=168h"
fi

docker info でデーモンの起動確認を行い、停止中なら何もしません。
--filter "until=168h" により、1週間以内に使用したリソースは保持されます。
直近の作業を壊さない安全策です。

キャッシュ容量の可視化

最後に ~/Library/Caches の上位10ディレクトリを表示します。

du -sh ~/Library/Caches/* 2>/dev/null | sort -rh | head -n 10

自動削除はしません。
アプリケーションキャッシュの削除判断は人間に委ねるべきです。

使い方

適当な場所に保存して実行権限を付与します。

chmod +x ~/scripts/maintenance.sh

手動で定期実行してもいいですし、launchd でスケジュールしても構いません。
ただし、Homebrewやmasは対話的な認証を求めることがあるため、完全な無人実行には向かない点に留意してください。

スクリプト全文

#!/bin/bash

# -------------------------------------------
# エラーハンドリング方針:
#   メンテナンススクリプトの性質上、一部失敗しても続行したいため
#   set -e は使わず、重要なコマンドのみ個別にハンドリングする。
#   未定義変数の参照とパイプ途中の失敗は検知する。
# -------------------------------------------
set -uo pipefail

# ===========================================
# Homebrewパッケージ更新とクリーンアップ
# ===========================================
brew update && brew upgrade && brew upgrade --cask --greedy
brew cleanup && brew autoremove
brew doctor

# ===========================================
# Mac App Storeアプリのアップデート
# ===========================================
mas upgrade

# ===========================================
# Xcode関連の不要ファイル削除
# ===========================================
xcrun simctl delete unavailable

# Xcodeが起動中でなければDerivedDataを削除
if ! pgrep -q Xcode; then
  rm -rf ~/Library/Developer/Xcode/DerivedData/*
else
  echo "⚠ Xcodeが起動中のためDerivedDataの削除をスキップしました"
fi

# ===========================================
# パッケージマネージャのキャッシュ/ストア整理
# ===========================================
# npm キャッシュの検証(整合性チェックのみ、全削除はしない)
if command -v npm &>/dev/null; then
  echo "--- npm cache verify ---"
  npm cache verify
fi

# pnpm ストアの最適化(未参照の依存パッケージを削除)
if command -v pnpm &>/dev/null; then
  echo "--- pnpm store prune ---"
  pnpm store prune
fi

# ===========================================
# Docker クリーンアップ
# ===========================================
if command -v docker &>/dev/null && docker info &>/dev/null 2>&1; then
  echo "--- Docker system prune ---"
  # 1週間以上前の未使用リソース + 未使用ボリュームを削除
  docker system prune -f --volumes --filter "until=168h"

  echo "--- Docker builder prune ---"
  docker builder prune -f --filter "until=168h"
fi

# ===========================================
# キャッシュ容量の確認(削除は手動判断)
# ===========================================
echo ""
echo "=== ~/Library/Caches 容量 Top 10 ==="
du -sh ~/Library/Caches/* 2>/dev/null | sort -rh | head -n 10
echo ""
echo "※ 不要なキャッシュがあれば手動で削除してください"
echo "  例: rm -rf ~/Library/Caches/<アプリ名>"

まとめ

このスクリプトが行うのは「安全に削除できるものだけを削除し、判断が必要なものは報告する」ことです。
定型作業を自動化するだけでも、ディスク容量とビルド環境の健全性は改善します。