다운로드 화면 추가 및 full exe 추가

This commit is contained in:
backuppc
2026-01-21 09:31:31 +09:00
parent a1a1971a1f
commit c5e7ec8436
8 changed files with 1894 additions and 191 deletions

2
.gitignore vendored
View File

@@ -23,3 +23,5 @@ dist-ssr
*.sln
*.sw?
public/webftp-backend.exe
public/WebZilla.exe
public/webftp.exe

19
App.tsx
View File

@@ -9,6 +9,7 @@ import SiteManagerModal from './components/SiteManagerModal';
import HelpModal from './components/HelpModal';
import { CreateFolderModal, RenameModal, DeleteModal } from './components/FileActionModals';
import ConflictModal from './components/ConflictModal';
import DownloadModal from './components/DownloadModal';
import { formatBytes } from './utils/formatters';
const App: React.FC = () => {
@@ -36,6 +37,7 @@ const App: React.FC = () => {
const [showConnectionHelp, setShowConnectionHelp] = useState(false);
const [showSiteManager, setShowSiteManager] = useState(false);
const [showHelp, setShowHelp] = useState(false);
const [showDownloadModal, setShowDownloadModal] = useState(false); // New state for Download Modal
const [helpInitialTab, setHelpInitialTab] = useState<'sites' | 'connection' | 'files' | 'backend'>('sites');
const [savedSites, setSavedSites] = useState<SiteConfig[]>([]);
@@ -917,6 +919,10 @@ const App: React.FC = () => {
onSkip={handleConflictSkip}
onClose={() => handleConflictSkip(false)} // Treat close as skip single
/>
<DownloadModal
isOpen={showDownloadModal}
onClose={() => setShowDownloadModal(false)}
/>
{/* Header */}
<header className="bg-white border-b border-slate-200 p-2 shadow-sm z-20">
@@ -1002,14 +1008,13 @@ const App: React.FC = () => {
{connection.connecting ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : (connection.connected ? <><WifiOff size={16} /> </> : '빠른 연결')}
</button>
<a
href={`${import.meta.env.BASE_URL}webftp-backend.exe`}
download="webftp-backend.exe"
className={`h-[30px] flex items-center justify-center px-3 rounded bg-emerald-600 hover:bg-emerald-500 text-white border border-emerald-500 shadow-md shadow-emerald-500/20 transition-all ${!connection.connected ? 'animate-pulse ring-2 ring-emerald-400/50' : ''}`}
title="백엔드 다운로드"
<button
onClick={() => setShowDownloadModal(true)}
className="h-[30px] flex items-center justify-center px-3 rounded bg-emerald-600 hover:bg-emerald-500 text-white border border-emerald-500 shadow-md shadow-emerald-500/20 transition-all"
title="다운로드 센터"
>
<Download size={14} className="mr-1.5" />
</a>
<Download size={14} className="mr-1.5" />
</button>
<button
onClick={() => setShowHelp(true)}

View File

@@ -18,6 +18,26 @@ const path = require('path');
const os = require('os');
const { exec } = require('child_process');
const readline = require('readline');
const http = require('http');
// MIME Types for static serving
const MIME_TYPES = {
'.html': 'text/html',
'.js': 'text/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.ttf': 'font/ttf',
'.eot': 'application/vnd.ms-fontobject',
'.otf': 'font/otf',
'.wasm': 'application/wasm'
};
// --- 로컬 저장소 경로 설정 (AppData 구현) ---
function getConfigDir() {
@@ -38,11 +58,91 @@ if (!fs.existsSync(configDir)) {
const PORT = 8090;
// --- 서버 시작 함수 (재시도 로직 포함) ---
// --- 서버 시작 함수 (재시도 로직 포함) ---
function startServer() {
const wss = new WebSocket.Server({ port: PORT });
const server = http.createServer((req, res) => {
// Basic Static File Server
// pkg friendly path: use path.join(__dirname, 'dist')
// When packaged with pkg, __dirname points to the virtual filesystem inside the executable
wss.on('error', (err) => {
// Special handling for executables (Download Center)
// Serve from the host filesystem (where the .exe is running)
// This prevents the need to bundle the executables inside the pkg bundle itself
if (req.url === '/webftp.exe' || req.url === '/webftp-backend.exe') {
const exeDir = path.dirname(process.execPath); // Directory of the running executable
// Fallback for dev mode (node backend_proxy.cjs) -> serve from public or current dir
const targetPath = process.pkg ? path.join(exeDir, req.url) : path.join(__dirname, 'public', req.url);
if (fs.existsSync(targetPath)) {
const stat = fs.statSync(targetPath);
// Handle HEAD request for size check
if (req.method === 'HEAD') {
res.writeHead(200, {
'Content-Type': 'application/octet-stream',
'Content-Length': stat.size
});
res.end();
return;
}
res.writeHead(200, {
'Content-Type': 'application/octet-stream',
'Content-Length': stat.size,
'Content-Disposition': `attachment; filename="${path.basename(targetPath)}"`
});
const readStream = fs.createReadStream(targetPath);
readStream.pipe(res);
return;
}
}
let filePath = path.join(__dirname, 'dist', req.url === '/' ? 'index.html' : req.url);
// Prevent directory traversal
if (!filePath.startsWith(path.join(__dirname, 'dist'))) {
res.writeHead(403);
res.end('Forbidden');
return;
}
const extname = path.extname(filePath);
let contentType = MIME_TYPES[extname] || 'application/octet-stream';
fs.readFile(filePath, (err, content) => {
if (err) {
if (err.code === 'ENOENT') {
// SPA fallback: serve index.html for unknown paths (if not an asset)
if (!extname || extname === '.html') {
fs.readFile(path.join(__dirname, 'dist', 'index.html'), (err2, content2) => {
if (err2) {
res.writeHead(404);
res.end('404 Not Found');
} else {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(content2, 'utf-8');
}
});
} else {
res.writeHead(404);
res.end('File Not Found');
}
} else {
res.writeHead(500);
res.end(`Server Error: ${err.code}`);
}
} else {
res.writeHead(200, { 'Content-Type': contentType });
res.end(content, 'utf-8');
}
});
});
const wss = new WebSocket.Server({ server });
server.on('error', (err) => {
if (err.code === 'EADDRINUSE') {
console.error(`\n❌ 포트 ${PORT}이(가) 이미 사용 중입니다.`);
handlePortConflict();
@@ -52,9 +152,12 @@ function startServer() {
}
});
wss.on('listening', () => {
console.log(`\n🚀 WebZilla FTP Proxy Server [v${APP_VERSION}] 가 ws://localhost:${PORT} 에서 실행 중입니다.`);
server.listen(PORT, () => {
console.log(`\n🚀 WebFTP Proxy Server [v${APP_VERSION}] 가 http://localhost:${PORT} 에서 실행 중입니다.`);
console.log(`📂 설정 폴더: ${configDir}`);
// Open Browser
openBrowser(`http://localhost:${PORT}`);
});
wss.on('connection', (ws) => {
@@ -383,5 +486,14 @@ function askToKill(pid) {
});
}
function openBrowser(url) {
const start = (process.platform == 'darwin' ? 'open' : process.platform == 'win32' ? 'start' : 'xdg-open');
exec(`${start} ${url}`, (err) => {
if (err) {
console.log("브라우저를 자동으로 열 수 없습니다:", err.message);
}
});
}
// 초기 실행
startServer();

View File

@@ -0,0 +1,112 @@
import React, { useState, useEffect } from 'react';
import { Download, Server, Package } from 'lucide-react';
import { formatBytes } from '../utils/formatters';
interface DownloadModalProps {
isOpen: boolean;
onClose: () => void;
}
const DownloadModal: React.FC<DownloadModalProps> = ({ isOpen, onClose }) => {
if (!isOpen) return null;
const baseUrl = import.meta.env.BASE_URL;
const [fileSizes, setFileSizes] = useState<{ [key: string]: string }>({});
useEffect(() => {
if (!isOpen) return;
const fetchSize = async (filename: string) => {
try {
const response = await fetch(`${baseUrl}${filename}`, { method: 'HEAD' });
const size = response.headers.get('Content-Length');
if (size) {
setFileSizes(prev => ({ ...prev, [filename]: formatBytes(parseInt(size, 10)) }));
}
} catch (e) {
console.error(`Failed to fetch size for ${filename}`, e);
}
};
fetchSize('webftp.exe');
fetchSize('webftp-backend.exe');
}, [isOpen, baseUrl]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/20 backdrop-blur-sm animate-in fade-in duration-200">
<div
className="fixed inset-0"
onClick={onClose}
></div>
<div className="bg-white/90 backdrop-blur-md rounded-xl shadow-2xl border border-white/50 w-full max-w-md p-6 relative animate-in zoom-in-95 duration-200">
<h2 className="text-xl font-bold text-slate-800 mb-2 flex items-center gap-2">
<Download className="text-blue-600" />
</h2>
<p className="text-sm text-slate-500 mb-6">
.
</p>
<div className="space-y-4">
<a
href={`${baseUrl}webftp.exe`}
download="webftp.exe"
className="flex items-start gap-4 p-4 rounded-lg border border-slate-200 bg-white hover:bg-blue-50 hover:border-blue-300 transition-all group shadow-sm hover:shadow-md"
onClick={onClose}
>
<div className="w-12 h-12 rounded-full bg-blue-100/50 flex items-center justify-center shrink-0 group-hover:bg-blue-200/50 transition-colors">
<Package className="text-blue-600 w-6 h-6" />
</div>
<div className="flex-1">
<div className="font-bold text-slate-800 mb-1 flex items-center justify-between">
WebFTP ()
WebFTP ()
<span className="flex gap-1">
{fileSizes['webftp.exe'] && <span className="text-[10px] bg-slate-100 text-slate-500 px-1.5 py-0.5 rounded opacity-80">{fileSizes['webftp.exe']}</span>}
<span className="text-[10px] font-bold bg-blue-100 text-blue-700 px-1.5 py-0.5 rounded">ALL-IN-ONE</span>
</span>
</div>
<p className="text-xs text-slate-500 leading-relaxed">
. <br />
<span className="font-semibold text-slate-600"> .</span>
</p>
</div>
</a>
<a
href={`${baseUrl}webftp-backend.exe`}
download="webftp-backend.exe"
className="flex items-start gap-4 p-4 rounded-lg border border-slate-200 bg-white hover:bg-slate-50 hover:border-slate-300 transition-all group shadow-sm hover:shadow-md"
onClick={onClose}
>
<div className="w-12 h-12 rounded-full bg-slate-100/50 flex items-center justify-center shrink-0 group-hover:bg-slate-200/50 transition-colors">
<Server className="text-slate-600 w-6 h-6" />
</div>
<div className="flex-1">
<div className="font-bold text-slate-800 mb-1 flex items-center justify-between">
Backend Only
{fileSizes['webftp-backend.exe'] && <span className="text-[10px] bg-slate-100 text-slate-500 px-1.5 py-0.5 rounded opacity-80">{fileSizes['webftp-backend.exe']}</span>}
</div>
<p className="text-xs text-slate-500 leading-relaxed">
. <br />
<span className="font-semibold text-slate-600"> </span>
</p>
</div>
</a>
</div>
<div className="mt-6 flex justify-end">
<button
onClick={onClose}
className="px-4 py-2 text-sm text-slate-600 hover:text-slate-900 font-medium transition-colors"
>
</button>
</div>
</div>
</div>
);
};
export default DownloadModal;

View File

@@ -1 +1 @@
pkg backend_proxy.cjs --targets node18-win-x64 --output .\public\webftp-backend.exe
call npm run build

1780
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,9 +3,13 @@
"private": true,
"version": "0.0.0",
"type": "module",
"bin": "backend_proxy.cjs",
"scripts": {
"dev": "vite",
"build": "pkg backend_proxy.cjs --targets node18-win-x64 --output public/webftp-backend.exe && vite build",
"clean": "rimraf public/webftp.exe public/webftp-backend.exe dist/webftp.exe dist/webftp-backend.exe",
"build": "npm run clean && vite build && npm run build:backend && npm run build:full",
"build:full": "pkg . --output ./public/webftp.exe",
"build:backend": "pkg backend_proxy.cjs --targets node18-win-x64 --output ./public/webftp-backend.exe",
"preview": "vite preview",
"proxy": "node backend_proxy.cjs"
},
@@ -21,7 +25,17 @@
"@types/node": "^22.14.0",
"@vitejs/plugin-react": "^5.0.0",
"pkg": "^5.8.1",
"rimraf": "^6.1.2",
"typescript": "~5.8.2",
"vite": "^6.2.0"
},
"pkg": {
"scripts": "backend_proxy.cjs",
"assets": [
"dist/**/*"
],
"targets": [
"node18-win-x64"
]
}
}

View File

@@ -3,22 +3,22 @@ import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '.', '');
return {
base: '/ftp/',
server: {
port: 3000,
host: '0.0.0.0',
},
plugins: [react()],
define: {
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
}
const env = loadEnv(mode, '.', '');
return {
base: './',
server: {
port: 3000,
host: '0.0.0.0',
},
plugins: [react()],
define: {
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
}
};
}
};
});