diff --git a/.wp-env.member.json b/.wp-env.member.json new file mode 100644 index 000000000..275451c99 --- /dev/null +++ b/.wp-env.member.json @@ -0,0 +1,12 @@ +{ + "core": null, + "plugins": [], + "mappings": { + "wp-content/plugins/desktop-mode": "." + }, + "lifecycleScripts": { + "afterStart": "./bin/setup-wp-env-member.sh" + }, + "port": 8892, + "testsEnvironment": false +} diff --git a/apps/network/network.css b/apps/network/network.css new file mode 100644 index 000000000..4a146f7ca --- /dev/null +++ b/apps/network/network.css @@ -0,0 +1,117 @@ +/* Network — the window that says who belongs. Tokens only; every + * literal is the pre-brand fallback. */ + +.os-network { + display: grid; + gap: 20px; + padding: 20px 24px; +} + +.os-network__section { + display: grid; + gap: 12px; +} + +.os-network__header { + display: grid; + gap: 8px; +} + +.os-network__title { + margin: 0; + font-size: 18px; + font-weight: 600; + color: var( --os-ui-fg, #1d2327 ); +} + +.os-network__subtitle { + margin: 0; + font-size: 14px; + font-weight: 600; + color: var( --os-ui-fg, #1d2327 ); +} + +.os-network__lede, +.os-network__meta { + margin: 0; + font-size: 13px; + line-height: 1.5; + color: var( --os-ui-fg-muted, #646970 ); +} + +.os-network__code { + display: inline-block; + margin-top: 4px; + padding: 2px 6px; + border-radius: 4px; + font-size: 12px; + background: var( --os-ui-surface-2, rgba( 0, 0, 0, 0.06 ) ); + color: var( --os-ui-fg, #1d2327 ); + user-select: all; +} + +.os-network__actions { + display: flex; + gap: 8px; +} + +.os-network__sites { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 6px; +} + +.os-network__site { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; + border-radius: 8px; + background: var( --os-ui-surface-2, rgba( 0, 0, 0, 0.04 ) ); +} + +.os-network__site-main { + display: grid; + gap: 2px; + min-width: 0; +} + +.os-network__site-name { + font-size: 13px; + color: var( --os-ui-fg, #1d2327 ); +} + +.os-network__site-url, +.os-network__site-error { + font-size: 12px; + color: var( --os-ui-fg-muted, #646970 ); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.os-network__site-error { + color: var( --os-ui-danger, #d63638 ); + white-space: normal; +} + +.os-network__site-side { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.os-network__form { + display: flex; + align-items: flex-end; + gap: 8px; +} + +.os-network__address { + flex: 1; + min-width: 0; +} diff --git a/apps/network/network.os.php b/apps/network/network.os.php new file mode 100644 index 000000000..3deb121d9 --- /dev/null +++ b/apps/network/network.os.php @@ -0,0 +1,448 @@ +'; + +/** + * Which face this install shows: `hub`, `member` or `unpaired`. A + * multisite is always the hub side; a single site is whichever role it + * took, or neither. + * + * @param Os $os Host. + * @return string + */ +function mode( Os $os ) { + if ( $os->env->is_network() ) { + return 'hub'; + } + if ( \openstation_network_is_member() ) { + return 'member'; + } + if ( \openstation_network_is_hub() ) { + return 'hub'; + } + return 'unpaired'; +} + +/** + * The gate: whoever manages this install's network or, on a single + * site, the site. + * + * @param Os $os Host. + * @return bool + */ +function can_use( Os $os ) { + return $os->env->is_network() ? $os->auth->can( 'manage_network' ) : $os->auth->can( 'manage_options' ); +} + +/** + * Record an outcome on the state: an error, or a notice. + * + * @param State $state State. + * @param mixed $result A WP_Error, or anything else for success. + * @param string $notice Notice on success. + */ +function outcome( State $state, $result, $notice ) { + if ( is_wp_error( $result ) ) { + $state->set( 'error', $result->get_error_message() )->set( 'notice', '' ); + return; + } + $state->set( 'error', '' )->set( 'notice', $notice ); +} + +/** + * A status badge for a member. + * + * @param string $status `paired`, `unreachable` or `key-changed`. + * @return string Markup. + */ +function status_badge( $status ) { + switch ( $status ) { + case 'unreachable': + return tag( 'os-badge', array( 'tone' => 'warning' ), esc( __( 'Unreachable', 'desktop-mode' ) ) ); + case 'key-changed': + return tag( 'os-badge', array( 'tone' => 'danger' ), esc( __( 'Key changed', 'desktop-mode' ) ) ); + default: + return tag( 'os-badge', array( 'tone' => 'success' ), esc( __( 'Paired', 'desktop-mode' ) ) ); + } +} + +/** + * One site row. + * + * @param array $site `id`, `name`, `url`, `shellUrl`, `kind`, `status`, `error`. + * @param bool $can_remove Whether a Remove button is offered. + */ +function site_row( array $site, $can_remove ) { + $is_member = 'member' === $site['kind']; + ?> +
  • +
    + + + + + +
    +
    + 'neutral' ), esc( __( 'This network', 'desktop-mode' ) ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Built by tag(); see above. + } + if ( $is_member && $can_remove ) { + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Built by tag(); see above. + echo tag( + 'os-button', + array( + 'variant' => 'ghost', + 'os-action' => 'remove', + 'os-arg-id' => substr( (string) $site['id'], strlen( 'member:' ) ), + /* translators: %s: site name. */ + 'os-confirm' => sprintf( __( 'Remove %s from the network? Its switcher will stop listing this network the next time it syncs.', 'desktop-mode' ), $site['name'] ), + 'os-confirm-label' => __( 'Remove', 'desktop-mode' ), + ), + esc( __( 'Remove', 'desktop-mode' ) ) + ); + } + ?> +
    +
  • + +
    + + +
    + get( 'error' ) ) { + echo '' . esc( (string) $state->get( 'error' ) ) . ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- esc() escapes. + } + if ( '' !== (string) $state->get( 'notice' ) ) { + echo '' . esc( (string) $state->get( 'notice' ) ) . ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- esc() escapes. + } +} + +/** + * The hub's face: every site, and the door. + * + * @param State $state State. + * @param Os $os Host. + */ +function hub_view( State $state, Os $os ) { + $identity = \openstation_network_identity(); + $sites = array_merge( \openstation_network_local_entries(), \openstation_network_member_entries() ); + $members = \openstation_network_members(); + ?> +
    +
    +

    +

    + env->is_network() + ? __( 'The sites of this WordPress network, and the OpenStation installs that joined it from elsewhere. Every one of them shows the same site switcher.', 'desktop-mode' ) + : __( 'This site is the hub of an OpenStation network: every install listed here shows the same site switcher.', 'desktop-mode' ) + ); + ?> +

    + + + +
    +
      + $site['id'], + 'name' => $site['name'], + 'url' => $site['url'], + 'kind' => $site['kind'], + 'status' => $site['status'], + 'error' => $member ? $member['error'] : '', + ), + true + ); + } + ?> +
    +
    +
    +

    +

    + + +

    + +
    + +
    +
    +

    + +

    +

    + + 0 ) : ?> + + env->format_datetime( $hub['fetched'] ) ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- esc() escapes. + ?> + + +

    + + + + + +
    + + +
    +
    + +
      + $site['id'], + 'name' => $site['name'], + 'url' => $site['url'], + 'kind' => $site['kind'], + 'status' => 'paired', + 'error' => '', + ), + false + ); + } + ?> +
    + +
    + +
    +

    +

    + + +

    + +
    +
    +

    +

    + +
    + '; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- esc() escapes. + notices( $state ); + if ( 'hub' === $mode ) { + hub_view( $state, $os ); + } elseif ( 'member' === $mode ) { + member_view( $state, $os ); + } else { + unpaired_view( $state, $os ); + } + echo ''; +} + +return App::define( APP_ID ) + ->title( __( 'Network', 'desktop-mode' ) ) + ->icon( ICON ) + ->size( 760, 560 ) + ->min_size( 520, 400 ) + // A multisite manages its network from the network admin's shell; + // a single site, from its own. + ->admin( function_exists( 'is_multisite' ) && is_multisite() ? 'network' : 'site' ) + ->can( __NAMESPACE__ . '\\can_use' ) + ->state( + array( + 'url' => '', + 'notice' => '', + 'error' => '', + ) + ) + ->action( + 'dismiss', + static function ( State $state ) { + $state->set( 'error', '' )->set( 'notice', '' ); + } + ) + ->action( + 'add', + static function ( State $state ) { + $member = \openstation_network_add_member( (string) $state->get( 'url' ) ); + outcome( + $state, + $member, + is_wp_error( $member ) + ? '' + /* translators: %s: site name. */ + : sprintf( __( '%s is in the network. It appears in the site switcher on the next load, and on that site once it joins from its Network window.', 'desktop-mode' ), $member['name'] ) + ); + if ( ! is_wp_error( $member ) ) { + $state->set( 'url', '' ); + } + } + ) + ->action( + 'remove', + static function ( State $state, Os $os, array $args ) { + $id = isset( $args['id'] ) ? sanitize_key( (string) $args['id'] ) : ''; + outcome( + $state, + \openstation_network_remove_member( $id ) ? true : new \WP_Error( 'openstation_network_unknown', __( 'That site is not in the network.', 'desktop-mode' ) ), + __( 'Removed from the network.', 'desktop-mode' ) + ); + } + ) + ->action( + 'check', + static function ( State $state ) { + \openstation_network_check_members(); + outcome( $state, true, __( 'Every site was checked.', 'desktop-mode' ) ); + } + ) + ->action( + 'join', + static function ( State $state ) { + $hub = \openstation_network_join( (string) $state->get( 'url' ) ); + outcome( + $state, + $hub, + is_wp_error( $hub ) + ? '' + : ( '' === $hub['error'] + /* translators: %s: network name. */ + ? sprintf( __( 'This site belongs to %s. The site switcher shows the network on the next load.', 'desktop-mode' ), $hub['name'] ) + /* translators: %s: network name. */ + : sprintf( __( 'Pinned %s. It has not added this site yet; sync once it has.', 'desktop-mode' ), $hub['name'] ) ) + ); + if ( ! is_wp_error( $hub ) ) { + $state->set( 'url', '' ); + } + } + ) + ->action( + 'leave', + static function ( State $state ) { + \openstation_network_leave(); + outcome( $state, true, __( 'Left the network.', 'desktop-mode' ) ); + } + ) + ->action( + 'sync', + static function ( State $state ) { + $list = \openstation_network_refresh_list(); + outcome( $state, $list, __( 'Site list synced. The switcher shows it on the next load.', 'desktop-mode' ) ); + } + ) + ->view( __NAMESPACE__ . '\\render' ); diff --git a/bin/setup-wp-env-member.sh b/bin/setup-wp-env-member.sh new file mode 100755 index 000000000..0e6369d7e --- /dev/null +++ b/bin/setup-wp-env-member.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# The member instance of a local OpenStation network (`.wp-env.member.json`, +# port 8892): a single site with the plugin active, OpenStation on for +# admin, and the dev shim that lets it reach the hub on :8890. +set -euo pipefail + +WP_ENV_BIN="${WP_ENV_BIN:-./node_modules/.bin/wp-env}" +CONFIG="--config=.wp-env.member.json" + +# `desktop-mode` is the plugin SLUG (frozen; see bin/setup-wp-env.sh). +"${WP_ENV_BIN}" run "${CONFIG}" cli wp plugin activate desktop-mode +"${WP_ENV_BIN}" run "${CONFIG}" cli wp option update blogname "Member site" +"${WP_ENV_BIN}" run "${CONFIG}" cli wp user meta update admin desktop_mode_mode 1 + +./bin/wp-env-network-dev.sh "${CONFIG}" diff --git a/bin/setup-wp-env.sh b/bin/setup-wp-env.sh index 6386ab56e..0bac578d5 100755 --- a/bin/setup-wp-env.sh +++ b/bin/setup-wp-env.sh @@ -29,3 +29,7 @@ enable_guidelines_experiment() { } enable_guidelines_experiment cli + +# Let this instance pair with the member instance (`.wp-env.member.json`) +# as an OpenStation network; see bin/wp-env-network-dev.sh. +./bin/wp-env-network-dev.sh diff --git a/bin/wp-env-network-dev.sh b/bin/wp-env-network-dev.sh new file mode 100755 index 000000000..0f0376bb6 --- /dev/null +++ b/bin/wp-env-network-dev.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# Lets two local wp-env instances pair as an OpenStation network. +# +# Inside a container, `localhost` is the container itself, so the hub +# on :8890 can never reach the member on :8892 by the address the +# browser uses. This drops an mu-plugin into the given instance that +# rewrites the URL an install reaches another by (the +# `openstation_network_request_url` filter, which exists for exactly +# this kind of thing: proxies, internal hostnames) onto Docker's host +# gateway. Dev tooling only; nothing here ships. +# +# Usage: bin/wp-env-network-dev.sh [--config=] +set -euo pipefail + +WP_ENV_BIN="${WP_ENV_BIN:-./node_modules/.bin/wp-env}" +# Find the instance directory. `wp-env install-path` answers on newer +# wp-env; older ones print nothing, so fall back to Docker's own mount +# table: the container publishing the config's port maps /var/www/html +# onto the instance's WordPress directory. +CONFIG_FILE=".wp-env.json" +for arg in "$@"; do + case "${arg}" in + --config=*) CONFIG_FILE="${arg#--config=}" ;; + esac +done +INSTALL_PATH="$("${WP_ENV_BIN}" install-path "$@" 2>/dev/null | grep -m1 '\.wp-env/' | tr -d '[:space:]' || true)" +if [ -z "${INSTALL_PATH}" ]; then + PORT="$(grep -o '"port":[[:space:]]*[0-9]*' "${CONFIG_FILE}" | grep -o '[0-9]*$')" + CONTAINER="$(docker ps --filter "publish=${PORT}" --format '{{.Names}}' | grep -- '-wordpress-' | head -1)" + if [ -n "${CONTAINER}" ]; then + WP_DIR="$(docker inspect "${CONTAINER}" --format '{{range .Mounts}}{{if eq .Destination "/var/www/html"}}{{.Source}}{{end}}{{end}}')" + INSTALL_PATH="${WP_DIR%/WordPress}" + fi +fi +if [ -z "${INSTALL_PATH}" ]; then + echo "Could not find the wp-env instance for ${CONFIG_FILE}; is it running?" >&2 + exit 1 +fi +MU_DIR="${INSTALL_PATH}/WordPress/wp-content/mu-plugins" + +mkdir -p "${MU_DIR}" +cat > "${MU_DIR}/openstation-network-dev.php" <<'PHP' +/`. Same directory + same config, same instance, every time. That's how `.wp-env.json` (QA) and `.wp-env.tests.json` (PHPUnit) run as two fully isolated stacks from one checkout. -- Ports: `8890` (QA instance, `.wp-env.json`) and `8891` (tests instance, `.wp-env.tests.json`). They are remapped from wp-env's defaults so the stacks coexist with a Core checkout's environment (see the PHPUnit section of `AGENTS.md`). +- Ports: `8890` (QA instance, `.wp-env.json`), `8891` (tests instance, `.wp-env.tests.json`) and `8892` (the member instance of a local OpenStation network, `.wp-env.member.json`, see below). They are remapped from wp-env's defaults so the stacks coexist with a Core checkout's environment (see the PHPUnit section of `AGENTS.md`). - `bin/sync-to-wp-develop.sh` does **not** feed this instance. It mirrors the tree into a wordpress-develop checkout, which is a different environment entirely. The wp-env instance always serves the start directory live through the mount; there is no copy step to forget. ### Testing several worktrees at once @@ -62,6 +62,17 @@ Notes: - **Cost.** Each instance is three containers (WordPress, CLI, database). `npm run env:stop` / `env:stop:tests` parks an instance and keeps its data; `npm run env:destroy` / `env:destroy:tests` deletes it. - `npm run test:php` in a worktree runs inside that worktree's own tests instance, so PHPUnit is isolated per worktree too. +### A local OpenStation network (two instances) + +[network.md](./network.md) pairs separate installs into one switcher. To try it on one machine, a third instance acts as the member: + +```bash +npm run env:start # the hub, :8890 (a multisite; pair from its network admin) +npm run env:start:member # the member, :8892 (a single site, admin / password) +``` + +Inside a container `localhost` is the container itself, so both setup scripts drop `bin/wp-env-network-dev.sh`'s mu-plugin into their instance: it rewrites `localhost:` onto `host.docker.internal` through the `openstation_network_request_url` filter, and plain HTTP is allowed because wp-env sets `WP_ENVIRONMENT_TYPE` to `local`. Then, in the hub's network shell, open **Network** and add `http://localhost:8892`; in the member's shell, open **Network** and join `http://localhost:8890`. Reload either shell and open Overview: the same row on both. `npm run env:stop:member` when done. + ## Measuring boot cost `bin/boot-cost.mjs` answers one question deterministically: what does the shell's boot document actually cost, and what changed between two builds. It logs into a local WordPress, fetches one document, then fetches every `