From b38e3d98bd47ace5d0bc521c276262a59cdb1883 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=9Cmit=20Tun=C3=A7?= Date: Sat, 28 Mar 2026 08:56:51 +0300 Subject: [PATCH] feat: implement core UI dashboard and backend service management for multi-PHP environment --- src/main/ipc/index.ts | 42 +++++ src/main/services/DownloadService.ts | 77 +++++---- src/main/services/PhpManagerService.ts | 114 +++++++++++++ src/main/services/ProjectService.ts | 58 +++++++ src/preload/index.ts | 8 +- src/renderer/src/App.tsx | 222 +++++++++++++++++++++++-- src/renderer/src/main.tsx | 64 ++++++- 7 files changed, 531 insertions(+), 54 deletions(-) create mode 100644 src/main/services/PhpManagerService.ts create mode 100644 src/main/services/ProjectService.ts diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index 116f399..94a72d5 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -2,6 +2,8 @@ import { ipcMain, app } from 'electron' import { processManager } from '../services/ProcessManager' import { configService } from '../services/ConfigService' import { downloadService } from '../services/DownloadService' +import { projectService } from '../services/ProjectService' +import { phpManagerService } from '../services/PhpManagerService' import path from 'path' export function registerIpcHandlers(): void { @@ -54,6 +56,33 @@ export function registerIpcHandlers(): void { }) // Binary management + ipcMain.handle('binaries:list-php', async () => { + return phpManagerService.getVersions() + }) + + ipcMain.handle('binaries:download-php', async (event, id: string) => { + const versions = await phpManagerService.getVersions() + const version = versions.find(v => v.id === id) + if (!version) return { success: false, message: 'Geçersiz sürüm.' } + + try { + // Signal immediate start + phpManagerService.updateProgress(id, 0, 'downloading') + event.sender.send('binaries:progress', { id, progress: 0 }) + + await downloadService.downloadAndExtract(version.url, id, (p) => { + phpManagerService.updateProgress(id, p) + event.sender.send('binaries:progress', { id, progress: p }) + }) + + phpManagerService.updateProgress(id, 100, 'installed') + return { success: true, message: 'İndirme tamamlandı.' } + } catch (error: any) { + phpManagerService.updateProgress(id, 0, 'available') + return { success: false, message: `Hata: ${error.message}` } + } + }) + ipcMain.handle('binaries:download', async (_event, { name, url }) => { return downloadService.downloadAndExtract(url, name, (p) => { // TODO: Emit progress to renderer via webContents.send @@ -61,6 +90,19 @@ export function registerIpcHandlers(): void { }) }) + // Project management + ipcMain.handle('projects:list', async () => { + return projectService.getProjects() + }) + + ipcMain.handle('projects:add', async (_event, project) => { + return projectService.addProject(project) + }) + + ipcMain.handle('projects:remove', async (_event, id) => { + return projectService.removeProject(id) + }) + // Config management ipcMain.handle('config:get', async (_event, key: string) => { return null diff --git a/src/main/services/DownloadService.ts b/src/main/services/DownloadService.ts index a062706..390b779 100644 --- a/src/main/services/DownloadService.ts +++ b/src/main/services/DownloadService.ts @@ -2,13 +2,14 @@ import axios from 'axios' import fs from 'fs' import path from 'path' import { app } from 'electron' -import * as decompress from 'decompress' +const decompress = require('decompress') export class DownloadService { private binDir: string constructor() { - this.binDir = path.join(app.getAppPath(), 'bin') + // Use userData for binaries to avoid permission issues in Program Files + this.binDir = path.join(app.getPath('userData'), 'bin') if (!fs.existsSync(this.binDir)) { fs.mkdirSync(this.binDir, { recursive: true }) } @@ -17,42 +18,58 @@ export class DownloadService { async downloadAndExtract(url: string, targetName: string, onProgress?: (p: number) => void): Promise { const tempFile = path.join(app.getPath('temp'), `${targetName}.zip`) const targetDir = path.join(this.binDir, targetName) + const phpExe = path.join(targetDir, 'php.exe') - if (fs.existsSync(targetDir)) { + // If already installed and working, skip + if (fs.existsSync(phpExe)) { return targetDir } + // Cleanup failed attempts + if (fs.existsSync(targetDir)) { + fs.rmSync(targetDir, { recursive: true, force: true }) + } + const writer = fs.createWriteStream(tempFile) - const response = await axios({ - url, - method: 'GET', - responseType: 'stream' - }) - const totalLength = response.headers['content-length'] - let downloadedLength = 0 - - response.data.on('data', (chunk: any) => { - downloadedLength += chunk.length - if (onProgress && totalLength) { - onProgress(Math.round((downloadedLength / totalLength) * 100)) - } - }) - - response.data.pipe(writer) - - return new Promise((resolve, reject) => { - writer.on('finish', async () => { - try { - await decompress(tempFile, targetDir) - fs.unlinkSync(tempFile) - resolve(targetDir) - } catch (err) { - reject(err) + try { + const response = await axios({ + url, + method: 'GET', + responseType: 'stream', + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' } }) - writer.on('error', reject) - }) + + const totalLength = parseInt(response.headers['content-length'] || '0') + let downloadedLength = 0 + + response.data.on('data', (chunk: any) => { + downloadedLength += chunk.length + if (onProgress && totalLength > 0) { + onProgress(Math.round((downloadedLength / totalLength) * 100)) + } + }) + + response.data.pipe(writer) + + return new Promise((resolve, reject) => { + writer.on('finish', async () => { + try { + await decompress(tempFile, targetDir) + if (fs.existsSync(tempFile)) fs.unlinkSync(tempFile) + resolve(targetDir) + } catch (err) { + reject(err) + } + }) + writer.on('error', reject) + }) + } catch (error) { + if (fs.existsSync(tempFile)) fs.unlinkSync(tempFile) + throw error + } } getBinPath(service: string, executable: string): string { diff --git a/src/main/services/PhpManagerService.ts b/src/main/services/PhpManagerService.ts new file mode 100644 index 0000000..871a0b6 --- /dev/null +++ b/src/main/services/PhpManagerService.ts @@ -0,0 +1,114 @@ +import fs from 'fs' +import path from 'path' +import { app } from 'electron' +import axios from 'axios' + +export interface PhpVersion { + id: string + version: string + url: string + status: 'available' | 'downloading' | 'installed' + progress: number +} + +export class PhpManagerService { + private versions: PhpVersion[] = [] + private binDir: string + private winPhpUrl = 'https://windows.php.net/downloads/releases/' + private winReleasesJsonUrl = 'https://windows.php.net/downloads/releases/releases.json' + private mainReleasesJsonUrl = 'https://www.php.net/releases/index.php?json' + + constructor() { + this.binDir = path.join(app.getPath('userData'), 'bin') + this.init() + } + + private async init() { + if (!fs.existsSync(this.binDir)) { + fs.mkdirSync(this.binDir, { recursive: true }) + } + await this.fetchAvailableVersions() + } + + private async fetchAvailableVersions() { + try { + // Fetch from windows.php.net for actual paths + const winResponse = await axios.get(this.winReleasesJsonUrl, { + headers: { 'User-Agent': 'Mozilla/5.0' } + }) + const winData = winResponse.data + + // Fetch from php.net for supported versions list + const mainResponse = await axios.get(this.mainReleasesJsonUrl, { + headers: { 'User-Agent': 'Mozilla/5.0' } + }) + const mainData = mainResponse.data + + const dynamicVersions: PhpVersion[] = [] + + // Method 1: Versions explicitly available in windows releases.json + for (const majorMinor in winData) { + const release = winData[majorMinor] + const version = release.version + const tsBuild = release['ts-vs16-x64'] || release['ts-vs17-x64'] || release['ts-vc15-x64'] + + if (tsBuild && tsBuild.zip) { + dynamicVersions.push({ + id: `php-${version}`, + version: version, + url: this.winPhpUrl + tsBuild.zip.path, + status: 'available', + progress: 0 + }) + } + } + + // Method 2: Check supported_versions from main php.net + // This helps if windows releases.json doesn't group some newer versions yet + if (mainData['8'] && mainData['8'].supported_versions) { + for (const ver of mainData['8'].supported_versions) { + // ver is "8.2", "8.3" etc. + // If we already have a version for this branch, we are good. + // Otherwise, we could try to guess or find more. + } + } + + // De-duplicate by version string + const uniqueVersionsMap = new Map() + dynamicVersions.forEach(v => uniqueVersionsMap.set(v.version, v)) + + this.versions = Array.from(uniqueVersionsMap.values()).sort((a, b) => b.version.localeCompare(a.version)) + } catch (e) { + console.error('Failed to fetch PHP versions:', e) + } + } + + private refreshInstalledStatus() { + this.versions = this.versions.map(v => { + const targetDir = path.join(this.binDir, v.id) + const phpExe = path.join(targetDir, 'php.exe') + if (fs.existsSync(phpExe)) { + return { ...v, status: 'installed', progress: 100 } + } + return v + }) + } + + async getVersions(): Promise { + if (this.versions.length === 0) { + await this.fetchAvailableVersions() + } + this.refreshInstalledStatus() + return this.versions + } + + updateProgress(id: string, progress: number, status: PhpVersion['status'] = 'downloading') { + const version = this.versions.find(v => v.id === id) + if (version) { + version.progress = progress + version.status = status + } + } +} + +export const phpManagerService = new PhpManagerService() diff --git a/src/main/services/ProjectService.ts b/src/main/services/ProjectService.ts new file mode 100644 index 0000000..e1462f1 --- /dev/null +++ b/src/main/services/ProjectService.ts @@ -0,0 +1,58 @@ +import fs from 'fs' +import path from 'path' +import { app } from 'electron' + +export interface Project { + id: string + name: string + path: string + phpVersion: string + host: string +} + +export class ProjectService { + private projectsFile: string + private projects: Project[] = [] + + constructor() { + this.projectsFile = path.join(app.getPath('userData'), 'projects.json') + this.loadProjects() + } + + private loadProjects() { + if (fs.existsSync(this.projectsFile)) { + try { + const data = fs.readFileSync(this.projectsFile, 'utf-8') + this.projects = JSON.parse(data) + } catch (e) { + console.error('Failed to load projects:', e) + this.projects = [] + } + } + } + + private saveProjects() { + fs.writeFileSync(this.projectsFile, JSON.stringify(this.projects, null, 2)) + } + + getProjects(): Project[] { + return this.projects + } + + addProject(project: Omit): Project { + const newProject = { + ...project, + id: Date.now().toString() + } + this.projects.push(newProject) + this.saveProjects() + return newProject + } + + removeProject(id: string) { + this.projects = this.projects.filter(p => p.id !== id) + this.saveProjects() + } +} + +export const projectService = new ProjectService() diff --git a/src/preload/index.ts b/src/preload/index.ts index bba2a27..734b19c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -7,7 +7,13 @@ const api = { stopService: (name: string) => electronAPI.ipcRenderer.invoke('services:stop', name), getServiceStatus: () => electronAPI.ipcRenderer.invoke('services:status'), getConfig: (key: string) => electronAPI.ipcRenderer.invoke('config:get', key), - saveConfig: (config: any) => electronAPI.ipcRenderer.invoke('config:save', config) + saveConfig: (config: any) => electronAPI.ipcRenderer.invoke('config:save', config), + getProjects: () => electronAPI.ipcRenderer.invoke('projects:list'), + addProject: (project: any) => electronAPI.ipcRenderer.invoke('projects:add', project), + removeProject: (id: string) => electronAPI.ipcRenderer.invoke('projects:remove', id), + getPhpVersions: () => electronAPI.ipcRenderer.invoke('binaries:list-php'), + downloadPhpVersion: (id: string) => electronAPI.ipcRenderer.invoke('binaries:download-php', id), + onBinaryProgress: (callback: any) => electronAPI.ipcRenderer.on('binaries:progress', (_event, data) => callback(data)) } // Use `contextBridge` APIs to expose Electron APIs to diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 5b1c05e..3c4d167 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -19,7 +19,17 @@ import { Divider, Button, Snackbar, - Alert + Alert, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + TextField, + Select, + MenuItem, + FormControl, + InputLabel, + LinearProgress } from '@mui/material' import { Dashboard as DashboardIcon, @@ -31,9 +41,13 @@ import { Code as PhpIcon, PlayArrow as StartIcon, Stop as StopIcon, - Refresh as RestartIcon + Delete as DeleteIcon, + Download as DownloadIcon, + CheckCircle as InstalledIcon, + CloudDownload as AvailableIcon } from '@mui/icons-material' import { useTranslation } from 'react-i18next' + declare global { interface Window { api: any @@ -56,21 +70,58 @@ function App(): JSX.Element { mysql: 'stopped' }) const [activeTab, setActiveTab] = useState('dashboard') + const [projects, setProjects] = useState([]) + const [phpVersions, setPhpVersions] = useState([]) + const [isProjectDialogOpen, setIsProjectDialogOpen] = useState(false) + const [newProject, setNewProject] = useState({ name: '', path: '', phpVersion: '8.2', host: 'localhost' }) const [notification, setNotification] = useState<{ open: boolean, message: string, severity: 'success' | 'error' }>({ open: false, message: '', severity: 'success' }) + const fetchStatus = async () => { + // @ts-ignore + const currentStatus = await window.api.getServiceStatus() + if (currentStatus) setStatus(currentStatus) + } + + const fetchProjects = async () => { + // @ts-ignore + const list = await window.api.getProjects() + setProjects(list || []) + } + + const fetchPhpVersions = async () => { + // @ts-ignore + const list = await window.api.getPhpVersions() + setPhpVersions(list || []) + } + useEffect(() => { - const fetchStatus = async () => { - // @ts-ignore - const currentStatus = await window.api.getServiceStatus() - if (currentStatus) setStatus(currentStatus) - } + if (!window.api) return + fetchStatus() - const interval = setInterval(fetchStatus, 3000) - return () => clearInterval(interval) + fetchProjects() + fetchPhpVersions() + + // @ts-ignore + const removeListener = window.api.onBinaryProgress((data) => { + if (!data) return + const { id, progress } = data + setPhpVersions(prev => prev.map(v => v.id === id ? { ...v, progress, status: progress === 100 ? 'installed' : 'downloading' } : v)) + }) + + const interval = setInterval(() => { + fetchStatus() + fetchProjects() + fetchPhpVersions() + }, 3000) + + return () => { + clearInterval(interval) + if (typeof removeListener === 'function') removeListener() + } }, []) const handleToggleService = async (service: keyof ServiceStatus) => { @@ -118,6 +169,33 @@ function App(): JSX.Element { return } + const handleAddProject = async () => { + // @ts-ignore + await window.api.addProject(newProject) + setIsProjectDialogOpen(false) + setNewProject({ name: '', path: '', phpVersion: '8.2', host: 'localhost' }) + setNotification({ open: true, message: 'Proje başarıyla eklendi.', severity: 'success' }) + fetchProjects() + } + + const handleRemoveProject = async (id: string) => { + // @ts-ignore + await window.api.removeProject(id) + setNotification({ open: true, message: 'Proje silindi.', severity: 'success' }) + fetchProjects() + } + + const handleDownloadPhp = async (id: string) => { + // @ts-ignore + const result = await window.api.downloadPhpVersion(id) + setNotification({ + open: true, + message: result.message, + severity: result.success ? 'success' : 'error' + }) + fetchPhpVersions() + } + return ( theme.zIndex.drawer + 1, bgcolor: 'rgba(21, 62, 94, 0.85)', backdropFilter: 'blur(10px)' }}> @@ -175,6 +253,49 @@ function App(): JSX.Element { + {activeTab === 'settings' && ( + + + Genel Ayarlar + + + + + PHP Sürümleri + Gereken PHP sürümlerini buradan indirebilir ve yönetebilirsiniz. + + + {phpVersions.map((v) => ( + + + {v.status === 'installed' ? : } + + + + {v.status === 'available' && ( + + )} + {v.status === 'downloading' && ( + + + + )} + {v.status === 'installed' && ( + + )} + + + ))} + + + + )} {activeTab === 'dashboard' && ( <> @@ -223,11 +344,41 @@ function App(): JSX.Element { )} {activeTab === 'projects' && ( - - Henüz bir proje eklenmemiş. - + + + + Projelerim + + + + + {projects.length === 0 ? ( + + Henüz bir proje eklenmemiş. + + ) : ( + + {projects.map((project) => ( + + handleRemoveProject(project.id)}> + + + } + > + + + + + ))} + + )} )} @@ -242,6 +393,49 @@ function App(): JSX.Element { {notification.message} + + setIsProjectDialogOpen(false)} fullWidth maxWidth="sm"> + Yeni Proje Ekle + + + setNewProject({ ...newProject, name: e.target.value })} + /> + setNewProject({ ...newProject, path: e.target.value })} + /> + setNewProject({ ...newProject, host: e.target.value })} + /> + + PHP Sürümü + + + + + + + + + ) } diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 8447aa2..73885a8 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -1,27 +1,73 @@ import React from 'react' import ReactDOM from 'react-dom/client' import App from './App' -import './i18n' +import i18n from './i18n' import { ThemeProvider, createTheme } from '@mui/material/styles' import CssBaseline from '@mui/material/CssBaseline' +import { I18nextProvider } from 'react-i18next' const theme = createTheme({ palette: { + mode: 'dark', primary: { - main: '#153E5E', - }, - secondary: { main: '#4A7CA3', }, - mode: 'dark', + secondary: { + main: '#153E5E', + }, + background: { + default: '#121212', + paper: '#1e1e1e', + }, + text: { + primary: '#ffffff', + secondary: 'rgba(255, 255, 255, 0.7)', + }, + }, + components: { + MuiPaper: { + styleOverrides: { + root: { + backgroundImage: 'none', + }, + }, + }, }, }) +// Error Boundary for better debugging +class ErrorBoundary extends React.Component<{ children: React.ReactNode }, { hasError: boolean, error: any }> { + constructor(props) { + super(props) + this.state = { hasError: false, error: null } + } + + static getDerivedStateFromError(error) { + return { hasError: true, error } + } + + render() { + if (this.state.hasError) { + return ( +
+

Görüntüleme Hatası Oluştu

+
{this.state.error?.toString()}
+
+ ) + } + return this.props.children + } +} + ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( - - - - + + + + + + + + )