[Astro] #140 IndexedDB × Dexie.js で Web ターミナルに壁紙キャッシュを実装する — Blob 永続化と iframe 透過調整ログ
はじめに
デスクトップ体験を提供する Web アプリ「PROTOCOL.LAIN」のターミナルウィンドウ(WiredSshWindow)において、ユーザーが好みの壁紙画像を設定できるカスタマイズ機能を拡張しました。
ローカルファイル選択ダイアログから画像を読み込むだけの単純な実装では、ブラウザをリロードしたりタブを閉じたりした際に blob: URL の有効期限が切れ、壁紙が消失してしまいます。また、大容量の画像データを localStorage に Base64 文字列として保存すると、容量上限(約 5MB)の圧迫やパースコストが発生します。
本記事では、既にプロジェクト内で 3D モデルやテクスチャのキャッシュ層として運用している Dexie.js(IndexedDB 内部ラッパー) の db.textures テーブルを活用し、画像のバイナリ(Blob)をそのまま永続化・高速復元する仕組みと、iframe 内部(ttyd / xterm.js)の背景透過設定および開発用モックの構築手順について記録します。
スクリーンショット
ローカルサーバDebianへWEBから接続
htopコマンド実行
オプション画面
壁紙設定
動画(GIF)
ターミナルから ssh コマンドで起動
オプション画面
TOP,HTOPコマンド、WINDOW resize、縮小化
関連記事
バックエンド側(ttyd / Cloudflare Tunnel / Debian)の Web ターミナル基盤構築については、以下の記事で解説しています。
Debian + Cloudflare Tunnel + ttyd で構築するセキュアな Web ターミナル環境
Cloudflare Tunnel と ttyd を組み合わせて、ブラウザから安全にアクセスできる Web SSH ターミナルを構築する手順の記録。
lain-lab.com1. データフローとアーキテクチャ
既存の WiredCacheDB(Dexie インスタンス)に定義された textures テーブルを活用し、ローカルで選択された壁紙ファイルを Blob のまま保存します。
[ ユーザー操作 (ファイル選択) ]
│
▼
[ (File / Blob) ]
│
├─► db.textures.put({ id: 'TERMINAL_WALLPAPER_CURRENT', data: file }) ──► [ IndexedDB ]
│ │ (永続化)
└─► URL.createObjectURL(file) │
│ │ (ページリロード時)
▼ ▼
[ bgImageDataUrl (React State) ] ◄── URL.createObjectURL(cached.data) ◄────────┘
│
▼
[ CSS style: backgroundImage ]
│
├─► [ ウィンドウ枠 / ヘッダー (半透明) ]
└─► [ Terminal Content Area ]
│
├─ (開発時) [ Local Test Mock View (背景 transparent) ]
└─ (本番時) [ ttyd (xterm.js theme: rgba 背景透過) ]
2. 開発で直面した 3 大地雷と解決策
| 番号 | 地雷カテゴリ | 主な症状 | 根本原因 |
|---|---|---|---|
| 1 | URL の失効 | リロードすると壁紙が消える | URL.createObjectURL() で生成した URL はセッション終了時に無効化される |
| 2 | 背景遮断 | 壁紙がヘッダー(タイトルバー)だけに映り、メインエリアが映らない | iframe 内の ttyd(xterm.js)の背景色が標準の不透明な黒で塗りつぶされていた |
| 3 | 検証サイクルの遅さ | 描画スタイルの調整に本番 SSH 接続が必要 | ローカル環境で iframe 接続エラーが起きると壁紙の透け感を確認できない |
地雷 1: Blob の永続化と URL.createObjectURL のメモリ管理
localStorage への保存はデータサイズの膨張を招くため、IndexedDB(Dexie.js)に File(Blob のサブクラス)を直接保存します。
初期化時に IndexedDB から取得した Blob を URL.createObjectURL でメモリ展開し、新しい画像がアップロードされた際や削除時には URL.revokeObjectURL を呼び出して旧 URL を解放します。
// 壁紙のロードと解放処理
useEffect(() => {
const loadWallpaper = async () => {
try {
const cached = await db.textures.get('TERMINAL_WALLPAPER_CURRENT');
if (cached) {
setBgImageDataUrl(URL.createObjectURL(cached.data));
}
} catch (err) {
console.error('[WALLPAPER_CACHE_LOAD_ERROR]', err);
}
};
loadWallpaper();
}, []);
地雷 2: iframe(ttyd / xterm.js)の背景遮断問題
親の div に backgroundImage を適用しても、iframe 内部で動作する xterm.js の <canvas> 背景が不透明な黒に塗りつぶされている場合、ヘッダー(透け感のある枠)にしか壁紙が表示されない現象が発生します。
- 解決策: サーバー側で
ttydを起動する際、-tオプションでxterm.jsテーマの背景色に透明度(rgba)を指定します。
# ttyd 起動オプション例: 背景を alpha: 0.4 に指定して透過させる
ttyd -t theme='{"background": "rgba(0, 0, 0, 0.4)"}' bash
地雷 3: ローカル検証用モック表示(Test Mode)の追加
本番の iframe URL(https://ssh.lain-lab.com/)に依存せず、ローカル開発環境(localhost:4321)で壁紙の透過グラデーションやフォント・色のフィット感を即座に確認できるよう、ダミーのターミナル出力(top コマンド風 UI)を描画するテスト表示分岐を導入しました。
3. 核心実装コード解剖
src/lib/db.ts(IndexedDB テーブル定義)
import Dexie, { type Table } from 'dexie';
export interface AssetData {
id: string;
data: Blob;
version: string;
thumb?: string;
}
export class WiredCacheDB extends Dexie {
models!: Table<AssetData>;
textures!: Table<AssetData>; // 共通テクスチャ・壁紙用テーブル
constructor() {
super('WiredAssetCache');
this.version(5).stores({
models: 'id',
textures: 'id'
});
}
}
export const db = new WiredCacheDB();
WiredSshWindow.tsx(壁紙ロード・保存・表示ロジック抜粋)
import React, { useState, useEffect } from 'react';
import { db } from '../../../lib/db';
const WALLPAPER_CACHE_KEY = 'TERMINAL_WALLPAPER_CURRENT';
export const WiredSshWindow = () => {
const [bgImageDataUrl, setBgImageDataUrl] = useState<string>('');
const [showConfig, setShowConfig] = useState(false);
// 1. キャッシュからの読み込み
useEffect(() => {
const loadWallpaper = async () => {
try {
const cached = await db.textures.get(WALLPAPER_CACHE_KEY);
if (cached) {
setBgImageDataUrl(URL.createObjectURL(cached.data));
}
} catch (err) {
console.error('[WALLPAPER_CACHE_LOAD_ERROR]', err);
}
};
loadWallpaper();
}, []);
// 2. 新規アップロード処理
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
await db.textures.put({
id: WALLPAPER_CACHE_KEY,
data: file,
version: '1.0',
});
if (bgImageDataUrl.startsWith('blob:')) {
URL.revokeObjectURL(bgImageDataUrl);
}
setBgImageDataUrl(URL.createObjectURL(file));
} catch (err) {
console.error('[WALLPAPER_CACHE_SAVE_ERROR]', err);
}
};
// 3. 削除処理
const handleClearWallpaper = async () => {
try {
await db.textures.delete(WALLPAPER_CACHE_KEY);
if (bgImageDataUrl.startsWith('blob:')) {
URL.revokeObjectURL(bgImageDataUrl);
}
setBgImageDataUrl('');
} catch (err) {
console.error('[WALLPAPER_CACHE_DELETE_ERROR]', err);
}
};
return (
<div
style={{
width: '700px',
height: '450px',
backgroundImage: bgImageDataUrl ? `url("${bgImageDataUrl}")` : 'none',
backgroundSize: 'cover',
backgroundPosition: 'center',
display: 'flex',
flexDirection: 'column',
}}
>
{/* 設定パネルやヘッダー表示 */}
{/* ... */}
{/* メイン描画領域 */}
<div style={{ flex: 1, position: 'relative' }}>
{typeof window !== 'undefined' && window.location.hostname === 'localhost' ? (
/* ローカル検証用モック */
<div style={{ padding: '12px', color: '#00ffcc', background: 'transparent' }}>
<div>lain login: guest</div>
<div>--- LOCAL TEST MODE (WALLPAPER & TEXT PREVIEW) ---</div>
</div>
) : (
/* 本番用 iframe */
<iframe
src="[https://ssh.lain-lab.com/](https://ssh.lain-lab.com/)"
style={{ width: '100%', height: '100%', border: 'none', backgroundColor: 'transparent' }}
/>
)}
</div>
</div>
);
};
4. まとめ
- IndexedDB × Blob 保存の優位性: 画像バイナリをそのまま Dexie.js 経由で IndexedDB に保存することで、Base64 変換のオーバーヘッドや
localStorage容量制限を気にせず永続化できる。 - メモリリークの防止:
URL.createObjectURLを使った動的 URL 生成時、再読み込みや画像削除のタイミングでURL.revokeObjectURLを明示的に呼ぶ構造が重要。 iframe/ 端末描画の透過調整: 壁紙を背景に敷く際は、親要素の CSS だけでなくiframe内部のレンダラー(xterm.jsやcanvas)のアルファ値を透過(rgba)に設定する必要がある。
これで、ブラウザを再起動しても自分好みのカスタム壁紙が維持されるサイバー感あふれる Web ターミナル環境が完成しました。
Web アプリケーション上でユーザーが選択したメディア素材を安全かつ高速に永続化したい方の参考になれば幸いです。