T-CREATOR

Tauri × PWA:Web アプリとデスクトップの橋渡し

Tauri × PWA:Web アプリとデスクトップの橋渡し

近年、アプリケーション開発の世界では、Web とデスクトップアプリケーションの境界線が曖昧になってきています。そんな中、注目を集めているのが Tauri と PWA(Progressive Web Apps)の組み合わせです。

この革新的なアプローチは、従来の開発手法では困難だった「一つのコードベースで、Web もデスクトップもネイティブレベルのユーザー体験を提供する」という理想を現実のものにしつつあります。開発者にとっては生産性の向上を、ユーザーにとってはより快適なアプリ体験をもたらす、まさに次世代の開発手法と言えるでしょう。

本記事では、Tauri と PWA の基礎から、実際の開発における具体的な実装方法まで、初心者の方にもわかりやすく解説いたします。

背景

Tauri とは何か

Tauri は、Rust 言語で開発されたクロスプラットフォームのデスクトップアプリケーション開発フレームワークです。従来の Electron のような重いランタイムに代わる、軽量で高速な選択肢として注目されています。

最大の特徴は、システムの各プラットフォームネイティブの WebView を活用することです。これにより、Chromium エンジンを内包する必要がなく、アプリケーションサイズを大幅に削減できます。

typescript// Tauri APIの基本的な使用例
import { invoke } from '@tauri-apps/api/tauri';

// Rustバックエンドの関数を呼び出し
const result = await invoke('greet', { name: 'World' });
console.log(result); // "Hello, World!"

以下の図は、Tauri の基本的なアーキテクチャを示しています:

mermaidflowchart TB
  frontend[フロントエンド<br/>HTML/CSS/JS] -->|API呼び出し| webview[システムWebView]
  webview -->|メッセージ| core[Tauriコア<br/>Rust]
  core -->|システムコール| os[OS機能<br/>ファイル/ネットワーク等]
  core -->|レスポンス| webview
  webview -->|結果| frontend

PWA とは何か

PWA(Progressive Web Apps)は、Web テクノロジーを使って、ネイティブアプリのような体験を提供するアプリケーションです。Google が主導している技術で、「段階的な機能向上」という概念に基づいています。

PWA の核となる技術要素は以下の通りです:

#技術要素役割
1Service Workerオフライン対応、キャッシュ管理
2Web App Manifestアプリメタデータ、インストール対応
3HTTPSセキュアな通信

Service Worker の基本的な実装例をご覧ください:

javascript// service-worker.js
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open('v1').then((cache) => {
      return cache.addAll([
        '/',
        '/index.html',
        '/style.css',
        '/app.js',
      ]);
    })
  );
});
javascript// フェッチイベントでキャッシュファーストの戦略を実装
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((response) => {
      // キャッシュから返すか、ネットワークから取得
      return response || fetch(event.request);
    })
  );
});

PWA の段階的機能向上の流れを図で確認しましょう:

mermaidflowchart LR
  basic[基本Web<br/>ページ] -->|HTTPS追加| secure[セキュア<br/>Webページ]
  secure -->|Manifest追加| installable[インストール<br/>可能なアプリ]
  installable -->|ServiceWorker追加| pwa[完全なPWA<br/>オフライン対応]

従来のデスクトップアプリ開発の課題

従来のデスクトップアプリ開発には、開発者が直面する多くの課題がありました。

まず、プラットフォーム固有の技術習得が必要でした。Windows 向けには.NET や Win32 API、macOS 向けには Swift や Objective-C、Linux 向けには GTK や Qt といった具合に、それぞれ異なる技術スタックを学習する必要がありました。

csharp// Windows .NET の例
using System;
using System.Windows.Forms;

public partial class Form1 : Form
{
    public Form1()
    {
        InitializeComponent();
    }

    private void button1_Click(object sender, EventArgs e)
    {
        MessageBox.Show("Hello World!");
    }
}
swift// macOS Swift の例
import Cocoa

class ViewController: NSViewController {
    @IBAction func buttonClicked(_ sender: NSButton) {
        let alert = NSAlert()
        alert.messageText = "Hello World!"
        alert.runModal()
    }
}

さらに、配布とメンテナンスの複雑さも大きな問題でした。各プラットフォームごとに異なるパッケージング、署名、配布チャネルを管理する必要があり、更新システムも個別に実装しなければなりませんでした。

従来のデスクトップアプリ開発における課題構造:

mermaidflowchart TD
  dev[開発チーム] -->|Windows開発| win_team[Windows専門]
  dev -->|macOS開発| mac_team[macOS専門]
  dev -->|Linux開発| linux_team[Linux専門]

  win_team -->|異なる技術| win_tech[.NET/WinAPI]
  mac_team -->|異なる技術| mac_tech[Swift/Cocoa]
  linux_team -->|異なる技術| linux_tech[GTK/Qt]

  win_tech -->|個別対応| maintenance[保守・配布]
  mac_tech -->|個別対応| maintenance
  linux_tech -->|個別対応| maintenance

課題

Web アプリの限界

Web アプリケーションは、ブラウザという制約の中で動作するため、ネイティブアプリケーションと比較していくつかの根本的な限界を抱えています。

ファイルシステムアクセスの制限が最も顕著な問題です。セキュリティ上の理由から、Web アプリは任意のファイルにアクセスできません。File System Access API が導入されつつありますが、対応ブラウザは限定的です。

javascript// Web標準のファイルアクセス(制限付き)
const input = document.createElement('input');
input.type = 'file';
input.onchange = (event) => {
  const file = event.target.files[0];
  // ユーザーが選択したファイルのみアクセス可能
  console.log(file.name);
};

システム統合の困難さも重要な課題です。通知システム、システムトレイ、自動起動、深い OS 統合などは、Web アプリでは実現が困難または不可能です。

さらに、パフォーマンスの制約があります。JavaScript の実行速度、メモリ使用量、ガベージコレクションによる一時停止など、重い処理には向いていません。

Web アプリの制限事項を整理すると以下のようになります:

#制限項目影響
1ファイルシステム任意ファイルアクセス不可
2ネットワークCORS 制限、プロキシ必要
3システム統合OS 機能への直接アクセス困難
4パフォーマンスCPU 集約的処理に不向き

デスクトップアプリ開発の複雑さ

デスクトップアプリケーション開発では、技術的な複雑さが開発効率を大幅に低下させています。

UI フレームワークの選択から既に複雑です。各プラットフォームには複数の選択肢があり、それぞれに学習コストと制約があります。

javascript// Electron を使った場合の例
const { app, BrowserWindow } = require('electron');

function createWindow() {
  const mainWindow = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      nodeIntegration: true,
    },
  });

  mainWindow.loadFile('index.html');
}

app.whenReady().then(createWindow);

ビルドシステムの構築も困難です。各プラットフォーム向けのコンパイル環境、依存関係管理、コード署名、パッケージングなど、多くの要素を組み合わせる必要があります。

yaml# GitHub Actions でのマルチプラットフォームビルド例
name: Build
on: [push]
jobs:
  build:
    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest, macos-latest]
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v2
      - name: Setup Node
        uses: actions/setup-node@v2
      - name: Install dependencies
        run: npm install
      - name: Build
        run: npm run build:${{ matrix.os }}

配布とアップデートの仕組みも、プラットフォームごとに大きく異なります。Windows Store、Mac App Store、各種 Linux パッケージマネージャーなど、それぞれ異なる要件と審査プロセスがあります。

デスクトップアプリ開発の複雑さの構造:

mermaidflowchart TD
  start[アプリ開発開始] -->|技術選択| tech_choice{プラットフォーム}
  tech_choice -->|Windows| win_complex[WinUI/WPF/.NET<br/>Visual Studio<br/>MSI/MSIX]
  tech_choice -->|macOS| mac_complex[SwiftUI/AppKit<br/>Xcode<br/>DMG/App Store]
  tech_choice -->|Linux| linux_complex[GTK/Qt<br/>多様なIDE<br/>多様なパッケージ形式]

  win_complex -->|個別学習| complexity[開発複雑度増加]
  mac_complex -->|個別学習| complexity
  linux_complex -->|個別学習| complexity

クロスプラットフォーム対応の難しさ

現代のソフトウェア開発では、多くのユーザーが異なるオペレーティングシステムを使用しているため、クロスプラットフォーム対応は必須要件となっています。しかし、この実現は多くの困難を伴います。

一貫性のある UI/UXの実現が最大の課題です。各プラットフォームには固有のデザインガイドライン、ユーザーの期待値、操作パターンがあります。

typescript// プラットフォーム固有の処理分岐例
const getPlatformSpecificStyle = () => {
  switch (process.platform) {
    case 'darwin': // macOS
      return {
        titleBarStyle: 'hiddenInset',
        border: false,
      };
    case 'win32': // Windows
      return {
        titleBarStyle: 'default',
        border: true,
      };
    default: // Linux
      return {
        titleBarStyle: 'default',
        border: true,
      };
  }
};

テスト環境の構築も大きな負担です。各プラットフォームでの動作確認、自動テストの実行、CI/CD パイプラインの構築など、開発リソースが分散してしまいます。

dockerfile# Docker を使った Linux テスト環境例
FROM ubuntu:20.04
RUN apt-get update && apt-get install -y \
    nodejs \
    npm \
    xvfb \
    libgtk-3-dev
COPY . /app
WORKDIR /app
RUN npm install && npm test

配布チャネルの管理も複雑化します。各プラットフォームの公式ストア、サードパーティの配布サイト、直接配布など、多くのチャネルを管理する必要があります。

クロスプラットフォーム開発の課題フロー:

mermaidflowchart LR
  single_code[単一コードベース] -->|分岐| platform_specific{プラットフォーム特化}
  platform_specific -->|Windows| win_issues[Windows固有問題<br/>・UI/UX差異<br/>・配布方式<br/>・テスト環境]
  platform_specific -->|macOS| mac_issues[macOS固有問題<br/>・署名要件<br/>・App Store審査<br/>・ハードウェア制約]
  platform_specific -->|Linux| linux_issues[Linux固有問題<br/>・ディストリビューション差異<br/>・パッケージ形式<br/>・依存関係管理]

  win_issues -->|統合| maintenance_burden[保守負担増大]
  mac_issues -->|統合| maintenance_burden
  linux_issues -->|統合| maintenance_burden

解決策

Tauri × PWA のアプローチ

Tauri と PWA の組み合わせは、前述の課題に対する革新的な解決策を提供します。この「ハイブリッドアプローチ」により、単一のコードベースで複数の配布形態を実現できるのです。

統一されたコードベースにより、開発効率が劇的に向上します。React、Vue、Angular などの既存の Web フレームワークをそのまま活用しながら、必要に応じてネイティブ機能にアクセスできます。

typescript// 統一されたフロントエンドコード
import { useState, useEffect } from 'react';
import { invoke } from '@tauri-apps/api/tauri';

function App() {
  const [isDesktop, setIsDesktop] = useState(false);

  useEffect(() => {
    // Tauri環境かどうかを判定
    setIsDesktop('__TAURI__' in window);
  }, []);

  const handleSaveFile = async () => {
    if (isDesktop) {
      // デスクトップ版:ネイティブファイル保存
      await invoke('save_file', {
        content: 'Hello World!',
      });
    } else {
      // PWA版:Web標準のダウンロード
      const blob = new Blob(['Hello World!'], {
        type: 'text/plain',
      });
      const url = URL.createObjectURL(blob);
      const a = document.createElement('a');
      a.href = url;
      a.download = 'file.txt';
      a.click();
    }
  };

  return (
    <div>
      <h1>Tauri × PWA アプリ</h1>
      <p>実行環境: {isDesktop ? 'デスクトップ' : 'Web'}</p>
      <button onClick={handleSaveFile}>
        ファイルを保存
      </button>
    </div>
  );
}

段階的機能提供により、ユーザーの環境に応じて最適な体験を提供できます。Web ブラウザでは PWA として、デスクトップでは Tauri アプリとして、それぞれの環境の利点を最大限活用します。

Tauri × PWA のアーキテクチャ全体像:

mermaidflowchart TB
  code[統一コードベース<br/>React/Vue/Angular]

  code -->|ビルド分岐| web_build[Web版ビルド]
  code -->|ビルド分岐| desktop_build[デスクトップ版ビルド]

  web_build -->|PWA| browser[ブラウザ実行<br/>・Service Worker<br/>・Web API制限あり]

  desktop_build -->|Tauri| desktop[デスクトップ実行<br/>・ネイティブAPI<br/>・システム統合]

  browser -->|段階的向上| install_pwa[PWAインストール<br/>・ホーム画面追加<br/>・オフライン対応]

  desktop -->|配布| native_dist[ネイティブ配布<br/>・.exe/.dmg/.AppImage<br/>・ストア配布]

ハイブリッド配布戦略

Tauri × PWA の最大の魅力は、マルチチャネル配布が可能になることです。同じコードベースから、Web 版とデスクトップ版を生成し、ユーザーの好みや環境に応じて最適な配布方法を選択できます。

Web 配布では、従来の Web アプリケーションと同様に、URL アクセスですぐに利用開始できます。PWA 機能により、ブラウザからホーム画面への追加やオフライン利用も可能です。

json// Web版のマニフェストファイル例
{
  "name": "My Hybrid App",
  "short_name": "HybridApp",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#000000",
  "icons": [
    {
      "src": "/icon-192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "/icon-512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ]
}

デスクトップ配布では、ネイティブアプリケーションとしてのフル機能を提供できます。Tauri の設定ファイルで、アプリケーションの詳細を定義します。

json// Tauri設定ファイル(tauri.conf.json)例
{
  "package": {
    "productName": "My Hybrid App",
    "version": "1.0.0"
  },
  "tauri": {
    "allowlist": {
      "all": false,
      "fs": {
        "all": true,
        "scope": ["$APPDATA/*", "$DOCUMENT/*"]
      },
      "dialog": {
        "all": true
      }
    },
    "bundle": {
      "active": true,
      "targets": ["dmg", "msi", "appimage"],
      "identifier": "com.example.hybridapp"
    }
  }
}

配布戦略の比較表:

#配布方式利点適用シーン
1Web/PWA即座にアクセス可能初回利用、試用
2デスクトップ版フル機能、オフライン完全対応ヘビーユーザー、業務利用
3ストア配布信頼性、自動更新一般消費者向け

開発効率の向上

Tauri × PWA アプローチにより、開発チームの生産性は大幅に向上します。最も重要なのは、スキルセットの統一です。

フロントエンド中心の開発が可能になります。チーム全体が HTML、CSS、JavaScript の知識を共有し、React や Vue などの現代的なフレームワークを活用できます。

typescript// 型安全な Tauri API の利用例
import { save } from '@tauri-apps/api/dialog';
import { writeTextFile } from '@tauri-apps/api/fs';

interface AppData {
  title: string;
  content: string;
  lastModified: Date;
}

const saveDocument = async (
  data: AppData
): Promise<void> => {
  try {
    const filePath = await save({
      filters: [
        {
          name: 'Text Files',
          extensions: ['txt'],
        },
      ],
    });

    if (filePath) {
      await writeTextFile(
        filePath,
        JSON.stringify(data, null, 2)
      );
    }
  } catch (error) {
    console.error('ファイル保存エラー:', error);
  }
};

開発ツールチェーンの一元化により、ビルド、テスト、デプロイメントプロセスが簡素化されます。

json// package.json での統一されたスクリプト例
{
  "scripts": {
    "dev": "vite",
    "dev:tauri": "tauri dev",
    "build": "vite build",
    "build:tauri": "tauri build",
    "test": "vitest",
    "lint": "eslint src",
    "type-check": "tsc --noEmit"
  }
}

Hot Reload とデバッグも統一された環境で行えます。Web 開発で慣れ親しんだ DevTools をそのまま使用できます。

開発効率向上の流れ:

mermaidflowchart LR
  team[開発チーム] -->|統一スキル| frontend[フロントエンド技術<br/>React/Vue/TypeScript]
  frontend -->|共通ツール| devtools[開発ツール<br/>・Vite/Webpack<br/>・ESLint/Prettier<br/>・Jest/Vitest]
  devtools -->|一元化ビルド| build[ビルドシステム<br/>・Web版自動生成<br/>・Tauri版自動生成]
  build -->|効率化| productivity[生産性向上<br/>・学習コスト削減<br/>・保守性向上<br/>・デプロイ簡素化]

具体例

基本的な Tauri PWA アプリの構築

実際に Tauri × PWA アプリを構築してみましょう。まずは、プロジェクトの初期セットアップから始めます。

プロジェクトの初期化

bash# Tauri プロジェクトの作成
npm create tauri-app@latest my-hybrid-app
cd my-hybrid-app

# 依存関係のインストール
yarn install
bash# PWA 関連のパッケージを追加
yarn add vite-plugin-pwa workbox-window
yarn add -D @types/serviceworker

Vite 設定で PWA 機能を有効化

Vite の設定ファイルに PWA プラグインを追加します:

typescript// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { VitePWA } from 'vite-plugin-pwa';

export default defineConfig({
  plugins: [
    react(),
    VitePWA({
      registerType: 'autoUpdate',
      includeAssets: [
        'favicon.ico',
        'apple-touch-icon.png',
      ],
      manifest: {
        name: 'My Hybrid App',
        short_name: 'HybridApp',
        description: 'Tauri × PWA ハイブリッドアプリ',
        theme_color: '#ffffff',
        background_color: '#ffffff',
        display: 'standalone',
        icons: [
          {
            src: 'pwa-192x192.png',
            sizes: '192x192',
            type: 'image/png',
          },
          {
            src: 'pwa-512x512.png',
            sizes: '512x512',
            type: 'image/png',
          },
        ],
      },
    }),
  ],
});

環境判定と API の条件分岐

アプリが Tauri 環境で動作しているかを判定し、それに応じて機能を切り替えます:

typescript// src/utils/platform.ts
export const isTauri = (): boolean => {
  return (
    typeof window !== 'undefined' && '__TAURI__' in window
  );
};

export const getAppVersion = async (): Promise<string> => {
  if (isTauri()) {
    const { getVersion } = await import(
      '@tauri-apps/api/app'
    );
    return await getVersion();
  }
  return '1.0.0'; // Web版のデフォルトバージョン
};
typescript// src/components/FileManager.tsx
import React, { useState } from 'react';
import { isTauri } from '../utils/platform';

export const FileManager: React.FC = () => {
  const [content, setContent] = useState<string>('');

  const handleSaveFile = async () => {
    if (isTauri()) {
      // Tauri環境:ネイティブファイル保存
      const { save } = await import(
        '@tauri-apps/api/dialog'
      );
      const { writeTextFile } = await import(
        '@tauri-apps/api/fs'
      );

      const filePath = await save({
        filters: [
          {
            name: 'Text',
            extensions: ['txt'],
          },
        ],
      });

      if (filePath) {
        await writeTextFile(filePath, content);
        alert('ファイルが保存されました');
      }
    } else {
      // Web環境:ダウンロード
      const blob = new Blob([content], {
        type: 'text/plain',
      });
      const url = URL.createObjectURL(blob);
      const a = document.createElement('a');
      a.href = url;
      a.download = 'document.txt';
      document.body.appendChild(a);
      a.click();
      document.body.removeChild(a);
      URL.revokeObjectURL(url);
    }
  };

  return (
    <div>
      <textarea
        value={content}
        onChange={(e) => setContent(e.target.value)}
        placeholder='ここにテキストを入力...'
        rows={10}
        cols={50}
      />
      <br />
      <button onClick={handleSaveFile}>
        {isTauri() ? 'ファイルを保存' : 'ダウンロード'}
      </button>
    </div>
  );
};

基本的なアプリ構築の流れ:

mermaidflowchart TD
  init[プロジェクト初期化] -->|Tauri + Vite| setup[開発環境セットアップ]
  setup -->|PWA Plugin| pwa_config[PWA設定追加]
  pwa_config -->|環境判定| platform_detect[プラットフォーム判定実装]
  platform_detect -->|条件分岐| api_impl[API実装分岐]
  api_impl -->|ビルド| build_both[Web版・Tauri版<br/>同時ビルド]

Service Worker との連携

PWA の中核機能である Service Worker を効果的に活用することで、オフライン対応とパフォーマンス向上を実現できます。

Service Worker の登録

アプリケーション起動時に Service Worker を登録します:

typescript// src/main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { registerSW } from 'virtual:pwa-register';

// Service Worker の自動更新設定
const updateSW = registerSW({
  onNeedRefresh() {
    if (
      confirm(
        '新しいバージョンが利用可能です。更新しますか?'
      )
    ) {
      updateSW(true);
    }
  },
  onOfflineReady() {
    console.log('アプリがオフラインで利用可能になりました');
  },
});

ReactDOM.createRoot(
  document.getElementById('root')!
).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

カスタム Service Worker の実装

より細かい制御が必要な場合は、カスタム Service Worker を実装します:

javascript// public/sw.js
const CACHE_NAME = 'hybrid-app-v1';
const urlsToCache = [
  '/',
  '/index.html',
  '/static/js/bundle.js',
  '/static/css/main.css',
  '/manifest.json',
];

// インストール時のキャッシュ
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => {
      console.log('キャッシュを開いています');
      return cache.addAll(urlsToCache);
    })
  );
});
javascript// フェッチイベントの処理
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((response) => {
      // キャッシュから発見された場合は返す
      if (response) {
        return response;
      }

      // ネットワークから取得を試行
      return fetch(event.request).then((response) => {
        // 無効なレスポンスはキャッシュしない
        if (
          !response ||
          response.status !== 200 ||
          response.type !== 'basic'
        ) {
          return response;
        }

        // レスポンスをクローンしてキャッシュに保存
        const responseToCache = response.clone();
        caches.open(CACHE_NAME).then((cache) => {
          cache.put(event.request, responseToCache);
        });

        return response;
      });
    })
  );
});

Tauri 環境での Service Worker 連携

Tauri 環境では、Service Worker とネイティブ API を組み合わせてより高度な機能を実現できます:

typescript// src/services/CacheManager.ts
import { isTauri } from '../utils/platform';

export class CacheManager {
  private static async getTauriCache(): Promise<Map<
    string,
    any
  > | null> {
    if (!isTauri()) return null;

    try {
      const { readTextFile, exists } = await import(
        '@tauri-apps/api/fs'
      );
      const { appDataDir } = await import(
        '@tauri-apps/api/path'
      );

      const appDir = await appDataDir();
      const cacheFile = `${appDir}/cache.json`;

      if (await exists(cacheFile)) {
        const content = await readTextFile(cacheFile);
        return new Map(JSON.parse(content));
      }
    } catch (error) {
      console.error(
        'Tauriキャッシュ読み込みエラー:',
        error
      );
    }

    return new Map();
  }

  private static async saveTauriCache(
    cache: Map<string, any>
  ): Promise<void> {
    if (!isTauri()) return;

    try {
      const { writeTextFile, createDir } = await import(
        '@tauri-apps/api/fs'
      );
      const { appDataDir } = await import(
        '@tauri-apps/api/path'
      );

      const appDir = await appDataDir();
      await createDir(appDir, { recursive: true });

      const cacheFile = `${appDir}/cache.json`;
      const content = JSON.stringify(
        Array.from(cache.entries())
      );
      await writeTextFile(cacheFile, content);
    } catch (error) {
      console.error('Tauriキャッシュ保存エラー:', error);
    }
  }

  public static async getItem<T>(
    key: string
  ): Promise<T | null> {
    if (isTauri()) {
      const cache = await this.getTauriCache();
      return cache?.get(key) || null;
    } else {
      // Web環境ではlocalStorageを使用
      const item = localStorage.getItem(key);
      return item ? JSON.parse(item) : null;
    }
  }

  public static async setItem<T>(
    key: string,
    value: T
  ): Promise<void> {
    if (isTauri()) {
      const cache =
        (await this.getTauriCache()) || new Map();
      cache.set(key, value);
      await this.saveTauriCache(cache);
    } else {
      localStorage.setItem(key, JSON.stringify(value));
    }
  }
}

Service Worker 連携の構造:

mermaidsequenceDiagram
  participant App as アプリケーション
  participant SW as Service Worker
  participant Cache as ブラウザキャッシュ
  participant Tauri as Tauriネイティブ
  participant FS as ファイルシステム

  App->>SW: リソース要求
  SW->>Cache: キャッシュ確認

  alt キャッシュ存在
    Cache->>SW: キャッシュされたレスポンス
    SW->>App: レスポンス返却
  else キャッシュなし
    SW->>Tauri: ネイティブストレージ確認
    Tauri->>FS: ファイル読み取り
    FS->>Tauri: データ返却
    Tauri->>SW: ネイティブキャッシュ
    SW->>App: レスポンス返却
  end

オフライン対応の実装

オフライン機能は、PWA と Tauri アプリの両方で重要な機能です。ネットワーク状況に関係なく、ユーザーが快適にアプリを利用できるように実装しましょう。

ネットワーク状態の監視

typescript// src/hooks/useNetworkStatus.ts
import { useState, useEffect } from 'react';
import { isTauri } from '../utils/platform';

interface NetworkStatus {
  isOnline: boolean;
  isSlowConnection: boolean;
}

export const useNetworkStatus = (): NetworkStatus => {
  const [networkStatus, setNetworkStatus] =
    useState<NetworkStatus>({
      isOnline: navigator.onLine,
      isSlowConnection: false,
    });

  useEffect(() => {
    const updateNetworkStatus = () => {
      const connection = (navigator as any).connection;
      setNetworkStatus({
        isOnline: navigator.onLine,
        isSlowConnection: connection
          ? connection.effectiveType === 'slow-2g'
          : false,
      });
    };

    // オンライン/オフライン状態の監視
    window.addEventListener('online', updateNetworkStatus);
    window.addEventListener('offline', updateNetworkStatus);

    // 接続速度の監視(対応ブラウザのみ)
    if ('connection' in navigator) {
      (navigator as any).connection.addEventListener(
        'change',
        updateNetworkStatus
      );
    }

    return () => {
      window.removeEventListener(
        'online',
        updateNetworkStatus
      );
      window.removeEventListener(
        'offline',
        updateNetworkStatus
      );
      if ('connection' in navigator) {
        (navigator as any).connection.removeEventListener(
          'change',
          updateNetworkStatus
        );
      }
    };
  }, []);

  return networkStatus;
};

オフライン対応データストレージ

typescript// src/services/OfflineStorage.ts
import { isTauri } from '../utils/platform';

interface OfflineData {
  id: string;
  data: any;
  timestamp: number;
  syncStatus: 'pending' | 'synced' | 'failed';
}

export class OfflineStorage {
  private static readonly STORAGE_KEY = 'offline_data';

  // データの保存
  public static async saveData(
    id: string,
    data: any
  ): Promise<void> {
    const offlineData: OfflineData = {
      id,
      data,
      timestamp: Date.now(),
      syncStatus: 'pending',
    };

    if (isTauri()) {
      await this.saveTauriData(offlineData);
    } else {
      await this.saveWebData(offlineData);
    }
  }

  // Tauri環境でのデータ保存
  private static async saveTauriData(
    data: OfflineData
  ): Promise<void> {
    try {
      const { writeTextFile, createDir } = await import(
        '@tauri-apps/api/fs'
      );
      const { appDataDir } = await import(
        '@tauri-apps/api/path'
      );

      const appDir = await appDataDir();
      const offlineDir = `${appDir}/offline`;
      await createDir(offlineDir, { recursive: true });

      const filePath = `${offlineDir}/${data.id}.json`;
      await writeTextFile(filePath, JSON.stringify(data));
    } catch (error) {
      console.error(
        'Tauriオフラインデータ保存エラー:',
        error
      );
    }
  }

  // Web環境でのデータ保存(IndexedDB使用)
  private static async saveWebData(
    data: OfflineData
  ): Promise<void> {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open('OfflineDB', 1);

      request.onerror = () => reject(request.error);

      request.onsuccess = () => {
        const db = request.result;
        const transaction = db.transaction(
          ['offline_data'],
          'readwrite'
        );
        const store =
          transaction.objectStore('offline_data');

        const putRequest = store.put(data);
        putRequest.onsuccess = () => resolve();
        putRequest.onerror = () => reject(putRequest.error);
      };

      request.onupgradeneeded = () => {
        const db = request.result;
        const store = db.createObjectStore('offline_data', {
          keyPath: 'id',
        });
        store.createIndex('timestamp', 'timestamp');
        store.createIndex('syncStatus', 'syncStatus');
      };
    });
  }

  // 同期待ちデータの取得
  public static async getPendingData(): Promise<
    OfflineData[]
  > {
    if (isTauri()) {
      return await this.getTauriPendingData();
    } else {
      return await this.getWebPendingData();
    }
  }

  private static async getTauriPendingData(): Promise<
    OfflineData[]
  > {
    try {
      const { readDir, readTextFile } = await import(
        '@tauri-apps/api/fs'
      );
      const { appDataDir } = await import(
        '@tauri-apps/api/path'
      );

      const appDir = await appDataDir();
      const offlineDir = `${appDir}/offline`;

      const entries = await readDir(offlineDir);
      const pendingData: OfflineData[] = [];

      for (const entry of entries) {
        if (entry.name?.endsWith('.json')) {
          const content = await readTextFile(
            `${offlineDir}/${entry.name}`
          );
          const data: OfflineData = JSON.parse(content);

          if (data.syncStatus === 'pending') {
            pendingData.push(data);
          }
        }
      }

      return pendingData;
    } catch (error) {
      console.error(
        'Tauri同期待ちデータ取得エラー:',
        error
      );
      return [];
    }
  }

  private static async getWebPendingData(): Promise<
    OfflineData[]
  > {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open('OfflineDB', 1);

      request.onsuccess = () => {
        const db = request.result;
        const transaction = db.transaction(
          ['offline_data'],
          'readonly'
        );
        const store =
          transaction.objectStore('offline_data');
        const index = store.index('syncStatus');

        const getRequest = index.getAll('pending');
        getRequest.onsuccess = () =>
          resolve(getRequest.result);
        getRequest.onerror = () => reject(getRequest.error);
      };

      request.onerror = () => reject(request.error);
    });
  }
}

オフライン同期機能

typescript// src/services/SyncManager.ts
import { OfflineStorage } from './OfflineStorage';
import { useNetworkStatus } from '../hooks/useNetworkStatus';

export class SyncManager {
  private static isCurrentlySyncing = false;

  // 自動同期の開始
  public static startAutoSync(): void {
    // オンラインになったときに同期を試行
    window.addEventListener('online', () => {
      this.performSync();
    });

    // 定期的な同期チェック(5分間隔)
    setInterval(() => {
      if (navigator.onLine) {
        this.performSync();
      }
    }, 5 * 60 * 1000);
  }

  // 手動同期の実行
  public static async performSync(): Promise<boolean> {
    if (this.isCurrentlySyncing || !navigator.onLine) {
      return false;
    }

    this.isCurrentlySyncing = true;

    try {
      const pendingData =
        await OfflineStorage.getPendingData();
      console.log(
        `${pendingData.length}件のデータを同期中...`
      );

      for (const item of pendingData) {
        try {
          // サーバーにデータを送信
          const response = await fetch('/api/sync', {
            method: 'POST',
            headers: {
              'Content-Type': 'application/json',
            },
            body: JSON.stringify(item.data),
          });

          if (response.ok) {
            // 同期成功:ステータスを更新
            await this.updateSyncStatus(item.id, 'synced');
          } else {
            // 同期失敗:ステータスを更新
            await this.updateSyncStatus(item.id, 'failed');
          }
        } catch (error) {
          console.error(
            `データ同期エラー (${item.id}):`,
            error
          );
          await this.updateSyncStatus(item.id, 'failed');
        }
      }

      return true;
    } catch (error) {
      console.error('同期プロセスエラー:', error);
      return false;
    } finally {
      this.isCurrentlySyncing = false;
    }
  }

  private static async updateSyncStatus(
    id: string,
    status: 'synced' | 'failed'
  ): Promise<void> {
    // 実装は OfflineStorage の対応メソッドを追加
    console.log(`同期ステータス更新: ${id} -> ${status}`);
  }
}

オフライン対応の全体フロー:

mermaidflowchart TD
  user_action[ユーザーアクション] -->|データ作成/更新| online_check{オンライン状態?}

  online_check -->|オンライン| direct_api[直接API送信]
  online_check -->|オフライン| offline_storage[オフラインストレージ保存]

  direct_api -->|成功| success[処理完了]
  direct_api -->|失敗| offline_storage

  offline_storage -->|保存完了| pending_status[同期待ちステータス]

  pending_status -->|オンライン復帰| sync_trigger[同期処理開始]
  sync_trigger -->|API送信| sync_api[バックグラウンド同期]

  sync_api -->|成功| sync_success[同期完了]
  sync_api -->|失敗| retry_later[再試行待ち]

  retry_later -->|時間経過| sync_trigger

図で理解できる要点:

  • ネットワーク状態の自動監視とそれに基づく処理分岐
  • オフライン時のローカルストレージ活用
  • オンライン復帰時の自動同期メカニズム

まとめ

Tauri と PWA の組み合わせは、現代のアプリケーション開発における多くの課題を解決する革新的なアプローチです。本記事では、基礎的な概念から具体的な実装方法まで、幅広くご紹介いたしました。

技術的なメリットとして、単一のコードベースで Web 版とデスクトップ版の両方を効率的に開発できることが最も重要です。開発チームは既存の Web フロントエンド技術を活用しながら、必要に応じてネイティブ機能にアクセスできます。これにより、学習コストを抑えつつ、より高機能なアプリケーションを構築できます。

ユーザーエクスペリエンスの向上も見逃せません。PWA による段階的機能向上により、ユーザーは自分の環境や使用パターンに最適な形でアプリケーションを利用できます。初回は Web ブラウザで試用し、気に入ったらデスクトップ版をインストールするといった、柔軟な使い方が可能です。

開発・運用コストの削減効果も大きな魅力です。プラットフォーム固有の技術を学習する必要がなく、ビルドシステムやテスト環境も一元化できるため、開発リソースをより効率的に活用できます。

今後、WebAssembly の普及やブラウザ API の拡充により、この技術の可能性はさらに広がっていくでしょう。ぜひ実際のプロジェクトで Tauri × PWA のアプローチを試してみて、その効果を実感していただければと思います。

関連リンク