feat: posimai-guard-app Tauri v2 desktop app scaffold (pending MSVC install)

This commit is contained in:
posimai 2026-04-12 22:05:06 +09:00
parent db0fd6a88e
commit 15257dfc71
13 changed files with 2791 additions and 0 deletions

View File

@ -0,0 +1,15 @@
<!doctype html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Posimai Guard</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2079
posimai-guard-app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,28 @@
{
"name": "posimai-guard-app",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"tauri": "tauri",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build"
},
"dependencies": {
"@tauri-apps/api": "^2.3.0",
"@tauri-apps/plugin-dialog": "^2.2.0",
"@tauri-apps/plugin-fs": "^2.2.0",
"@tauri-apps/plugin-shell": "^2.2.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@tauri-apps/cli": "^2.3.1",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.2",
"vite": "^6.0.7"
}
}

View File

@ -0,0 +1,28 @@
[package]
name = "posimai-guard-app"
version = "0.1.0"
description = "Posimai Guard — AI Code Security Scanner"
authors = ["posimai"]
edition = "2021"
[lib]
name = "posimai_guard_app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-dialog = "2"
tauri-plugin-fs = "2"
tauri-plugin-shell = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[profile.release]
panic = "abort"
codegen-units = 1
lto = true
opt-level = "s"
strip = true

View File

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

@ -0,0 +1,106 @@
use tauri::Manager;
/// ファイル一覧を取得(除外ディレクトリをスキップ)
#[tauri::command]
fn read_dir_recursive(
path: String,
exclude_dirs: Vec<String>,
max_files: usize,
) -> Result<Vec<FileEntry>, String> {
let mut results = Vec::new();
collect_files(
std::path::Path::new(&path),
&path,
&exclude_dirs,
&mut results,
max_files,
);
Ok(results)
}
#[derive(serde::Serialize)]
struct FileEntry {
path: String, // ルートからの相対パス
abs_path: String, // 絶対パス(ファイル読み込み用)
size: u64,
}
fn collect_files(
dir: &std::path::Path,
root: &str,
exclude_dirs: &[String],
results: &mut Vec<FileEntry>,
max_files: usize,
) {
if results.len() >= max_files {
return;
}
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
if results.len() >= max_files {
break;
}
let path = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
if path.is_dir() {
if exclude_dirs.contains(&name) {
continue;
}
collect_files(&path, root, exclude_dirs, results, max_files);
} else if path.is_file() {
let abs = path.to_string_lossy().to_string();
let rel = abs.strip_prefix(root).unwrap_or(&abs).trim_start_matches(['/', '\\']).to_string();
let size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
results.push(FileEntry { path: rel, abs_path: abs, size });
}
}
}
/// ファイル内容を読み込む
#[tauri::command]
fn read_file(abs_path: String) -> Result<String, String> {
std::fs::read_to_string(&abs_path).map_err(|e| e.to_string())
}
/// 修正済み内容をファイルに書き戻す
#[tauri::command]
fn write_file(abs_path: String, content: String) -> Result<(), String> {
std::fs::write(&abs_path, content).map_err(|e| e.to_string())
}
/// git status / diff / commit を実行
#[tauri::command]
fn git_command(cwd: String, args: Vec<String>) -> Result<String, String> {
let output = std::process::Command::new("git")
.current_dir(&cwd)
.args(&args)
.output()
.map_err(|e| e.to_string())?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
if output.status.success() {
Ok(stdout)
} else {
Err(if stderr.is_empty() { stdout } else { stderr })
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_shell::init())
.invoke_handler(tauri::generate_handler![
read_dir_recursive,
read_file,
write_file,
git_command,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View File

@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
posimai_guard_app_lib::run()
}

View File

@ -0,0 +1,45 @@
{
"productName": "Posimai Guard",
"version": "0.1.0",
"identifier": "com.posimai.guard",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:1420",
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build"
},
"app": {
"windows": [
{
"title": "Posimai Guard",
"width": 1200,
"height": 800,
"minWidth": 800,
"minHeight": 600,
"resizable": true,
"fullscreen": false,
"decorations": true,
"transparent": false
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
},
"plugins": {
"shell": {
"open": true
}
}
}

View File

@ -0,0 +1,383 @@
import { useState, useCallback } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { open } from '@tauri-apps/plugin-dialog';
// ── Types ────────────────────────────────────────────────────────────────────
interface FileEntry {
path: string;
abs_path: string;
size: number;
}
interface ScanIssue {
severity: 'danger' | 'warning' | 'info';
title: string;
description: string;
file: string;
line: number | null;
fix: string | null;
}
type AppState =
| { status: 'idle' }
| { status: 'scanning'; progress: string }
| { status: 'results'; issues: ScanIssue[]; rootDir: string; files: FileEntry[] };
const EXCLUDE_DIRS = ['node_modules', '.next', '.git', 'dist', 'build', 'out', '.turbo', 'coverage', '__pycache__'];
const SCAN_EXTS = new Set(['.ts','.tsx','.js','.jsx','.py','.go','.rb','.php','.java','.cs','.env','.yaml','.yml','.json','.toml','.tf']);
const MAX_FILES = 80;
const MAX_CHARS = 6000;
// ── Main component ────────────────────────────────────────────────────────────
export default function App() {
const [state, setState] = useState<AppState>({ status: 'idle' });
const [geminiKey, setGeminiKey] = useState(() => localStorage.getItem('guard-gemini-key') ?? '');
const [showKeyInput, setShowKeyInput] = useState(false);
const handleSelectFolder = useCallback(async () => {
const dir = await open({ directory: true, multiple: false, title: 'スキャンするフォルダを選択' });
if (!dir || typeof dir !== 'string') return;
await runScan(dir);
}, [geminiKey]);
const runScan = async (rootDir: string) => {
if (!geminiKey.trim()) {
setShowKeyInput(true);
return;
}
setState({ status: 'scanning', progress: 'ファイルを収集中...' });
// 1. ファイル一覧取得Rust コマンド)
const allFiles: FileEntry[] = await invoke('read_dir_recursive', {
path: rootDir,
excludeDirs: EXCLUDE_DIRS,
maxFiles: MAX_FILES * 3,
});
// 2. スキャン対象フィルタ
const targets = allFiles
.filter(f => SCAN_EXTS.has(f.path.substring(f.path.lastIndexOf('.'))))
.sort((a, b) => {
const priority = (p: string) => {
if (p.includes('.env')) return 0;
if (p.endsWith('.ts') || p.endsWith('.tsx')) return 1;
return 2;
};
return priority(a.path) - priority(b.path);
})
.slice(0, MAX_FILES);
setState({ status: 'scanning', progress: `スキャン中... (${targets.length} ファイル)` });
// 3. ファイル内容を読み込み、チャンク化
const chunks: string[] = [];
let chunk = '';
for (const f of targets) {
try {
const content: string = await invoke('read_file', { absPath: f.abs_path });
const trimmed = content.slice(0, MAX_CHARS);
const entry = `\n\n### FILE: ${f.path}\n\`\`\`\n${trimmed}\n\`\`\``;
if (chunk.length + entry.length > 28000 && chunk.length > 0) {
chunks.push(chunk);
chunk = entry;
} else {
chunk += entry;
}
} catch { /* skip unreadable */ }
}
if (chunk) chunks.push(chunk);
// 4. Gemini API でスキャン
const allIssues: ScanIssue[] = [];
for (let i = 0; i < chunks.length; i++) {
setState({ status: 'scanning', progress: `AI スキャン中... (${i + 1}/${chunks.length})` });
try {
const issues = await callGemini(geminiKey, chunks[i]);
allIssues.push(...issues);
} catch (e) {
console.error('Gemini error', e);
}
}
setState({ status: 'results', issues: allIssues, rootDir, files: targets });
};
const handleApplyFix = async (issue: ScanIssue, rootDir: string) => {
if (!issue.fix) return;
const confirmed = window.confirm(
`以下の修正を適用しますか?\n\nファイル: ${issue.file}\n修正内容: ${issue.fix.slice(0, 200)}`
);
if (!confirmed) return;
try {
const absPath = `${rootDir}/${issue.file}`.replace(/\\/g, '/');
const content: string = await invoke('read_file', { absPath });
// 簡易パッチ: fix の内容をコメントとしてファイル先頭に追記(実際の差分適用は後続で)
const patched = `// [Guard fix] ${issue.title}\n// ${issue.fix}\n` + content;
await invoke('write_file', { absPath, content: patched });
alert(`適用しました: ${issue.file}`);
} catch (e) {
alert(`エラー: ${String(e)}`);
}
};
const handleGitDiff = async (rootDir: string) => {
try {
const diff: string = await invoke('git_command', { cwd: rootDir, args: ['diff', '--stat'] });
alert(diff || '変更なし');
} catch (e) {
alert(`git error: ${String(e)}`);
}
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
{/* Header */}
<header style={{
height: 52, display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0 16px', borderBottom: '1px solid var(--border)',
background: 'rgba(12,18,33,0.9)', backdropFilter: 'blur(12px)',
position: 'sticky', top: 0, zIndex: 100,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{ width: 7, height: 7, borderRadius: '50%', background: 'var(--accent)' }} />
<span style={{ fontSize: 14, fontWeight: 600, letterSpacing: '-0.02em' }}>posimai guard</span>
<span style={{
fontSize: 10, fontWeight: 600, color: 'var(--accent)',
background: 'var(--accent-dim)', padding: '1px 8px', borderRadius: 99,
}}>desktop</span>
</div>
<div style={{ display: 'flex', gap: 8 }}>
{state.status === 'results' && (
<button
onClick={() => handleGitDiff(state.rootDir)}
style={btnStyle}
>
git diff
</button>
)}
<button onClick={() => setShowKeyInput(v => !v)} style={btnStyle}>
API
</button>
</div>
</header>
{/* API Key input (collapsible) */}
{showKeyInput && (
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border)', background: 'var(--surface)', display: 'flex', gap: 8, alignItems: 'center' }}>
<input
type="password"
placeholder="Gemini API キー (AIza...)"
value={geminiKey}
onChange={e => {
setGeminiKey(e.target.value);
localStorage.setItem('guard-gemini-key', e.target.value);
}}
style={{ flex: 1, background: 'var(--surface2)', border: '1px solid var(--border)', borderRadius: 8, padding: '6px 12px', color: 'var(--text)', fontSize: 13, outline: 'none' }}
/>
<button onClick={() => setShowKeyInput(false)} style={{ ...btnStyle, color: 'var(--ok)' }}></button>
</div>
)}
{/* Main */}
<main style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
{state.status === 'idle' && (
<IdleView onSelect={handleSelectFolder} />
)}
{state.status === 'scanning' && (
<ScanningView progress={state.progress} />
)}
{state.status === 'results' && (
<ResultsView
issues={state.issues}
rootDir={state.rootDir}
onApplyFix={handleApplyFix}
onRescan={() => runScan(state.rootDir)}
/>
)}
</main>
</div>
);
}
// ── Sub views ────────────────────────────────────────────────────────────────
function IdleView({ onSelect }: { onSelect: () => void }) {
return (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 20, padding: 40 }}>
<div style={{ fontSize: 40 }}>🛡</div>
<h1 style={{ fontSize: 24, fontWeight: 600, letterSpacing: '-0.03em', textAlign: 'center', lineHeight: 1.3 }}>
AI生成コードのリスクを<br />
</h1>
<p style={{ fontSize: 13, color: 'var(--text2)', textAlign: 'center', maxWidth: 380, lineHeight: 1.7 }}>
Gemini APIにのみ送信されます
</p>
<button
onClick={onSelect}
style={{
padding: '12px 28px', borderRadius: 'var(--radius)', border: 'none',
background: 'var(--accent)', color: '#fff', fontSize: 14, fontWeight: 600,
cursor: 'pointer', letterSpacing: '-0.01em',
}}
>
</button>
</div>
);
}
function ScanningView({ progress }: { progress: string }) {
return (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 16 }}>
<div style={{ width: 36, height: 36, border: '3px solid var(--border)', borderTopColor: 'var(--accent)', borderRadius: '50%', animation: 'spin 0.8s linear infinite' }} />
<p style={{ fontSize: 13, color: 'var(--text2)' }}>{progress}</p>
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
</div>
);
}
function ResultsView({
issues, rootDir, onApplyFix, onRescan,
}: {
issues: ScanIssue[];
rootDir: string;
onApplyFix: (issue: ScanIssue, rootDir: string) => void;
onRescan: () => void;
}) {
const danger = issues.filter(i => i.severity === 'danger').length;
const warning = issues.filter(i => i.severity === 'warning').length;
return (
<div style={{ maxWidth: 720, width: '100%', margin: '0 auto', padding: '28px 20px' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
<div style={{ display: 'flex', gap: 8 }}>
{danger > 0 && <Chip count={danger} label="危険" color="var(--danger)" dim="var(--danger-dim)" />}
{warning > 0 && <Chip count={warning} label="注意" color="var(--warning)" dim="var(--warning-dim)" />}
{issues.length === 0 && <span style={{ fontSize: 13, color: 'var(--ok)' }}></span>}
</div>
<button onClick={onRescan} style={btnStyle}></button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{issues.map((issue, idx) => (
<IssueCard key={idx} issue={issue} onApplyFix={() => onApplyFix(issue, rootDir)} />
))}
</div>
</div>
);
}
function IssueCard({ issue, onApplyFix }: { issue: ScanIssue; onApplyFix: () => void }) {
const [expanded, setExpanded] = useState(false);
const [copied, setCopied] = useState(false);
const color = issue.severity === 'danger' ? 'var(--danger)' : issue.severity === 'warning' ? 'var(--warning)' : 'var(--info)';
const dim = issue.severity === 'danger' ? 'var(--danger-dim)' : issue.severity === 'warning' ? 'var(--warning-dim)' : 'var(--info-dim)';
const label = issue.severity === 'danger' ? '危険' : issue.severity === 'warning' ? '注意' : '改善';
const copyPrompt = async () => {
const loc = issue.line ? `${issue.file}${issue.line}行目付近)` : issue.file;
const text = [
'以下のセキュリティ問題を修正してください。',
'',
`ファイル: ${loc}`,
`問題: ${issue.title}`,
`詳細: ${issue.description}`,
...(issue.fix ? [`修正案: ${issue.fix}`] : []),
'',
'該当箇所のコードを貼り付けて、上記の問題を安全に修正してください。',
].join('\n');
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 1800);
};
return (
<div style={{ background: 'rgba(17,24,39,0.7)', border: `1px solid var(--border)`, borderLeft: `2px solid ${color}`, borderRadius: 'var(--radius)', overflow: 'hidden' }}>
<button
onClick={() => setExpanded(v => !v)}
style={{ width: '100%', padding: '12px 14px', display: 'flex', alignItems: 'flex-start', gap: 10, background: 'transparent', border: 'none', cursor: 'pointer', textAlign: 'left' }}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 7, marginBottom: 2 }}>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text)' }}>{issue.title}</span>
<span style={{ fontSize: 10, fontWeight: 600, color, background: dim, padding: '1px 7px', borderRadius: 99, letterSpacing: '0.06em' }}>{label}</span>
</div>
<span style={{ fontSize: 11, color: 'var(--text3)', fontFamily: "'JetBrains Mono', monospace" }}>
{issue.file}{issue.line ? `:${issue.line}` : ''}
</span>
</div>
<span style={{ color: 'var(--text3)', fontSize: 13 }}>{expanded ? '▲' : '▼'}</span>
</button>
{expanded && (
<div style={{ borderTop: '1px solid var(--border)', padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 10 }}>
<p style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.7 }}>{issue.description}</p>
{issue.fix && (
<pre style={{ background: 'var(--surface2)', border: '1px solid var(--border)', borderRadius: 8, padding: '10px 12px', fontSize: 12, color: 'var(--text)', overflowX: 'auto', lineHeight: 1.6, fontFamily: "'JetBrains Mono', monospace", whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{issue.fix}
</pre>
)}
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button onClick={copyPrompt} style={btnStyle}>
{copied ? '✓ コピー済み' : 'AIプロンプトをコピー'}
</button>
{issue.fix && (
<button
onClick={onApplyFix}
style={{ ...btnStyle, color: 'var(--ok)', borderColor: 'rgba(50,215,75,0.3)' }}
>
</button>
)}
</div>
</div>
)}
</div>
);
}
function Chip({ count, label, color, dim }: { count: number; label: string; color: string; dim: string }) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '5px 12px', borderRadius: 99, background: dim }}>
<span style={{ fontSize: 15, fontWeight: 700, color }}>{count}</span>
<span style={{ fontSize: 12, fontWeight: 500, color }}>{label}</span>
</div>
);
}
// ── Styles ───────────────────────────────────────────────────────────────────
const btnStyle: React.CSSProperties = {
display: 'flex', alignItems: 'center', gap: 5,
padding: '5px 12px', borderRadius: 8,
border: '1px solid var(--border)', background: 'transparent',
color: 'var(--text2)', fontSize: 12, fontWeight: 500, cursor: 'pointer',
};
// ── Gemini API call ──────────────────────────────────────────────────────────
async function callGemini(apiKey: string, codeBlock: string): Promise<ScanIssue[]> {
const SYSTEM = `あなたはセキュリティ専門家です。提供されたコードを解析し、セキュリティリスクを JSON 配列で返してください。
: { severity: "danger"|"warning"|"info", title: string, description: string, file: string, line: number|null, fix: string|null }
[] JSON `;
const resp = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${apiKey}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
system_instruction: { parts: [{ text: SYSTEM }] },
contents: [{ role: 'user', parts: [{ text: `以下のコードを解析してください:\n${codeBlock}` }] }],
generationConfig: { temperature: 0.1, responseMimeType: 'application/json' },
}),
}
);
if (!resp.ok) throw new Error(`Gemini ${resp.status}`);
const data = await resp.json();
const text = data.candidates?.[0]?.content?.parts?.[0]?.text ?? '[]';
try {
return JSON.parse(text);
} catch {
return [];
}
}

View File

@ -0,0 +1,53 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0C1221;
--surface: #111827;
--surface2: #1A2332;
--border: #1F2D40;
--text: #F3F4F6;
--text2: #9CA3AF;
--text3: #4B5563;
--accent: #F97316;
--accent-dim: rgba(249,115,22,0.10);
--accent-border: rgba(249,115,22,0.28);
--danger: #FF453A;
--danger-dim: rgba(255,69,58,0.10);
--warning: #F97316;
--warning-dim: rgba(249,115,22,0.10);
--info: #A78BFA;
--info-dim: rgba(167,139,250,0.10);
--ok: #32D74B;
--ok-dim: rgba(50,215,75,0.10);
--radius: 12px;
--radius-sm: 8px;
}
html, body, #root {
height: 100%;
background: var(--bg);
color: var(--text);
font-family: 'Inter', system-ui, sans-serif;
font-size: 14px;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
/* grid lines */
body::before {
content: '';
position: fixed;
inset: 0;
background-image:
linear-gradient(rgba(255,255,255,0.028) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.028) 1px, transparent 1px);
background-size: 48px 48px;
pointer-events: none;
z-index: 0;
}
#root { position: relative; z-index: 1; }
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 99px; }

View File

@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

View File

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2021",
"useDefineForClassFields": true,
"lib": ["ES2021", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true
},
"include": ["src"]
}

View File

@ -0,0 +1,18 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// Tauri expects a fixed port during dev
export default defineConfig({
plugins: [react()],
clearScreen: false,
server: {
port: 1420,
strictPort: true,
},
envPrefix: ['VITE_', 'TAURI_'],
build: {
target: ['es2021', 'chrome105', 'safari15'],
minify: !process.env.TAURI_DEBUG ? 'esbuild' : false,
sourcemap: !!process.env.TAURI_DEBUG,
},
});