feat: posimai-guard-app Tauri v2 desktop app scaffold (pending MSVC install)
This commit is contained in:
parent
db0fd6a88e
commit
15257dfc71
|
|
@ -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>
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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; }
|
||||||
|
|
@ -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>,
|
||||||
|
);
|
||||||
|
|
@ -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"]
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue