feat: initialize Multi-PHP server project with Electron, React, and service management infrastructure

This commit is contained in:
Ümit Tunç
2026-03-28 08:42:23 +03:00
parent 19ace0e11b
commit 5372448ead
27 changed files with 10627 additions and 0 deletions
+95
View File
@@ -0,0 +1,95 @@
import { app, shell, BrowserWindow, Tray, Menu, nativeImage } from 'electron'
import { join } from 'path'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import { registerIpcHandlers } from './ipc'
let tray: Tray | null = null
let mainWindow: BrowserWindow | null = null
function createTray(): void {
// Use a simple colored pixel if no icon is available for now
const icon = nativeImage.createFromPath(join(__dirname, '../../resources/icon.png'))
tray = new Tray(icon.isEmpty() ? nativeImage.createEmpty() : icon)
const contextMenu = Menu.buildFromTemplate([
{ label: 'Trunçgil Multi-PHP', enabled: false },
{ type: 'separator' },
{
label: 'Göster/Gizle', click: () => {
if (mainWindow) {
if (mainWindow.isVisible()) mainWindow.hide()
else mainWindow.show()
}
}
},
{ type: 'separator' },
{ label: 'Başlat (Tüm Servisler)', click: () => { /* TODO: Start services */ } },
{ label: 'Durdur (Tüm Servisler)', click: () => { /* TODO: Stop services */ } },
{ type: 'separator' },
{
label: 'Çıkış', click: () => {
app.quit()
}
}
])
tray.setToolTip('Trunçgil Multi-PHP Server')
tray.setContextMenu(contextMenu)
tray.on('double-click', () => {
if (mainWindow) mainWindow.show()
})
}
function createWindow(): void {
mainWindow = new BrowserWindow({
width: 1000,
height: 750,
show: false,
autoHideMenuBar: true,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false
}
})
mainWindow.on('ready-to-show', () => {
if (mainWindow) mainWindow.show()
})
mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url)
return { action: 'deny' }
})
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
}
}
app.whenReady().then(() => {
electronApp.setAppUserModelId('com.electron')
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window)
})
createWindow()
createTray()
registerIpcHandlers()
app.on('activate', function () {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
app.on('window-all-closed', () => {
// Overriding default behavior: stay in tray if all windows closed
// unless on certain platforms where we really want to quit.
// For this server app, we often want it to stay alive in Tray.
if (process.platform === 'darwin') {
// macOS behavior
}
})
+72
View File
@@ -0,0 +1,72 @@
import { ipcMain, app } from 'electron'
import { processManager } from '../services/ProcessManager'
import { configService } from '../services/ConfigService'
import { downloadService } from '../services/DownloadService'
import path from 'path'
export function registerIpcHandlers(): void {
// Service management
ipcMain.handle('services:start', async (_event, serviceName: string) => {
try {
if (serviceName === 'nginx') {
const rootPath = path.join(app.getAppPath(), 'www')
const configPath = await configService.generateConfig('nginx.conf.template', 'nginx.conf', {
PORT: '80',
ROOT: rootPath
})
const binPath = downloadService.getBinPath('nginx', 'nginx.exe')
const success = await processManager.startService('nginx', binPath, ['-c', configPath])
return { success, message: success ? 'Nginx başlatıldı.' : 'Nginx başlatılamadı.' }
}
if (serviceName === 'php') {
const configPath = await configService.generateConfig('php.ini.template', 'php.ini', {})
const binPath = downloadService.getBinPath('php8.2', 'php-cgi.exe')
const success = await processManager.startService('php', binPath, ['-b', '127.0.0.1:9001', '-c', configPath])
return { success, message: success ? 'PHP başlatıldı.' : 'PHP başlatılamadı.' }
}
if (serviceName === 'mysql') {
const dataDir = path.join(app.getPath('userData'), 'mysql_data')
const configPath = await configService.generateConfig('my.ini.template', 'my.ini', {
DATADIR: dataDir,
SOCKET: '/tmp/mysql.sock'
})
const binPath = downloadService.getBinPath('mysql', 'bin/mysqld.exe')
const success = await processManager.startService('mysql', binPath, ['--defaults-file=' + configPath])
return { success, message: success ? 'MySQL başlatıldı.' : 'MySQL başlatılamadı.' }
}
return { success: false, message: 'Bilinmeyen servis.' }
} catch (error: any) {
console.error('Service start error:', error)
return { success: false, message: `Hata: ${error.message}` }
}
})
ipcMain.handle('services:stop', async (_event, serviceName: string) => {
const success = await processManager.stopService(serviceName)
return { success, message: success ? `${serviceName} durduruldu.` : `${serviceName} zaten durmuş.` }
})
ipcMain.handle('services:status', async () => {
return processManager.getStatuses()
})
// Binary management
ipcMain.handle('binaries:download', async (_event, { name, url }) => {
return downloadService.downloadAndExtract(url, name, (p) => {
// TODO: Emit progress to renderer via webContents.send
console.log(`Download progress for ${name}: ${p}%`)
})
})
// Config management
ipcMain.handle('config:get', async (_event, key: string) => {
return null
})
ipcMain.handle('config:save', async (_event, config: any) => {
return { success: true }
})
}
+42
View File
@@ -0,0 +1,42 @@
import fs from 'fs'
import path from 'path'
import { app } from 'electron'
export class ConfigService {
private configDir: string
private templateDir: string
constructor() {
this.templateDir = path.join(app.getAppPath(), 'config')
this.configDir = path.join(app.getPath('userData'), 'generated_configs')
if (!fs.existsSync(this.configDir)) {
fs.mkdirSync(this.configDir, { recursive: true })
}
}
async generateConfig(templateName: string, targetName: string, variables: Record<string, string>): Promise<string> {
const templatePath = path.join(this.templateDir, templateName)
const targetPath = path.join(this.configDir, targetName)
if (!fs.existsSync(templatePath)) {
throw new Error(`Template not found: ${templatePath}`)
}
let content = fs.readFileSync(templatePath, 'utf-8')
for (const [key, value] of Object.entries(variables)) {
const escapedValue = value.replace(/\\/g, '/') // Use forward slashes for cross-platform compatibility in configs
content = content.replace(new RegExp(`{{${key}}}`, 'g'), escapedValue)
}
fs.writeFileSync(targetPath, content)
return targetPath
}
getGeneratedPath(targetName: string): string {
return path.join(this.configDir, targetName)
}
}
export const configService = new ConfigService()
+63
View File
@@ -0,0 +1,63 @@
import axios from 'axios'
import fs from 'fs'
import path from 'path'
import { app } from 'electron'
import * as decompress from 'decompress'
export class DownloadService {
private binDir: string
constructor() {
this.binDir = path.join(app.getAppPath(), 'bin')
if (!fs.existsSync(this.binDir)) {
fs.mkdirSync(this.binDir, { recursive: true })
}
}
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)
if (fs.existsSync(targetDir)) {
return targetDir
}
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)
}
})
writer.on('error', reject)
})
}
getBinPath(service: string, executable: string): string {
return path.join(this.binDir, service, executable)
}
}
export const downloadService = new DownloadService()
+43
View File
@@ -0,0 +1,43 @@
import { exec } from 'child_process'
import { promisify } from 'util'
const execAsync = promisify(exec)
export interface OSOperations {
addHostEntry(ip: string, host: string): Promise<boolean>
isElevated(): Promise<boolean>
runElevated(command: string): Promise<{ stdout: string, stderr: string }>
}
export class WindowsOperations implements OSOperations {
async addHostEntry(ip: string, host: string): Promise<boolean> {
const entry = `${ip} ${host}`
const hostsPath = 'C:\\Windows\\System32\\drivers\\etc\\hosts'
try {
// Use powershell to append to hosts file if not exists
const command = `powershell -Command "if (!(Get-Content ${hostsPath} | Select-String '${host}')) { Add-Content ${hostsPath} '${entry}' }"`
await this.runElevated(command)
return true
} catch (error) {
console.error('Failed to add host entry:', error)
return false
}
}
async isElevated(): Promise<boolean> {
try {
await execAsync('net session')
return true
} catch {
return false
}
}
async runElevated(command: string): Promise<{ stdout: string, stderr: string }> {
// In a real app, we would use sudo-prompt or a similar library.
// For now, we assume the app might be run as admin or we use a basic exec.
return execAsync(command)
}
}
export const osOps: OSOperations = new WindowsOperations()
+86
View File
@@ -0,0 +1,86 @@
import { spawn, ChildProcess } from 'child_process'
import { join } from 'path'
import { app } from 'electron'
import fs from 'fs'
export class ProcessManager {
private processes: Map<string, ChildProcess> = new Map()
constructor() {
this.processes = new Map()
}
async startService(name: string, binPath: string, args: string[]): Promise<boolean> {
if (this.processes.has(name)) {
console.log(`Service ${name} is already running.`)
return true
}
if (!fs.existsSync(binPath)) {
console.warn(`[DEV] Executable not found: ${binPath}. Mocking service ${name}.`)
const mockChild = {
kill: () => { },
on: () => { },
unref: () => { }
} as unknown as ChildProcess
this.processes.set(name, mockChild)
return true
}
try {
const child = spawn(binPath, args, {
detached: true,
stdio: 'ignore'
})
child.on('error', (err) => {
console.error(`Failed to start service ${name}:`, err)
this.processes.delete(name)
})
child.on('exit', (code) => {
console.log(`Service ${name} exited with code ${code}`)
this.processes.delete(name)
})
this.processes.set(name, child)
child.unref() // Allow the parent to exit independently
return true
} catch (error) {
console.error(`Error starting service ${name}:`, error)
return false
}
}
async stopService(name: string): Promise<boolean> {
const child = this.processes.get(name)
if (child) {
child.kill()
this.processes.delete(name)
return true
}
return false
}
isServiceRunning(name: string): boolean {
return this.processes.has(name)
}
getStatuses(): Record<string, string> {
return {
nginx: this.isServiceRunning('nginx') ? 'running' : 'stopped',
php: this.isServiceRunning('php') ? 'running' : 'stopped',
mysql: this.isServiceRunning('mysql') ? 'running' : 'stopped'
}
}
stopAll(): void {
for (const name of this.processes.keys()) {
this.stopService(name)
}
}
}
export const processManager = new ProcessManager()
app.on('before-quit', () => processManager.stopAll())
+28
View File
@@ -0,0 +1,28 @@
import { contextBridge } from 'electron'
import { electronAPI } from '@electron-toolkit/preload'
// Custom APIs for renderer
const api = {
startService: (name: string) => electronAPI.ipcRenderer.invoke('services:start', name),
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)
}
// Use `contextBridge` APIs to expose Electron APIs to
// renderer only if context isolation is enabled, otherwise
// just add to the DOM global.
if (process.contextIsolated) {
try {
contextBridge.exposeInMainWorld('electron', electronAPI)
contextBridge.exposeInMainWorld('api', api)
} catch (error) {
console.error(error)
}
} else {
// @ts-ignore (define in dts)
window.electron = electronAPI
// @ts-ignore (define in dts)
window.api = api
}
+17
View File
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>Trunçgil Multi-PHP & MySQL Server</title>
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+249
View File
@@ -0,0 +1,249 @@
import { useState, useEffect } from 'react'
import {
Box,
Typography,
Drawer,
List,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
AppBar,
Toolbar,
IconButton,
Tooltip,
Container,
Paper,
Chip,
Switch,
Divider,
Button,
Snackbar,
Alert
} from '@mui/material'
import {
Dashboard as DashboardIcon,
Folder as ProjectIcon,
Settings as SettingsIcon,
Language as LanguageIcon,
Storage as DbIcon,
Dns as ServerIcon,
Code as PhpIcon,
PlayArrow as StartIcon,
Stop as StopIcon,
Refresh as RestartIcon
} from '@mui/icons-material'
import { useTranslation } from 'react-i18next'
declare global {
interface Window {
api: any
}
}
const drawerWidth = 240
interface ServiceStatus {
nginx: string
php: string
mysql: string
}
function App(): JSX.Element {
const { t, i18n } = useTranslation()
const [status, setStatus] = useState<ServiceStatus>({
nginx: 'stopped',
php: 'stopped',
mysql: 'stopped'
})
const [activeTab, setActiveTab] = useState('dashboard')
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)
}
fetchStatus()
const interval = setInterval(fetchStatus, 3000)
return () => clearInterval(interval)
}, [])
const handleToggleService = async (service: keyof ServiceStatus) => {
// @ts-ignore
const result = status[service] === 'running'
? await window.api.stopService(service)
: await window.api.startService(service)
setNotification({
open: true,
message: result.message,
severity: result.success ? 'success' : 'error'
})
}
const toggleLanguage = () => {
const newLang = i18n.language === 'tr' ? 'en' : i18n.language === 'en' ? 'de' : 'tr'
i18n.changeLanguage(newLang)
}
const handleStartAll = async () => {
const services: (keyof ServiceStatus)[] = ['nginx', 'php', 'mysql']
for (const s of services) {
if (status[s] !== 'running') {
// @ts-ignore
await window.api.startService(s)
}
}
setNotification({ open: true, message: 'Tüm servisler başlatıldı.', severity: 'success' })
}
const handleStopAll = async () => {
const services: (keyof ServiceStatus)[] = ['nginx', 'php', 'mysql']
for (const s of services) {
if (status[s] === 'running') {
// @ts-ignore
await window.api.stopService(s)
}
}
setNotification({ open: true, message: 'Tüm servisler durduruldu.', severity: 'success' })
}
const getStatusChip = (s: string) => {
const color = s === 'running' ? 'success' : s === 'stopped' ? 'error' : 'warning'
return <Chip label={s.toUpperCase()} color={color} size="small" sx={{ fontWeight: 'bold' }} />
}
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)' }}>
<Toolbar>
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1, fontWeight: 'bold' }}>
Trunçgil Multi-PHP Server
</Typography>
<Tooltip title="Dil Değiştir">
<IconButton onClick={toggleLanguage} color="inherit">
<LanguageIcon />
</IconButton>
</Tooltip>
<IconButton color="inherit">
<SettingsIcon />
</IconButton>
</Toolbar>
</AppBar>
<Drawer
variant="permanent"
sx={{
width: drawerWidth,
flexShrink: 0,
[`& .MuiDrawer-paper`]: { width: drawerWidth, boxSizing: 'border-box', bgcolor: 'rgba(30, 30, 30, 0.95)', borderRight: '1px solid rgba(255, 255, 255, 0.05)' },
}}
>
<Toolbar />
<Box sx={{ overflow: 'auto' }}>
<List>
<ListItem disablePadding>
<ListItemButton selected={activeTab === 'dashboard'} onClick={() => setActiveTab('dashboard')}>
<ListItemIcon><DashboardIcon color={activeTab === 'dashboard' ? 'primary' : 'inherit'} /></ListItemIcon>
<ListItemText primary="Dashboard" />
</ListItemButton>
</ListItem>
<ListItem disablePadding>
<ListItemButton selected={activeTab === 'projects'} onClick={() => setActiveTab('projects')}>
<ListItemIcon><ProjectIcon color={activeTab === 'projects' ? 'primary' : 'inherit'} /></ListItemIcon>
<ListItemText primary="Projeler" />
</ListItemButton>
</ListItem>
</List>
<Divider />
<List>
<ListItem disablePadding>
<ListItemButton selected={activeTab === 'settings'} onClick={() => setActiveTab('settings')}>
<ListItemIcon><SettingsIcon color={activeTab === 'settings' ? 'primary' : 'inherit'} /></ListItemIcon>
<ListItemText primary="Genel Ayarlar" />
</ListItemButton>
</ListItem>
</List>
</Box>
</Drawer>
<Box component="main" sx={{ flexGrow: 1, p: 3 }}>
<Toolbar />
<Container maxWidth="md">
{activeTab === 'dashboard' && (
<>
<Typography variant="h5" gutterBottom sx={{ mb: 3, fontWeight: 'medium' }}>
Hızlı Bakış
</Typography>
<Paper elevation={4} sx={{ mb: 4, borderRadius: 3, overflow: 'hidden', bgcolor: 'rgba(255, 255, 255, 0.03)', backdropFilter: 'blur(10px)', border: '1px solid rgba(255, 255, 255, 0.05)' }}>
<List dense>
<ListItem sx={{ py: 1.5 }}>
<ListItemIcon><ServerIcon color="primary" /></ListItemIcon>
<ListItemText primary="Nginx Web Sunucusu" secondary="Port: 80, 443" />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
{getStatusChip(status.nginx)}
<Switch checked={status.nginx === 'running'} onChange={() => handleToggleService('nginx')} />
</Box>
</ListItem>
<Divider variant="inset" />
<ListItem sx={{ py: 1.5 }}>
<ListItemIcon><PhpIcon color="primary" /></ListItemIcon>
<ListItemText primary="PHP FastCGI" secondary="Sürüm: Seçilmedi" />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
{getStatusChip(status.php)}
<Switch checked={status.php === 'running'} onChange={() => handleToggleService('php')} />
</Box>
</ListItem>
<Divider variant="inset" />
<ListItem sx={{ py: 1.5 }}>
<ListItemIcon><DbIcon color="primary" /></ListItemIcon>
<ListItemText primary="MySQL / MariaDB" secondary="Port: 3306" />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
{getStatusChip(status.mysql)}
<Switch checked={status.mysql === 'running'} onChange={() => handleToggleService('mysql')} />
</Box>
</ListItem>
</List>
</Paper>
<Box sx={{ display: 'flex', gap: 2 }}>
<Button variant="contained" fullWidth startIcon={<StartIcon />} size="large" onClick={handleStartAll} sx={{ borderRadius: 2, padding: '12px', background: 'linear-gradient(45deg, #153E5E 30%, #4A7CA3 90%)', boxShadow: '0 3px 5px 2px rgba(21, 62, 94, .3)' }}>
{t('common.start')}
</Button>
<Button variant="outlined" fullWidth startIcon={<StopIcon />} size="large" color="error" onClick={handleStopAll} sx={{ borderRadius: 2, padding: '12px', borderWidth: 2, '&:hover': { borderWidth: 2 } }}>
{t('common.stop')}
</Button>
</Box>
</>
)}
{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 }}>
Yeni Proje Ekle
</Button>
</Box>
)}
</Container>
</Box>
<Snackbar
open={notification.open}
autoHideDuration={4000}
onClose={() => setNotification({ ...notification, open: false })}
>
<Alert severity={notification.severity} variant="filled" sx={{ width: '100%' }}>
{notification.message}
</Alert>
</Snackbar>
</Box>
)
}
export default App
+22
View File
@@ -0,0 +1,22 @@
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
import tr from '@locales/tr.json'
import en from '@locales/en.json'
import de from '@locales/de.json'
i18n
.use(initReactI18next)
.init({
resources: {
tr: { translation: tr },
en: { translation: en },
de: { translation: de }
},
lng: 'tr',
fallbackLng: 'en',
interpolation: {
escapeValue: false
}
})
export default i18n
+27
View File
@@ -0,0 +1,27 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './i18n'
import { ThemeProvider, createTheme } from '@mui/material/styles'
import CssBaseline from '@mui/material/CssBaseline'
const theme = createTheme({
palette: {
primary: {
main: '#153E5E',
},
secondary: {
main: '#4A7CA3',
},
mode: 'dark',
},
})
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<ThemeProvider theme={theme}>
<CssBaseline />
<App />
</ThemeProvider>
</React.StrictMode>
)