feat: implement core UI dashboard and backend service management for multi-PHP environment

This commit is contained in:
Ümit Tunç
2026-03-28 08:56:51 +03:00
parent 5372448ead
commit b38e3d98bd
7 changed files with 531 additions and 54 deletions
+42
View File
@@ -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
+24 -7
View File
@@ -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,24 +18,36 @@ export class DownloadService {
async downloadAndExtract(url: string, targetName: string, onProgress?: (p: number) => void): Promise<string> {
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)
try {
const response = await axios({
url,
method: 'GET',
responseType: 'stream'
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'
}
})
const totalLength = response.headers['content-length']
const totalLength = parseInt(response.headers['content-length'] || '0')
let downloadedLength = 0
response.data.on('data', (chunk: any) => {
downloadedLength += chunk.length
if (onProgress && totalLength) {
if (onProgress && totalLength > 0) {
onProgress(Math.round((downloadedLength / totalLength) * 100))
}
})
@@ -45,7 +58,7 @@ export class DownloadService {
writer.on('finish', async () => {
try {
await decompress(tempFile, targetDir)
fs.unlinkSync(tempFile)
if (fs.existsSync(tempFile)) fs.unlinkSync(tempFile)
resolve(targetDir)
} catch (err) {
reject(err)
@@ -53,6 +66,10 @@ export class DownloadService {
})
writer.on('error', reject)
})
} catch (error) {
if (fs.existsSync(tempFile)) fs.unlinkSync(tempFile)
throw error
}
}
getBinPath(service: string, executable: string): string {
+114
View File
@@ -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<string, PhpVersion>()
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<PhpVersion[]> {
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()
+58
View File
@@ -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, 'id'>): 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()
+7 -1
View File
@@ -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
+202 -8
View File
@@ -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<any[]>([])
const [phpVersions, setPhpVersions] = useState<any[]>([])
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'
})
useEffect(() => {
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(() => {
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 <Chip label={s.toUpperCase()} color={color} size="small" sx={{ fontWeight: 'bold' }} />
}
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 (
<Box sx={{ display: 'flex', minHeight: '100vh', backgroundImage: 'radial-gradient(circle at 50% 0%, #153E5E 0%, #121212 100%)', bgcolor: 'transparent' }}>
<AppBar position="fixed" sx={{ zIndex: (theme) => theme.zIndex.drawer + 1, bgcolor: 'rgba(21, 62, 94, 0.85)', backdropFilter: 'blur(10px)' }}>
@@ -175,6 +253,49 @@ function App(): JSX.Element {
<Box component="main" sx={{ flexGrow: 1, p: 3 }}>
<Toolbar />
<Container maxWidth="md">
{activeTab === 'settings' && (
<Box>
<Typography variant="h5" sx={{ fontWeight: 'medium', mb: 3 }}>
Genel Ayarlar
</Typography>
<Paper sx={{ mb: 4, bgcolor: 'rgba(255, 255, 255, 0.03)', borderRadius: 2 }}>
<Box sx={{ p: 2, borderBottom: '1px solid rgba(255, 255, 255, 0.05)' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>PHP Sürümleri</Typography>
<Typography variant="body2" color="text.secondary">Gereken PHP sürümlerini buradan indirebilir ve yönetebilirsiniz.</Typography>
</Box>
<List>
{phpVersions.map((v) => (
<ListItem key={v.id} sx={{ py: 2 }}>
<ListItemIcon>
{v.status === 'installed' ? <InstalledIcon color="success" /> : <AvailableIcon color="action" />}
</ListItemIcon>
<ListItemText
primary={`PHP ${v.version}`}
secondary={v.status === 'downloading' ? `İndiriliyor... %${Math.round(v.progress)}` : v.status === 'installed' ? 'Kurulu' : 'İndirilebilir'}
sx={{ flexGrow: 1 }}
/>
<Box sx={{ minWidth: 120, textAlign: 'right' }}>
{v.status === 'available' && (
<Button variant="outlined" size="small" startIcon={<DownloadIcon />} onClick={() => handleDownloadPhp(v.id)}>
İNDİR
</Button>
)}
{v.status === 'downloading' && (
<Box sx={{ width: '100%', mt: 1 }}>
<LinearProgress variant="determinate" value={v.progress} sx={{ height: 8, borderRadius: 5 }} />
</Box>
)}
{v.status === 'installed' && (
<Chip label="HAZIR" color="success" variant="outlined" size="small" />
)}
</Box>
</ListItem>
))}
</List>
</Paper>
</Box>
)}
{activeTab === 'dashboard' && (
<>
<Typography variant="h5" gutterBottom sx={{ mb: 3, fontWeight: 'medium' }}>
@@ -223,12 +344,42 @@ function App(): JSX.Element {
)}
{activeTab === 'projects' && (
<Box sx={{ textAlign: 'center', mt: 4 }}>
<Typography variant="h6">Henüz bir proje eklenmemiş.</Typography>
<Button variant="contained" startIcon={<ProjectIcon />} sx={{ mt: 2 }}>
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Typography variant="h5" sx={{ fontWeight: 'medium' }}>
Projelerim
</Typography>
<Button variant="contained" startIcon={<ProjectIcon />} onClick={() => setIsProjectDialogOpen(true)}>
Yeni Proje Ekle
</Button>
</Box>
{projects.length === 0 ? (
<Paper sx={{ p: 4, textAlign: 'center', bgcolor: 'rgba(255, 255, 255, 0.03)' }}>
<Typography variant="body1" color="text.secondary">Henüz bir proje eklenmemiş.</Typography>
</Paper>
) : (
<List>
{projects.map((project) => (
<Paper key={project.id} sx={{ mb: 2, bgcolor: 'rgba(255, 255, 255, 0.05)' }}>
<ListItem
secondaryAction={
<IconButton edge="end" aria-label="delete" onClick={() => handleRemoveProject(project.id)}>
<DeleteIcon color="error" />
</IconButton>
}
>
<ListItemIcon><ProjectIcon color="primary" /></ListItemIcon>
<ListItemText
primary={project.name}
secondary={`${project.path} - PHP: ${project.phpVersion} - Host: ${project.host}`}
/>
</ListItem>
</Paper>
))}
</List>
)}
</Box>
)}
</Container>
</Box>
@@ -242,6 +393,49 @@ function App(): JSX.Element {
{notification.message}
</Alert>
</Snackbar>
<Dialog open={isProjectDialogOpen} onClose={() => setIsProjectDialogOpen(false)} fullWidth maxWidth="sm">
<DialogTitle>Yeni Proje Ekle</DialogTitle>
<DialogContent>
<Box sx={{ mt: 2, display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
label="Proje Adı"
fullWidth
value={newProject.name}
onChange={(e) => setNewProject({ ...newProject, name: e.target.value })}
/>
<TextField
label="Dosya Yolu (Absolute path)"
fullWidth
value={newProject.path}
onChange={(e) => setNewProject({ ...newProject, path: e.target.value })}
/>
<TextField
label="Hostname (örn: local.test)"
fullWidth
value={newProject.host}
onChange={(e) => setNewProject({ ...newProject, host: e.target.value })}
/>
<FormControl fullWidth>
<InputLabel>PHP Sürümü</InputLabel>
<Select
value={newProject.phpVersion}
label="PHP Sürümü"
onChange={(e) => setNewProject({ ...newProject, phpVersion: e.target.value })}
>
<MenuItem value="8.0">PHP 8.0</MenuItem>
<MenuItem value="8.1">PHP 8.1</MenuItem>
<MenuItem value="8.2">PHP 8.2</MenuItem>
<MenuItem value="8.3">PHP 8.3</MenuItem>
</Select>
</FormControl>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setIsProjectDialogOpen(false)}>İptal</Button>
<Button onClick={handleAddProject} variant="contained" disabled={!newProject.name || !newProject.path}>Ekle</Button>
</DialogActions>
</Dialog>
</Box>
)
}
+51 -5
View File
@@ -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 (
<div style={{ padding: 20, color: 'white' }}>
<h1>Görüntüleme Hatası Oluştu</h1>
<pre style={{ whiteSpace: 'pre-wrap' }}>{this.state.error?.toString()}</pre>
</div>
)
}
return this.props.children
}
}
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<ErrorBoundary>
<I18nextProvider i18n={i18n}>
<ThemeProvider theme={theme}>
<CssBaseline />
<App />
</ThemeProvider>
</I18nextProvider>
</ErrorBoundary>
</React.StrictMode>
)