diff --git a/README.md b/README.md
index 016d9a4..9369ed1 100644
--- a/README.md
+++ b/README.md
@@ -2,38 +2,26 @@
-ZAMPP
+ZAMPP for Windows
-
-
+
+
-Zero-config Apache MySQL PHP Platform
+Zero-config Apache MySQL PHP Platform (Windows Edition)
-ZAMPP is a Native Desktop application for macOS that serves as a Local Web Development Environment. It is designed to be a much lighter, faster, and fully *Zero-Configuration* alternative to MAMP/XAMPP.
+ZAMPP for Windows adalah aplikasi desktop native yang berfungsi sebagai Lingkungan Pengembangan Web Lokal (*Local Web Development Environment*). Ini merupakan hasil porting khusus untuk sistem operasi Windows, dirancang agar jauh lebih ringan, cepat, dan sepenuhnya *Zero-Configuration* dibandingkan alternatif seperti XAMPP atau WampServer.
-No more dealing with complex port setups or memory-heavy applications. Just one click, and your local server is up and running.
+Konfigurasi port default telah disesuaikan secara khusus agar tidak bentrok (*conflict-free*) dan sangat bersahabat saat berjalan berdampingan dengan lingkungan WSL (Windows Subsystem for Linux), Docker, maupun service database lokal lainnya.
## Key Features
-- **Zero-config:** Just click "Start" and start coding. No configuration file headaches.
-- **Native macOS UI:** Built with Golang and Wails, delivering a clean, lightweight interface that blends perfectly with the macOS environment.
-- **Modular Architecture:** The main installer is incredibly small! Additional PHP versions (7.4 - 8.5) can be downloaded directly from within the app only when you need them (*On-Demand Download*).
-- **Multi PHP Version** ZAMPP can switch multi php Version
-- **Fast Startup:** Nginx/Apache, MySQL, and PHP engines run independently in the background at maximum speed.
-- **Terminal Smart** Auto switch php and composer direct to terminal
-
----
-
-## Installation Guide
-
-1. Visit the [**Releases**](https://github.com/semutdev/zampp/releases/latest) page.
-2. Download For Mac the [**ZAMPP-macOS.dmg**](https://github.com/semutdev/zampp/releases/download/v1.0.0/ZAMPP-macOS.dmg) file (Do not download the engine zip files manually).
-3. Open the downloaded `.dmg` file and drag **`ZAMPP.app`** into your `Applications` folder.
-4. Launch the ZAMPP app. *(On the first run, the app will automatically download the base server engines in the background).*
-
-> **⚠️ Note for Mac Users:**
-> Since this application does not yet have an Apple Developer certificate (Unsigned), macOS Gatekeeper might block it on the first launch.
-> **Solution:** Right-click (or Control-click) on the ZAMPP app in your Applications folder -> Select **Open** -> Click **Open** again on the warning prompt.
+- **Zero-config:** Cukup klik "Start" dan mulai coding tanpa perlu repot konfigurasi file manual.
+- **Native Windows Integration:** Dibangun dengan Golang dan Wails, menggunakan native Windows process management, File Explorer, dan PowerShell terminal session.
+- **WSL & Docker Friendly Ports:** Konfigurasi port default diatur pada range port 9000 & 3309 agar terhindar dari konflik port standar (seperti port 80, 8080, 8000, 3306).
+- **Modular Architecture:** Installer utama berukuran sangat ringkas. Versi PHP tambahan (7.4 - 8.5) dapat diunduh langsung dari aplikasi sesuai kebutuhan (*On-Demand Download*).
+- **Multi PHP Version:** Mendukung perpindahan versi PHP secara instan.
+- **Fast Startup:** Engine Nginx/Apache, MySQL, dan PHP berjalan independen di background dengan performa maksimal.
+- **Smart PowerShell Terminal:** Membuka sesi PowerShell baru dengan `$env:Path` PHP aktif dan alias fungsi `composer` yang langsung siap digunakan di direktori `htdocs`.
---
@@ -47,65 +35,65 @@ No more dealing with complex port setups or memory-heavy applications. Just one
## Directory Structure (htdocs)
-All your website project files (HTML/PHP/WordPress) should be placed inside the *Document Root* folder. You can open this directory directly by clicking the **"📁 Open htdocs"** button in the ZAMPP app, or access it manually at:
+Semua file proyek website (HTML/PHP/WordPress) diletakkan di dalam folder *Document Root*. Anda dapat membuka direktori ini secara langsung dengan mengklik tombol **"📁 Open htdocs"** di aplikasi ZAMPP, atau mengaksesnya di:
```
-~/.zampp/
- ├── bin/ binaries (php, mysql, nginx, apache)
- ├── conf/ generated configs (nginx.conf, httpd.conf)
- ├── data/mysql/ MySQL data + socket
- ├── logs/ per-engine logs
- └── htdocs/ document root
+C:\Users\\.zampp\
+ ├── bin\ binaries (php, mysql, nginx, apache)
+ ├── conf\ generated configs (nginx.conf, httpd.conf)
+ ├── data\mysql\ MySQL data + socket
+ ├── logs\ per-engine logs
+ └── htdocs\ document root
```
---
## Ports & Defaults
-| Service | Port | Notes |
-|----------------|------|--------------------------------------------|
-| Web Server | 8000 | Nginx or Apache (user-selectable) |
-| MySQL | 3307 | Avoids clashing with XAMPP's 3306 |
-| PHP (internal) | 8001 | Proxied through the chosen web server |
-
-Zero manual configuration — config is regenerated on each start.
+| Service | Port | Deskripsi |
+| :--- | :--- | :--- |
+| **Web Server** | `9000` | Nginx atau Apache (dapat dipilih via dropdown) |
+| **PHP FastCGI (internal)** | `9001` | Diproyeksikan melalui Web Server aktif |
+| **MySQL Database** | `3309` | Menghindari bentrok dengan default MySQL (3306) & WSL/Docker |
---
## First-Run Engine Bundle & Modular PHP
-On first launch, ZAMPP downloads the base engine bundle (which ships **PHP 7.4**) from GitHub Releases and extracts it into `~/.zampp`:
+Saat pertama kali dijalankan, ZAMPP akan mengunduh bundel engine dasar untuk Windows dari GitHub Releases dan mengekstraknya ke direktori `%USERPROFILE%\.zampp`:
```
-https://github.com/semutdev/zampp/releases/download/v1.0.0/zampp-mac-x64-v1.zip
+https://github.com/semutdev/zampp/releases/download/v1.0.0/zampp-win-x64-v1.zip
```
-Additional PHP versions (7.4 - 8.5) are **modular** — downloaded on-demand directly from the app when you select a version that isn't installed yet:
+Versi PHP tambahan bersifat modular dan diunduh on-demand saat versi dipilih:
```
-https://github.com/semutdev/zampp/releases/download/v1.0.0/php-{version}.zip
+https://github.com/semutdev/zampp/releases/download/v1.0.0/php-{version}-win.zip
```
-Progress for both flows is reported to the frontend via events emitted from Go:
-
-- `download-progress` / `php-download-progress` — payload `0`–`100`
-- `download-complete` / `php-download-complete` — emitted after extraction and cleanup
+Progress pengunduhan dilaporkan ke frontend secara real-time melalui event:
+- `download-progress` / `php-download-progress` (0–100%)
+- `download-complete` / `php-download-complete`
---
## Live Development
-To run in live development mode, run `wails dev` in the project directory. This will run a Vite development server that will provide very fast hot reload of your frontend changes. If you want to develop in a browser and have access to your Go methods, there is also a dev server that runs on http://localhost:34115. Connect to this in your browser, and you can call your Go code from devtools.
+Untuk menjalankan aplikasi dalam mode live development:
-## Building
+```bash
+wails dev
+```
-To build a redistributable, production mode package, use `wails build`.
+Untuk melakukan build installer aplikasi produksi:
-You can configure the project by editing `wails.json`. More information about the project settings can be found here: https://wails.io/docs/reference/project-config
+```bash
+wails build
+```
---
Built with ❤️ by semut.dev
-
diff --git a/app.go b/app_darwin.go
similarity index 99%
rename from app.go
rename to app_darwin.go
index eec42d1..42e9d0f 100644
--- a/app.go
+++ b/app_darwin.go
@@ -1,3 +1,5 @@
+//go:build darwin
+
package main
import (
diff --git a/app_windows.go b/app_windows.go
new file mode 100644
index 0000000..c3c8f38
--- /dev/null
+++ b/app_windows.go
@@ -0,0 +1,2404 @@
+//go:build windows
+
+package main
+
+import (
+ "archive/zip"
+ "bytes"
+ "context"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+ goruntime "runtime"
+ "sort"
+ "strconv"
+ "strings"
+ "sync"
+ "syscall"
+ "time"
+
+ "github.com/wailsapp/wails/v2/pkg/runtime"
+)
+
+// App struct
+type App struct {
+ ctx context.Context
+}
+
+// NewApp creates a new App application struct
+func NewApp() *App {
+ return &App{}
+}
+
+// homeDir returns the user's absolute home directory path.
+func homeDir() (string, error) {
+ // Use os.UserHomeDir() instead of user.Current().HomeDir so the path is
+ // resolved via $HOME (prefers the same value the user sees in a shell),
+ // and so we don't pull in os/user's cgo path on platforms where it can
+ // be avoided. Apache does not understand `~`, so the absolute path
+ // returned here is what gets written into httpd.conf.
+ return os.UserHomeDir()
+}
+
+// startup is called when the app starts. The context is saved
+// so we can call the runtime methods
+func (a *App) startup(ctx context.Context) {
+ a.ctx = ctx
+ // Ensure the standalone directory structure exists on first run.
+ if err := EnsureAppDirs(); err != nil {
+ fmt.Println("startup: failed to ensure app dirs:", err)
+ }
+ // Pre-generate web server configs so they're ready before Start is clicked.
+ if err := GenerateNginxConfig(); err != nil {
+ fmt.Println("startup: failed to generate nginx.conf:", err)
+ }
+ if err := GenerateApacheConfig(); err != nil {
+ fmt.Println("startup: failed to generate httpd.conf:", err)
+ }
+ // Drop the default Welcome page into htdocs on first run.
+ if err := GenerateDefaultIndexPHP(); err != nil {
+ fmt.Println("startup: failed to generate default index.php:", err)
+ }
+}
+
+// shutdown is called when the app is closing (normal close, Cmd+Q, or
+// window close). It stops every engine ZAMPP started so that
+// http://127.0.0.1:9000, the PHP built-in server, and MySQL do not keep
+// running in the background after the app exits. Without this hook, a
+// force-quit leaves orphan daemons bound to our ports.
+func (a *App) shutdown(ctx context.Context) {
+ a.stopAllEngines()
+}
+
+// stopAllEngines stops every ZAMPP engine (nginx, apache, php, mysql) using
+// the multi-layer fallbacks already defined in StopNginx/etc. It is safe to
+// call from any goroutine and is used by both OnShutdown and the signal
+// handler in main() to cover force-quit / SIGTERM paths that bypass
+// Wails' shutdown hook.
+func (a *App) stopAllEngines() {
+ _ = a.StopNginx()
+ _ = a.StopApache()
+ _ = a.StopPHP()
+ _ = a.StopMySQL()
+}
+
+const appDirName = ".zampp"
+
+// mySQLPort is the TCP port used by the standalone mysqld. It is set to 3309
+// (instead of MySQL's default 3306) to avoid clashes with XAMPP's MySQL on 3306.
+const mySQLPort = "3309"
+
+// nginxPort is the public-facing port for Nginx on Windows.
+const nginxPort = "9000"
+
+// apachePort is the public-facing port for Apache on Windows. Now equal to nginxPort
+// (9000) so the UI can treat them as a single "Web Server" slot — the user
+// picks either engine via the dropdown, never both at once.
+const apachePort = "9000"
+
+// phpInternalPort is the internal PHP built-in server port that Nginx/Apache
+// proxy PHP requests to. The UI does not need to know about this port.
+const phpInternalPort = "9001"
+
+// isProcessAlive checks if a command process is still actively running on Windows.
+func isProcessAlive(cmd *exec.Cmd) bool {
+ if cmd == nil || cmd.Process == nil || cmd.Process.Pid <= 0 {
+ return false
+ }
+ tasklistCmd := exec.Command("tasklist", "/FI", fmt.Sprintf("PID eq %d", cmd.Process.Pid), "/NH")
+ tasklistCmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
+ out, err := tasklistCmd.Output()
+ if err != nil {
+ return false
+ }
+ return strings.Contains(string(out), fmt.Sprintf("%d", cmd.Process.Pid))
+}
+
+// getPIDsOnPort returns all process IDs currently listening on the given TCP port on Windows via netstat.
+func getPIDsOnPort(port string) []string {
+ netstatCmd := exec.Command("netstat", "-ano", "-p", "tcp")
+ netstatCmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
+ out, err := netstatCmd.Output()
+ if err != nil {
+ return nil
+ }
+ var pids []string
+ seen := make(map[string]bool)
+ lines := strings.Split(string(out), "\n")
+ targetSuffix := ":" + port
+ for _, line := range lines {
+ line = strings.TrimSpace(line)
+ if !strings.Contains(line, "LISTENING") {
+ continue
+ }
+ fields := strings.Fields(line)
+ if len(fields) < 5 {
+ continue
+ }
+ localAddr := fields[1]
+ if strings.HasSuffix(localAddr, targetSuffix) {
+ pid := fields[len(fields)-1]
+ if pid != "" && pid != "0" && !seen[pid] {
+ seen[pid] = true
+ pids = append(pids, pid)
+ }
+ }
+ }
+ return pids
+}
+
+// killProcessByPID kills a process by its PID using native Go os.FindProcess and Kill(),
+// with taskkill /F /T /PID as a fallback on Windows.
+func killProcessByPID(pid int) error {
+ if pid <= 0 {
+ return nil
+ }
+ if proc, err := os.FindProcess(pid); err == nil {
+ _ = proc.Kill()
+ }
+ cmd := exec.Command("taskkill", "/F", "/T", "/PID", fmt.Sprintf("%d", pid))
+ cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
+ _ = cmd.Run()
+ return nil
+}
+
+// killPIDString terminates a process identified by PID string on Windows.
+func killPIDString(pidStr string) error {
+ pidStr = strings.TrimSpace(pidStr)
+ if pidStr == "" || pidStr == "0" || strings.HasPrefix(pidStr, "-") {
+ return nil
+ }
+ pid, err := strconv.Atoi(pidStr)
+ if err == nil && pid > 0 {
+ if proc, err := os.FindProcess(pid); err == nil {
+ _ = proc.Kill()
+ }
+ }
+ cmd := exec.Command("taskkill", "/F", "/T", "/PID", pidStr)
+ cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
+ return cmd.Run()
+}
+
+// mysqlProcess holds a reference to the running mysqld process (if any).
+// It is managed via StartMySQL / StopMySQL.
+var mysqlProcess *exec.Cmd
+
+// nginxProcess holds a reference to the running nginx process (if any).
+// It is managed via StartNginx / StopNginx.
+var nginxProcess *exec.Cmd
+
+// apacheProcess holds a reference to the running httpd process (if any).
+// It is managed via StartApache / StopApache.
+var apacheProcess *exec.Cmd
+
+// phpProcess holds a reference to the running PHP built-in server process
+// (if any). It is managed via StartPHP / StopPHP.
+var phpProcess *exec.Cmd
+
+// safeBuffer is a goroutine-safe bytes.Buffer used to capture the first chunk
+// of a child process's stderr so we can report it synchronously after a
+// startup-timeout, while also teeing it to the parent stderr.
+type safeBuffer struct {
+ mu sync.Mutex
+ buf bytes.Buffer
+}
+
+func newSafeBuffer() *safeBuffer { return &safeBuffer{} }
+
+func (s *safeBuffer) Write(p []byte) (int, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.buf.Write(p)
+}
+func (s *safeBuffer) String() string {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.buf.String()
+}
+
+
+// attachLogger wires a child process's stdout/stderr to a per-engine log
+// file under ~/.zampp/logs/{engine}/, echoes them to the parent terminal so
+// errors are visible in `wails dev`, and optionally tees stderr into an
+// extra writer (e.g. a safeBuffer) so the caller can read the first chunk of
+// startup errors synchronously after a port-listen timeout.
+//
+// IMPORTANT: callers MUST NOT also assign cmd.Stderr or call cmd.StderrPipe()
+// after this — that double-wires stderr and causes a nil-pointer panic at
+// cmd.Start() time, because StderrPipe() returns nil when Stderr != nil.
+//
+// Returns the log file handle; it must remain open for the lifetime of the
+// child process, otherwise the child loses its stderr/stdout target.
+func attachLogger(cmd *exec.Cmd, engineLogDir string, filename string, extraStderr io.Writer) (*os.File, error) {
+ if err := os.MkdirAll(engineLogDir, 0755); err != nil {
+ return nil, fmt.Errorf("cannot create log dir %s: %w", engineLogDir, err)
+ }
+ logPath := filepath.Join(engineLogDir, filename)
+ f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND|os.O_TRUNC, 0644)
+ if err != nil {
+ return nil, fmt.Errorf("cannot open log %s: %w", logPath, err)
+ }
+
+ stderrWriters := []io.Writer{f, os.Stderr}
+ if extraStderr != nil {
+ stderrWriters = append(stderrWriters, extraStderr)
+ }
+
+ // Go's exec package spawns its own internal goroutine to pump the child's
+ // stdout/stderr into these writers. We never need to call StderrPipe()
+ // ourselves (and must not — StderrPipe returns nil if Stderr != nil,
+ // which then panics io.Copy).
+ cmd.Stdout = io.MultiWriter(f, os.Stdout)
+ cmd.Stderr = io.MultiWriter(stderrWriters...)
+ return f, nil
+}
+
+// appRootDir returns the absolute path to the app's root directory
+// inside the user's home directory (e.g. C:\Users\user\.zampp).
+func appRootDir() (string, error) {
+ home, err := homeDir()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(home, appDirName), nil
+}
+
+// htdocsPath returns the absolute path to the htdocs directory.
+func htdocsPath() (string, error) {
+ root, err := appRootDir()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(root, "htdocs"), nil
+}
+
+// phpBaseDir returns the absolute path under which per-version PHP binaries
+// are stored (e.g. ~/.zampp/bin/php).
+func phpBaseDir() (string, error) {
+ root, err := appRootDir()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(root, "bin", "php"), nil
+}
+
+// CheckFirstRun reports whether the ZAMPP base engine bundle has been fully
+// set up in ~/.zampp. Returns true only when every required BASE binary
+// exists as a regular file:
+//
+// - bin/apache/httpd
+// - bin/nginx/nginx
+// - bin/mysql/bin/mysqld
+//
+// PHP 7.4 is intentionally NOT checked here — it is part of the base
+// engine bundle but, like every other PHP version, also has its own
+// per-version Download button in the UI (driven by CheckPHPVersion).
+// This separation means a missing 7.4 alone does not trigger a full
+// re-download of the ~300 MB engine bundle; the user can just click the
+// per-version Download button for 7.4 instead.
+//
+// The frontend uses this to decide whether to show the first-run
+// setup/download overlay. Checking only the directory existence was too
+// lax — a stale or partially-populated ~/.zampp would pass and the app
+// would skip the engine download, leaving the user with no working
+// servers.
+func (a *App) CheckFirstRun() bool {
+ root, err := appRootDir()
+ if err != nil {
+ return false
+ }
+ // Required base binaries for the engine to be considered "installed".
+ required := []string{
+ filepath.Join(root, "bin", "apache", "bin", "httpd.exe"),
+ filepath.Join(root, "bin", "nginx", "nginx.exe"),
+ filepath.Join(root, "bin", "mysql", "bin", "mysqld.exe"),
+ }
+ for _, p := range required {
+ // Also allow apache directly under bin/apache/httpd.exe
+ if strings.Contains(p, "apache") {
+ altPath := filepath.Join(root, "bin", "apache", "httpd.exe")
+ if info, err := os.Stat(altPath); err == nil && !info.IsDir() {
+ continue
+ }
+ }
+ info, err := os.Stat(p)
+ if err != nil {
+ return false
+ }
+ if info.IsDir() {
+ return false
+ }
+ }
+ return true
+}
+
+// binariesZipURL is the GitHub release URL for the ZAMPP engine bundle
+// (contains .zampp/bin and .zampp/conf at the archive root).
+const binariesZipURL = "https://github.com/semutdev/zampp/releases/download/v1.0.0/zampp-win-x64-v1.zip"
+
+// DownloadAndExtractBinaries downloads the engine zip from GitHub releases,
+// streaming 'download-progress' events (0-100) to the frontend, then
+// extracts it into the user's home directory.
+func (a *App) DownloadAndExtractBinaries() error {
+ if a.ctx == nil {
+ return fmt.Errorf("app context not initialized")
+ }
+
+ tmpZip := filepath.Join(os.TempDir(), "zampp-engine.zip")
+
+ // 1) Download with progress streaming.
+ total, err := a.downloadWithProgress(binariesZipURL, tmpZip, "download-progress")
+ if err != nil {
+ return err
+ }
+ _ = total
+ runtime.EventsEmit(a.ctx, "download-progress", 100)
+
+ // 2) Extract zip into home directory (zip already contains .zampp/ root).
+ home, err := homeDir()
+ if err != nil {
+ return fmt.Errorf("cannot resolve home dir: %w", err)
+ }
+ if err := extractZipToDir(tmpZip, home); err != nil {
+ return fmt.Errorf("extract failed: %w", err)
+ }
+
+ // 3) Cleanup temp file.
+ _ = os.Remove(tmpZip)
+
+ // 4) Notify frontend.
+ runtime.EventsEmit(a.ctx, "download-complete")
+ return nil
+}
+
+// downloadWithProgress downloads url to destPath, emitting progress events
+// (0-100) on the given eventName as bytes stream in. Returns the
+// ContentLength (may be -1 if unknown).
+func (a *App) downloadWithProgress(url, destPath, eventName string) (int64, error) {
+ if a.ctx == nil {
+ return -1, fmt.Errorf("app context not initialized")
+ }
+
+ req, err := http.NewRequestWithContext(a.ctx, http.MethodGet, url, nil)
+ if err != nil {
+ return -1, fmt.Errorf("failed to create request: %w", err)
+ }
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return -1, fmt.Errorf("failed to download: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return -1, fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
+ }
+
+ total := resp.ContentLength
+ fmt.Printf("[download] %s -> %s (ContentLength=%d)\n", url, destPath, total)
+ out, err := os.Create(destPath)
+ if err != nil {
+ return total, fmt.Errorf("cannot create temp file %s: %w", destPath, err)
+ }
+
+ // Emit a "starting" 0% so the UI shows activity even before the first
+ // full 1% chunk arrives (for a 529MB download, 1% = ~5MB which can take
+ // a few seconds on slow connections — during which the bar would appear
+ // stuck without this initial ping).
+ runtime.EventsEmit(a.ctx, eventName, 0)
+ fmt.Printf("[download] emit %s = 0%%\n", eventName)
+
+ var downloaded int64
+ buf := make([]byte, 32*1024)
+ lastPct := 0
+ for {
+ n, readErr := resp.Body.Read(buf)
+ if n > 0 {
+ if _, werr := out.Write(buf[:n]); werr != nil {
+ out.Close()
+ return total, fmt.Errorf("write error: %w", werr)
+ }
+ downloaded += int64(n)
+ if total > 0 {
+ pct := int(float64(downloaded) / float64(total) * 100)
+ if pct > lastPct && pct <= 100 {
+ lastPct = pct
+ runtime.EventsEmit(a.ctx, eventName, pct)
+ // Log every 5% to terminal so we can confirm the Go side
+ // is actually emitting events even if the frontend
+ // listener is silent (helps diagnose Wails dev-mode
+ // event delivery issues).
+ if pct%5 == 0 {
+ fmt.Printf("[download] emit %s = %d%% (%d / %d bytes)\n",
+ eventName, pct, downloaded, total)
+ }
+ }
+ } else {
+ // Unknown total — emit a pseudo-progress based on bytes
+ // downloaded so the UI shows something moving. We bend the
+ // 0-100 contract here only while Content-Length is absent;
+ // once we know the real total, we switch back.
+ mbDownloaded := downloaded / (1024 * 1024)
+ pseudo := int(mbDownloaded % 100)
+ if pseudo != lastPct {
+ lastPct = pseudo
+ runtime.EventsEmit(a.ctx, eventName, pseudo)
+ }
+ }
+ }
+ if readErr == io.EOF {
+ break
+ }
+ if readErr != nil {
+ out.Close()
+ return total, fmt.Errorf("download read error: %w", readErr)
+ }
+ }
+ out.Close()
+ fmt.Printf("[download] %s done (%d bytes)\n", url, downloaded)
+ return total, nil
+}
+
+// CheckPHPVersion reports whether the given PHP version has been fully
+// installed under ~/.zampp/bin/php/{version} with an executable `php`
+// binary present. Returns true if installed, false otherwise. The frontend
+// uses this to decide whether the selected version needs to be downloaded
+// before starting the web server — including PHP 7.4, which is part of the
+// base engine bundle but may still be missing on a fresh / stale install.
+func (a *App) CheckPHPVersion(version string) bool {
+ if version == "" {
+ return false
+ }
+ _, err := getPHPExecutablePath(version)
+ return err == nil
+}
+
+// phpVersionZipURL builds the GitHub Releases download URL for a per-version
+// PHP bundle. The archive is expected to contain a top-level folder named
+// {version} so that extraction into ~/.zampp/bin/php/ yields
+// ~/.zampp/bin/php/{version}.
+//
+// The zip filename is platform-specific:
+// - darwin -> php-{version}-mac.zip
+// - windows -> php-{version}-win.zip
+//
+// This allows shipping separate builds per OS while keeping the extracted
+// folder layout ({version}/) identical across archives.
+func phpVersionZipURL(version string) string {
+ var osSuffix string
+ switch goruntime.GOOS {
+ case "darwin":
+ osSuffix = "mac"
+ case "windows":
+ osSuffix = "win"
+ default:
+ // Unknown platform — fall back to the mac suffix so the URL is still
+ // well-formed rather than producing "php-8.2-.zip".
+ osSuffix = "mac"
+ }
+ return fmt.Sprintf("https://github.com/semutdev/zampp/releases/download/v1.0.0/php-%s-%s.zip", version, osSuffix)
+}
+
+// DownloadPHPVersion downloads and extracts the per-version PHP bundle for the
+// given version (e.g. "8.2").
+//
+// Flow:
+// 1. Download https://github.com/semutdev/zampp/releases/download/v1.0.0/php-{version}-win.zip
+// to temp zip in os.TempDir(), emitting 'php-download-progress' (0-100).
+// 2. Extract into ~/.zampp/bin/php/ (zip already contains a top-level {version} folder).
+// 3. Delete temp zip file.
+// 4. Emit 'php-download-complete'.
+//
+// The base engine (DownloadAndExtractBinaries) is independent and remains
+// unchanged — it runs only on First Run.
+func (a *App) DownloadPHPVersion(version string) error {
+ if version == "" {
+ return fmt.Errorf("version is empty")
+ }
+ if a.ctx == nil {
+ return fmt.Errorf("app context not initialized")
+ }
+
+ base, err := phpBaseDir()
+ if err != nil {
+ return fmt.Errorf("cannot resolve php base dir: %w", err)
+ }
+ if err := os.MkdirAll(base, 0755); err != nil {
+ return fmt.Errorf("cannot create php base dir %s: %w", base, err)
+ }
+
+ // Derive the platform-suffixed filename from the URL pattern so the
+ // temp zip filename stays consistent with the remote asset name.
+ var osSuffix string
+ switch goruntime.GOOS {
+ case "darwin":
+ osSuffix = "mac"
+ case "windows":
+ osSuffix = "win"
+ default:
+ osSuffix = "win"
+ }
+ tmpZip := filepath.Join(os.TempDir(), fmt.Sprintf("php-%s-%s.zip", version, osSuffix))
+ url := phpVersionZipURL(version)
+
+ // 1) Download with progress streaming.
+ total, err := a.downloadWithProgress(url, tmpZip, "php-download-progress")
+ if err != nil {
+ return err
+ }
+ _ = total
+ runtime.EventsEmit(a.ctx, "php-download-progress", 100)
+
+ // 2) Extract into ~/.zampp/bin/php/.
+ if err := extractZipToDir(tmpZip, base); err != nil {
+ return fmt.Errorf("extract failed: %w", err)
+ }
+
+ // 3) Cleanup temp file.
+ _ = os.Remove(tmpZip)
+
+ // 4) Notify frontend.
+ runtime.EventsEmit(a.ctx, "php-download-complete")
+ return nil
+}
+
+// extractZipToDir extracts all entries of the zip archive at zipPath into
+// destDir, preserving the archive's internal directory structure.
+func extractZipToDir(zipPath, destDir string) error {
+ r, err := zip.OpenReader(zipPath)
+ if err != nil {
+ return fmt.Errorf("cannot open zip: %w", err)
+ }
+ defer r.Close()
+
+ for _, f := range r.File {
+ target := filepath.Join(destDir, f.Name)
+
+ // Zip slip protection: ensure target stays within destDir.
+ if !strings.HasPrefix(filepath.Clean(target)+string(os.PathSeparator), filepath.Clean(destDir)+string(os.PathSeparator)) {
+ return fmt.Errorf("zip entry outside destination dir: %s", f.Name)
+ }
+
+ if f.FileInfo().IsDir() {
+ if err := os.MkdirAll(target, 0755); err != nil {
+ return fmt.Errorf("cannot create dir %s: %w", target, err)
+ }
+ continue
+ }
+
+ if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
+ return fmt.Errorf("cannot create parent dir for %s: %w", target, err)
+ }
+
+ rc, err := f.Open()
+ if err != nil {
+ return fmt.Errorf("cannot open zip entry %s: %w", f.Name, err)
+ }
+
+ out, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
+ if err != nil {
+ rc.Close()
+ return fmt.Errorf("cannot create %s: %w", target, err)
+ }
+
+ if _, err := io.Copy(out, rc); err != nil {
+ rc.Close()
+ out.Close()
+ return fmt.Errorf("cannot write %s: %w", target, err)
+ }
+ rc.Close()
+ out.Close()
+ }
+ return nil
+}
+
+// getPHPExecutablePath resolves the PHP binary path for the given version,
+// supporting two installation layouts:
+//
+// Jalur A (SPC) : ~/.zampp/bin/php/{version}/php.exe
+// Jalur B (MAMP) : ~/.zampp/bin/php/{version}/bin/php.exe
+//
+// It checks Jalur A first, then Jalur B. If a regular file is found at either
+// location, its absolute path is returned. If neither exists, an error is
+// returned describing the expected locations.
+func getPHPExecutablePath(version string) (string, error) {
+ base, err := phpBaseDir()
+ if err != nil {
+ return "", err
+ }
+
+ candidates := []string{
+ filepath.Join(base, version, "php.exe"), // Jalur A (SPC)
+ filepath.Join(base, version, "bin", "php.exe"), // Jalur B (MAMP)
+ filepath.Join(base, version, "php"),
+ filepath.Join(base, version, "bin", "php"),
+ }
+
+ for _, p := range candidates {
+ info, err := os.Stat(p)
+ if err == nil && !info.IsDir() {
+ return p, nil
+ }
+ }
+
+ return "", fmt.Errorf(
+ "Binary PHP %s belum terpasang di ~/.zampp/bin/php/%s/ (diperiksa: ./php.exe dan ./bin/php.exe)",
+ version, version,
+ )
+}
+
+// mysqlBaseDir returns the absolute path under which MySQL binaries are stored
+// (e.g. ~/.zampp/bin/mysql).
+func mysqlBaseDir() (string, error) {
+ root, err := appRootDir()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(root, "bin", "mysql"), nil
+}
+
+// mysqlDataDir returns the absolute path to the MySQL data directory
+// (e.g. ~/.zampp/data/mysql).
+func mysqlDataDir() (string, error) {
+ root, err := appRootDir()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(root, "data", "mysql"), nil
+}
+
+// mysqldPath returns the absolute path to the mysqld binary
+// (e.g. ~/.zampp/bin/mysql/bin/mysqld.exe).
+func mysqldPath() (string, error) {
+ base, err := mysqlBaseDir()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(base, "bin", "mysqld.exe"), nil
+}
+
+// isMySQLInstalled reports whether the mysqld binary exists.
+func isMySQLInstalled() bool {
+ p, err := mysqldPath()
+ if err != nil {
+ return false
+ }
+ info, err := os.Stat(p)
+ if err != nil {
+ return false
+ }
+ return !info.IsDir()
+}
+
+// EnsureAppDirs creates the standalone directory structure used by the app:
+//
+// ~/.zampp/
+// ├── bin/php/ (per-version PHP binaries go here)
+// ├── bin/mysql/ (MySQL binaries go here)
+// ├── bin/nginx/ (Nginx binary goes here)
+// ├── bin/apache/ (Apache httpd + modules go here)
+// ├── conf/nginx/ (generated nginx.conf goes here)
+// ├── conf/apache/ (generated httpd.conf goes here)
+// ├── data/mysql/ (MySQL data directory)
+// ├── logs/nginx/ (Nginx logs)
+// ├── logs/apache/ (Apache logs)
+// └── htdocs/ (document root)
+func EnsureAppDirs() error {
+ root, err := appRootDir()
+ if err != nil {
+ return err
+ }
+ dirs := []string{
+ root,
+ filepath.Join(root, "bin"),
+ filepath.Join(root, "bin", "php"),
+ filepath.Join(root, "bin", "mysql"),
+ filepath.Join(root, "bin", "nginx"),
+ filepath.Join(root, "bin", "apache"),
+ filepath.Join(root, "conf"),
+ filepath.Join(root, "conf", "nginx"),
+ filepath.Join(root, "conf", "apache"),
+ filepath.Join(root, "data"),
+ filepath.Join(root, "data", "mysql"),
+ filepath.Join(root, "logs"),
+ filepath.Join(root, "logs", "nginx"),
+ filepath.Join(root, "logs", "apache"),
+ filepath.Join(root, "htdocs"),
+ }
+ for _, d := range dirs {
+ if err := os.MkdirAll(d, 0755); err != nil {
+ return fmt.Errorf("cannot create directory %s: %w", d, err)
+ }
+ }
+ return nil
+}
+
+// isPHPInstalled reports whether the PHP binary exists for the given version.
+func isPHPInstalled(version string) bool {
+ _, err := getPHPExecutablePath(version)
+ return err == nil
+}
+
+// phpVariantForVersion decides which PHP SAPI binary to launch for a given
+// version and how to address it (FastCGI bind target).
+//
+// - 7.4 → php-cgi -b 127.0.0.1:9001 (CGI, ships with base engine)
+// - 8.x+ → php-fpm --fpm-config (FPM, modular downloads)
+//
+// php-fpm cannot bind to a port via a CLI flag — it must be configured through
+// an FPM pool config file. writePHPFPMConfig generates a minimal config at
+// ~/.zampp/config/php-fpm-{version}.conf with `listen = 127.0.0.1:9001`.
+//
+// Returns the resolved absolute binary path and the arg slice to pass to exec.
+func phpLaunchCommand(version string) (binaryPath string, args []string, err error) {
+ base, err := phpBaseDir()
+ if err != nil {
+ return "", nil, err
+ }
+
+ // Candidates: php-cgi.exe / php-fpm.exe may sit next to php (SPC layout) or
+ // inside a bin/ subfolder (MAMP layout).
+ lookup := func(name string) (string, bool) {
+ for _, p := range []string{
+ filepath.Join(base, version, name+".exe"),
+ filepath.Join(base, version, "bin", name+".exe"),
+ filepath.Join(base, version, name),
+ filepath.Join(base, version, "bin", name),
+ } {
+ if info, statErr := os.Stat(p); statErr == nil && !info.IsDir() {
+ return p, true
+ }
+ }
+ return "", false
+ }
+
+ isCGI := version == "7.4"
+
+ var sapiName string
+ if isCGI {
+ sapiName = "php-cgi"
+ } else {
+ sapiName = "php-fpm"
+ }
+
+ bp, ok := lookup(sapiName)
+ if !ok {
+ // Also fallback to checking php-cgi for 8.x if php-fpm is missing (common on Windows)
+ if !isCGI {
+ if bpCgi, okCgi := lookup("php-cgi"); okCgi {
+ bp = bpCgi
+ isCGI = true
+ sapiName = "php-cgi"
+ ok = true
+ }
+ }
+ }
+
+ if !ok {
+ verDir := filepath.Join(base, version)
+ cliOnly := false
+ if info, statErr := os.Stat(verDir); statErr == nil && info.IsDir() {
+ for _, candidate := range []string{
+ filepath.Join(verDir, "php.exe"),
+ filepath.Join(verDir, "bin", "php.exe"),
+ filepath.Join(verDir, "php"),
+ filepath.Join(verDir, "bin", "php"),
+ } {
+ if pi, e := os.Stat(candidate); e == nil && !pi.IsDir() {
+ cliOnly = true
+ break
+ }
+ }
+ }
+ if cliOnly {
+ return "", nil, fmt.Errorf(
+ "Error: PHP %s hanya berisi binary CLI (php.exe), tanpa %s.exe. "+
+ "Bundel zip untuk PHP %s tidak menyertakan SAPI FastCGI — "+
+ "re-pack zip-nya agar menyertakan php-cgi.exe atau php-fpm.exe. Lihat: ~/.zampp/bin/php/%s/",
+ version, sapiName, version, version,
+ )
+ }
+ return "", nil, fmt.Errorf(
+ "Error: %s untuk PHP %s belum terpasang di ~/.zampp/bin/php/%s/ "+
+ "(diperiksa: ./%s.exe dan ./bin/%s.exe). Pastikan versi PHP tersebut sudah di-download.",
+ sapiName, version, version, sapiName, sapiName,
+ )
+ }
+
+ if isCGI {
+ // php-cgi supports the FastCGI bind flag directly.
+ return bp, []string{"-b", "127.0.0.1:" + phpInternalPort}, nil
+ }
+
+ // php-fpm: generate/update the pool config, then pass --fpm-config.
+ cfgPath, err := writePHPFPMConfig(version)
+ if err != nil {
+ return "", nil, fmt.Errorf("cannot write php-fpm config: %w", err)
+ }
+ return bp, []string{"-F", "--fpm-config", filepath.ToSlash(cfgPath)}, nil
+}
+
+// writePHPFPMConfig renders the minimal FPM pool config for a PHP version
+// into ~/.zampp/config/php-fpm-{version}.conf and returns its absolute path.
+func writePHPFPMConfig(version string) (string, error) {
+ root, err := appRootDir()
+ if err != nil {
+ return "", err
+ }
+ cfgDir := filepath.Join(root, "config")
+ if err := os.MkdirAll(cfgDir, 0755); err != nil {
+ return "", fmt.Errorf("cannot create config dir %s: %w", cfgDir, err)
+ }
+
+ cfgPath := filepath.Join(cfgDir, fmt.Sprintf("php-fpm-%s.conf", version))
+ pidPath := filepath.ToSlash(filepath.Join(os.TempDir(), "zampp-php-fpm.pid"))
+ logPath := filepath.ToSlash(filepath.Join(os.TempDir(), "zampp-php-fpm.log"))
+
+ content := fmt.Sprintf(`[global]
+pid = %s
+error_log = %s
+
+[www]
+listen = 127.0.0.1:%s
+pm = dynamic
+pm.max_children = 5
+pm.start_servers = 2
+pm.min_spare_servers = 1
+pm.max_spare_servers = 3
+`, pidPath, logPath, phpInternalPort)
+
+ if err := os.WriteFile(cfgPath, []byte(content), 0644); err != nil {
+ return "", fmt.Errorf("cannot write php-fpm config %s: %w", cfgPath, err)
+ }
+ return cfgPath, nil
+}
+
+// probeBinaryRuns executes the PHP SAPI binary briefly (with the same args
+// StartPHP would use) to catch immediate-failure errors that don't reach the
+// listening stage. We run the binary with a 1200ms timeout.
+func probeBinaryRuns(binaryPath string, args []string) error {
+ probe := exec.Command(binaryPath, "-v")
+ probe.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
+ out, err := probe.CombinedOutput()
+ if err != nil {
+ text := strings.TrimSpace(string(out))
+ if text == "" {
+ return fmt.Errorf("%s (no output)", err.Error())
+ }
+ return fmt.Errorf("%s: %s", err.Error(), text)
+ }
+ return nil
+}
+
+// StartPHP launches the PHP FastCGI worker on 127.0.0.1:9001 for the given
+// version. The worker is spawned in the background (non-blocking) using
+// cmd.Start(); Nginx/Apache on :9000 then proxies .php requests here.
+//
+// Version-specific SAPI:
+// - 7.4 → php-cgi -b 127.0.0.1:9001
+// - 8.x+ → php-fpm -F --fpm-config ~/.zampp/config/php-fpm-{version}.conf
+// (config carries `listen = 127.0.0.1:9001`)
+func (a *App) StartPHP(version string) string {
+ if version == "" {
+ return "Error: PHP version is empty"
+ }
+
+ binaryPath, args, err := phpLaunchCommand(version)
+ if err != nil {
+ return err.Error()
+ }
+
+ // Ensure it is executable. If not, try to fix it.
+ if err := os.Chmod(binaryPath, 0755); err != nil {
+ return fmt.Sprintf("Error: cannot make %s executable: %s", binaryPath, err.Error())
+ }
+
+ docRoot, err := htdocsPath()
+ if err != nil {
+ return fmt.Sprintf("Error: %s", err.Error())
+ }
+ if err := os.MkdirAll(docRoot, 0755); err != nil {
+ return fmt.Sprintf("Error: cannot create htdocs at %s: %s", docRoot, err.Error())
+ }
+
+ // Stop any existing PHP server on the internal port before starting a new one.
+ a.StopPHP()
+ // Wait briefly for the port to actually free up after StopPHP — php-cgi
+ // and php-fpm can take a few hundred ms to release the listening socket
+ // after SIGTERM. If we Start() immediately, the new process can fail to
+ // bind with "Address already in use" and the port never comes up.
+ for i := 0; i < 30; i++ {
+ if !portListenerExists(phpInternalPort) {
+ break
+ }
+ time.Sleep(100 * time.Millisecond)
+ }
+
+ cmd := exec.Command(binaryPath, args...)
+ cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
+
+ // Stream stdout/stderr to log file + parent terminal so PHP startup
+ // errors are not lost. We tee BOTH stdout and stderr into the safeBuffer —
+ // some PHP SAPI builds (notably certain php-cgi builds) write their fatal
+ // startup errors to stdout instead of stderr, so capturing only stderr
+ // leaves the user with an empty diagnostic and a misleading "Periksa log".
+ phpLogDir, _ := phpLogDir()
+ startupBuffer := newSafeBuffer()
+ attachLogger(cmd, phpLogDir, fmt.Sprintf("php-%s.log", version), startupBuffer)
+ // Also tee stdout into the startup buffer so we catch php-cgi failures
+ // that print to stdout. attachLogger normally wires cmd.Stdout to a
+ // MultiWriter(file, os.Stdout); we wrap it again here to also feed the
+ // startupBuffer. (cmd.Stdout may already be an io.MultiWriter; we just
+ // compose one more level.)
+ if cmd.Stdout != nil {
+ cmd.Stdout = io.MultiWriter(cmd.Stdout, startupBuffer)
+ } else {
+ cmd.Stdout = startupBuffer
+ }
+
+ if err := cmd.Start(); err != nil {
+ return fmt.Sprintf("Error: failed to start PHP %s: %s", version, err.Error())
+ }
+
+ phpProcess = cmd
+ fmt.Printf("[php] started PID %d: %s %v\n", cmd.Process.Pid, binaryPath, args)
+ // Do NOT call Process.Release() — php-cgi (-b) and php-fpm (-F) both run
+ // in foreground mode and stay alive as our child. We want a live handle
+ // for StopPHP (Signal/Kill) + cmd.Wait() (reap zombie).
+
+ // Verify the PHP worker actually bound the FastCGI port. php-fpm can fail
+ // on startup (bad config, missing module, port in use) even though
+ // cmd.Start() returned nil.
+ if !waitForPort(phpInternalPort, 3000*time.Millisecond) {
+ startupText := strings.TrimSpace(startupBuffer.String())
+ // Inspect process exit state: did the child die immediately, or
+ // is it still alive but not listening? This distinguishes a crash
+ // from a silent bind failure.
+ pid := cmd.Process.Pid
+ isAlive := isProcessAlive(cmd)
+ fmt.Printf("[php] waitForPort failed. PID=%d isAlive=%v startupText=%q\n",
+ pid, isAlive, startupText)
+ a.StopPHP()
+ // Also probe whether the process is actually still alive — if
+ // php-cgi forked & exited immediately, the port won't come up and
+ // the child may already be a zombie.
+ if startupText != "" {
+ return fmt.Sprintf("Error: PHP %s gagal start (port %s tidak listening): %s", version, phpInternalPort, startupText)
+ }
+ // Process exited silently with no output — give the user something
+ // actionable instead of just "Periksa log" (which is empty).
+ // First verify the binary itself runs at all (e.g. dyld errors).
+ if err := probeBinaryRuns(binaryPath, args); err != nil {
+ return fmt.Sprintf("Error: PHP %s gagal start (port %s tidak listening): binary probe failed: %s", version, phpInternalPort, err.Error())
+ }
+ return fmt.Sprintf("Error: PHP %s gagal start (port %s tidak listening dalam 3s, tidak ada output). Periksa ~/.zampp/logs/php/php-%s.log", version, phpInternalPort, version)
+ }
+
+ sapi := "php-fpm"
+ if version == "7.4" {
+ sapi = "php-cgi"
+ }
+ return fmt.Sprintf("Started PHP %s (%s) on 127.0.0.1:%s (docroot: %s)", version, sapi, phpInternalPort, docRoot)
+}
+
+// StopPHP stops the PHP process currently listening on the internal port (9001).
+func (a *App) StopPHP() (result string) {
+ defer func() {
+ if r := recover(); r != nil {
+ result = fmt.Sprintf("Error: StopPHP panic: %v", r)
+ }
+ }()
+
+ var messages []string
+
+ // Primary path: kill the tracked PHP process using native Go os.Process.Kill()
+ if phpProcess != nil && phpProcess.Process != nil {
+ pid := phpProcess.Process.Pid
+ if pid > 0 {
+ _ = phpProcess.Process.Kill()
+ _ = killProcessByPID(pid)
+ go func(c *exec.Cmd) {
+ if c != nil {
+ _ = c.Wait()
+ }
+ }(phpProcess)
+ messages = append(messages, fmt.Sprintf("stopped PHP (PID %d)", pid))
+ }
+ phpProcess = nil
+ }
+
+ // Secondary fallback: find any listener on the PHP internal port via netstat and kill
+ pids := getPIDsOnPort(phpInternalPort)
+ for _, pid := range pids {
+ if err := killPIDString(pid); err != nil {
+ messages = append(messages, fmt.Sprintf("failed to kill PID %s: %s", pid, err.Error()))
+ } else {
+ messages = append(messages, fmt.Sprintf("stopped PID %s", pid))
+ }
+ }
+
+ if len(messages) == 0 {
+ return "Info: no process found on PHP port " + phpInternalPort + " (already stopped)"
+ }
+ return strings.Join(messages, "; ")
+}
+
+// GetInstalledVersions returns the sorted list of PHP versions that have a
+// binary installed under ~/.zampp/bin/php// (supporting both
+// the SPC layout ./php and the MAMP layout ./bin/php).
+func (a *App) GetInstalledVersions() []string {
+ base, err := phpBaseDir()
+ if err != nil {
+ return []string{}
+ }
+
+ entries, err := os.ReadDir(base)
+ if err != nil {
+ return []string{}
+ }
+
+ var versions []string
+ for _, e := range entries {
+ if !e.IsDir() {
+ continue
+ }
+ name := e.Name()
+ if _, err := getPHPExecutablePath(name); err == nil {
+ versions = append(versions, name)
+ }
+ }
+ sort.Strings(versions)
+ return versions
+}
+
+// IsMySQLInstalled reports whether the mysqld binary is present, so the
+// frontend can show an Installed / Not Installed status.
+func (a *App) IsMySQLInstalled() bool {
+ return isMySQLInstalled()
+}
+
+// nginxBaseDir returns the absolute path under which the Nginx binary is stored
+// (e.g. ~/.zampp/bin/nginx).
+func nginxBaseDir() (string, error) {
+ root, err := appRootDir()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(root, "bin", "nginx"), nil
+}
+
+// nginxBinaryPath returns the absolute path to the nginx binary
+// (e.g. ~/.zampp/bin/nginx/nginx.exe).
+func nginxBinaryPath() (string, error) {
+ base, err := nginxBaseDir()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(base, "nginx.exe"), nil
+}
+
+// nginxConfDir returns the absolute path to the Nginx config directory
+// (e.g. ~/.zampp/conf/nginx).
+func nginxConfDir() (string, error) {
+ root, err := appRootDir()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(root, "conf", "nginx"), nil
+}
+
+// nginxConfPath returns the absolute path to nginx.conf.
+func nginxConfPath() (string, error) {
+ d, err := nginxConfDir()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(d, "nginx.conf"), nil
+}
+
+// nginxLogDir returns the absolute path to the Nginx logs directory
+// (e.g. ~/.zampp/logs/nginx).
+func nginxLogDir() (string, error) {
+ root, err := appRootDir()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(root, "logs", "nginx"), nil
+}
+
+// phpLogDir returns the absolute path to the PHP per-version logs directory
+// (e.g. ~/.zampp/logs/php). Each PHP version gets its own log file inside.
+func phpLogDir() (string, error) {
+ root, err := appRootDir()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(root, "logs", "php"), nil
+}
+
+// isNginxInstalled reports whether the nginx binary exists.
+func isNginxInstalled() bool {
+ p, err := nginxBinaryPath()
+ if err != nil {
+ return false
+ }
+ info, err := os.Stat(p)
+ if err != nil {
+ return false
+ }
+ return !info.IsDir()
+}
+
+// IsNginxInstalled exposes nginx-installation status to the frontend.
+func (a *App) IsNginxInstalled() bool {
+ return isNginxInstalled()
+}
+
+// nginxTempDirs returns the list of temp directories nginx needs writable
+// access to at runtime: client_body, proxy, fastcgi, uwsgi, scgi temp paths
+// under ~/.zampp/tmp/nginx/.
+func nginxTempDirs() ([]string, error) {
+ root, err := appRootDir()
+ if err != nil {
+ return nil, err
+ }
+ base := filepath.Join(root, "tmp", "nginx")
+ subs := []string{
+ "client_body",
+ "proxy",
+ "fastcgi",
+ "uwsgi",
+ "scgi",
+ }
+ out := make([]string, 0, len(subs))
+ for _, s := range subs {
+ out = append(out, filepath.Join(base, s))
+ }
+ return out, nil
+}
+
+// ensureNginxTempDirs creates the nginx temp directories and the nginx log
+// directory before nginx is started.
+func ensureNginxTempDirs() error {
+ logDir, err := nginxLogDir()
+ if err != nil {
+ return err
+ }
+ if err := os.MkdirAll(logDir, 0755); err != nil {
+ return fmt.Errorf("cannot create nginx log dir %s: %w", logDir, err)
+ }
+ dirs, err := nginxTempDirs()
+ if err != nil {
+ return err
+ }
+ for _, d := range dirs {
+ if err := os.MkdirAll(d, 0755); err != nil {
+ return fmt.Errorf("cannot create nginx temp dir %s: %w", d, err)
+ }
+ }
+ return nil
+}
+
+// GenerateNginxConfig writes a fresh nginx.conf into ~/.zampp/conf/nginx/.
+// It is called on startup and on every StartNginx, so config always reflects
+// the current port/docroot settings. The conf dir is created if missing.
+func GenerateNginxConfig() error {
+ confDir, err := nginxConfDir()
+ if err != nil {
+ return err
+ }
+ if err := os.MkdirAll(confDir, 0755); err != nil {
+ return fmt.Errorf("cannot create nginx conf dir: %w", err)
+ }
+
+ docRoot, err := htdocsPath()
+ if err != nil {
+ return err
+ }
+ if err := os.MkdirAll(docRoot, 0755); err != nil {
+ return fmt.Errorf("cannot create htdocs: %w", err)
+ }
+
+ logDir, err := nginxLogDir()
+ if err != nil {
+ return err
+ }
+ if err := os.MkdirAll(logDir, 0755); err != nil {
+ return fmt.Errorf("cannot create nginx log dir: %w", err)
+ }
+ if err := ensureNginxTempDirs(); err != nil {
+ return fmt.Errorf("cannot prepare nginx temp dirs: %w", err)
+ }
+
+ accessLog := filepath.ToSlash(filepath.Join(logDir, "access.log"))
+ errorLog := filepath.ToSlash(filepath.Join(logDir, "error.log"))
+ pidFile := filepath.ToSlash(filepath.Join(logDir, "nginx.pid"))
+ docRootSlash := filepath.ToSlash(docRoot)
+
+ tmpDirs, err := nginxTempDirs()
+ if err != nil {
+ return err
+ }
+ clientBodyTmp := filepath.ToSlash(tmpDirs[0])
+ proxyTmp := filepath.ToSlash(tmpDirs[1])
+ fastcgiTmp := filepath.ToSlash(tmpDirs[2])
+ uwsgiTmp := filepath.ToSlash(tmpDirs[3])
+ scgiTmp := filepath.ToSlash(tmpDirs[4])
+
+ conf := "worker_processes 1;\n" +
+ "pid \"" + pidFile + "\";\n" +
+ "error_log \"" + errorLog + "\";\n\n" +
+ "events {\n" +
+ " worker_connections 1024;\n" +
+ "}\n\n" +
+ "http {\n" +
+ " client_body_temp_path \"" + clientBodyTmp + "\";\n" +
+ " proxy_temp_path \"" + proxyTmp + "\";\n" +
+ " fastcgi_temp_path \"" + fastcgiTmp + "\";\n" +
+ " uwsgi_temp_path \"" + uwsgiTmp + "\";\n" +
+ " scgi_temp_path \"" + scgiTmp + "\";\n\n" +
+ " access_log \"" + accessLog + "\";\n" +
+ " error_log \"" + errorLog + "\";\n\n" +
+ " server {\n" +
+ " listen " + nginxPort + ";\n" +
+ " server_name localhost;\n\n" +
+ " root \"" + docRootSlash + "\";\n" +
+ " index index.php index.html index.htm;\n\n" +
+ " location / {\n" +
+ " try_files $uri $uri/ =404;\n" +
+ " }\n\n" +
+ " location ~ \\.php$ {\n" +
+ " fastcgi_pass 127.0.0.1:" + phpInternalPort + ";\n" +
+ " fastcgi_index index.php;\n" +
+ " fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;\n" +
+ " include fastcgi_params;\n" +
+ " }\n" +
+ " }\n" +
+ "}\n"
+
+ confPath, err := nginxConfPath()
+ if err != nil {
+ return err
+ }
+ if err := os.WriteFile(confPath, []byte(conf), 0644); err != nil {
+ return fmt.Errorf("cannot write nginx.conf: %w", err)
+ }
+
+ fcgiParamsPath := filepath.Join(filepath.Dir(confPath), "fastcgi_params")
+ if err := os.WriteFile(fcgiParamsPath, []byte(fastcgiParamsContent), 0644); err != nil {
+ return fmt.Errorf("cannot write fastcgi_params: %w", err)
+ }
+
+ return nil
+}
+
+// fastcgiParamsContent is the standard set of FastCGI variables nginx passes to PHP.
+const fastcgiParamsContent = `fastcgi_param QUERY_STRING $query_string;
+fastcgi_param REQUEST_METHOD $request_method;
+fastcgi_param CONTENT_TYPE $content_type;
+fastcgi_param CONTENT_LENGTH $content_length;
+
+fastcgi_param SCRIPT_NAME $fastcgi_script_name;
+fastcgi_param REQUEST_URI $request_uri;
+fastcgi_param DOCUMENT_URI $document_uri;
+fastcgi_param DOCUMENT_ROOT $document_root;
+fastcgi_param SERVER_PROTOCOL $server_protocol;
+fastcgi_param REQUEST_SCHEME $scheme;
+fastcgi_param HTTPS $https if_not_empty;
+
+fastcgi_param GATEWAY_INTERFACE CGI/1.1;
+fastcgi_param SERVER_SOFTWARE nginx/$nginx_version;
+
+fastcgi_param REMOTE_ADDR $remote_addr;
+fastcgi_param REMOTE_PORT $remote_port;
+fastcgi_param SERVER_ADDR $server_addr;
+fastcgi_param SERVER_PORT $server_port;
+fastcgi_param SERVER_NAME $server_name;
+
+fastcgi_param REDIRECT_STATUS 200;
+`
+
+// StartNginx starts the standalone nginx server using the generated conf.
+func (a *App) StartNginx() string {
+ if nginxProcess != nil && nginxProcess.Process != nil {
+ if isProcessAlive(nginxProcess) {
+ return "Info: Nginx is already running"
+ }
+ }
+
+ binaryPath, err := nginxBinaryPath()
+ if err != nil {
+ return err.Error()
+ }
+
+ // Check that the binary file is present.
+ if _, err := os.Stat(binaryPath); err != nil {
+ return "Nginx Not Installed — letakkan binary di ~/.zampp/bin/nginx/nginx.exe"
+ }
+
+ // Ensure it is executable.
+ if err := os.Chmod(binaryPath, 0755); err != nil {
+ return fmt.Sprintf("Error: cannot make %s executable: %s", binaryPath, err.Error())
+ }
+
+ // (Re)generate nginx.conf so it always reflects current settings.
+ if err := GenerateNginxConfig(); err != nil {
+ return fmt.Sprintf("Error: gagal membuat nginx.conf: %s", err.Error())
+ }
+
+ confPath, err := nginxConfPath()
+ if err != nil {
+ return err.Error()
+ }
+
+ // Defensive cleanup of any stale nginx on our port.
+ a.stopNginxOnPort()
+
+ appRoot, err := appRootDir()
+ if err != nil {
+ return err.Error()
+ }
+
+ logDir, _ := nginxLogDir()
+ globalDirectives := fmt.Sprintf(
+ "error_log \"%s\"; daemon off;",
+ filepath.ToSlash(filepath.Join(logDir, "error.log")),
+ )
+ cmd := exec.Command(binaryPath,
+ "-p", filepath.ToSlash(appRoot)+"/",
+ "-c", filepath.ToSlash(confPath),
+ "-g", globalDirectives,
+ )
+ cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
+
+ // Stream stdout/stderr to log file + parent terminal so nginx startup
+ // errors (missing module, bad config) are visible in `wails dev` and in
+ // ~/.zampp/logs/nginx/stdout.log. extraStderr captures them for the
+ // synchronous error message below.
+ usedLogDir, _ := nginxLogDir()
+ stderrBuffer := newSafeBuffer()
+ attachLogger(cmd, usedLogDir, "stdout.log", stderrBuffer)
+
+ if err := cmd.Start(); err != nil {
+ return fmt.Sprintf("Error: failed to start nginx: %s", err.Error())
+ }
+
+ nginxProcess = cmd
+ // Do NOT call Process.Release() — with `daemon off;` (set in -g) the
+ // nginx master stays alive as our child and we want a live handle for
+ // StopNginx (Signal/Kill) + cmd.Wait() (reap zombie).
+
+ // Verify nginx really bound the port; if it crashed on startup (bad
+ // config, missing module, port in use) report it instead of pretending
+ // the server is up.
+ if !waitForPort(nginxPort, 3000*time.Millisecond) {
+ stderrText := strings.TrimSpace(stderrBuffer.String())
+ a.StopNginx()
+ if stderrText != "" {
+ return fmt.Sprintf("Error: Nginx gagal start (port %s tidak listening): %s", nginxPort, stderrText)
+ }
+ return fmt.Sprintf("Error: Nginx gagal start (port %s tidak listening dalam 3s). Periksa ~/.zampp/logs/nginx/stdout.log", nginxPort)
+ }
+
+ return fmt.Sprintf("Started Nginx on port %s (conf: %s)", nginxPort, confPath)
+}
+
+// StopNginx stops the running nginx process on Windows.
+func (a *App) StopNginx() (result string) {
+ defer func() {
+ if r := recover(); r != nil {
+ result = fmt.Sprintf("Error: StopNginx panic: %v", r)
+ }
+ }()
+
+ var messages []string
+
+ // Primary path: kill the tracked process using native Go os.Process.Kill()
+ if nginxProcess != nil && nginxProcess.Process != nil {
+ pid := nginxProcess.Process.Pid
+ if pid > 0 {
+ _ = nginxProcess.Process.Kill()
+ _ = killProcessByPID(pid)
+ go func(c *exec.Cmd) {
+ if c != nil {
+ _ = c.Wait()
+ }
+ }(nginxProcess)
+ messages = append(messages, fmt.Sprintf("stopped Nginx (PID %d)", pid))
+ }
+ nginxProcess = nil
+ }
+
+ // Fallback A: nginx's own graceful stop, using the generated conf
+ if confPath, err := nginxConfPath(); err == nil {
+ if binaryPath, err := nginxBinaryPath(); err == nil {
+ stopCmd := exec.Command(binaryPath, "-s", "stop", "-c", confPath)
+ stopCmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
+ _ = stopCmd.Run()
+ }
+ }
+
+ // Fallback B: kill any PID listening on nginxPort
+ msgs := a.stopNginxOnPort()
+ if msgs != "" && !strings.HasPrefix(msgs, "Info:") {
+ messages = append(messages, msgs)
+ }
+
+ if len(messages) == 0 {
+ return "Info: Nginx already stopped"
+ }
+ return strings.Join(messages, "; ")
+}
+
+// stopNginxOnPort kills any process listening on the nginx port on Windows.
+func (a *App) stopNginxOnPort() string {
+ pids := getPIDsOnPort(nginxPort)
+ if len(pids) == 0 {
+ return "Info: no Nginx process found on port " + nginxPort + " (already stopped)"
+ }
+ var messages []string
+ for _, pid := range pids {
+ if err := killPIDString(pid); err != nil {
+ messages = append(messages, fmt.Sprintf("failed to kill PID %s: %s", pid, err.Error()))
+ } else {
+ messages = append(messages, fmt.Sprintf("stopped PID %s", pid))
+ }
+ }
+ if len(messages) == 0 {
+ return "Info: no Nginx process stopped"
+ }
+ return strings.Join(messages, "; ")
+}
+
+// apacheBaseDir returns the absolute path under which the Apache httpd binary
+// and modules are stored (e.g. ~/.zampp/bin/apache).
+func apacheBaseDir() (string, error) {
+ root, err := appRootDir()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(root, "bin", "apache"), nil
+}
+
+// apacheBinaryPath returns the absolute path to the httpd binary
+// (e.g. ~/.zampp/bin/apache/bin/httpd.exe).
+func apacheBinaryPath() (string, error) {
+ base, err := apacheBaseDir()
+ if err != nil {
+ return "", err
+ }
+ candidates := []string{
+ filepath.Join(base, "bin", "httpd.exe"),
+ filepath.Join(base, "httpd.exe"),
+ filepath.Join(base, "bin", "httpd"),
+ filepath.Join(base, "httpd"),
+ }
+ for _, p := range candidates {
+ if info, err := os.Stat(p); err == nil && !info.IsDir() {
+ return p, nil
+ }
+ }
+ return filepath.Join(base, "bin", "httpd.exe"), nil
+}
+
+// apacheConfDir returns the absolute path to the Apache config directory
+// (e.g. ~/.zampp/conf/apache).
+func apacheConfDir() (string, error) {
+ root, err := appRootDir()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(root, "conf", "apache"), nil
+}
+
+// apacheConfPath returns the absolute path to httpd.conf.
+func apacheConfPath() (string, error) {
+ d, err := apacheConfDir()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(d, "httpd.conf"), nil
+}
+
+// apacheLogDir returns the absolute path to the Apache logs directory
+// (e.g. ~/.zampp/logs/apache).
+func apacheLogDir() (string, error) {
+ root, err := appRootDir()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(root, "logs", "apache"), nil
+}
+
+// isApacheInstalled reports whether the httpd binary exists.
+func isApacheInstalled() bool {
+ p, err := apacheBinaryPath()
+ if err != nil {
+ return false
+ }
+ info, err := os.Stat(p)
+ if err != nil {
+ return false
+ }
+ return !info.IsDir()
+}
+
+// IsApacheInstalled exposes apache-installation status to the frontend.
+func (a *App) IsApacheInstalled() bool {
+ return isApacheInstalled()
+}
+
+// portListenerExists returns true if any process is listening on the given TCP port.
+func portListenerExists(port string) bool {
+ conn, err := net.DialTimeout("tcp", "127.0.0.1:"+port, 200*time.Millisecond)
+ if err == nil {
+ conn.Close()
+ return true
+ }
+ return len(getPIDsOnPort(port)) > 0
+}
+
+// waitForPort polls for a TCP listener on the given port up to the specified timeout.
+func waitForPort(port string, timeout time.Duration) bool {
+ deadline := time.Now().Add(timeout)
+ for time.Now().Before(deadline) {
+ if portListenerExists(port) {
+ return true
+ }
+ time.Sleep(100 * time.Millisecond)
+ }
+ return false
+}
+
+// adminerDownloadURL is the official Adminer single-file PHP release.
+const adminerDownloadURL = "https://github.com/vrana/adminer/releases/download/v4.8.1/adminer-4.8.1-en.php"
+
+// ensureAdminerDownloaded makes sure ~/.zampp/htdocs/adminer.php exists.
+// If it does not, it downloads the official Adminer PHP file from GitHub
+// Releases and writes it directly into the htdocs directory. This is an
+// on-demand download — it only runs the first time the user clicks the
+// Adminer button.
+func ensureAdminerDownloaded() error {
+ docRoot, err := htdocsPath()
+ if err != nil {
+ return fmt.Errorf("cannot resolve htdocs: %w", err)
+ }
+ if err := os.MkdirAll(docRoot, 0755); err != nil {
+ return fmt.Errorf("cannot create htdocs at %s: %w", docRoot, err)
+ }
+
+ target := filepath.Join(docRoot, "adminer.php")
+
+ // Already present — nothing to do.
+ if info, err := os.Stat(target); err == nil && !info.IsDir() && info.Size() > 0 {
+ return nil
+ }
+
+ // Download Adminer PHP file directly to the target path.
+ req, err := http.NewRequest(http.MethodGet, adminerDownloadURL, nil)
+ if err != nil {
+ return fmt.Errorf("cannot create request: %w", err)
+ }
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return fmt.Errorf("failed to download Adminer: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("adminer download failed: HTTP %d", resp.StatusCode)
+ }
+
+ out, err := os.Create(target)
+ if err != nil {
+ return fmt.Errorf("cannot create %s: %w", target, err)
+ }
+ if _, err := io.Copy(out, resp.Body); err != nil {
+ out.Close()
+ _ = os.Remove(target)
+ return fmt.Errorf("cannot write %s: %w", target, err)
+ }
+ out.Close()
+ return nil
+}
+
+// OpenAdminer opens Adminer in the user's default browser on Windows.
+// It routes the URL through whichever web server is currently running
+// (Nginx :9000 or Apache :9000), and prefills the MySQL server field to
+// 127.0.0.1:3309 so login works against the app's standalone MySQL on port 3309.
+func (a *App) OpenAdminer() string {
+ // 1) Ensure Adminer is present in htdocs (download on-demand).
+ if err := ensureAdminerDownloaded(); err != nil {
+ return fmt.Sprintf("Error: %s", err.Error())
+ }
+
+ // 2) Resolve the running web server's base URL.
+ var baseURL string
+ if isProcessAlive(nginxProcess) || portListenerExists(nginxPort) {
+ baseURL = "http://127.0.0.1:" + nginxPort
+ } else if isProcessAlive(apacheProcess) || portListenerExists(apachePort) {
+ baseURL = "http://127.0.0.1:" + apachePort
+ }
+ if baseURL == "" {
+ return "Error: jalankan Nginx atau Apache terlebih dahulu"
+ }
+
+ // 3) Build Adminer URL with prefilled MySQL server (127.0.0.1:3309) and username=root.
+ url := baseURL + "/adminer.php?server=127.0.0.1:" + mySQLPort + "&username=root&password=root"
+
+ cmd := exec.Command("cmd", "/c", "start", url)
+ cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
+ if err := cmd.Start(); err != nil {
+ cmd2 := exec.Command("explorer", url)
+ cmd2.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
+ if err2 := cmd2.Start(); err2 != nil {
+ return fmt.Sprintf("Error: gagal membuka browser: %s", err.Error())
+ }
+ }
+ return "Opened Adminer at " + url
+}
+
+// OpenWebRoot opens the running web server's root URL (http://127.0.0.1:9000)
+// in the user's default browser on Windows.
+func (a *App) OpenWebRoot() string {
+ if !portListenerExists(nginxPort) && !portListenerExists(apachePort) {
+ return "Error: jalankan Web Server terlebih dahulu"
+ }
+ url := "http://127.0.0.1:" + nginxPort
+ cmd := exec.Command("cmd", "/c", "start", url)
+ cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
+ if err := cmd.Start(); err != nil {
+ cmd2 := exec.Command("explorer", url)
+ cmd2.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
+ if err2 := cmd2.Start(); err2 != nil {
+ return fmt.Sprintf("Error: gagal membuka browser: %s", err.Error())
+ }
+ }
+ return "Opened WebRoot at " + url
+}
+
+// OpenHtdocsFolder opens the ~/.zampp/htdocs directory in Windows File Explorer.
+func (a *App) OpenHtdocsFolder() error {
+ docRoot, err := htdocsPath()
+ if err != nil {
+ return err
+ }
+ if err := os.MkdirAll(docRoot, 0755); err != nil {
+ return fmt.Errorf("cannot create htdocs at %s: %w", docRoot, err)
+ }
+ cmd := exec.Command("explorer", docRoot)
+ cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
+ if err := cmd.Start(); err != nil {
+ return fmt.Errorf("failed to open Explorer: %w", err)
+ }
+ return nil
+}
+
+// composerPharURL is the official Composer stable phar download URL.
+const composerPharURL = "https://getcomposer.org/composer-stable.phar"
+
+// ensureComposerDownloaded makes sure ~/.zampp/bin/composer.phar exists. If it
+// does not, it downloads the official Composer stable phar and writes it
+// directly into ~/.zampp/bin/. This is an on-demand download — it only runs
+// the first time the user opens the ZAMPP Terminal.
+func ensureComposerDownloaded() error {
+ root, err := appRootDir()
+ if err != nil {
+ return fmt.Errorf("cannot resolve app root: %w", err)
+ }
+ binDir := filepath.Join(root, "bin")
+ if err := os.MkdirAll(binDir, 0755); err != nil {
+ return fmt.Errorf("cannot create bin dir %s: %w", binDir, err)
+ }
+
+ target := filepath.Join(binDir, "composer.phar")
+
+ // Already present — nothing to do.
+ if info, err := os.Stat(target); err == nil && !info.IsDir() && info.Size() > 0 {
+ _ = os.Chmod(target, 0755)
+ return nil
+ }
+
+ // Download Composer phar directly to the target path.
+ req, err := http.NewRequest(http.MethodGet, composerPharURL, nil)
+ if err != nil {
+ return fmt.Errorf("cannot create request: %w", err)
+ }
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return fmt.Errorf("failed to download Composer: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("composer download failed: HTTP %d", resp.StatusCode)
+ }
+
+ out, err := os.Create(target)
+ if err != nil {
+ return fmt.Errorf("cannot create %s: %w", target, err)
+ }
+ if _, err := io.Copy(out, resp.Body); err != nil {
+ out.Close()
+ _ = os.Remove(target)
+ return fmt.Errorf("cannot write %s: %w", target, err)
+ }
+ out.Close()
+
+ if err := os.Chmod(target, 0755); err != nil {
+ return fmt.Errorf("cannot chmod %s: %w", target, err)
+ }
+ return nil
+}
+
+// OpenTerminal opens a new Windows PowerShell terminal pre-configured for ZAMPP.
+// The active PHP directory is temporarily prepended to PATH so that php.exe is immediately
+// available in the session, and a composer helper function is defined to execute composer.phar.
+func (a *App) OpenTerminal(activePhpVersion string) error {
+ version := strings.TrimSpace(activePhpVersion)
+ if version == "" {
+ return fmt.Errorf("activePhpVersion is empty")
+ }
+
+ if err := ensureComposerDownloaded(); err != nil {
+ return err
+ }
+
+ home, err := homeDir()
+ if err != nil {
+ return fmt.Errorf("cannot resolve home dir: %w", err)
+ }
+ phpBase := filepath.Join(home, appDirName, "bin", "php", version)
+ phpExecutablePath := phpBase
+ if _, err := os.Stat(filepath.Join(phpBase, "bin", "php.exe")); err == nil {
+ phpExecutablePath = filepath.Join(phpBase, "bin")
+ } else if _, err := os.Stat(filepath.Join(phpBase, "php.exe")); err == nil {
+ phpExecutablePath = phpBase
+ }
+
+ docRoot, err := htdocsPath()
+ if err != nil {
+ return err
+ }
+ composerPath := filepath.Join(home, appDirName, "bin", "composer.phar")
+
+ psInit := fmt.Sprintf(
+ `$env:Path = '%s;' + $env:Path; function composer { php '%s' @args }; Set-Location '%s'; Clear-Host; Write-Host '⚡️ ZAMPP Smart Terminal Ready!' -ForegroundColor Cyan; Write-Host 'Active PHP: %s' -ForegroundColor Green; Write-Host 'Composer is ready to use. Type composer to test.' -ForegroundColor Yellow; php -v`,
+ phpExecutablePath,
+ composerPath,
+ docRoot,
+ version,
+ )
+
+ cmd := exec.Command("cmd", "/c", "start", "powershell", "-NoExit", "-Command", psInit)
+ cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
+ if err := cmd.Start(); err != nil {
+ return fmt.Errorf("failed to open PowerShell: %w", err)
+ }
+ return nil
+}
+
+// StartWebServer starts the PHP built-in server (internal port 9001) and then
+// the chosen web server engine (nginx or apache) on port 9000. The UI calls
+// this single API so it only needs one Start button + an engine dropdown.
+//
+// engine is "nginx" or "apache". phpVersion is the folder name under
+// ~/.zampp/bin/php/ (e.g. "7.4", "8.2").
+func (a *App) StartWebServer(engine string, phpVersion string) string {
+ engine = strings.ToLower(strings.TrimSpace(engine))
+ if engine != "nginx" && engine != "apache" {
+ return "Error: engine harus \"nginx\" atau \"apache\""
+ }
+
+ // 1) Start PHP first on the internal port.
+ phpResult := a.StartPHP(phpVersion)
+ if strings.HasPrefix(phpResult, "Error") {
+ return "Error: PHP gagal start — " + phpResult
+ }
+
+ // 2) Start the chosen engine.
+ var engResult string
+ switch engine {
+ case "nginx":
+ engResult = a.StartNginx()
+ case "apache":
+ engResult = a.StartApache()
+ }
+ if strings.HasPrefix(engResult, "Error") {
+ // Roll back PHP if the web server failed to start.
+ a.StopPHP()
+ return engResult
+ }
+
+ return fmt.Sprintf("Started %s + PHP (port %s). %s | %s", engine, nginxPort, engResult, phpResult)
+}
+
+// StopWebServer stops the chosen web server engine (nginx or apache) first,
+// then stops the PHP built-in server.
+func (a *App) StopWebServer(engine string) string {
+ engine = strings.ToLower(strings.TrimSpace(engine))
+ if engine != "nginx" && engine != "apache" {
+ return "Error: engine harus \"nginx\" atau \"apache\""
+ }
+
+ var engResult string
+ switch engine {
+ case "nginx":
+ engResult = a.StopNginx()
+ case "apache":
+ engResult = a.StopApache()
+ }
+
+ phpResult := a.StopPHP()
+
+ return engResult + " | " + phpResult
+}
+
+// defaultIndexPHP is the Welcome Screen shown when the user opens the web
+// root for the first time. It reports the active PHP version and the running
+// web server (Nginx or Apache) and links back to semut.dev.
+const defaultIndexPHP = `
+
+
+
+ Welcome to ZAMPP
+
+
+
+
+
+
Welcome to ZAMPP
+
Your local development environment is running perfectly.
+
+
+
PHP Version
+
+
Active Server
+
+
+
+
+
+`
+
+// GenerateDefaultIndexPHP writes the Welcome page to ~/.zampp/htdocs/index.php
+// only if it does not already exist. This guarantees new users see a default
+// landing page the first time they open the web root, without overwriting an
+// existing index.php the user may have placed there.
+func GenerateDefaultIndexPHP() error {
+ docRoot, err := htdocsPath()
+ if err != nil {
+ return fmt.Errorf("cannot resolve htdocs: %w", err)
+ }
+ if err := os.MkdirAll(docRoot, 0755); err != nil {
+ return fmt.Errorf("cannot create htdocs at %s: %w", docRoot, err)
+ }
+
+ target := filepath.Join(docRoot, "index.php")
+
+ // Do not clobber an existing index.php.
+ if info, err := os.Stat(target); err == nil && !info.IsDir() && info.Size() > 0 {
+ return nil
+ }
+
+ if err := os.WriteFile(target, []byte(defaultIndexPHP), 0644); err != nil {
+ return fmt.Errorf("cannot write %s: %w", target, err)
+ }
+ return nil
+}
+
+// All paths are resolved to absolute form using the user's home directory.
+// It is called on startup and on every StartApache so the config always
+// reflects the current ports/paths.
+func GenerateApacheConfig() error {
+ home, err := homeDir()
+ if err != nil {
+ return err
+ }
+ appRoot := filepath.Join(home, appDirName)
+
+ confDir, err := apacheConfDir()
+ if err != nil {
+ return err
+ }
+ if err := os.MkdirAll(confDir, 0755); err != nil {
+ return fmt.Errorf("cannot create apache conf dir: %w", err)
+ }
+
+ docRoot, err := htdocsPath()
+ if err != nil {
+ return err
+ }
+ if err := os.MkdirAll(docRoot, 0755); err != nil {
+ return fmt.Errorf("cannot create htdocs: %w", err)
+ }
+
+ logDir, err := apacheLogDir()
+ if err != nil {
+ return err
+ }
+ if err := os.MkdirAll(logDir, 0755); err != nil {
+ return fmt.Errorf("cannot create apache log dir: %w", err)
+ }
+ pidFile := filepath.ToSlash(filepath.Join(logDir, "httpd.pid"))
+ errorLog := filepath.ToSlash(filepath.Join(logDir, "error.log"))
+ serverRoot := filepath.ToSlash(filepath.Join(appRoot, "bin", "apache"))
+ absoluteHtdocsPath := filepath.ToSlash(docRoot)
+
+ var b strings.Builder
+ // --- Global directives (must be OUTSIDE ) ---
+ b.WriteString("ServerRoot \"" + serverRoot + "\"\n")
+ b.WriteString("Listen " + apachePort + "\n\n")
+ b.WriteString("LoadModule authz_core_module modules/mod_authz_core.so\n")
+ b.WriteString("LoadModule dir_module modules/mod_dir.so\n")
+ b.WriteString("LoadModule mime_module modules/mod_mime.so\n")
+ b.WriteString("LoadModule rewrite_module modules/mod_rewrite.so\n")
+ b.WriteString("LoadModule proxy_module modules/mod_proxy.so\n")
+ b.WriteString("LoadModule proxy_fcgi_module modules/mod_proxy_fcgi.so\n\n")
+ b.WriteString("ServerName localhost\n")
+ b.WriteString("PidFile \"" + pidFile + "\"\n")
+ b.WriteString("ErrorLog \"" + errorLog + "\"\n")
+ b.WriteString("LogLevel warn\n")
+ b.WriteString("ProxyTimeout 60\n\n")
+
+ b.WriteString("\n")
+ b.WriteString(" DocumentRoot \"" + absoluteHtdocsPath + "\"\n\n")
+ b.WriteString(" ProxyFCGISetEnvIf \"true\" SCRIPT_FILENAME \"%{reqenv:DOCUMENT_ROOT}%{reqenv:SCRIPT_NAME}\"\n")
+ b.WriteString(" \n")
+ b.WriteString(" SetHandler \"proxy:fcgi://127.0.0.1:" + phpInternalPort + "/\"\n")
+ b.WriteString(" \n\n")
+ b.WriteString(" \n")
+ b.WriteString(" Options Indexes FollowSymLinks\n")
+ b.WriteString(" AllowOverride All\n")
+ b.WriteString(" Require all granted\n")
+ b.WriteString(" \n\n")
+ b.WriteString(" DirectoryIndex index.php index.html\n")
+ b.WriteString(" \n\n")
+ b.WriteString("TypesConfig conf/mime.types\n")
+
+ confPath, err := apacheConfPath()
+ if err != nil {
+ return err
+ }
+ if err := os.WriteFile(confPath, []byte(b.String()), 0644); err != nil {
+ return fmt.Errorf("cannot write httpd.conf: %w", err)
+ }
+ return nil
+}
+
+// StartApache starts the standalone httpd server using the generated conf.
+func (a *App) StartApache() string {
+ if apacheProcess != nil && apacheProcess.Process != nil {
+ if isProcessAlive(apacheProcess) {
+ return "Info: Apache is already running"
+ }
+ }
+
+ binaryPath, err := apacheBinaryPath()
+ if err != nil {
+ return err.Error()
+ }
+
+ // Check that the binary file is present.
+ if _, err := os.Stat(binaryPath); err != nil {
+ return "Apache Not Installed — letakkan binary di ~/.zampp/bin/apache/bin/httpd.exe"
+ }
+
+ // Ensure it is executable.
+ if err := os.Chmod(binaryPath, 0755); err != nil {
+ return fmt.Sprintf("Error: cannot make %s executable: %s", binaryPath, err.Error())
+ }
+
+ // (Re)generate httpd.conf so it always reflects current settings.
+ if err := GenerateApacheConfig(); err != nil {
+ return fmt.Sprintf("Error: gagal membuat httpd.conf: %s", err.Error())
+ }
+
+ confPath, err := apacheConfPath()
+ if err != nil {
+ return err.Error()
+ }
+
+ // Defensive cleanup of any stale httpd on our port.
+ a.stopApacheOnPort()
+
+ cmd := exec.Command(binaryPath, "-X", "-f", filepath.ToSlash(confPath))
+ cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
+
+ // Stream stdout/stderr to log file + parent terminal
+ apacheLogDir, _ := apacheLogDir()
+ stderrBuffer := newSafeBuffer()
+ attachLogger(cmd, apacheLogDir, "stdout.log", stderrBuffer)
+
+ if err := cmd.Start(); err != nil {
+ return fmt.Sprintf("Error: failed to start apache: %s", err.Error())
+ }
+
+ apacheProcess = cmd
+
+ if !waitForPort(apachePort, 3000*time.Millisecond) {
+ stderrText := strings.TrimSpace(stderrBuffer.String())
+ a.StopApache()
+ if stderrText != "" {
+ return fmt.Sprintf("Error: Apache gagal start (port %s tidak listening): %s", apachePort, stderrText)
+ }
+ return fmt.Sprintf("Error: Apache gagal start (port %s tidak listening dalam 3s). Periksa ~/.zampp/logs/apache/stdout.log", apachePort)
+ }
+
+ return fmt.Sprintf("Started Apache on port %s (conf: %s)", apachePort, confPath)
+}
+
+// StopApache stops the running httpd process on Windows.
+func (a *App) StopApache() (result string) {
+ defer func() {
+ if r := recover(); r != nil {
+ result = fmt.Sprintf("Error: StopApache panic: %v", r)
+ }
+ }()
+
+ var messages []string
+
+ // Primary path: kill the tracked process using native Go os.Process.Kill()
+ if apacheProcess != nil && apacheProcess.Process != nil {
+ pid := apacheProcess.Process.Pid
+ if pid > 0 {
+ _ = apacheProcess.Process.Kill()
+ _ = killProcessByPID(pid)
+ go func(c *exec.Cmd) {
+ if c != nil {
+ _ = c.Wait()
+ }
+ }(apacheProcess)
+ messages = append(messages, fmt.Sprintf("stopped Apache (PID %d)", pid))
+ }
+ apacheProcess = nil
+ }
+
+ // Fallback A: Apache's own stop, using generated conf
+ if confPath, err := apacheConfPath(); err == nil {
+ if binaryPath, err := apacheBinaryPath(); err == nil {
+ stopCmd := exec.Command(binaryPath, "-k", "stop", "-f", confPath)
+ stopCmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
+ _ = stopCmd.Run()
+ }
+ }
+
+ // Fallback B: kill any PID listening on apachePort
+ msgs := a.stopApacheOnPort()
+ if msgs != "" && !strings.HasPrefix(msgs, "Info:") {
+ messages = append(messages, msgs)
+ }
+
+ if len(messages) == 0 {
+ return "Info: Apache already stopped"
+ }
+ return strings.Join(messages, "; ")
+}
+
+// stopApacheOnPort kills any process listening on the apache port on Windows.
+func (a *App) stopApacheOnPort() string {
+ pids := getPIDsOnPort(apachePort)
+ if len(pids) == 0 {
+ return "Info: no Apache process found on port " + apachePort + " (already stopped)"
+ }
+ var messages []string
+ for _, pid := range pids {
+ if err := killPIDString(pid); err != nil {
+ messages = append(messages, fmt.Sprintf("failed to kill PID %s: %s", pid, err.Error()))
+ } else {
+ messages = append(messages, fmt.Sprintf("stopped PID %s", pid))
+ }
+ }
+ if len(messages) == 0 {
+ return "Info: no Apache process stopped"
+ }
+ return strings.Join(messages, "; ")
+}
+// It uses:
+//
+// --datadir=~/.zampp/data/mysql
+// --port=3309 (avoids clashing with XAMPP on 3306)
+//
+// The process reference is stored in the package-level mysqlProcess variable.
+func (a *App) StartMySQL() string {
+ if mysqlProcess != nil && mysqlProcess.Process != nil {
+ if isProcessAlive(mysqlProcess) {
+ return "Info: MySQL is already running"
+ }
+ }
+
+ binaryPath, err := mysqldPath()
+ if err != nil {
+ return err.Error()
+ }
+
+ // Check that the binary file is present.
+ if _, err := os.Stat(binaryPath); err != nil {
+ return "MySQL Not Installed — letakkan binary di ~/.zampp/bin/mysql/bin/mysqld.exe"
+ }
+
+ // Ensure it is executable.
+ if err := os.Chmod(binaryPath, 0755); err != nil {
+ return fmt.Sprintf("Error: cannot make %s executable: %s", binaryPath, err.Error())
+ }
+
+ dataDir, err := mysqlDataDir()
+ if err != nil {
+ return fmt.Sprintf("Error: %s", err.Error())
+ }
+ if err := os.MkdirAll(dataDir, 0755); err != nil {
+ return fmt.Sprintf("Error: cannot create data dir %s: %s", dataDir, err.Error())
+ }
+
+ // Stop any stale mysqld listening on our port (defensive cleanup).
+ a.stopMySQLOnPort()
+
+ cmd := exec.Command(binaryPath,
+ "--datadir="+filepath.Clean(dataDir),
+ "--port="+mySQLPort,
+ )
+ cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
+
+ // Detach stdout/stderr so the process keeps running after the call returns.
+ if err := cmd.Start(); err != nil {
+ return fmt.Sprintf("Error: failed to start mysqld: %s", err.Error())
+ }
+
+ mysqlProcess = cmd
+
+ // Release the process so it keeps running in the background.
+ _ = cmd.Process.Release()
+
+ // Ensure the root account uses username=root / password=root so the
+ // frontend (and Adminer) can log in predictably.
+ go ensureMySQLRootCredentials()
+
+ return fmt.Sprintf("Started MySQL on port %s (datadir: %s)", mySQLPort, dataDir)
+}
+
+// ensureMySQLRootCredentials guarantees the root MySQL account uses password=root.
+// It is run as a goroutine shortly after mysqld starts.
+func ensureMySQLRootCredentials() {
+ mysqlClient, err := mysqlClientPath()
+ if err != nil {
+ fmt.Println("mysql: cannot resolve mysql client path:", err)
+ return
+ }
+ if _, err := os.Stat(mysqlClient); err != nil {
+ fmt.Println("mysql: client binary not present, skipping root password set")
+ return
+ }
+
+ // 1) Wait for the MySQL TCP port to be listening (up to 5s).
+ if !waitForPort(mySQLPort, 5000*time.Millisecond) {
+ fmt.Printf("mysql: port %s never became ready, skipping root password set\n", mySQLPort)
+ return
+ }
+
+ // 2) Try password=root first — if it already works, nothing to do.
+ if tryMySQLCommand(mysqlClient, mySQLPort, "root", "root", "SELECT 1") {
+ fmt.Println("mysql: root password already 'root' — nothing to do")
+ return
+ }
+
+ // 3) Try no password (fresh data dir) -> set root password to 'root'.
+ if tryMySQLCommand(mysqlClient, mySQLPort, "", "", "ALTER USER 'root'@'localhost' IDENTIFIED BY 'root'; FLUSH PRIVILEGES;") {
+ fmt.Println("mysql: root password set to 'root' (was empty)")
+ return
+ }
+
+ // 4) Last resort: skip-grant-tables bootstrap.
+ fmt.Println("mysql: falling back to --skip-grant-tables to force root password")
+ if err := forceRootPasswordViaGrantTables(); err != nil {
+ fmt.Printf("mysql: could not force root password: %s\n", err.Error())
+ return
+ }
+ fmt.Println("mysql: root password forced to 'root' via skip-grant-tables")
+}
+
+// tryMySQLCommand runs the mysql client with the given credentials and a
+// SQL payload over TCP, returning true on success.
+func tryMySQLCommand(client, port, user, pass, sql string) bool {
+ args := []string{"-h", "127.0.0.1", "-P", port}
+ if user != "" {
+ args = append(args, "-u", user)
+ }
+ if pass != "" {
+ args = append(args, "-p"+pass)
+ }
+ args = append(args, "-e", sql)
+ cmd := exec.Command(client, args...)
+ cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ fmt.Printf("mysql: attempt with user=%q pass_set=%v failed: %s: %s\n",
+ user, pass != "", err.Error(), strings.TrimSpace(string(out)))
+ return false
+ }
+ return true
+}
+
+// forceRootPasswordViaGrantTables starts a temporary mysqld with
+// --skip-grant-tables on an isolated port, runs SQL to set root@localhost's password to 'root',
+// then stops the temporary mysqld.
+func forceRootPasswordViaGrantTables() error {
+ binaryPath, err := mysqldPath()
+ if err != nil {
+ return err
+ }
+ dataDir, err := mysqlDataDir()
+ if err != nil {
+ return err
+ }
+ mysqlClient, err := mysqlClientPath()
+ if err != nil {
+ return err
+ }
+
+ tempPort := "3310"
+ mysqld := exec.Command(binaryPath,
+ "--datadir="+filepath.Clean(dataDir),
+ "--port="+tempPort,
+ "--skip-grant-tables",
+ )
+ mysqld.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
+ if err := mysqld.Start(); err != nil {
+ return fmt.Errorf("cannot start temp mysqld: %w", err)
+ }
+ defer func() {
+ _ = mysqld.Process.Kill()
+ _ = killProcessByPID(mysqld.Process.Pid)
+ }()
+
+ // Wait for temp port to appear.
+ if !waitForPort(tempPort, 5000*time.Millisecond) {
+ return fmt.Errorf("temp mysqld port %s never appeared", tempPort)
+ }
+
+ // skip-grant-tables means no password is needed AND ALTER USER works.
+ sql := "FLUSH PRIVILEGES; ALTER USER 'root'@'localhost' IDENTIFIED BY 'root'; FLUSH PRIVILEGES;"
+ cmd := exec.Command(mysqlClient, "-h", "127.0.0.1", "-P", tempPort, "-e", sql)
+ cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ return fmt.Errorf("could not set root password via grant tables: %s: %s", err.Error(), strings.TrimSpace(string(out)))
+ }
+ return nil
+}
+
+// mysqlClientPath returns the absolute path to the mysql client binary
+// (e.g. ~/.zampp/bin/mysql/bin/mysql.exe).
+func mysqlClientPath() (string, error) {
+ base, err := mysqlBaseDir()
+ if err != nil {
+ return "", err
+ }
+ candidates := []string{
+ filepath.Join(base, "bin", "mysql.exe"),
+ filepath.Join(base, "bin", "mysqladmin.exe"),
+ filepath.Join(base, "bin", "mysql"),
+ filepath.Join(base, "bin", "mysqladmin"),
+ }
+ for _, p := range candidates {
+ if info, err := os.Stat(p); err == nil && !info.IsDir() {
+ return p, nil
+ }
+ }
+ return filepath.Join(base, "bin", "mysql.exe"), nil
+}
+
+// StopMySQL stops the running MySQL process on Windows.
+func (a *App) StopMySQL() (result string) {
+ defer func() {
+ if r := recover(); r != nil {
+ result = fmt.Sprintf("Error: StopMySQL panic: %v", r)
+ }
+ }()
+
+ var messages []string
+
+ // Primary path: kill the tracked process using native Go os.Process.Kill()
+ if mysqlProcess != nil && mysqlProcess.Process != nil {
+ pid := mysqlProcess.Process.Pid
+ if pid > 0 {
+ _ = mysqlProcess.Process.Kill()
+ _ = killProcessByPID(pid)
+ go func(c *exec.Cmd) {
+ if c != nil {
+ _ = c.Wait()
+ }
+ }(mysqlProcess)
+ messages = append(messages, fmt.Sprintf("stopped MySQL (PID %d)", pid))
+ }
+ mysqlProcess = nil
+ }
+
+ // Fallback: cleanup any listener on mySQLPort
+ msgs := a.stopMySQLOnPort()
+ if msgs != "" && !strings.HasPrefix(msgs, "Info:") {
+ messages = append(messages, msgs)
+ }
+
+ if len(messages) == 0 {
+ return "Info: MySQL already stopped"
+ }
+ return strings.Join(messages, "; ")
+}
+
+// stopMySQLOnPort searches for any process listening on tcp:3309 and kills it on Windows.
+func (a *App) stopMySQLOnPort() string {
+ pids := getPIDsOnPort(mySQLPort)
+ if len(pids) == 0 {
+ return "Info: no MySQL process found on port " + mySQLPort + " (already stopped)"
+ }
+
+ var messages []string
+ for _, pid := range pids {
+ if err := killPIDString(pid); err != nil {
+ messages = append(messages, fmt.Sprintf("failed to kill PID %s: %s", pid, err.Error()))
+ } else {
+ messages = append(messages, fmt.Sprintf("stopped PID %s", pid))
+ }
+ }
+
+ if len(messages) == 0 {
+ return "Info: no MySQL process stopped"
+ }
+ return strings.Join(messages, "; ")
+}
diff --git a/frontend/package.json.md5 b/frontend/package.json.md5
index d4671a1..c0099f5 100755
--- a/frontend/package.json.md5
+++ b/frontend/package.json.md5
@@ -1 +1 @@
-5fbf12469d224a93954efecb5886e8a6
\ No newline at end of file
+be0c7dc3573b2470ab6a8ec4c48845b6
\ No newline at end of file
diff --git a/frontend/src/main.js b/frontend/src/main.js
index 399006c..d2d8e95 100644
--- a/frontend/src/main.js
+++ b/frontend/src/main.js
@@ -76,7 +76,7 @@ document.querySelector('#app').innerHTML = `
PHP 8.5
- Port: 8000
+ Port: 9000
Start
@@ -93,7 +93,7 @@ document.querySelector('#app').innerHTML = `
MySQL 5.7
- Port: 3307 • User: root • Pass: root
+ Port: 3309 • User: root • Pass: root
Start
@@ -434,7 +434,7 @@ adminerBtn.addEventListener('click', () => {
htdocsBtn.addEventListener('click', () => {
OpenHtdocsFolder()
- .then(() => showToast('Opened htdocs folder in Finder.', false))
+ .then(() => showToast('Opened htdocs folder in File Explorer.', false))
.catch((err) => {
const msg = typeof err === 'string' ? err : String(err);
showToast(msg, true);
diff --git a/frontend/wailsjs/runtime/runtime.d.ts b/frontend/wailsjs/runtime/runtime.d.ts
index 4445dac..3bbea84 100644
--- a/frontend/wailsjs/runtime/runtime.d.ts
+++ b/frontend/wailsjs/runtime/runtime.d.ts
@@ -246,4 +246,85 @@ export function OnFileDropOff() :void
export function CanResolveFilePaths(): boolean;
// Resolves file paths for an array of files
-export function ResolveFilePaths(files: File[]): void
\ No newline at end of file
+export function ResolveFilePaths(files: File[]): void
+
+// Notification types
+export interface NotificationOptions {
+ id: string;
+ title: string;
+ subtitle?: string; // macOS and Linux only
+ body?: string;
+ categoryId?: string;
+ data?: { [key: string]: any };
+}
+
+export interface NotificationAction {
+ id?: string;
+ title?: string;
+ destructive?: boolean; // macOS-specific
+}
+
+export interface NotificationCategory {
+ id?: string;
+ actions?: NotificationAction[];
+ hasReplyField?: boolean;
+ replyPlaceholder?: string;
+ replyButtonTitle?: string;
+}
+
+// [InitializeNotifications](https://wails.io/docs/reference/runtime/notification#initializenotifications)
+// Initializes the notification service for the application.
+// This must be called before sending any notifications.
+export function InitializeNotifications(): Promise;
+
+// [CleanupNotifications](https://wails.io/docs/reference/runtime/notification#cleanupnotifications)
+// Cleans up notification resources and releases any held connections.
+export function CleanupNotifications(): Promise;
+
+// [IsNotificationAvailable](https://wails.io/docs/reference/runtime/notification#isnotificationavailable)
+// Checks if notifications are available on the current platform.
+export function IsNotificationAvailable(): Promise;
+
+// [RequestNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#requestnotificationauthorization)
+// Requests notification authorization from the user (macOS only).
+export function RequestNotificationAuthorization(): Promise;
+
+// [CheckNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#checknotificationauthorization)
+// Checks the current notification authorization status (macOS only).
+export function CheckNotificationAuthorization(): Promise;
+
+// [SendNotification](https://wails.io/docs/reference/runtime/notification#sendnotification)
+// Sends a basic notification with the given options.
+export function SendNotification(options: NotificationOptions): Promise;
+
+// [SendNotificationWithActions](https://wails.io/docs/reference/runtime/notification#sendnotificationwithactions)
+// Sends a notification with action buttons. Requires a registered category.
+export function SendNotificationWithActions(options: NotificationOptions): Promise;
+
+// [RegisterNotificationCategory](https://wails.io/docs/reference/runtime/notification#registernotificationcategory)
+// Registers a notification category that can be used with SendNotificationWithActions.
+export function RegisterNotificationCategory(category: NotificationCategory): Promise;
+
+// [RemoveNotificationCategory](https://wails.io/docs/reference/runtime/notification#removenotificationcategory)
+// Removes a previously registered notification category.
+export function RemoveNotificationCategory(categoryId: string): Promise;
+
+// [RemoveAllPendingNotifications](https://wails.io/docs/reference/runtime/notification#removeallpendingnotifications)
+// Removes all pending notifications from the notification center.
+export function RemoveAllPendingNotifications(): Promise;
+
+// [RemovePendingNotification](https://wails.io/docs/reference/runtime/notification#removependingnotification)
+// Removes a specific pending notification by its identifier.
+export function RemovePendingNotification(identifier: string): Promise;
+
+// [RemoveAllDeliveredNotifications](https://wails.io/docs/reference/runtime/notification#removealldeliverednotifications)
+// Removes all delivered notifications from the notification center.
+export function RemoveAllDeliveredNotifications(): Promise;
+
+// [RemoveDeliveredNotification](https://wails.io/docs/reference/runtime/notification#removedeliverednotification)
+// Removes a specific delivered notification by its identifier.
+export function RemoveDeliveredNotification(identifier: string): Promise;
+
+// [RemoveNotification](https://wails.io/docs/reference/runtime/notification#removenotification)
+// Removes a notification by its identifier (cross-platform convenience function).
+export function RemoveNotification(identifier: string): Promise;
\ No newline at end of file
diff --git a/frontend/wailsjs/runtime/runtime.js b/frontend/wailsjs/runtime/runtime.js
index 7cb89d7..556621e 100644
--- a/frontend/wailsjs/runtime/runtime.js
+++ b/frontend/wailsjs/runtime/runtime.js
@@ -239,4 +239,60 @@ export function CanResolveFilePaths() {
export function ResolveFilePaths(files) {
return window.runtime.ResolveFilePaths(files);
+}
+
+export function InitializeNotifications() {
+ return window.runtime.InitializeNotifications();
+}
+
+export function CleanupNotifications() {
+ return window.runtime.CleanupNotifications();
+}
+
+export function IsNotificationAvailable() {
+ return window.runtime.IsNotificationAvailable();
+}
+
+export function RequestNotificationAuthorization() {
+ return window.runtime.RequestNotificationAuthorization();
+}
+
+export function CheckNotificationAuthorization() {
+ return window.runtime.CheckNotificationAuthorization();
+}
+
+export function SendNotification(options) {
+ return window.runtime.SendNotification(options);
+}
+
+export function SendNotificationWithActions(options) {
+ return window.runtime.SendNotificationWithActions(options);
+}
+
+export function RegisterNotificationCategory(category) {
+ return window.runtime.RegisterNotificationCategory(category);
+}
+
+export function RemoveNotificationCategory(categoryId) {
+ return window.runtime.RemoveNotificationCategory(categoryId);
+}
+
+export function RemoveAllPendingNotifications() {
+ return window.runtime.RemoveAllPendingNotifications();
+}
+
+export function RemovePendingNotification(identifier) {
+ return window.runtime.RemovePendingNotification(identifier);
+}
+
+export function RemoveAllDeliveredNotifications() {
+ return window.runtime.RemoveAllDeliveredNotifications();
+}
+
+export function RemoveDeliveredNotification(identifier) {
+ return window.runtime.RemoveDeliveredNotification(identifier);
+}
+
+export function RemoveNotification(identifier) {
+ return window.runtime.RemoveNotification(identifier);
}
\ No newline at end of file
diff --git a/go.mod b/go.mod
index 9c8d83e..560bfab 100644
--- a/go.mod
+++ b/go.mod
@@ -1,10 +1,11 @@
module zampp
-go 1.23
+go 1.25.0
-require github.com/wailsapp/wails/v2 v2.11.0
+require github.com/wailsapp/wails/v2 v2.14.0
require (
+ git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
@@ -28,10 +29,10 @@ require (
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wailsapp/go-webview2 v1.0.22 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect
- golang.org/x/crypto v0.33.0 // indirect
- golang.org/x/net v0.35.0 // indirect
- golang.org/x/sys v0.30.0 // indirect
- golang.org/x/text v0.22.0 // indirect
+ golang.org/x/crypto v0.53.0 // indirect
+ golang.org/x/net v0.56.0 // indirect
+ golang.org/x/sys v0.46.0 // indirect
+ golang.org/x/text v0.39.0 // indirect
)
// replace github.com/wailsapp/wails/v2 v2.11.0 => /Users/jamal/go/pkg/mod
diff --git a/go.sum b/go.sum
index e3658ec..47f8f62 100644
--- a/go.sum
+++ b/go.sum
@@ -1,3 +1,5 @@
+git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
+git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -45,8 +47,8 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
-github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
-github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
@@ -57,25 +59,25 @@ github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6N
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
-github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
-github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
-golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
-golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
+github.com/wailsapp/wails/v2 v2.14.0 h1:+GlF6BM8Mg7Saa1lxUAzfZ6jsEMg2zQhQvUHtqB7xpc=
+github.com/wailsapp/wails/v2 v2.14.0/go.mod h1:scxrgwfsv6yR6fE6cCF+Flfl+JeU+SR87T9x4kILJ6M=
+golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
+golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
-golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
-golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
+golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
+golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
-golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
+golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
-golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
+golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
+golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/temp_build/temp_build b/temp_build/temp_build
new file mode 100644
index 0000000..eed9843
--- /dev/null
+++ b/temp_build/temp_build
@@ -0,0 +1,2 @@
+$env:TMP="C:\Users\abdul\OneDrive\Documents\zampp\temp_build"
+$env:TEMP="C:\Users\abdul\OneDrive\Documents\zampp\temp_build"
\ No newline at end of file