Sik.limited Logo

SvelteKit + Tauri v2を始める: 静的ビルド・ウィンドウ・ファイル権限まで

SvelteKitアプリをTauri v2のデスクトップアプリへ移す際に必要な、静的ビルド、ウィンドウ構造、IPC、ファイル権限設計を段階的に説明します。

Sik ·

Webプロジェクトをデスクトップアプリへ移すとき、最初に変わるのは配布方式だ

SvelteKitで作った画面をTauriへ入れる作業は、一見すると単純です。フロントエンドをビルドし、Tauriが成果物を開けばよいように見えます。最初のウィンドウまでは早くても、その後は同じSvelteKitアプリを移すのではなく、サーバーを持たないデスクトップランタイムに合わせて境界を定め直す作業になります。

TauriはSvelteKitのNodeサーバーを一緒に動かしません。ビルド済みのHTML・CSS・JavaScriptをアプリ内で提供し、必要なOS機能だけをRustとプラグインでつなぎます。+page.server.ts、form action、server hookのようにサーバープロセスを前提とする機能をデスクトップバンドル内で期待すると、構造はすぐに崩れます。

本稿では、SvelteKit経験者がTauri v2でデスクトップアプリを始めるための最小構成を、静的ビルド、ウィンドウ設定、フロントエンドとRustの境界、ファイルの読書き権限まで一続きで扱います。TauriとElectronの選択はSvelteKitデスクトップアプリ比較を参照してください。ここではTauriを決めた後に失敗しない出発点に集中します。


サーバー機能を捨てるのではなく、サーバーの役割を分ける

SvelteKitはSSRとサーバールートを自然に扱います。Webサービスではページのloadでサーバーからデータを取り、ログインをform actionで、秘密鍵を使う処理を+server.tsで行えます。しかしTauriの標準配布物には、それらを継続実行するNodeランタイムは入りません。

これは機能をあきらめるという意味ではありません。役割を移すという意味です。公開・共用データは既存のHTTPS APIへ、端末内のファイルとウィンドウ制御はTauriプラグインまたはRust commandへ、秘密値を要する処理は外部バックエンドへ残します。デスクトップアプリへAPIキーを入れ、サーバー役まで背負わせると、便利そうに見えた構造は配布物で露出します。

  • ブラウザでも実行できるUI、状態、API呼出し
  • ユーザー端末だけで行うファイル、ウィンドウ、通知、トレイ操作
  • サーバーに残す認証、決済、秘密鍵、権限検証

この分類ができればTauriはWebアプリの代替ではなく、Webフロントエンドへ薄く明確なローカル機能を加えるシェルになります。

静的ビルドはオプションではなくデスクトップの基本形式だ

TauriでSvelteKitを使う鍵は@sveltejs/adapter-staticです。アプリが読める静的成果物を作り、TauriのfrontendDistをそのディレクトリへ向けます。動的URLを持つSPAならfallback: 'index.html'を設定し、更新と直接遷移もクライアントルーターが受けるようにします。

npm install -D @sveltejs/adapter-static @tauri-apps/cli
npm install @tauri-apps/api
npm run tauri init

既存プロジェクトでは、上記は出発点にすぎません。パッケージマネージャーとコマンド体系に合わせ、生成されたsrc-tauriを独立したネイティブプロジェクトのように扱います。フロントエンド依存とRust依存は同じアプリを構成しても、更新周期と失敗の仕方は異なります。

// svelte.config.js
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';

/** @type {import('@sveltejs/kit').Config} */
const config = {
  preprocess: vitePreprocess(),
  kit: { adapter: adapter({ fallback: 'index.html' }) }
};
export default config;

fallbackは万能設定ではありません。/projects/123のようにビルド時に分からない経路をクライアントで処理できるようindex.htmlを返す仕組みです。画面がサーバー専用loadやform actionに依存するなら、fallbackがあっても動きません。データ取得をブラウザから呼べるAPIへ変えます。

// src/routes/+layout.ts
export const ssr = false;
export const prerender = false;

ssr = falseはTauri APIのようにwindowを要するコードを扱いやすくします。静的文書のように全経路がビルド時に確定するアプリではprerender = trueも選べます。混在させる前に、誰がいつどこでデータを取る画面かを記録します。

Tauri設定はビルドパスを一つに合意させる作業だ

開発中はVite dev serverがUIを提供し、リリースでは静的ディレクトリが提供します。両者のパスがずれると、開発モードでは開くのにパッケージアプリでは空のウィンドウになる典型的な問題が起きます。

// src-tauri/tauri.conf.json
{
  "$schema": "../gen/schemas/desktop-schema.json",
  "productName": "Desk Notes",
  "version": "0.1.0",
  "identifier": "com.example.desknote",
  "build": {
    "beforeDevCommand": "npm run dev",
    "devUrl": "http://localhost:5173",
    "beforeBuildCommand": "npm run build",
    "frontendDist": "../build"
  },
  "app": { "windows": [{
    "label": "main", "title": "Desk Notes", "width": 1200, "height": 800,
    "minWidth": 900, "minHeight": 640, "resizable": true
  }] },
  "bundle": { "active": true, "targets": "all" }
}

静的アダプターの出力先とfrontendDistはプロジェクトごとに異なります。慣例ではなく実際のnpm run buildの結果を確認し、build/index.htmlがないなら、アダプター出力先またはTauri設定の片方だけを直します。同時に推測で変えると原因を失います。

# 開発サーバー + Tauriウィンドウ
npm run tauri dev

# 静的SvelteKitビルド + ネイティブバンドル
npm run tauri build

開発モードではdevUrlのサーバーが多くを肩代わりします。機能を足すたび、少なくとも一度はパッケージ済み結果で実行してください。相対パス画像、動的ルート、外部OAuthリダイレクトはリリースで先に壊れやすい箇所です。


ウィンドウは画面ではなく権限境界にもなる

ウィンドウを幅、高さ、タイトルだけのデザイン課題として扱いがちですが、Tauri v2ではwindow labelが権限を束ねる単位になります。見えるtitleとセキュリティ設定に使うlabelは別物です。

編集機能を持つメインウィンドウだけがローカルファイルを保存し、ヘルプ・ログインウィンドウには不要だとします。全ウィンドウへ同じcapabilityを付けると、ヘルプで動くフロントエンドにも書込み権限が与えられ、攻撃面が広がります。新規ウィンドウでは、表示内容より先に「何ができる必要があるか」を決め、次にlabelとcapabilityを決めます。

use tauri::{Manager, WebviewUrl, WebviewWindowBuilder};

fn open_preferences(app: &tauri::AppHandle) -> tauri::Result<()> {
    if app.get_webview_window("preferences").is_none() {
        WebviewWindowBuilder::new(app, "preferences", WebviewUrl::App("/preferences".into()))
            .title("環境設定")
            .inner_size(720.0, 560.0)
            .resizable(false)
            .build()?;
    }
    Ok(())
}

ユーザー入力をそのままlabelに使いません。権限設定はtitleでなくlabelと結び付くため、即席の名前はcapability設計を追跡しにくくします。

フロントエンドとRustは薄い契約でつなぐ

TauriでRustを初めて使うと、すべてのロジックをRustへ移したくなります。しかしUI状態、フォームの即時フィードバック、画面遷移、ネットワーク応答の表示はSvelteKitに残せます。Rust側にはOSに近い作業と信頼境界内で検証すべき作業だけを置きます。

// src-tauri/src/lib.rs
#[tauri::command]
fn app_data_dir(app: tauri::AppHandle) -> Result<String, String> {
    app.path().app_data_dir()
        .map(|path| path.to_string_lossy().to_string())
        .map_err(|error| error.to_string())
}
pub fn run() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![app_data_dir])
        .run(tauri::generate_context!())
        .expect("error while running Tauri application");
}

フロントエンドでは実行環境でだけ呼びます。ブラウザプレビューと同じコンポーネントを使うなら、Tauri判定を薄いアダプターへ閉じ込めます。

// src/lib/platform/desktop.ts
import { browser } from '$app/environment';
export async function getAppDataDir(): Promise<string | null> {
  if (!browser || !('__TAURI_INTERNALS__' in window)) return null;
  const { invoke } = await import('@tauri-apps/api/core');
  return invoke<string>('app_data_dir');
}

invokeをサービス各所から直接呼ばず、このモジュールを通せば、Webプレビュー、テスト、将来のCapacitor拡張でも分岐を一か所に保てます。SvelteKit + Capacitorで出会うWebView境界CapacitorとTauriの選択基準も同じ観点で読めます。


ファイル選択とファイルアクセスは別の作業だ

「ファイルを開く」ボタンで権限エラーになる場合、選択とアクセスを同じ機能と考えたことが原因です。dialogプラグインはユーザーにファイルを選ばせ、fsプラグインはそのパスを読書きさせます。fsにはコマンド許可とパスscopeという二層の制限があります。

npm run tauri add dialog
npm run tauri add fs
// src-tauri/capabilities/main-editor.json
{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "main-editor",
  "description": "Capability for the local note editor",
  "windows": ["main"],
  "permissions": [
    "dialog:allow-open",
    "fs:allow-read-text-file",
    { "identifier": "fs:allow-write-text-file", "allow": [{ "path": "$APPDATA/notes/*.md" }] },
    { "identifier": "fs:allow-read-text-file", "allow": [{ "path": "$APPDATA/notes/*.md" }] }
  ]
}

これは例です。実際のpermission identifierは、導入したプラグイン版が生成するschemaと公式文書で確認してください。一件の保存のためにfs:defaultを入れ、ホーム全体を読書き可能にするのは、動いても設計として悪い選択です。

import { open } from '@tauri-apps/plugin-dialog';
import { readTextFile, writeTextFile } from '@tauri-apps/plugin-fs';
export async function importMarkdown(): Promise<string | null> {
  const path = await open({ multiple: false, directory: false,
    filters: [{ name: 'Markdown', extensions: ['md', 'mdx'] }] });
  return typeof path === 'string' ? readTextFile(path) : null;
}
export async function saveDraft(path: string, content: string) { await writeTextFile(path, content); }

任意の外部ファイルを継続編集する製品なら、最初に選んだパスを再び開くライフサイクルを設計・検証します。複雑さを下げるにはimport時にアプリデータフォルダーへ複製し、以後は$APPDATA/notesだけで編集する方法があります。

権限エラーは、設定を広げる前に質問を減らす合図だ

not allowedやpromise rejectionを見て全権限を開きたくなりますが、このエラーはよい問いを返します。このウィンドウに本当にこのAPIが必要か。読取りだけでよいか、書込みも必要か。ディレクトリ全体か、アプリデータ配下だけでよいか。UIがパスを受けるよりRust commandで検証すべきか。

Tauri v2のcapability、permission、scopeはTauri v2最小権限設計で深掘りします。ここでは権限エラーを機能失敗でなく設計レビュー地点として扱うことを覚えておけば十分です。


開発モードで終わらせない検証順序

アプリが起動することは検証の始まりにすぎません。Webフロントエンドとネイティブシェルの境界は、開発モードとパッケージモードで違って動くことがあります。次の順で原因を絞ります。

  1. npm run buildが静的成果物を作り、frontendDistindex.htmlがあるか確認します。
  2. npm run tauri devでウィンドウ、ルーティング、ブラウザプレビュー分岐を確認します。
  3. npm run tauri buildのアプリで直接URL遷移、更新、動的ルートを確認します。
  4. ファイル選択、読取り、保存を別々に一度ずつ行い、権限エラーの段階を分けます。
  5. ヘルプ、ログイン、設定など別ウィンドウでメインのファイル機能を呼べないことを確認します。
  6. 実際に対応するmacOS、Windows、Linuxの各環境で最低一度はパッケージを実行します。

「開発中のMacで動く」と「他ユーザーのOSへインストールできる」は同じ合格条件ではありません。コード署名、OSのセキュリティ警告、WebView準備、ファイルポリシーは別種類の運用問題です。

良い出発点は機能が少ないアプリではなく、境界が少ないアプリだ

初期に必要なのは機能リストを増やすことより、境界を明確にすることです。フロントエンドは静的バンドルで動き、サーバーの仕事はサーバーに残り、ローカル機能は明示的なTauri APIだけを通り、ファイル権限は必要な経路だけを持ちます。

この構造ならトレイ、自動更新、ウィンドウ状態復元、ローカルDB、モバイル拡張を足しても置き場所を判断できます。Tauriを選ぶ理由が軽い配布物でも狭いネイティブ権限でも、ユーザー端末へ何を入れ、何を要求し、どこまでアクセスできるかを、コードと設定の同じ言葉で説明できることが出発点です。

出典

最新の記事