[Astro] #140 IndexedDB × Dexie.js で Web ターミナルに壁紙キャッシュを実装する — Blob 永続化と iframe 透過調整ログ

[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から接続

[Astro] #140 Web ターミナル壁紙カスタマイズと背景透過描画

htopコマンド実行

[Astro] #140 Web ターミナル壁紙カスタマイズと背景透過描画

オプション画面

[Astro] #140 Web ターミナル壁紙カスタマイズと背景透過描画

壁紙設定

[Astro] #140 Web ターミナル壁紙カスタマイズと背景透過描画

動画(GIF)

ターミナルから ssh コマンドで起動

[Astro] #140 Web ターミナル壁紙カスタマイズと背景透過描画

オプション画面

[Astro] #140 Web ターミナル壁紙カスタマイズと背景透過描画

TOP,HTOPコマンド、WINDOW resize、縮小化

[Astro] #140 Web ターミナル壁紙カスタマイズと背景透過描画

関連記事

バックエンド側(ttyd / Cloudflare Tunnel / Debian)の Web ターミナル基盤構築については、以下の記事で解説しています。

1. データフローとアーキテクチャ

既存の 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 大地雷と解決策

番号地雷カテゴリ主な症状根本原因
1URL の失効リロードすると壁紙が消えるURL.createObjectURL() で生成した URL はセッション終了時に無効化される
2背景遮断壁紙がヘッダー(タイトルバー)だけに映り、メインエリアが映らないiframe 内の ttydxterm.js)の背景色が標準の不透明な黒で塗りつぶされていた
3検証サイクルの遅さ描画スタイルの調整に本番 SSH 接続が必要ローカル環境で iframe 接続エラーが起きると壁紙の透け感を確認できない

地雷 1: Blob の永続化と URL.createObjectURL のメモリ管理

localStorage への保存はデータサイズの膨張を招くため、IndexedDB(Dexie.js)に FileBlob のサブクラス)を直接保存します。

初期化時に IndexedDB から取得した BlobURL.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)の背景遮断問題

親の divbackgroundImage を適用しても、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.jscanvas)のアルファ値を透過(rgba)に設定する必要がある。

これで、ブラウザを再起動しても自分好みのカスタム壁紙が維持されるサイバー感あふれる Web ターミナル環境が完成しました。

Web アプリケーション上でユーザーが選択したメディア素材を安全かつ高速に永続化したい方の参考になれば幸いです。