The frontend

Windows that show web pages show the pages of the app's frontend: HTML,

CSS and JavaScript built with any tools. New projects of the web template

use TypeScript and Vite, but MyPHP only needs a URL to

load, during development, and a directory of built files, for builds.

Windows of native UI need no frontend, and an app that only

has those has none.

How pages load

Windows load pages of the frontend with URLs without a scheme, such as /

or /settings?tab=general:

new Window(new WindowOptions(url: '/'));
$win->page()->loadURL('/settings');

They resolve against:

  • during myphp dev, the dev server at devUrl in myphp.config.ts or

myphp.json, which devCommand starts, e.g. http://localhost:5173/, so

the dev server's hot reload works in the app;

  • in builds, myphp://localhost/, which serves the files of

frontendDist that myphp build puts in the bundle after running

buildCommand.

{
  "devUrl": "http://localhost:5173",
  "devCommand": "bun run dev:web",
  "buildCommand": "bun run build:web",
  "frontendDist": "dist"
}

Without devUrl, myphp dev serves frontendDist from disk instead.

Paths without a file extension that match no file serve index.html, so

client-side routers work.

The frontend is a secure context, and its origin is myphp://localhost.

Storage such as localStorage and IndexedDB belongs to an origin, so data

a page stores under myphp dev (the dev server's origin) is not what the

built app sees. Keep data you care about in PHP, for example in a file in

App::path(PathName::UserData), and hand it to pages through bound

methods.

Windows can also load other content into their page:

  • $win->page()->loadURL('https://example.com'), a web page;
  • $win->page()->loadHTML($html, $baseURL), an HTML string, or

WindowOptions::$html;

  • $win->page()->loadFile('docs/index.html'), a local file.

Pages that are not the app's own cannot call PHP methods: see [who may

call](bindings.md#who-may-call).

Without the CLI

App::setFrontend($dir) serves a directory as myphp://localhost/, for a

script run without myphp build:

App::setFrontend(__DIR__ . '/dist');

Custom protocols

Protocol::handle() serves a URL scheme from a PHP function, for content

your app produces or reads at run time: pages, images, files of the user.

A handler gets a ProtocolRequest and returns a ProtocolResponse, on the

main thread; a body that is a generator streams, so a large file is never

held in memory whole. Pages load such URLs like web URLs, with fetch,

<img>, ES modules and relative URLs:

// Thumbnails generated into the cache directory.
Protocol::handle('thumbs', Protocol::fileServer(App::path(PathName::Cache)));

// Anything a function answers.
Protocol::handle('api', function (ProtocolRequest $r): ProtocolResponse {
    return new ProtocolResponse(200, ['Content-Type' => 'application/json'], json_encode(['path' => $r->path()]));
});
<img src="thumbs://localhost/photo-42.png" />

Register schemes before creating the windows that use them.

Protocol::fileServer() serves a directory with content types and range

requests, falling back to index.html like the frontend. Handling the

myphp scheme replaces the frontend with your handler. Pages of custom

schemes are the app's own: they may call PHP methods, see trust.

Audio and video are the exception on Linux: WebKitGTK's media player does

not play from a custom scheme. Serve media from a loopback HTTP server

instead (php -S 127.0.0.1:0 in a process of the app's, say),

and give the page its URL. Every program of the machine can reach such a

server: serve it only what they may read.

Each scheme is its own origin, so a page fetching another scheme makes a

cross-origin request. Responses to the app's own pages, those that may

call PHP methods, carry the CORS headers that let them read the response,

and MyPHP answers their preflights. Other sites get none, unless the window

trusts them (PageOptions::$trustedOrigins), and handlers may set their

own.

Trust

A window trusts the app's own pages: its frontend, pages of custom

schemes, file: and about: pages, origins in

PageOptions::$trustedOrigins, and in development pages served from

localhost. Trust follows the page's URL, in a window or a [web

view](ui/web-view.md), and changes as it navigates. A trusted page may:

  • call every method bound with Ipc::bind(), and the plugins the app

uses;

  • call window.myphp.window to set the title of its window, minimize,

maximize, full-screen or close it;

  • get the camera, the microphone, the location and notifications without

a permission handler;

  • read the responses of every custom scheme, which carry CORS headers for

it;

  • hear the paths of the files dropped on it.

So never serve third-party or user-supplied HTML from a custom scheme, nor

load it with loadHTML() without a base URL, in a page that trusts it.

Show it in an untrusted page instead, or from an origin the window does

not trust, such as an https site or a data: URL: it calls no PHP

method, and gets no permission unless a permission handler grants it.

Untrusted pages

PageOptions::$untrusted makes a page someone else's whatever its URL,

for a window or a web view showing a plugin's UI from a

custom scheme. It trusts no origin, trustedOrigins included: its calls,

to bound methods and to window.myphp.window, are rejected, and its drag

regions do nothing; broadcast() does not reach it, nor emit() to the

window its web view is in, so send to it with emitPage(). It gets no

permission without setPermissionHandler(), opens no window nor the

browser without setWindowOpenHandler(), downloads nothing without

willDownload listeners, and goes to no other origin than its page's

without willNavigate listeners. It still reads its own scheme, whose

handler answers it.

The myphp-runtime package

MyPHP injects its runtime into every page as window.myphp. The

myphp-runtime npm package, which new projects depend on, gives it types

and imports; the generated client is built on it.

import { currentWindow, isMyPHP, runtime } from "myphp-runtime";

if (isMyPHP() && runtime().platform === "darwin") {
  document.documentElement.classList.add("mac");
}

await currentWindow.toggleMaximize();

| Export | What it does |

|---|---|

| call(method, ...args), on(event, listener), once(event, listener), event<T>(name) | untyped calls and events, see bindings |

| Channel | a stream of values from a PHP method, see channels |

| isCallError(err) | whether a rejection came from a PHP exception |

| isMyPHP() | whether the page runs in a MyPHP window, not in a browser tab of the dev server |

| runtime() | the runtime: platform, windowId (the window's id()) and version; throws outside MyPHP |

| currentWindow | the page's window: minimize, maximize, unmaximize, toggleMaximize, isMaximized, toggleFullScreen, close and setTitle |

| onFileDrop(listener) | files dropped on the window, with their paths |

| preferences(), onPreferences(listener) | the accent color and the accessibility settings of the desktop, and their changes |

| locale(), direction(), onLocaleChange(listener) | the language the app shows (App::locale()), its direction, "ltr" or "rtl", and its changes |

| onDocumentState(listener), onDocumentReload(listener) | the state of the window's document |

myphp-runtime/i18n formats messages of ICU MessageFormat, as

MyPHP\Message does in PHP. Opened in a regular browser, which is handy

for working on the layout, pages have no runtime: calls reject and

isMyPHP() is false.

Custom title bars

A frameless window has no title bar or borders, and the page draws its own.

Mark the elements that drag the window with the CSS property

--app-region: drag:

new Window(new WindowOptions(url: '/', frameless: true));
.titlebar {
  --app-region: drag;
  height: 40px;
}
.titlebar .search {
  --app-region: no-drag;
}
import { currentWindow } from "myphp-runtime";

closeButton.onclick = () => currentWindow.close();

A press on a drag region moves the window, and a double click zooms it as

macOS is set to; controls inside stay controls.

Hidden title bars

titleBarStyle: 'hidden' keeps the window buttons over the page, which

draws the rest of the title bar under them. The page gets the room the

buttons take as CSS variables, --myphp-titlebar-height,

--myphp-titlebar-inset-left and --myphp-titlebar-inset-right, which

are zero in full screen:

.titlebar {
  --app-region: drag;
  height: var(--myphp-titlebar-height, 32px);
  padding-left: var(--myphp-titlebar-inset-left, 0);
  padding-right: var(--myphp-titlebar-inset-right, 0);
}

trafficLightPosition moves the buttons (macOS), and hideWindowButtons

hides them for the page to draw its own.

Dropped files

Files dropped on a page reach it with their paths, through onFileDrop

of myphp-runtime, and PHP through the window's fileDrop event:

import { onFileDrop } from "myphp-runtime";

onFileDrop(({ paths }) => importFiles(paths));

Dark mode

Pages follow the system's appearance with prefers-color-scheme, and

color-scheme: light dark gives their controls the matching look. A

window's backgroundColor given as light-dark(<light>, <dark>)

switches with them. Theme::setSource() fixes the appearance of the app's

pages and windows (see dark mode).

Accent color and accessibility

preferences() of myphp-runtime returns the accent color the user chose

and whether they asked for less motion, less transparency or more

contrast, and onPreferences() hears them change. CSS's

prefers-reduced-motion, prefers-reduced-transparency and

prefers-contrast work as in a browser, and accent-color: auto takes

the system's accent.

Pages that size their window

A flyout or a window with

fitContent takes the size of its page's content and follows it.

window.open and links with target="_blank" go through the page's open

handler. Without one, http and https URLs open in the default browser

and the rest is denied. setWindowOpenHandler() decides: it gets a

WindowOpenRequest (its url, frameName, size and whether the user

started it) and returns the options of the window to open the page in, or

null to deny it:

$win->page()->setWindowOpenHandler(function (WindowOpenRequest $r): ?WindowOptions {
    if (str_starts_with($r->url, 'myphp://')) {
        return new WindowOptions(width: 600, height: 400);
    }
    Shell::openExternal($r->url);
    return null;
});

The new page keeps its opener (window.opener).

Preload scripts

PageOptions::$preloadScript runs JavaScript before every page's own

scripts, with window.myphp ready: for polyfills, globals the pages

expect, or a page of another site the app adapts. preloadAllFrames runs

it in the page's frames too.

The web inspector

In development, right-click a page and choose Inspect Element, or call

$win->page()->openDevTools(). PageOptions::$devTools turns it on or off

for a window (DevTools::Enabled, Disabled), and the menu role

toggleDevTools toggles it.