diff --git a/docs/dev/CORE_FRAMEWORK_ARCHITECTURE.md b/docs/dev/CORE_FRAMEWORK_ARCHITECTURE.md new file mode 100644 index 0000000..69cf518 --- /dev/null +++ b/docs/dev/CORE_FRAMEWORK_ARCHITECTURE.md @@ -0,0 +1,1584 @@ +# PHP GUI Framework — Core Architecture + +> **Version:** V1 Target Specification +> **Status:** Pre-implementation +> **Based on:** [Framework Architecture Review](./FRAMEWORK_ARCHITECTURE_REVIEW.md) + +This document is the engineering blueprint. It specifies exactly what gets built, how the pieces connect, and where the boundaries are. No aspirational features — only what ships in V1. + +--- + +## Table of Contents + +1. [System Overview](#1-system-overview) +2. [Current State & What Changes](#2-current-state--what-changes) +3. [Core Components](#3-core-components) +4. [The App Class](#4-the-app-class) +5. [Callback Bridge (Socket-Based)](#5-callback-bridge-socket-based) +6. [Tcl Security Layer](#6-tcl-security-layer) +7. [WebView Bind API (v2)](#7-webview-bind-api-v2) +8. [JS Bridge (Auto-Injected)](#8-js-bridge-auto-injected) +9. [Error Handling](#9-error-handling) +10. [Event Loop — Revised](#10-event-loop--revised) +11. [CLI Tooling](#11-cli-tooling) +12. [Build & Distribution](#12-build--distribution) +13. [Project Structure (When You Need One)](#13-project-structure-when-you-need-one) +14. [File Manifest](#14-file-manifest) +15. [Migration Path from Current API](#15-migration-path-from-current-api) +16. [What Is Explicitly NOT in V1](#16-what-is-explicitly-not-in-v1) +17. [Implementation Order](#17-implementation-order) + +--- + +## 1. System Overview + +``` +┌─────────────────────────────────────────────────────────┐ +│ User Code │ +│ │ +│ $app = App::create(); │ +│ $app->window(...) or $app->webview(...) or both │ +│ $app->run(); │ +├─────────────────────────────────────────────────────────┤ +│ PhpGui\App │ +│ Single entry point. No config files. │ +│ No containers. No providers. │ +├──────────────────────┬──────────────────────────────────┤ +│ Tcl/Tk Engine │ WebView Engine │ +│ │ │ +│ ProcessTCL (FFI) │ ProcessWebView (stdio IPC) │ +│ AbstractWidget │ WebView widget │ +│ Window, Button... │ bind/emit/invoke │ +├──────────────────────┴──────────────────────────────────┤ +│ Callback Bridge │ +│ SocketBridge (Linux/macOS) │ +│ FileBridge (Windows fallback) │ +├─────────────────────────────────────────────────────────┤ +│ Platform Layer │ +│ OS detection, library paths, binary lookup │ +└─────────────────────────────────────────────────────────┘ +``` + +**Invariants:** +- Single-file apps work. Always. No config files required. +- Zero Composer dependencies. No exceptions. +- The current widget API continues to work unchanged. +- `App` is sugar on top of existing classes, not a replacement. + +--- + +## 2. Current State & What Changes + +### What exists today and stays untouched + +| Component | File | Status | +|---|---|---| +| `ProcessTCL` | `src/ProcessTCL.php` | **Keep.** Singleton FFI bridge. Works. | +| `Application` | `src/Application.php` | **Keep.** Still works for users who don't want `App`. | +| `AbstractWidget` | `src/Widget/AbstractWidget.php` | **Keep.** Base class for all Tk widgets. | +| All widgets | `src/Widget/*.php` | **Keep.** Button, Label, Input, Canvas, Menu, etc. | +| `ProcessWebView` | `src/ProcessWebView.php` | **Keep.** stdio IPC to helper binary. | +| `WebView` | `src/Widget/WebView.php` | **Extend.** Add auto-return bind, keep old API working. | + +### What gets added + +| Component | File | Purpose | +|---|---|---| +| `App` | `src/App.php` | Unified entry point with fluent API | +| `SocketBridge` | `src/Bridge/SocketBridge.php` | Unix socket callback transport | +| `FileBridge` | `src/Bridge/FileBridge.php` | Extract current file-based logic | +| `BridgeInterface` | `src/Bridge/BridgeInterface.php` | Contract (3 methods) | +| `Tcl` | `src/Support/Tcl.php` | Static escaper for Tcl strings | +| `TclException` | `src/Support/TclException.php` | Rich error context | +| `phpgui.js` | `src/WebView/phpgui.js` | Auto-injected JS bridge | +| CLI commands | `src/Console/*.php` | `new`, `build`, `check` | + +### What gets modified + +| Component | Change | +|---|---| +| `ProcessTCL::evalTcl()` | Throws `TclException` instead of generic `RuntimeException` | +| `ProcessTCL::definePhpCallbackBridge()` | Uses socket bridge when available | +| `Application::tick()` | Delegates to bridge instead of direct file I/O | +| `WebView::bind()` | Adds auto-return overload (old signature still works) | + +--- + +## 3. Core Components + +### Dependency Graph + +``` +App + ├── Application (existing, unchanged) + ├── ProcessTCL (existing, patched for TclException + bridge) + ├── BridgeInterface + │ ├── SocketBridge (default on Linux/macOS) + │ └── FileBridge (default on Windows, fallback everywhere) + ├── Window, Button, Label... (existing widgets, unchanged) + └── WebView (existing, extended with auto-return bind) + └── ProcessWebView (existing, unchanged) +``` + +No circular dependencies. No container. No service location. `App` constructs what it needs directly. + +--- + +## 4. The App Class + +### Design Constraints + +- Must not break existing `new Application()` usage +- Must not require any config files +- Must support Tcl-only, WebView-only, and hybrid apps +- Must be the only new class a user needs to learn + +### Interface + +```php +namespace PhpGui; + +class App +{ + // ── Construction ────────────────────────────────────── + + /** + * Create a new App instance. + * This is the single entry point for the framework. + */ + public static function create(): self; + + // ── Tcl/Tk Windows ──────────────────────────────────── + + /** + * Create a Tk window. Returns the Window widget. + * Shorthand for: new Window(['title' => $title, ...]) + */ + public function window( + string $title = 'PhpGui App', + int $width = 800, + int $height = 600, + array $options = [], + ): Widget\Window; + + // ── WebView Windows ─────────────────────────────────── + + /** + * Create a WebView window. Returns the WebView widget. + * Auto-registers it for event polling. + * Auto-injects the JS bridge (phpgui.invoke, phpgui.on). + */ + public function webview( + string $title = 'PhpGui App', + int $width = 800, + int $height = 600, + array $options = [], + ): Widget\WebView; + + // ── Event Loop ──────────────────────────────────────── + + /** + * Start the event loop. Blocks until quit() is called. + */ + public function run(): void; + + /** + * Stop the event loop and clean up. + */ + public function quit(): void; + + // ── Lifecycle Hooks ─────────────────────────────────── + + /** + * Called once after all engines are initialized, + * before the event loop starts. + */ + public function onReady(callable $callback): self; + + /** + * Called before shutdown. Use for cleanup. + */ + public function onQuit(callable $callback): self; + + // ── Escape Hatch ────────────────────────────────────── + + /** + * Direct access to the Tcl interpreter. + * For advanced users who need raw Tcl commands. + */ + public function tcl(): ProcessTCL; + + /** + * Direct access to the underlying Application instance. + */ + public function application(): Application; +} +``` + +### Internal Implementation + +```php +class App +{ + private Application $application; + private BridgeInterface $bridge; + private array $webviews = []; + private array $onReadyCallbacks = []; + private array $onQuitCallbacks = []; + private bool $tclInitialized = false; + + private function __construct() + { + // Bridge selection — one decision, no factory + $this->bridge = match (PHP_OS_FAMILY) { + 'Windows' => new Bridge\FileBridge(), + default => $this->trySocketBridge(), + }; + } + + public static function create(): self + { + return new self(); + } + + public function window( + string $title = 'PhpGui App', + int $width = 800, + int $height = 600, + array $options = [], + ): Widget\Window { + $this->ensureTclInitialized(); + return new Widget\Window(array_merge( + ['title' => $title, 'width' => $width, 'height' => $height], + $options, + )); + } + + public function webview( + string $title = 'PhpGui App', + int $width = 800, + int $height = 600, + array $options = [], + ): Widget\WebView { + $wv = new Widget\WebView(array_merge( + ['title' => $title, 'width' => $width, 'height' => $height], + $options, + )); + + // Auto-inject JS bridge on ready + $wv->onReady(function () use ($wv) { + $wv->initJs($this->getJsBridgeCode()); + }); + + $this->webviews[] = $wv; + return $wv; + } + + public function run(): void + { + // Lazy-init Application only when run() is called + $this->ensureTclInitialized(); + + // Register all WebViews with the Application event loop + foreach ($this->webviews as $wv) { + $this->application->addWebView($wv); + } + + // Fire onReady callbacks + foreach ($this->onReadyCallbacks as $cb) { + $cb($this); + } + + $this->application->run(); + } + + public function quit(): void + { + foreach ($this->onQuitCallbacks as $cb) { + $cb($this); + } + $this->application->quit(); + } + + private function ensureTclInitialized(): void + { + if (!$this->tclInitialized) { + $this->application = new Application(); + $this->tclInitialized = true; + } + } + + private function trySocketBridge(): BridgeInterface + { + if (extension_loaded('sockets')) { + try { + return new Bridge\SocketBridge(); + } catch (\Throwable) { + // Fall back to file bridge + } + } + return new Bridge\FileBridge(); + } + + private function getJsBridgeCode(): string + { + static $code = null; + if ($code === null) { + $code = file_get_contents(__DIR__ . '/WebView/phpgui.js'); + } + return $code; + } +} +``` + +### Usage — Simplest Possible App + +```php +window('Hello World', 400, 300); + +$label = new \PhpGui\Widget\Label($win->getId(), ['text' => 'Hello from PHP!']); +$label->pack(['pady' => 20]); + +$app->run(); +``` + +### Usage — Full WebView App + +```php +webview('Todo App', 900, 700); +$wv->serve(__DIR__ . '/ui'); + +$todos = []; + +$wv->bind('addTodo', function (array $args) use (&$todos, $wv) { + $todos[] = ['id' => uniqid(), 'text' => $args[0], 'done' => false]; + $wv->emit('todosChanged', $todos); + return ['ok' => true]; +}); + +$wv->bind('getTodos', fn () => $todos); + +$wv->bind('toggleTodo', function (array $args) use (&$todos, $wv) { + foreach ($todos as &$todo) { + if ($todo['id'] === $args[0]) { + $todo['done'] = !$todo['done']; + } + } + $wv->emit('todosChanged', $todos); + return ['ok' => true]; +}); + +$wv->onClose(fn () => $app->quit()); + +$app->run(); +``` + +### Usage — Hybrid (Tk + WebView) + +```php +webview('Dashboard', 1024, 768); +$wv->serve(__DIR__ . '/dashboard'); + +// Tk window for developer controls +$ctrl = $app->window('Controls', 300, 200); + +$status = new Label($ctrl->getId(), ['text' => 'Ready']); +$status->pack(['pady' => 10]); + +new Button($ctrl->getId(), [ + 'text' => 'Reload', + 'command' => fn () => $wv->evalJs('location.reload()'), +]); +new Button($ctrl->getId(), [ + 'text' => 'Quit', + 'command' => fn () => $app->quit(), +]); + +$wv->onClose(fn () => $app->quit()); + +$app->run(); +``` + +--- + +## 5. Callback Bridge (Socket-Based) + +### Why + +The current callback system writes to `/tmp/phpgui_callback.txt` on every user interaction. Problems: + +1. **Race condition:** Two rapid button clicks → second write overwrites first → callback lost. +2. **Latency:** File I/O + `file_exists()` polling adds 50-100ms per callback. +3. **Orphan files:** Crash leaves `/tmp/phpgui_callback.txt` on disk permanently. +4. **Security:** Any process on the machine can read/write the callback file. + +### Bridge Interface + +```php +namespace PhpGui\Bridge; + +interface BridgeInterface +{ + /** + * Start the bridge. Called once during initialization. + * Returns the Tcl script fragment that the callback procedure should use + * to notify PHP (e.g., writing to a socket or file). + */ + public function start(): string; + + /** + * Poll for pending callback IDs. Non-blocking. Returns immediately. + * @return string[] Array of callback IDs that fired since last poll. + */ + public function poll(): array; + + /** + * Clean up resources (close sockets, delete files). + */ + public function shutdown(): void; +} +``` + +The key insight: `start()` returns a **Tcl script fragment**. This fragment replaces the body of `php::executeCallback`. The bridge tells Tcl *how* to notify PHP, and Tcl executes that notification when a callback fires. + +### SocketBridge Implementation + +```php +namespace PhpGui\Bridge; + +class SocketBridge implements BridgeInterface +{ + private \Socket $server; + private string $socketPath; + /** @var \Socket[] */ + private array $clients = []; + + public function __construct() + { + // Unique socket per app instance — no collisions + $this->socketPath = sys_get_temp_dir() . '/phpgui_' . getmypid() . '.sock'; + + // Clean up stale socket from previous crash + if (file_exists($this->socketPath)) { + unlink($this->socketPath); + } + + $this->server = socket_create(AF_UNIX, SOCK_STREAM, 0); + socket_bind($this->server, $this->socketPath); + socket_listen($this->server, 5); + socket_set_nonblock($this->server); + + // Restrictive permissions — only current user can connect + chmod($this->socketPath, 0600); + } + + public function start(): string + { + // Tcl script: open a socket, write the callback ID, close. + // Tcl uses its own socket client — no FFI callback needed. + $path = $this->socketPath; + return <<server); + if ($client === false) break; + socket_set_nonblock($client); + $this->clients[] = $client; + } + + // Read from connected clients + foreach ($this->clients as $i => $client) { + $data = @socket_read($client, 1024); + if ($data === false || $data === '') { + socket_close($client); + unset($this->clients[$i]); + continue; + } + // Multiple IDs may arrive in one read (newline-separated) + foreach (explode("\n", trim($data)) as $id) { + $id = trim($id); + if ($id !== '') { + $ids[] = $id; + } + } + socket_close($client); + unset($this->clients[$i]); + } + + $this->clients = array_values($this->clients); + return $ids; + } + + public function shutdown(): void + { + foreach ($this->clients as $client) { + @socket_close($client); + } + @socket_close($this->server); + @unlink($this->socketPath); + } +} +``` + +### FileBridge Implementation + +Extracts the current logic from `ProcessTCL::definePhpCallbackBridge()` and `Application::tick()`. + +```php +namespace PhpGui\Bridge; + +class FileBridge implements BridgeInterface +{ + private string $callbackFile; + + public function __construct() + { + $tempDir = str_replace('\\', '/', sys_get_temp_dir()); + $this->callbackFile = $tempDir . '/phpgui_callback_' . getmypid() . '.txt'; + } + + public function start(): string + { + $path = $this->callbackFile; + return <<callbackFile)) { + return []; + } + + $id = trim(file_get_contents($this->callbackFile)); + unlink($this->callbackFile); + + return $id !== '' ? [$id] : []; + } + + public function shutdown(): void + { + @unlink($this->callbackFile); + } +} +``` + +**Note:** FileBridge now appends PID to the filename. This prevents collisions when multiple php-gui apps run simultaneously — a real bug in the current implementation. + +### Integration with ProcessTCL + +The bridge modifies how `definePhpCallbackBridge()` works: + +```php +// In ProcessTCL — new method +public function setBridge(BridgeInterface $bridge): void +{ + $this->bridge = $bridge; +} + +// Modified definePhpCallbackBridge() +private function definePhpCallbackBridge($interp): void +{ + $this->evalTcl(' + namespace eval php { + variable callbacks + array set callbacks {} + } + '); + + if ($this->bridge !== null) { + // Bridge provides the Tcl procedure body + $this->evalTcl($this->bridge->start()); + } else { + // Legacy fallback — current file-based behavior (unchanged) + $tempDir = str_replace('\\', '/', sys_get_temp_dir()); + $callbackFile = $tempDir . "/phpgui_callback.txt"; + $this->evalTcl("proc php::executeCallback {id} { + set f [open \"{$callbackFile}\" w] + puts \$f \$id + close \$f + update + }"); + } +} +``` + +### Integration with Application::tick() + +```php +// In Application — modified tick() +public function tick(): void +{ + $this->tcl->evalTcl("update"); + + // Use bridge if available, fall back to legacy file check + if ($this->bridge !== null) { + $ids = $this->bridge->poll(); + foreach ($ids as $id) { + ProcessTCL::getInstance()->executeCallback($id); + } + } else { + // Legacy file-based check (current behavior, preserved) + $tempDir = str_replace('\\', '/', sys_get_temp_dir()); + $callbackFile = $tempDir . "/phpgui_callback.txt"; + if (file_exists($callbackFile)) { + $id = trim(file_get_contents($callbackFile)); + unlink($callbackFile); + ProcessTCL::getInstance()->executeCallback($id); + } + } + + // Quit file check (unchanged) + $tempDir = str_replace('\\', '/', sys_get_temp_dir()); + $quitFile = $tempDir . "/phpgui_quit.txt"; + if (file_exists($quitFile)) { + unlink($quitFile); + $this->running = false; + } + + // WebView polling (unchanged) + foreach ($this->webviews as $key => $wv) { + if ($wv->isClosed()) { + unset($this->webviews[$key]); + continue; + } + $wv->processEvents(); + } +} +``` + +### Performance Comparison + +| Metric | FileBridge (current) | SocketBridge (V1) | +|---|---|---| +| Callback latency | ~50-100ms (file I/O + poll interval) | ~1-5ms (socket notification) | +| Rapid clicks (10/sec) | Drops callbacks (race condition) | Handles all (queued) | +| Cleanup on crash | Orphaned temp file | Socket auto-cleaned by OS | +| Security | World-readable temp file | User-only socket (0600) | +| Windows support | Works | Falls back to FileBridge | + +--- + +## 6. Tcl Security Layer + +### The Problem + +Every widget in the current codebase does this: + +```php +// Button.php line 34 — user input directly in Tcl command +$this->tcl->evalTcl("button .{$this->parentId}.{$this->id} -text \"{$text}\" ..."); +``` + +If `$text` contains `"; destroy .;#` — the app crashes. If it contains `"; exec rm -rf /;#` — Tcl executes the command. This is **Tcl injection**, analogous to SQL injection. + +### The Fix + +```php +namespace PhpGui\Support; + +/** + * Tcl string escaping utilities. + * + * Tcl has specific quoting rules. Curly braces {} are the safest + * quoting mechanism — they prevent all substitution. But the string + * itself cannot contain unbalanced braces. + * + * Strategy: + * 1. If string has no special chars → return as-is + * 2. If string has balanced braces and no backslash-newline → use {$str} + * 3. Otherwise → escape every special char with backslashes + */ +class Tcl +{ + /** + * Escape a value for safe inclusion in a Tcl command. + * + * Tcl::escape('Hello World') → '{Hello World}' + * Tcl::escape('Say "hello"') → '{Say "hello"}' + * Tcl::escape('Tricky {brace') → 'Tricky\ \{brace' + * Tcl::escape('') → '{}' + */ + public static function escape(string $value): string + { + if ($value === '') { + return '{}'; + } + + // No special characters — return bare word + if (preg_match('/^[a-zA-Z0-9_.\/:-]+$/', $value)) { + return $value; + } + + // Try brace quoting (safest, most readable) + if (self::canBraceQuote($value)) { + return '{' . $value . '}'; + } + + // Fallback: backslash-escape every special character + return self::backslashEscape($value); + } + + /** + * Format a Tcl option string from an associative array. + * All values are escaped. + * + * Tcl::options(['text' => 'Click "me"', 'bg' => 'red']) + * → '-text {Click "me"} -bg red' + */ + public static function options(array $options, array $skip = []): string + { + $parts = []; + foreach ($options as $key => $value) { + if (in_array($key, $skip, true)) { + continue; + } + $parts[] = "-{$key} " . self::escape((string) $value); + } + return implode(' ', $parts); + } + + private static function canBraceQuote(string $value): bool + { + // Cannot brace-quote if: unbalanced braces, or contains backslash-newline + if (str_contains($value, "\\\n")) { + return false; + } + $depth = 0; + for ($i = 0, $len = strlen($value); $i < $len; $i++) { + if ($value[$i] === '{') $depth++; + elseif ($value[$i] === '}') $depth--; + if ($depth < 0) return false; + } + return $depth === 0; + } + + private static function backslashEscape(string $value): string + { + // Characters that need escaping in Tcl + $special = ['\\', '{', '}', '[', ']', '$', '"', ';', ' ', "\t", "\n"]; + $escaped = ''; + for ($i = 0, $len = strlen($value); $i < $len; $i++) { + if (in_array($value[$i], $special, true)) { + $escaped .= '\\' . $value[$i]; + } else { + $escaped .= $value[$i]; + } + } + return $escaped; + } +} +``` + +### Adoption in Widgets + +Widgets adopt `Tcl::escape()` incrementally. Example for Button: + +```php +// Before (vulnerable): +$this->tcl->evalTcl("button .{$this->parentId}.{$this->id} -text \"{$text}\" {$extra}"); + +// After (safe): +$opts = Tcl::options($this->options, skip: ['command']); +$this->tcl->evalTcl("button .{$this->parentId}.{$this->id} {$opts}"); +``` + +The `Tcl::options()` method replaces the various `formatOptions()` / `getOptionString()` methods scattered across widgets. One implementation, used everywhere. + +--- + +## 7. WebView Bind API (v2) + +### The Problem + +Current bind API is verbose and error-prone: + +```php +// Current — 6 lines of boilerplate per command +$wv->bind('getTodos', function (string $requestId, string $argsJson) use ($wv, $db) { + $args = json_decode($argsJson, true); + $todos = $db->query('SELECT * FROM todos'); + $wv->returnValue($requestId, 0, json_encode($todos)); +}); +``` + +Every bind callback must: decode args, encode result, handle errors, call returnValue. Developers will forget. They'll ship bugs. + +### The Solution + +Add auto-return bind alongside the existing raw bind. Detect which style to use by whether the callback declares `$requestId` as its first param. + +```php +// In WebView::bind() — extended, not replaced + +/** + * Bind a JS function name to a PHP callback. + * + * Supports two callback signatures: + * + * 1. Auto-return (recommended): + * fn(array $args): mixed + * Return value is JSON-encoded and sent to JS automatically. + * Exceptions are caught and sent as error responses. + * + * 2. Raw (for advanced control): + * fn(string $requestId, string $argsJson): void + * You call returnValue() yourself. + */ +public function bind(string $name, callable $callback): void +{ + // Detect signature: if first param is type-hinted as array, use auto-return + $ref = new \ReflectionFunction(\Closure::fromCallable($callback)); + $params = $ref->getParameters(); + + $isAutoReturn = true; + if (count($params) >= 2) { + $firstType = $params[0]->getType(); + if ($firstType instanceof \ReflectionNamedType && $firstType->getName() === 'string') { + $isAutoReturn = false; // Raw mode: fn(string $requestId, string $argsJson) + } + } + + if ($isAutoReturn) { + // Wrap in auto-return handler + $wrapped = function (string $requestId, string $argsJson) use ($callback, $name) { + try { + $args = json_decode($argsJson, true) ?? []; + $result = $callback($args); + $this->returnValue($requestId, 0, json_encode( + $result, + JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE + )); + } catch (\Throwable $e) { + $this->returnValue($requestId, 1, json_encode($e->getMessage())); + if ($this->onErrorCallback) { + ($this->onErrorCallback)("Command '{$name}' threw: " . $e->getMessage()); + } + } + }; + $this->commandHandlers[$name] = $wrapped; + } else { + // Raw mode — current behavior, unchanged + $this->commandHandlers[$name] = $callback; + } + + $this->process->sendCommand(['cmd' => 'bind', 'name' => $name]); +} +``` + +### Result + +```php +// Auto-return — clean, no boilerplate +$wv->bind('getTodos', fn (array $args) => $db->query('SELECT * FROM todos')); + +$wv->bind('addTodo', function (array $args) use (&$todos, $wv) { + $todos[] = ['text' => $args[0], 'done' => false]; + $wv->emit('todosChanged', $todos); + return ['ok' => true]; +}); + +// Errors sent to JS automatically +$wv->bind('divide', function (array $args) { + if ($args[1] === 0) { + throw new \InvalidArgumentException('Division by zero'); + } + return $args[0] / $args[1]; +}); + +// Raw mode still works for advanced use cases (streaming, etc.) +$wv->bind('streamData', function (string $requestId, string $argsJson) use ($wv) { + // Custom handling... + $wv->returnValue($requestId, 0, json_encode($result)); +}); +``` + +--- + +## 8. JS Bridge (Auto-Injected) + +### The Problem + +Currently, users must know the raw WebView IPC protocol to call PHP from JavaScript. There's no standard JS API — every app reinvents the wheel. + +### The Solution + +A tiny JS module injected via `initJs()` when using `App::webview()`. ~35 lines. + +```js +// src/WebView/phpgui.js +// Auto-injected by App::webview(). Provides the JS-side API. +;(function () { + if (window.__phpgui) return; // Already injected + + const listeners = {}; + + window.__phpgui = { + /** + * Call a PHP-bound function and return a Promise. + * Usage: const result = await phpgui.invoke('getTodos', []); + */ + invoke(command, args = []) { + // The webview helper binary creates global functions for each bind(). + // Those globals accept JSON args and return a Promise. + // This wrapper provides a consistent namespace. + if (typeof window[command] === 'function') { + return window[command](...args); + } + return Promise.reject(new Error(`Command "${command}" is not bound`)); + }, + + /** + * Listen for events emitted from PHP. + * Usage: phpgui.on('todosChanged', (data) => { ... }); + */ + on(event, callback) { + if (!listeners[event]) listeners[event] = []; + listeners[event].push(callback); + }, + + /** + * Remove an event listener. + */ + off(event, callback) { + if (!listeners[event]) return; + listeners[event] = listeners[event].filter(cb => cb !== callback); + }, + + /** @internal — called by the PHP emit() mechanism */ + _dispatch(event, data) { + if (!listeners[event]) return; + listeners[event].forEach(cb => { + try { cb(data); } catch (e) { console.error(`[phpgui] Event handler error:`, e); } + }); + }, + }; + + // Expose as both phpgui and __phpgui for flexibility + window.phpgui = window.__phpgui; + + // Hook into the existing onPhpEvent mechanism + // (emitted by the webview helper via eval) + window.onPhpEvent = function (event, callback) { + window.phpgui.on(event, callback); + }; +})(); +``` + +### Usage from JavaScript + +```js +// Call PHP +const todos = await phpgui.invoke('getTodos'); +await phpgui.invoke('addTodo', ['Buy groceries']); + +// Listen for PHP events +phpgui.on('todosChanged', (todos) => { + renderTodoList(todos); +}); + +// Old API still works +onPhpEvent('todosChanged', (todos) => { ... }); +``` + +--- + +## 9. Error Handling + +### TclException + +```php +namespace PhpGui\Support; + +class TclException extends \RuntimeException +{ + public function __construct( + public readonly string $tclCommand, + public readonly string $tclError, + public readonly string $tclErrorInfo, + ?\Throwable $previous = null, + ) { + $message = "Tcl Error: {$tclError}"; + if ($tclCommand !== '') { + // Truncate very long commands + $cmd = strlen($tclCommand) > 200 + ? substr($tclCommand, 0, 200) . '...' + : $tclCommand; + $message .= "\n Command: {$cmd}"; + } + if ($tclErrorInfo !== '' && $tclErrorInfo !== $tclError) { + $message .= "\n Stack: {$tclErrorInfo}"; + } + + parent::__construct($message, 0, $previous); + } +} +``` + +### Modified evalTcl() + +```php +// In ProcessTCL +public function evalTcl(string $command) +{ + $interp = $this->getInterp(); + if ($this->isTcl9) { + $result = $this->ffi->Tcl_EvalEx($interp, $command, -1, 0); + } else { + $result = $this->ffi->Tcl_Eval($interp, $command); + } + if ($result !== 0) { + $error = $this->getResult(); + $errorInfo = $this->getVar('errorInfo'); + throw new TclException($command, $error, $errorInfo); + } + return $this->getResult(); +} +``` + +**Before:** +``` +RuntimeException: Tcl Error: invalid command name "buttton" +``` + +**After:** +``` +TclException: Tcl Error: invalid command name "buttton" + Command: buttton .w6789.w1234 -text {Hello} + Stack: invalid command name "buttton" + while executing + "buttton .w6789.w1234 -text {Hello}" +``` + +--- + +## 10. Event Loop — Revised + +### Timing Model + +``` +Mode Sleep per tick Why +───────────────── ────────────── ───────────────────────── +Tcl-only 50ms Tk events are coarse-grained +WebView-only 10ms stdio IPC needs responsiveness +Hybrid 10ms Limited by the fastest engine +Socket bridge 5ms Socket notification is near-instant +``` + +The current 100ms sleep for Tcl-only mode is too slow — UI feels sluggish on interactions like typing in Entry widgets. 50ms is the right balance for Tk without eating CPU. + +### Revised tick() Pseudocode + +``` +tick(): + tcl.evalTcl("update") # Process pending Tk events + + ids = bridge.poll() # Non-blocking callback check + for each id in ids: + tcl.executeCallback(id) # Run the PHP closure + + for each webview in webviews: + if webview.isClosed(): + remove(webview) + continue + webview.processEvents() # Poll WebView IPC + + check quit signal # File-based (keep for WM_DELETE_WINDOW) +``` + +--- + +## 11. CLI Tooling + +### Binary + +Entry point: `bin/phpgui` (added to `composer.json` `bin` field). + +```php +#!/usr/bin/env php + (new PhpGui\Console\NewCommand())($argv), + 'build' => (new PhpGui\Console\BuildCommand())($argv), + 'check' => (new PhpGui\Console\CheckCommand())($argv), + default => printUsage(), +}; + +function printUsage(): void +{ + echo << [--mode=webview|tcl|hybrid] Create a new project + build Package app for distribution + check Verify system requirements + + USAGE; +} +``` + +### `phpgui new` + +Creates a minimal, working project. Not a skeleton — a real app that runs. + +**Tcl mode output:** + +``` +my-app/ +├── app.php +├── composer.json +``` + +`app.php`: +```php +window('my-app', 600, 400); + +$label = new Label($win->getId(), [ + 'text' => 'Welcome to my-app!', + 'font' => 'Helvetica 18', +]); +$label->pack(['pady' => 30]); + +$count = 0; +new Button($win->getId(), [ + 'text' => 'Click me', + 'command' => function () use ($label, &$count) { + $count++; + $label->setText("Clicked {$count} times"); + }, +]); + +$app->run(); +``` + +**WebView mode output:** + +``` +my-app/ +├── app.php +├── composer.json +└── ui/ + └── index.html +``` + +`app.php`: +```php +webview('my-app', 900, 700); +$wv->serveFromDisk(__DIR__ . '/ui'); + +$wv->bind('greet', fn (array $args) => "Hello, {$args[0]}!"); + +$wv->onClose(fn () => $app->quit()); + +$app->run(); +``` + +`ui/index.html`: +```html + + + + + my-app + + + +

my-app

+ + +
+ + + +``` + +### `phpgui build` + +Phase 1 (V1): PHAR packaging only. + +```php +class BuildCommand +{ + public function __invoke(array $argv): void + { + $root = getcwd(); + + // 1. Detect mode from app.php + $appFile = $root . '/app.php'; + if (!file_exists($appFile)) { + $this->error("No app.php found in current directory"); + return; + } + + // 2. Build PHAR + $pharFile = $root . '/dist/' . basename($root) . '.phar'; + @mkdir($root . '/dist', 0755, true); + + $phar = new \Phar($pharFile); + $phar->startBuffering(); + + // Add PHP source + $phar->buildFromDirectory($root, '/\.(php|json)$/'); + + // Add frontend assets if ui/ exists + if (is_dir($root . '/ui')) { + $this->addDirectory($phar, $root . '/ui', 'ui'); + } + + // Add vendor (including phpgui framework + native libs) + $this->addDirectory($phar, $root . '/vendor', 'vendor'); + + // Set entry point + $phar->setDefaultStub('app.php'); + $phar->stopBuffering(); + + // Make executable + chmod($pharFile, 0755); + + echo "Built: {$pharFile}\n"; + echo "Run: php {$pharFile}\n"; + } +} +``` + +### `phpgui check` + +System requirements verification. + +``` +$ phpgui check + +PHP GUI System Check +──────────────────── + +PHP Version ✓ 8.3.4 (requires ≥8.2) +ext-ffi ✓ enabled +ext-sockets ✓ enabled (optional, improves performance) +Tcl/Tk ✓ 8.6.13 (bundled) +WebView Helper ✓ installed (linux_x86_64) + +All checks passed. +``` + +--- + +## 12. Build & Distribution + +### V1 Strategy: PHAR Only + +One format. One build path. No platform-specific packaging in V1. + +``` +phpgui build + ├── Scan app.php for mode detection + ├── Include: src/, vendor/, ui/ (if exists), composer.json + ├── Exclude: tests/, .git/, node_modules/, docs/ + ├── Output: dist/app-name.phar + └── User runs: php dist/app-name.phar +``` + +**Requirements for the target machine:** +- PHP 8.2+ with ext-ffi +- Tcl/Tk libs (bundled in the PHAR via vendor) +- WebView libs (system-installed: WebKitGTK on Linux, native on macOS/Windows) + +### Future (V2+): Static Binary + +When `static-php-cli` matures, add an opt-in flag: + +```bash +phpgui build --static # Produces a single self-contained binary +``` + +This concatenates a minimal PHP binary (~10MB) with the PHAR. Output is a single file that runs on machines without PHP installed. Not in V1 scope. + +--- + +## 13. Project Structure (When You Need One) + +### Single-File App (default, encouraged) + +``` +my-app/ +├── app.php # Everything in one file +└── composer.json +``` + +### Small WebView App + +``` +my-app/ +├── app.php # PHP backend +├── composer.json +└── ui/ # Frontend (plain HTML/CSS/JS) + ├── index.html + ├── style.css + └── app.js +``` + +### Larger WebView App (with frontend tooling) + +``` +my-app/ +├── app.php # PHP entry point +├── composer.json +├── package.json # Frontend deps (Vite, React, etc.) +├── src/ # PHP source (optional, for larger apps) +│ ├── Handlers.php # Grouped bind handlers +│ └── Database.php # App logic +└── ui/ + ├── src/ # Frontend source (JSX, TS, etc.) + ├── dist/ # Built output (served by app.php) + └── vite.config.js +``` + +**There is no mandated structure.** The framework doesn't scan directories. It doesn't auto-discover files. `app.php` is the entry point. The developer decides how to organize from there. + +--- + +## 14. File Manifest + +### New Files + +``` +src/ +├── App.php ~120 lines +├── Bridge/ +│ ├── BridgeInterface.php ~20 lines +│ ├── SocketBridge.php ~100 lines +│ └── FileBridge.php ~45 lines +├── Support/ +│ ├── Tcl.php ~70 lines +│ └── TclException.php ~25 lines +├── WebView/ +│ └── phpgui.js ~40 lines +└── Console/ + ├── NewCommand.php ~120 lines + ├── BuildCommand.php ~80 lines + └── CheckCommand.php ~60 lines + +bin/ +└── phpgui ~30 lines + +Total new code: ~710 lines +``` + +### Modified Files + +``` +src/ProcessTCL.php +15 lines (TclException, bridge injection) +src/Application.php +10 lines (bridge polling in tick()) +src/Widget/WebView.php +25 lines (auto-return bind detection) +composer.json +3 lines (bin, ext-sockets suggest) + +Total modifications: ~53 lines changed +``` + +### Untouched Files + +Everything else. All existing widgets, ProcessWebView, Config, Install — unchanged. + +--- + +## 15. Migration Path from Current API + +### Zero breaking changes. + +The current API continues to work exactly as it does today: + +```php +// This still works — nothing changes +$app = new Application(); +$window = new Window(['title' => 'Hello', 'width' => 800, 'height' => 600]); +$label = new Label($window->getId(), ['text' => 'Hello']); +$label->pack(['pady' => 20]); +$app->run(); +``` + +`App::create()` is a new alternative, not a replacement. Users migrate when they want to, or never. + +### Gradual Widget Migration + +Widgets can adopt `Tcl::escape()` one at a time. Each widget fix is a standalone PR. Priority order: + +1. **Button** — most common widget, most exposed to user input +2. **Label** — `setText()` takes arbitrary strings +3. **Window/TopLevel** — titles come from user config +4. **Input/Entry** — default text values +5. **Menu** — menu labels +6. **Canvas** — text drawing +7. **All others** + +--- + +## 16. What Is Explicitly NOT in V1 + +| Feature | Why Not | +|---|---| +| **DI Container** | No use case. `App` constructs directly. | +| **Plugin system** | No ecosystem. Add when users demand it. | +| **Reactive state store** | PHP is not React. Let devs manage state. | +| **Command router classes** | Closures work. Class-per-command is over-engineering. | +| **Vite/HMR integration** | Devs can run Vite themselves. Don't couple to a JS tool. | +| **`phpgui dev` with file watcher** | `php app.php` in one terminal, edit in another. | +| **`phpgui.json` config file** | Everything is PHP code. No config files. | +| **AppImage / .app / .exe packaging** | PHAR is enough for V1. Native packaging is V2. | +| **Static PHP binary builds** | Depends on `static-php-cli` project maturity. V2. | +| **Custom themes/styling** | Tcl/Tk theming is a rabbit hole. WebView has CSS. | +| **Data binding** | Too opinionated for V1. Ship helpers, not a framework. | +| **Accessibility** | Important but requires per-platform audit. V2. | +| **Event hook system** | `onReady` and `onQuit` are enough. | + +--- + +## 17. Implementation Order + +### Phase 1: Foundation (Week 1-2) + +``` +Priority Task Depends On +──────── ────────────────────────────────────────── ────────── +P0 Tcl::escape() + TclException Nothing +P0 BridgeInterface + FileBridge + SocketBridge Nothing +P0 Integrate bridge into ProcessTCL Bridge +P0 Integrate bridge into Application::tick() Bridge +``` + +Ship these four as a single PR. They fix real bugs (injection, race condition) with zero API changes. The existing test suite should pass unchanged. + +### Phase 2: App Class (Week 2-3) + +``` +Priority Task Depends On +──────── ────────────────────────────────────────── ────────── +P1 App class Phase 1 +P1 WebView auto-return bind Nothing +P1 phpgui.js auto-injection App class +P1 Tests for App, auto-return, JS bridge All above +``` + +Ship as a second PR. This is the new API surface. + +### Phase 3: CLI (Week 3-4) + +``` +Priority Task Depends On +──────── ────────────────────────────────────────── ────────── +P2 bin/phpgui + NewCommand Phase 2 +P2 CheckCommand Nothing +P2 BuildCommand (PHAR) Nothing +P2 Update composer.json (bin, suggest) All above +``` + +Ship as a third PR. This completes the developer-facing toolchain. + +### Phase 4: Polish (Week 4-5) + +``` +Priority Task Depends On +──────── ────────────────────────────────────────── ────────── +P3 Migrate Button to Tcl::escape() Phase 1 +P3 Migrate Label to Tcl::escape() Phase 1 +P3 Migrate Window/TopLevel to Tcl::escape() Phase 1 +P3 Update README with App examples Phase 2 +P3 Demo app (WebView todo) Phase 2 +``` + +--- + +## Appendix A: Decisions Log + +| Decision | Chosen | Rejected | Why | +|---|---|---|---| +| Entry point | `App::create()` static factory | Constructor `new App()` | Factory allows future internal changes without breaking `new` calls | +| Bridge selection | Auto-detect in constructor | User-configured | Users shouldn't know bridges exist | +| WebView bind detection | Reflection on first param type | Separate method name (`bindRaw`) | One method name, auto-detected, less API surface | +| JS bridge injection | `initJs()` on WebView ready | Separate ` + + +'); + +// Command handlers (like Tauri's #[tauri::command]) +$webview->bind('greet', function(string $id, string $args) use ($webview) { + $data = json_decode($args, true); + $name = $data[0] ?? 'World'; + $webview->returnValue($id, 0, json_encode("Hello, {$name}! From PHP " . PHP_VERSION)); +}); + +$webview->bind('getSystemInfo', function(string $id, string $args) use ($webview) { + $info = [ + 'php_version' => PHP_VERSION, + 'os' => PHP_OS, + 'hostname' => gethostname(), + 'memory' => memory_get_usage(true), + 'pid' => getmypid(), + ]; + $webview->returnValue($id, 0, json_encode($info)); +}); + +// Push events from PHP to JS +$webview->emit('statusUpdate', ['message' => 'App initialized']); + +$app->run(); +``` + +### Bidirectional: Tk + WebView Communication + +```php +$app = new Application(); +$window = new Window(['title' => 'Dashboard Controls', 'width' => 300, 'height' => 400]); + +$label = new Label($window->getId(), ['text' => 'Messages: 0']); +$label->pack(['pady' => 10]); + +$messageCount = 0; + +// Tk button pushes event to WebView +$button = new Button($window->getId(), [ + 'text' => 'Send Alert to WebView', + 'command' => function() use ($webview, &$messageCount) { + $messageCount++; + $webview->emit('alert', [ + 'title' => 'Alert from Tk', + 'body' => "Message #{$messageCount}", + ]); + } +]); +$button->pack(['pady' => 5]); + +$webview = new WebView([ + 'title' => 'Web Dashboard', + 'width' => 600, + 'height' => 400, +]); +$app->addWebView($webview); + +// WebView command updates Tk label +$webview->bind('notify', function(string $id, string $args) use ($label, $webview, &$messageCount) { + $data = json_decode($args, true); + $messageCount++; + $label->setText("Messages: {$messageCount}"); + $webview->returnValue($id, 0, json_encode('ok')); +}); + +$webview->setHtml(' + + +

Web Dashboard

+ +
+ + + +'); + +$app->run(); +``` + +--- + +## Bundled Chromium Option (Future) + +For users who need pixel-perfect cross-platform consistency, a bundled Chromium option can be offered as an alternative. The helper process architecture stays the same — only the binary changes. + +| Approach | Bundle Size | Consistency | Effort | +|----------|------------|-------------|--------| +| **Native webview** (default) | ~2 MB | Varies slightly by OS | Current plan | +| WebView2 fixed version (Windows) | ~150 MB Win, tiny others | Partial | Low | +| CEF helper binary | ~200 MB | Identical everywhere | High | +| Electron shell | ~180 MB | Identical everywhere | Medium | + +**Recommendation:** Start with native webview. The architecture supports swapping the helper binary for a CEF-based one later without changing any PHP code. + +--- + +## Risks & Mitigations + +| Risk | Mitigation | +|------|-----------| +| Orphan helper processes | Helper detects stdin EOF → self-terminates. PHP registers shutdown function to kill child. | +| 50ms polling latency | Acceptable for most UI. Can reduce to 10ms if needed (minimal CPU cost). | +| WebView is a separate window, not embedded in Tk | Inherent to the architecture. Most desktop apps (VS Code, Slack, 1Password) use this pattern. Tauri also uses a separate webview process. | +| Build complexity for helper binary | Ship prebuilt binaries. CI builds for all platforms. Users only need build tools if compiling from source. | +| Windows `stream_set_blocking` quirks | Use `stream_select()` with 0 timeout as fallback. | +| WebKitGTK not installed on user's Linux | Runtime check with clear error: "Install libwebkit2gtk-4.1". | +| IPC message tampering | All commands validated in PHP Core before dispatch. URL allowlist for navigation. Binding allowlist for commands. | + +--- + +## Implementation Order + +1. **webview_helper.c** — the C helper binary (most critical, standalone testable) +2. **ProcessWebView.php** — process manager with IPC +3. **WebView.php** — PHP widget API with Commands + Events +4. **Application.php** modifications — event loop integration +5. **Security layer** — permissions, URL allowlist, command validation +6. **Build scripts + CI** — cross-platform compilation +7. **Example + docs** + +--- + +## Documentation Requirements + +**Every completed phase MUST produce two separate documents before moving to the next phase.** + +### 1. Public User Manual (`docs/manual/`) + +End-user facing documentation. Written for PHP developers who want to use the WebView widget in their apps. + +- **Per-phase pages**: Each phase adds its section (e.g., `docs/manual/webview.md`, `docs/manual/webview-ipc.md`) +- **Must include**: constructor options, all public methods with signatures, return types, parameter descriptions, and working code examples +- **Code examples must be copy-paste runnable** — no pseudocode, no "..." placeholders +- **Follow the existing widget documentation style** already used for Button, Label, etc. + +### 2. Development Progress Log (`docs/dev/webview-progress.md`) + +Internal-facing changelog tracking what was built, decisions made, and problems encountered. + +- **Per-phase entries** with date, what was implemented, what changed from the plan, and why +- **Architecture decisions** — record any deviations from this plan and the reasoning +- **Known issues / technical debt** — anything deferred or worked around +- **Performance notes** — benchmarks, latency measurements, memory usage observations + +--- + +## Test-Driven Development + +**Every phase MUST have tests written BEFORE or alongside the implementation. No phase is complete without passing tests.** + +### Testing Strategy Per Phase + +| Phase | Test Type | What to Test | +|-------|-----------|-------------| +| Phase 1: Helper Binary | Integration | Binary launches, accepts JSON stdin, produces JSON stdout, self-terminates on stdin EOF, handles malformed input gracefully | +| Phase 2: ProcessWebView | Unit + Integration | Process spawns/kills correctly, `sendCommand()` writes valid JSON, `pollEvents()` returns parsed events, non-blocking behavior verified, orphan cleanup on shutdown | +| Phase 3: WebView Widget | Unit + Integration | All public methods dispatch correct commands, `bind()`/`unbind()` register/deregister callbacks, `emit()` sends proper JSON, `processEvents()` dispatches to correct handlers | +| Phase 4: Event Loop | Integration | Tk + WebView polling coexists without blocking, WebView closure is detected and cleaned up, multiple WebViews work simultaneously | +| Phase 5: Security | Unit | URL allowlist blocks/permits correctly, unknown commands are rejected, malformed IPC payloads don't crash | +| Phase 6: Build & Dist | CI/Smoke | Binary compiles on all platforms, runtime detection picks correct binary, clear error when binary is missing | + +### Test File Locations + +``` +tests/ +├── webview/ +│ ├── HelperBinaryTest.php # Phase 1 +│ ├── ProcessWebViewTest.php # Phase 2 +│ ├── WebViewWidgetTest.php # Phase 3 +│ ├── EventLoopIntegrationTest.php # Phase 4 +│ └── SecurityTest.php # Phase 5 +``` + +### Test Conventions + +- Tests are plain PHP scripts (consistent with existing project — no PHPUnit) +- Each test file must be runnable standalone: `php tests/webview/HelperBinaryTest.php` +- Use simple assert helper or `assert()` with descriptive messages +- Exit code 0 = all pass, non-zero = failure +- Helper binary tests may require platform-specific skip logic (e.g., skip on Windows CI if no WebView2) + +--- + +## Suggestions for Future-Proofing + +These are architectural notes and recommendations to keep in mind during implementation. They don't require immediate action but will save significant rework later. + +### 1. Define a Versioned IPC Protocol + +Add a `"version": 1` field to every IPC message from the start. When the protocol evolves (and it will), this allows the PHP side and helper binary to negotiate compatibility. Without it, upgrading the helper binary independently from the PHP library becomes a breaking change. + +```json +{"version":1,"cmd":"navigate","url":"https://example.com"} +``` + +### 2. Inject `window.invoke()` Bridge in the Helper + +The current plan's HTML examples call `invoke("greet", name)` and `notify(...)`, but neither is defined in the bridge JS. The helper must inject a `window.invoke()` function that calls the bound function and returns a Promise. Without this, the JS→PHP command flow is broken. Define this in Phase 1 alongside `__phpEmit` and `onPhpEvent`. + +```javascript +// Must be injected via webview_init() in the helper +window.invoke = function(name, ...args) { + return window[name](...args); // calls webview_bind'd function, returns Promise +}; +``` + +### 3. Structured Error Propagation + +Add an `onError(callable $callback)` method to `WebView.php`. Currently, if the helper crashes or `webview_navigate()` fails, there's no way for PHP code to react. The `{"event":"error",...}` message is defined in the protocol but has no handler on the PHP side. + +### 4. Adaptive Polling Interval + +Instead of a fixed `usleep(50000)`, use an adaptive approach: poll faster (10-20ms) when WebViews are active and there's recent IPC activity, fall back to slower polling (100ms) when idle. This balances responsiveness with CPU usage. Avoids punishing users who don't use WebView. + +### 5. Consider a Shared `invoke()` Helper for Existing Tk Callbacks + +The current Tk callback mechanism (temp file `/tmp/phpgui_callback.txt`) is a bottleneck — only one callback can fire per poll cycle. The WebView's stdio-based IPC is strictly better. In a future major version, consider migrating Tk callbacks to a similar pipe/socket mechanism to unify event handling. + +### 6. Helper Binary Health Check + +Add a `{"cmd":"ping"}` / `{"event":"pong"}` heartbeat to the IPC protocol. If PHP sends a ping and gets no pong within N ms, it can detect a hung or crashed helper early instead of waiting for the next failed command. Useful for `isClosed()` reliability. + +### 7. Plan for `webview_dispatch()` Thread Safety + +The helper's stdin reader thread will call `webview_dispatch()` to marshal commands to the main thread. This is correct, but be careful: the dispatch callback must not reference freed memory if `webview_destroy()` races with a pending dispatch. Use a simple flag or atomic to gate dispatches after destroy is requested. + +### 8. Bundle a Minimal Test HTML Page + +Ship a `tests/webview/fixtures/test.html` with known DOM structure. Use it in integration tests to verify `evalJs()`, `bind()`, and `emit()` work end-to-end. Testing against live URLs is flaky; testing against a local fixture is deterministic. + +### 9. Log IPC Traffic in Debug Mode + +When `debug: true`, log all IPC messages (both directions) to a file or stderr. This is invaluable for diagnosing timing issues, malformed messages, or dropped events. Tauri has a similar feature. + +### 10. Document the "Two Windows" UX Upfront + +The WebView opens as a **separate native window**, not embedded in the Tk window. This is an inherent architectural constraint. Document this prominently in the user manual with a clear explanation of why (Tk and GTK can't share a window) and how to design apps around it (e.g., use Tk for controls, WebView for content display — or go full WebView with no Tk). + +--- + +## Future Roadmap + +- **Phase 7: Plugin system** — Tauri-style plugins (PHP impl + JS API) for filesystem, shell, dialog, clipboard, notification +- **Phase 8: DevTools integration** — conditional devtools access based on debug flag +- **Phase 9: Hot reload** — file watcher that auto-reloads HTML/CSS/JS during development +- **Phase 10: Bundler** — package PHP + helper binary + frontend assets into a single distributable app diff --git a/docs/dev/coreframeowrkv1.md b/docs/dev/coreframeowrkv1.md new file mode 100644 index 0000000..b421cba --- /dev/null +++ b/docs/dev/coreframeowrkv1.md @@ -0,0 +1,410 @@ +# PHP GUI Framework — Architecture Plan + +--- + +## 1. Core Architecture Decisions + +### Layered Architecture + +``` +┌─────────────────────────────────────────────────┐ +│ Application Layer (User Code) │ +├─────────────────────────────────────────────────┤ +│ Framework Layer │ +│ ┌──────────┐ ┌──────────┐ ┌────────────────┐ │ +│ │ Router │ │ State │ │ Plugin Manager │ │ +│ │ (cmds) │ │ Manager │ │ │ │ +│ └──────────┘ └──────────┘ └────────────────┘ │ +├─────────────────────────────────────────────────┤ +│ Core Layer │ +│ ┌──────────┐ ┌──────────┐ ┌────────────────┐ │ +│ │ Kernel │ │ Event │ │ IPC Bridge │ │ +│ │ │ │ Dispatch │ │ (File→Socket) │ │ +│ └──────────┘ └──────────┘ └────────────────┘ │ +├─────────────────────────────────────────────────┤ +│ Backend Engines │ +│ ┌──────────────────┐ ┌─────────────────────┐ │ +│ │ Tcl/Tk (FFI) │ │ WebView (stdio IPC) │ │ +│ └──────────────────┘ └─────────────────────┘ │ +└─────────────────────────────────────────────────┘ +``` + +### Key Decisions + +| Decision | Choice | Rationale | +|---|---|---| +| Rendering | Dual-engine: Tcl/Tk native + WebView hybrid | Covers both native and modern UI needs. Positions as Tauri-for-PHP. | +| DI Container | Lightweight PSR-11 container (custom, no dep) | Replace singleton anti-pattern. Zero dependencies mandate. | +| Event System | PSR-14 compatible event dispatcher | Replace temp-file bridge with Unix domain sockets. Keep file bridge as fallback on Windows. | +| Config | Convention over configuration with `phpgui.json` | Single manifest for app metadata, window config, build options. | +| CLI Tool | `vendor/bin/phpgui` binary | `create`, `dev`, `build`, `serve` commands. | +| Min PHP | 8.2+ | Readonly classes, DNF types, fibers for async. | + +--- + +## 2. Project Structure + +### User App Structure + +``` +my-app/ +├── phpgui.json # App manifest (name, version, window config, build) +├── src/ +│ ├── App.php # Application entry point +│ ├── Commands/ # Bound commands (JS→PHP handlers) +│ │ └── GetTodos.php +│ ├── Events/ # App-level event listeners +│ └── Providers/ # Service providers (plugin registration) +│ └── AppServiceProvider.php +├── resources/ +│ ├── views/ # For WebView mode: HTML/JS/CSS +│ │ ├── index.html +│ │ └── assets/ +│ └── icons/ # App icons per platform +├── tests/ +├── dist/ # Build output (generated) +└── composer.json +``` + +### Framework Package Structure (`phpgui/framework`) + +``` +src/ +├── Foundation/ +│ ├── Kernel.php # Boot sequence, provider loading +│ ├── Application.php # Enhanced event loop (replaces current) +│ └── Config.php # phpgui.json parser +├── Container/ +│ └── Container.php # PSR-11 DI container +├── Events/ +│ ├── EventDispatcher.php # PSR-14 dispatcher +│ ├── AppEvent.php # Lifecycle events (boot, ready, quit) +│ └── WidgetEvent.php # Widget interaction events +├── Bridge/ +│ ├── BridgeInterface.php # Contract for callback transport +│ ├── SocketBridge.php # Unix socket implementation (Linux/macOS) +│ ├── NamedPipeBridge.php # Named pipe (Windows) +│ └── FileBridge.php # Legacy file-based (fallback) +├── Widget/ # Enhanced widget set (extends current) +│ ├── Concerns/ +│ │ ├── HasState.php # Reactive state trait +│ │ └── HasEvents.php # Event binding trait +│ └── WebView/ +│ ├── WebView.php # Enhanced WebView widget +│ ├── CommandRouter.php # Maps JS invoke() → PHP handler classes +│ └── DevServer.php # HMR-capable dev server +├── Plugin/ +│ ├── PluginInterface.php +│ ├── PluginManager.php +│ └── ServiceProvider.php # Base provider class +├── State/ +│ └── Store.php # Reactive state store (emit on change) +├── Console/ +│ ├── CreateCommand.php # phpgui create +│ ├── DevCommand.php # phpgui dev (watch + rebuild) +│ ├── BuildCommand.php # phpgui build (package for distribution) +│ └── ServeCommand.php # phpgui serve (WebView dev server) +└── Support/ + ├── TclEscaper.php # Parameterized Tcl command builder + └── Platform.php # OS/arch detection singleton +``` + +--- + +## 3. Build & Packaging Workflow + +### `phpgui.json` Manifest + +```json +{ + "name": "my-app", + "version": "1.0.0", + "main": "src/App.php", + "mode": "webview", + "window": { + "title": "My App", + "width": 1024, + "height": 768, + "resizable": true + }, + "build": { + "frontend": { + "dir": "resources/views", + "command": "npm run build", + "output": "resources/views/dist" + }, + "targets": ["linux-x64", "darwin-arm64", "win-x64"], + "php": "embed", + "obfuscate": false + }, + "plugins": [] +} +``` + +### Build Pipeline + +``` +phpgui build + │ + ├── 1. Validate phpgui.json + ├── 2. Run frontend build (if configured) + │ └── npm run build → resources/views/dist/ + ├── 3. Compile PHP → single PHAR or micro binary + │ ├── Option A: PHAR archive (php app.phar) + │ └── Option B: php-micro (static PHP + PHAR = single binary) + ├── 4. Bundle native deps per target + │ ├── Tcl/Tk libs (from src/lib/) + │ ├── webview_helper binary + │ └── Frontend assets (embedded in PHAR or alongside) + ├── 5. Platform packaging + │ ├── Linux: AppImage or tar.gz + .desktop + │ ├── macOS: .app bundle (Info.plist + dylibs) + │ └── Windows: .exe (NSIS installer or portable zip) + └── 6. Output → dist/{target}/ +``` + +### Packaging Strategy + +| Platform | Format | Tooling | +|---|---|---| +| Linux | AppImage | `appimagetool` — single file, no install needed | +| macOS | `.app` bundle | Directory structure + `codesign` | +| Windows | Portable `.exe` + installer | `php-micro` + NSIS or Inno Setup | + +> **PHP Embedding:** Use `static-php-cli` to compile a minimal PHP binary with only `ffi`, `json`, `sockets` extensions. Concatenate with PHAR to produce a single executable (~15–20 MB). + +--- + +## 4. Runtime Model + +### Boot Sequence + +**User entry point** (`src/App.php`): + +```php +webview('main', function ($wv) { + $wv->serveDirectory(__DIR__ . '/resources/views/dist'); + $wv->bind('getTodos', GetTodosCommand::class); + $wv->bind('saveTodo', SaveTodoCommand::class); +}); + +$app->run(); +``` + +**Internal boot sequence:** + +``` +Kernel::boot() + ├── Load phpgui.json + ├── Initialize DI Container + ├── Register core services (EventDispatcher, BridgeFactory, Platform) + ├── Detect mode (tcl | webview | hybrid) + ├── Load user ServiceProviders + ├── Boot plugins + ├── Initialize rendering engine(s) + │ ├── Tcl/Tk: ProcessTCL::init() via FFI + │ └── WebView: Spawn helper process + ├── Select callback bridge (socket > pipe > file) + └── Return Application instance +``` + +### Event Loop (Enhanced) + +``` +Application::run() + └── while (running) { + ├── Bridge::poll() // Check for callbacks (socket-based, non-blocking) + │ └── Dispatch to EventDispatcher + ├── Tcl: evalTcl("update") // Process Tk events (if Tcl mode) + ├── WebView::processEvents() // Poll each WebView (if WebView mode) + ├── State::flush() // Emit pending state changes to frontend + └── usleep(adaptive) // 1ms socket, 5ms WebView, 50ms idle + } +``` + +### Command Routing (WebView Mode) + +```php +// Commands/GetTodos.php +class GetTodos implements CommandInterface +{ + public function __construct(private Store $store) {} + + public function handle(string $requestId, array $args): mixed + { + return $this->store->get('todos', []); + } +} +``` + +> The framework auto-serializes the return value and calls `returnValue()`. No manual JSON encoding. Exceptions are caught and sent as error responses to JS. + +--- + +## 5. Extensibility & Plugin System + +### Service Provider Pattern + +```php +abstract class ServiceProvider +{ + abstract public function register(Container $container): void; + public function boot(Application $app): void {} +} +``` + +### Plugin Interface + +```php +interface PluginInterface +{ + public function name(): string; + public function version(): string; + public function providers(): array; // ServiceProvider classes +} +``` + +### Registration + +```json +// phpgui.json +{ + "plugins": [ + "phpgui/sqlite-plugin", + "phpgui/tray-plugin" + ] +} +``` + +### Plugin Capabilities (First-Party Roadmap) + +| Plugin | Provides | +|---|---| +| `phpgui/sqlite` | SQLite via FFI, no `ext-pdo` needed | +| `phpgui/tray` | System tray icon + menu | +| `phpgui/notifications` | Native OS notifications | +| `phpgui/dialog` | Enhanced file/color/font dialogs | +| `phpgui/updater` | Auto-update mechanism (check + download + replace) | +| `phpgui/devtools` | WebView inspector, state viewer, event logger | + +### Extension Points + +| Hook | When | Use Case | +|---|---|---| +| `app.booting` | Before providers loaded | Modify container bindings | +| `app.ready` | After all engines initialized | Initial data loading | +| `app.tick` | Each event loop iteration | Custom polling | +| `app.quit` | Before shutdown | Cleanup | +| `webview.navigate` | Before navigation | URL rewriting, auth injection | +| `widget.created` | After widget instantiation | Auto-attach behaviors | +| `state.changed` | Reactive state mutation | Logging, sync | + +--- + +## 6. Security Considerations + +### Code Protection + +| Threat | Mitigation | +|---|---| +| PHP source exposure | PHAR with compactors — strip comments/whitespace. Optional: use `php-scoper` to prefix namespaces + obfuscate class names. | +| Frontend source exposure | Standard JS minification/bundling (Vite/esbuild). For sensitive logic, keep it in PHP commands, not JS. | +| Binary tampering | Code-sign binaries on macOS/Windows. Embed checksum in PHAR stub. | +| IPC interception | Unix sockets with restrictive permissions (`0600`). Named pipes with ACL on Windows. | + +### Runtime Security + +| Concern | Approach | +|---|---| +| Tcl injection | `TclEscaper::quote()` — proper Tcl list quoting for all user input. Never interpolate raw strings into `evalTcl()`. | +| WebView XSS | CSP headers injected via `initJs()`. Default policy: `script-src 'self'`. | +| JS→PHP bridge | Command whitelist — only explicitly `bind()`-ed commands are callable. Type validation on args. | +| File system access | `WebView::serveFromDisk()` confined to declared directory. Path traversal check on all serve operations. | +| Process isolation | WebView helper runs as separate process. Crash doesn't take down PHP. PHP can kill helper on timeout. | + +### Supply Chain + +- Zero Composer dependencies (maintained). +- WebView helper binaries: publish SHA-256 checksums alongside releases. Verify on download. +- Static PHP binary: reproducible build via `static-php-cli` with pinned versions. + +--- + +## 7. Developer Workflow (DX Focus) + +### Scaffolding + +```bash +composer create-project phpgui/skeleton my-app +cd my-app +phpgui dev +``` + +Generates full project structure with `phpgui.json`, example command, and basic HTML frontend. + +### Development Mode + +```bash +phpgui dev +``` + +Does three things simultaneously: + +1. **PHP watcher** — monitors `src/` for changes, restarts app process +2. **Frontend dev server** — Vite HMR on `resources/views/` (WebView mode) +3. **WebView** — connects to Vite dev server instead of built assets + +File changes = instant reload. No manual restart. + +### CLI Commands + +| Command | Action | +|---|---| +| `phpgui create ` | Scaffold new project (interactive: Tcl/WebView/hybrid) | +| `phpgui dev` | Development mode with hot reload | +| `phpgui build` | Production build for current platform | +| `phpgui build --target=all` | Cross-platform build | +| `phpgui serve` | Start frontend dev server only | +| `phpgui doctor` | Check system dependencies (PHP version, FFI, Tcl, WebView) | +| `phpgui plugin add ` | Install and register plugin | + +### Debugging + +- `phpgui dev --verbose` — log all IPC messages (Tcl commands, WebView JSON) +- `phpgui dev --inspect` — open WebView DevTools automatically +- **State inspector:** built-in devtools plugin shows reactive state tree in a side panel + +### Testing + +```php +// Framework provides test helpers +use PhpGui\Testing\TestCase; + +class TodoTest extends TestCase +{ + public function test_get_todos(): void + { + $result = $this->invoke('getTodos', []); + $this->assertIsArray($result); + } +} +``` + +> `TestCase` mocks the WebView bridge so commands can be tested without spawning a window. Runs under PHPUnit. + +--- + +## Summary of Priorities + +| Phase | Focus | Deliverables | +|---|---|---| +| Phase 1 | Core framework | Kernel, Container, EventDispatcher, Socket Bridge, `phpgui.json`, CLI (`create`, `dev`) | +| Phase 2 | WebView DX | CommandRouter, reactive State, Vite integration, HMR, `phpgui dev` | +| Phase 3 | Build & distribute | PHAR packaging, `static-php-cli` integration, AppImage/`.app`/`.exe`, `phpgui build` | +| Phase 4 | Plugin ecosystem | Plugin interface, first-party plugins (sqlite, tray, notifications, updater) | +| Phase 5 | Polish | `phpgui doctor`, devtools plugin, docs site, starter templates | diff --git a/webview_test_app/index.html b/webview_test_app/index.html new file mode 100644 index 0000000..9411d2b --- /dev/null +++ b/webview_test_app/index.html @@ -0,0 +1,446 @@ + + + + + + PhpGui WebView Todo App + + + + + + +
+
+

Tasks

+

Testing all features of PhpGui WebView Widget

+
+ +
+ + + + +
+ +
+ + +
+ +
    + +
+
+ +
+ + Notification +
+ + + + diff --git a/webview_test_app/index.php b/webview_test_app/index.php new file mode 100644 index 0000000..6cdf254 --- /dev/null +++ b/webview_test_app/index.php @@ -0,0 +1,107 @@ + 'PhpGui WebView Test - Todo App', + 'width' => 900, + 'height' => 700, + 'debug' => true, +]); +$app->addWebView($webview); + +// Data +$todos = [ + ['id' => '1', 'text' => 'php task 1', 'completed' => true], + ['id' => '2', 'text' => 'Build a nice GUI with web tech', 'completed' => false], +]; + +$webview->initJs(' + console.log("initJs: This runs before anything else."); + window.AppConfig = { version: "1.0.0" }; +'); + +$html = file_get_contents(__DIR__ . '/index.html'); +$webview->setHtml($html); + +// Lifecycle Hooks +$webview->onReady(function() { + echo "WebView Ready\n"; +}); + +$webview->onClose(function() use ($app) { + echo "WebView Closed\n"; + $app->quit(); +}); + +$webview->onError(function() { + echo "WebView Error\n"; +}); + +// (JS -> PHP) +$webview->bind('getTodos', function(string $reqId, string $args) use ($webview, &$todos) { + $webview->returnValue($reqId, 0, json_encode($todos)); +}); + +$webview->bind('addTodo', function(string $reqId, string $args) use ($webview, &$todos) { + $data = json_decode($args, true); + $text = $data[0] ?? ''; + if (trim($text) !== '') { + $todos[] = [ + 'id' => uniqid(), + 'text' => $text, + 'completed' => false, + ]; + // Emit event (PHP -> JS) + $webview->emit('todosUpdated', $todos); + } + $webview->returnValue($reqId, 0, json_encode(true)); +}); + +$webview->bind('toggleTodo', function(string $reqId, string $args) use ($webview, &$todos) { + $data = json_decode($args, true); + $id = $data[0] ?? ''; + foreach ($todos as &$todo) { + if ($todo['id'] == $id) { + $todo['completed'] = !$todo['completed']; + break; + } + } + $webview->emit('todosUpdated', $todos); + $webview->returnValue($reqId, 0, json_encode(true)); +}); + +$webview->bind('deleteTodo', function(string $reqId, string $args) use ($webview, &$todos) { + $data = json_decode($args, true); + $id = $data[0] ?? ''; + $todos = array_filter($todos, fn($todo) => $todo['id'] != $id); + $todos = array_values($todos); + $webview->emit('todosUpdated', $todos); + $webview->returnValue($reqId, 0, json_encode(true)); +}); + +$webview->bind('setTitle', function(string $reqId, string $args) use ($webview) { + $data = json_decode($args, true); + $title = $data[0] ?? 'PhpGui WebView Test'; + $webview->setTitle($title); + $webview->returnValue($reqId, 0, json_encode(true)); +}); + +$webview->bind('setSize', function(string $reqId, string $args) use ($webview) { + $data = json_decode($args, true); + $width = (int)($data[0] ?? 800); + $height = (int)($data[1] ?? 600); + $webview->setSize($width, $height); + $webview->returnValue($reqId, 0, json_encode(true)); +}); + +$webview->bind('evalAlert', function(string $reqId, string $args) use ($webview) { + $webview->evalJs('alert("This is an alert triggered from PHP via evalJs!");'); + $webview->returnValue($reqId, 0, json_encode(true)); +}); + +$app->run(); diff --git a/webview_test_app/test_serve_from_disk.php b/webview_test_app/test_serve_from_disk.php new file mode 100644 index 0000000..06f17fc --- /dev/null +++ b/webview_test_app/test_serve_from_disk.php @@ -0,0 +1,111 @@ + 'serveFromDisk Test', + 'width' => 900, + 'height' => 700, + 'debug' => true, +]); + +// Inject AppConfig for the InitJS test to verify +$webview->initJs(' + window.AppConfig = { version: "1.0.0" }; +'); + +// Serve the current directory's index.html via native custom URI scheme +$webview->serveFromDisk(__DIR__); + +$webview->onServeDirReady(function (string $url) { + echo "[PHP] Serving from: {$url}\n"; +}); + +$webview->onReady(function () { + echo "[PHP] WebView ready\n"; +}); + +$webview->onClose(function () { + echo "[PHP] WebView closed\n"; +}); + +$webview->onError(function (string $msg) { + echo "[PHP] Error: {$msg}\n"; +}); + +// Bind the commands the test app expects +$todos = [ + ['id' => '1', 'text' => 'Test serveFromDisk()', 'completed' => false], + ['id' => '2', 'text' => 'No HTTP server needed!', 'completed' => true], + ['id' => '3', 'text' => 'Custom URI scheme works', 'completed' => false], +]; + +$webview->bind('getTodos', function (string $id, string $args) use ($webview, &$todos) { + $webview->returnValue($id, 0, json_encode($todos)); +}); + +$webview->bind('addTodo', function (string $id, string $args) use ($webview, &$todos) { + $parsed = json_decode($args, true); + $text = $parsed[0] ?? ''; + if (trim($text) !== '') { + $todos[] = ['id' => uniqid(), 'text' => $text, 'completed' => false]; + $webview->emit('todosUpdated', $todos); + } + $webview->returnValue($id, 0, json_encode(true)); +}); + +$webview->bind('toggleTodo', function (string $id, string $args) use ($webview, &$todos) { + $parsed = json_decode($args, true); + $todoId = $parsed[0] ?? ''; + foreach ($todos as &$t) { + if ($t['id'] === $todoId) { + $t['completed'] = !$t['completed']; + break; + } + } + $webview->returnValue($id, 0, json_encode(true)); + $webview->emit('todosUpdated', $todos); +}); + +$webview->bind('deleteTodo', function (string $id, string $args) use ($webview, &$todos) { + $parsed = json_decode($args, true); + $todoId = $parsed[0] ?? ''; + $todos = array_values(array_filter($todos, fn($t) => $t['id'] !== $todoId)); + $webview->returnValue($id, 0, json_encode(true)); + $webview->emit('todosUpdated', $todos); +}); + +$webview->bind('setTitle', function (string $id, string $args) use ($webview) { + $parsed = json_decode($args, true); + $webview->setTitle($parsed[0] ?? 'WebView'); + $webview->returnValue($id, 0, json_encode(true)); +}); + +$webview->bind('setSize', function (string $id, string $args) use ($webview) { + $parsed = json_decode($args, true); + $webview->setSize($parsed[0] ?? 800, $parsed[1] ?? 600); + $webview->returnValue($id, 0, json_encode(true)); +}); + +$webview->bind('evalAlert', function (string $id, string $args) use ($webview) { + $webview->evalJs('alert("Hello from PHP via evalJs!")'); + $webview->returnValue($id, 0, json_encode(true)); +}); + +echo "[PHP] Serving from disk (no HTTP server) — close window to exit\n"; + +// Simple event loop +while (!$webview->isClosed()) { + $webview->processEvents(); + usleep(20000); // 20ms +} + +echo "[PHP] Done\n";