はじめに
こんにちは、genimuraです。
今回はTauri 2 × Svelte 5でデスクトップアプリを作ってみました。
ターミナル作業中にエイリアスを素早く追加・整理したい一方、.zprofileを毎回エディタで開いて手作業するのは面倒で、記述ミスで壊れやすいという問題がありました。そこで「安全に、直感的に」追加・編集できるデスクトップアプリとして、「Alias Editor」を実装してみました。
この記事では、初心者でも「同じものを作れる」ように、段階的に学べる内容と手順をまとめています。躓きやすい点はチェックリスト化し、コードは最小限のスニペットで理解を助けます。
この記事で学べること
- Tauri×Rustの基本(IPCの流れ、非同期I/O、Resultでのエラー設計)
- Svelte 5の新リアクティブAPI($state/$effect/$props)とストア設計の基礎
-
.zprofileを壊さないための「パース→統合→生成」戦略と型同期
こんな感じのアプリを作りました。
まずは全体像を掴んでおくといいと思います
- フロント(Svelte):UI、フォーム検証、検索・フィルタ、表示
- バック(Tauri/Rust):
.zprofileの読み書き、バックアップ、パース/生成、構文検証 - 通信(IPC):Svelteから
invoke('command')でRustの#[command]関数を呼ぶ
作っていく流れはこんな感じです
- 開発環境を整える(Node/Rust/Tauri CLI)
- 最初のIPCとして
read_zprofileを作り、画面に中身を出す - 正規表現で「alias行」を理解・抽出する(コメントアウトも含む)
- エイリアス以外の行を壊さないように分離して保持する
- Svelteのフォームで追加/編集/検証を実装する
- 保存時は「生成→バックアップ→書き込み」の順で安全に処理する
- 最小権限・CSPなど、必要なセキュリティ設定を確認する
始める前に確認しておくこと
- Node.js 18+ と Rust(stable)がインストールされている
- Tauriの前提(Xcode Command Line Tools 等)が満たされている(macOS)
-
.zprofileの場所とバックアップ戦略を理解している
技術スタックとアーキテクチャ
今回使った技術スタックは以下の通りです。
- フロントエンド: Svelte 5 + TypeScript + Tailwind + Vite
- バックエンド: Tauri 2 + Rust(Tokio/Serde)
責務分離はそこまで複雑ではないですが、以下のように分けています。
- Rust側:
.zprofile読み書き、バックアップ、パース/生成、構文検証 - Svelte側: 表示・編集UI、検索/フィルタ、検証と保存フロー
Tauri×Rustで学んだこと
IPCコマンド定義と呼び出し
Tauriでは、Rust側でコマンドを定義して、フロントエンドから呼び出すことができます。
#[command]
pub async fn read_zprofile() -> Result<String, String> { /* ... */ }
フロントエンドからはこんな感じで呼び出します。
import { invoke } from '@tauri-apps/api/core';
const content = await invoke<string>('read_zprofile');
非同期ファイルI/OとResultエラー設計
Tokioでブロッキングを回避し、Result<T, String>で明確な失敗経路を作るようにしました。
例外ではなく戻り値でハンドリングし、UIへ人間が読めるメッセージを返すようにしています。
バックアップと最小権限、コンテキスト保持
書き込み前に自動バックアップするようにしました。
また、.zprofileの「エイリアス行」と「非エイリアス行」を分離して保持しています。保存時に非エイリアス行を先頭に復元し、エイリアスはカテゴリ別に追記するようにしています。
// エイリアス行の抽出(コメントアウト含む)と非エイリアス行の保持
let re = Regex::new(r#"^alias\s+([a-zA-Z0-9_-]+)=(?:'([^']*)'|\"([^\"]*)\")\s*(?:#\s*(.*))?$"#).unwrap();
Svelte 5で学んだこと
新リアクティブAPIでのフォーム検証と編集フロー
Svelte 5の新しいリアクティブAPIを使って、フォームの検証を実装しました。
<script lang="ts">
let name = $state(''); let command = $state('');
let nameError = $state(''); let commandError = $state('');
$effect(() => { nameError = !name.trim() ? 'エイリアス名は必須' : ''; });
$effect(() => { commandError = !command.trim() ? 'コマンドは必須' : ''; });
</script>
ストア設計(検索・フィルタ・統計)
複数コンポーネント間でエイリアス一覧を共有し、検索/カテゴリ/有効状態で絞り込むために、ストアを使いました。
export const aliases = writable<Alias[]>([]);
export const searchFilters = writable({ query: '', category: '', enabled: null as boolean | null });
export const filteredAliases = derived([aliases, searchFilters], ([$a, $f]) =>
$a.filter(x => /* クエリ/カテゴリ/有効で絞り込み */)
);
UI/UXと責務分離
編集フォームは保存ロジックと検証に集中させ、リスト/検索は親に委譲するようにしました。
Tailwindで軽量にモダンな見た目とダークモード対応も実装しています。
事前知識の超要約:エイリアス/.zprofileとTauri/Svelteの基礎
エイリアスと.zprofile
エイリアスとは、alias ll='ls -la' のようにコマンドに別名を付ける仕組みです。
macOSのzshでは通常ホーム直下の.zprofileに保存され、ログインシェルで読み込まれます。
壊しやすいポイントとしては、クォートの閉じ忘れ、等号やスペースの混入、エンコーディングの不一致などがあります。
Tauri 2(Rustバックエンド)
フロントエンドからinvoke('command')でRustの#[command] fnを呼ぶIPC構造になっています。
ファイルI/OやOS連携はRust側で行い、UIはWeb技術で書けるのが特徴です。
セキュリティは「コマンドのホワイトリスト化」「CSP」「ファイルアクセスの最小化」が基本です。
Svelte 5(フロントエンド)
新リアクティブAPIとして、$stateで状態、$effectで副作用、$propsで親からの受け渡しができます。
ストア(writable/derived)で複数コンポーネント間の状態を共有・計算できます。
Tailwindで見た目を素早く構築できるのも便利です。
覚えておくと楽になること
- Rustは例外ではなく
Resultで失敗を返す文化なので、UI側で人間が理解できるメッセージを表示するようにしています - 文字列はエンコーディングと改行コードの違いで壊れやすいので、保存前にバックアップが必須です
環境構築
インストール
まずは必要なツールをインストールします。
- Rust(rustup):
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh - Node.js 18+(nvm推奨):
nvm install 18 && nvm use 18 - Tauri CLI:
npm i -D @tauri-apps/cli
プロジェクト作成
プロジェクトを作成してみましょう。
npx @tauri-apps/cli create-app --template svelte-ts alias-editor
cd alias-editor
npm install
動作確認
開発サーバーを起動して、動作確認をしてみます。
npm run tauri:dev
チェックリスト
以下の点を確認しておくと良いと思います。
- Rust toolchain がエラーなく動く(
rustc -V) - Tauriの前提(macOSのCommand Line Tools等)が満たされている
-
npm run tauri:devでウィンドウが起動する
トラブル時のヒント
署名・権限関連は「リリースビルド時」に現れやすいので、開発中はまずdev起動を安定化させることをおすすめします。
依存の再インストールが必要な場合は、rm -rf node_modules && npm ci、Rustはcargo cleanも試してみてください。
最初のIPC:read_zprofile をつくる(Rust⇔TSの往復)
まずは、フロント(Svelte)からRustコマンドを呼び、.zprofileの内容を取得して画面に表示してみましょう。
手順
1. Rustでコマンドを定義
// src-tauri/src/commands/mod.rs
#[command]
pub async fn read_zprofile() -> Result<String, String> {
let path = get_zprofile_path().map_err(|e| e.to_string())?;
if !path.exists() { return Ok(String::new()); }
tokio::fs::read_to_string(&path)
.await
.map_err(|e| format!("読み込み失敗: {}", e))
}
2. lib.rsでコマンドを登録
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![read_zprofile])
.run(tauri::generate_context!())
.expect("error while running tauri application");
3. フロントから呼び出して表示
import { invoke } from '@tauri-apps/api/core';
import { writable } from 'svelte/store';
export const rawZprofile = writable('');
export async function loadZprofile() {
const content = await invoke<string>('read_zprofile');
rawZprofile.set(content);
}
4. 画面で表示
<script lang="ts">
import { rawZprofile, loadZprofile } from './stores';
$: $rawZprofile;
loadZprofile();
</script>
<pre class="whitespace-pre-wrap">{$rawZprofile}</pre>
理解ポイント
-
#[command]で公開、invoke('name')で呼ぶ。名前は関数名と一致させます - 戻り値は
Result<T, String>にして、UIに人間が読めるエラーを届けるようにしています
よくある失敗
- IPCコマンド未登録:
Unrecognized IPC command→generate_handler![...]に追加するのを忘れがちです - 型不一致:
invoke<string>(...)の型引数とRust側のResult<String, String>が対応しているか確認しましょう
チェックリスト
- dev起動時にIPCエラーが出ていない
- 画面に
.zprofileの内容が表示されている
パース入門:正規表現でエイリアスを理解する(境界ケースと簡易テスト)
alias name='command' # desc 形式を抽出し、名前・コマンド・説明・有効/無効を識別できるようにします。
要点コード(Rust)
let re = Regex::new(r#"^alias\s+([a-zA-Z0-9_-]+)=(?:'([^']*)'|\"([^\"]*)\")\s*(?:#\s*(.*))?$"#)
.map_err(|e| format!("正規表現のコンパイルに失敗: {}", e))?;
if let Some(c) = re.captures(&alias_line) {
let name = c.get(1).unwrap().as_str();
let command = c.get(2).or_else(|| c.get(3)).unwrap().as_str();
let description = c.get(4).map(|m| m.as_str().trim().to_string());
}
境界ケース
以下のようなケースに対応する必要があります。
- コメントアウト行:
# alias foo='bar'→ 無効扱いだがパースはします - ダブル/シングルクォート両対応:
"cmd"と'cmd' - 説明の有無:
#以降をtrim()して空ならNone - 日本語説明:UTF-8前提、エディタと保存形式を統一します
簡易テスト(擬似)
以下のようなパターンでテストしてみると良いと思います。
OK: alias ll='ls -la'
OK: alias gs="git status" # gitの状態
OK: # alias old='legacy' # 旧設定
NG: alias spaced name='x' # スペース入りの名前
NG: alias eq=ual='x' # =を含む名前
チェックリスト
以下の点を確認しておくと良いと思います。
- 代表パターン(上記OK例)がすべて通る
- NG例はスキップまたは検知される
非エイリアス行の保持:壊さないための設計(再構成の流れ)
.zprofileにはエイリアス以外の設定(Amazon Q、Homebrew、環境変数、PATH設定など)が多数あります。これらを破壊せず、エイリアスだけを編集・再生成したいと思います。
アプローチ
以下のような流れで実装しました。
- 読み込み時に、行ごとに「エイリアス」「非エイリアス」に分離
- UIで編集するのはエイリアス側のみ
- 保存時は「非エイリアス行(原文)→ エイリアス行(生成)」の順で統合
要点コード(Rust)
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with("alias ") || trimmed.starts_with("# alias ") {
// パースしてVec<Alias>に格納(enabledはコメントで判定)
} else if !trimmed.is_empty() {
// ツール由来の見出し行などは除外しつつ、非エイリアス行として保持
non_alias_lines.push(line.to_string());
}
}
生成時の並び
以下のような順序で生成するようにしています。
- 先に非エイリアス行をそのまま出力
- 続けて「# Aliases managed by Alias Editor」などの見出し
- カテゴリ別(common/dev/files/network/custom)にエイリアスを整形して出力
利点
このアプローチの利点は以下の通りです。
- 既存ファイルの文脈・コメント・順序を最大限尊重できる
- ツールが触る範囲を明確化(事故を減らす)
チェックリスト
以下の点を確認しておくと良いと思います。
- 保存後も、非エイリアスの設定が元のとおり残っている
- エイリアスセクションはツール管理の見出し以下にまとまっている
UIを組む:Svelte 5の$state/$effectによるフォームと検証
エイリアスの新規追加/編集フォームを作り、入力中にリアルタイム検証を行うようにしました。
要点コード
<script lang="ts">
let name = $state('');
let command = $state('');
let description = $state('');
let enabled = $state(true);
let nameError = $state('');
let commandError = $state('');
function validateName(v: string) {
if (!v.trim()) return 'エイリアス名は必須です';
if (v.includes(' ') || v.includes('=')) return 'スペース/="は使用不可';
if (v.length > 20) return '20文字以内にしてください';
return '';
}
function validateCommand(v: string) {
if (!v.trim()) return 'コマンドは必須です';
return '';
}
$effect(() => { nameError = validateName(name); });
$effect(() => { commandError = validateCommand(command); });
</script>
理解ポイント
-
$stateは「その値を使う場所が自動で再描画」されるようになっています -
$effectで依存値が変わるたびにバリデーションを走らせるようにしています
チェックリスト
以下の点を確認しておくと良いと思います。
- 無効な入力時は保存ボタンが無効化されるか
- エラーメッセージがリアルタイムで切り替わるか
ストア設計:検索・フィルタ・統計を段階実装(derivedの感覚)
複数コンポーネント間でエイリアス一覧を共有し、検索/カテゴリ/有効状態で絞り込むようにしました。
要点コード
export const aliases = writable<Alias[]>([]);
export const searchFilters = writable<{ query: string; category: string; enabled: boolean | null }>({
query: '', category: '', enabled: null
});
export const filteredAliases = derived([aliases, searchFilters], ([$a, $f]) =>
$a.filter(a => {
if ($f.query) {
const q = $f.query.toLowerCase();
if (!a.name.toLowerCase().includes(q)
&& !a.command.toLowerCase().includes(q)
&& !(a.description ?? '').toLowerCase().includes(q)) return false;
}
if ($f.category && a.category !== $f.category) return false;
if ($f.enabled !== null && a.enabled !== $f.enabled) return false;
return true;
})
);
チェックリスト
以下の点を確認しておくと良いと思います。
- クエリ/カテゴリ/有効状態を変えると即座にリストが変わる
- 統計(総数/有効/無効)が正しく更新される
保存フロー:生成→バックアップ→書き込み(ロールバック観点)
保存時の事故(上書き破壊)を避け、復旧可能な形にするようにしました。
フロー
以下のような流れで実装しています。
- UIから現在のエイリアス一覧を取得
- Rustへ渡して
.zprofile文字列を生成(カテゴリ別、コメント対応) - 既存
.zprofileがあればバックアップを作成 - 新しい内容で書き込み
要点コード(Rust)
#[command]
pub async fn write_zprofile(content: String) -> Result<(), String> {
let path = get_zprofile_path().map_err(|e| e.to_string())?;
if path.exists() { create_backup().await.map_err(|e| e.to_string())?; }
tokio::fs::write(&path, content).await
.map_err(|e| format!("書き込み失敗: {}", e))
}
UI側の流れ(擬似)
const content = await invoke<string>('generate_zprofile_content_with_context', {
aliases: $aliases,
nonAliasLines: $nonAliasLines
});
await invoke('write_zprofile', { content });
ロールバック観点
以下の点に注意して実装しました。
- バックアップの命名はタイムスタンプを含める(上書き防止)
- 失敗時はパスを表示して手動復旧できるようにする
チェックリスト
以下の点を確認しておくと良いと思います。
- バックアップが期待どおりのディレクトリに作成される
- 書き込み失敗時に、UIでユーザーが復旧方法を把握できる
セキュリティと配布:最小権限/CSP/ビルドと署名
最小権限
Rust側コマンドは必要最小に限定し、generate_handler![...]に明示登録するようにしました。
.zprofile以外のパスには触れないようにしています(将来の拡張時も意識します)。
CSP
開発時は緩めでも、配布時はscript-src等を見直すようにしています。
外部通信が不要なら拒否し、必要ならドメインを限定するようにしています。
ビルドと署名(macOS)
- dev:
npm run tauri:dev - release:
npm run tauri:build - 配布には署名やGatekeeperの考慮が必要(組織の手順に従う)
チェックリスト
以下の点を確認しておくと良いと思います。
- 未登録コマンドは呼べないことを確認
- 配布バイナリでCSP/署名のエラーが出ない
つまずきノート:実際のエラーと解決の道筋
実際にハマったポイントと、その解決方法をまとめました。
よくある症状と対処
- IPCコマンド未登録 →
generate_handler!を確認するのを忘れがちです - 権限/パスで失敗 →
get_home_dir()やパス解決をログに出し検証します - 文字化け/改行混在 → 編集エディタの設定統一、
\nに正規化します - 正規表現がヒットしない → クォートやコメントの扱い、前後の空白を再確認します
診断のコツ
まず「どちら側で失敗したか」(UI or Rust)を切り分けることが重要です。
RustはResultで詳細を返す習慣にし、UIで人間が読める形に変換するようにしています。
用語ミニ辞典(初心者向け)
- IPC(Inter-Process Communication):プロセス間通信。ここではSvelte(Web)→Rust(Tauri)間の呼び出し
-
#[command]:Rust関数をTauriから呼べるように公開する属性 -
$state/$effect:Svelte 5の新リアクティブAPI。状態と副作用 - derived store:他のストアから計算される値を提供するストア
まとめ:再現手順と次の拡張
再現手順まとめ
以下のような流れで実装しました。
- 環境構築 → dev起動
-
read_zprofileで内容の取得(IPC往復) - 正規表現でエイリアスを抽出、非エイリアス行を保持
- Svelteフォームで編集と検証
- 生成→バックアップ→書き込みの保存フロー
- 最小権限/CSPで安全性を確認
次の拡張
今後追加したい機能としては、以下のようなものがあります。
- よく使うテンプレート、インポート/エクスポート、履歴、構文提案
型の同期とデータ整合性
Rust構造体とTSインターフェースを対応させ、IPC越しのデータ整合性を確保するようにしました。
命名規則差(snake_case/camelCase)とOptionalの取り扱いを明示しています。
#[derive(Serialize, Deserialize)]
pub struct Alias { pub id: String, pub name: String, pub command: String, pub description: Option<String>, /* ... */ }
export interface Alias { id: string; name: string; command: string; description?: string; /* ... */ }
つまずきと回避策(Tips)
正規表現の罠
シングル/ダブルクォート両対応が必要です。コメントアウト行(# alias ...)の扱いに注意が必要です。
コメント/空行処理
非エイリアス行は極力そのまま保持し、ツール由来コメントは再生成で制御するようにしています。
権限系エラー
Tauri側の許可設定とホームディレクトリ解決を先に検証するようにしています。
感想
初めてTauriとSvelteを使ってデスクトップアプリを作ってみました。
TauriもRustもほとんど初めて使ったのですが、AIといっしょに対話をしながら作ることができたので、意外とすんなりと作ることができました。
SvelteはWeb技術でフロントエンドを書けるので、開発速度が速く、学習コストが低いと感じました。Vue Likeに近いので、Vueの経験者であればすぐに使い始められると思います。
