| 1 |
<?php |
| 2 |
|
| 3 |
namespace YayMailScoped\YayCommerce\AdminShell; |
| 4 |
|
| 5 |
use YayMailScoped\YayCommerce\AdminShell\Contracts\AddonHostAdapter; |
| 6 |
use YayMailScoped\YayCommerce\AdminShell\Contracts\PluginMenuAdapter; |
| 7 |
use YayMailScoped\YayCommerce\AdminShell\License\Contracts\LicenseConfigAdapter; |
| 8 |
use YayMailScoped\YayCommerce\AdminShell\License\LicenseHandler; |
| 9 |
use YayMailScoped\YayCommerce\AdminShell\License\PluginInfoFactory; |
| 10 |
use YayMailScoped\YayCommerce\AdminShell\Menu\ExternalPluginMenuAdapter; |
| 11 |
use YayMailScoped\YayCommerce\AdminShell\Menu\PluginSubmenu; |
| 12 |
use YayMailScoped\YayCommerce\AdminShell\Menu\MenuSuppressor; |
| 13 |
use YayMailScoped\YayCommerce\AdminShell\Menu\PagesRouter; |
| 14 |
use YayMailScoped\YayCommerce\AdminShell\Menu\SubmenuPositioner; |
| 15 |
use YayMailScoped\YayCommerce\AdminShell\Menu\TopLevelMenu; |
| 16 |
use YayMailScoped\YayCommerce\AdminShell\Pages\RecommendedPluginsPage; |
| 17 |
use YayMailScoped\YayCommerce\AdminShell\Registry\AddonBridge; |
| 18 |
use YayMailScoped\YayCommerce\AdminShell\Registry\LegacyBridge; |
| 19 |
use YayMailScoped\YayCommerce\AdminShell\Registry\LicenseRegistry; |
| 20 |
use YayMailScoped\YayCommerce\AdminShell\Support\AdminContext; |
| 21 |
use YayMailScoped\YayCommerce\AdminShell\Support\Constants; |
| 22 |
defined('ABSPATH') || exit; |
| 23 |
/** |
| 24 |
* Public facade — entry point for consuming plugins. |
| 25 |
* |
| 26 |
* Version election: when multiple scoped copies coexist, the highest |
| 27 |
* VERSION wins and registers menus/pages. All copies' register_plugin() |
| 28 |
* and enable_license() still run — they hook global WP actions. |
| 29 |
*/ |
| 30 |
class AdminShell |
| 31 |
{ |
| 32 |
/** Package version — used for cross-scope version election. */ |
| 33 |
const VERSION = '2.8.5'; |
| 34 |
private static ?self $instance = null; |
| 35 |
private static bool $booted = \false; |
| 36 |
private static array $enabled_slugs = []; |
| 37 |
/** The scoped prefix for THIS copy (derived from namespace). */ |
| 38 |
private static string $prefix = ''; |
| 39 |
private LicenseRegistry $registry; |
| 40 |
private function __construct() |
| 41 |
{ |
| 42 |
$this->registry = new LicenseRegistry(); |
| 43 |
} |
| 44 |
/** |
| 45 |
* Bootstrap the shared admin shell. |
| 46 |
* |
| 47 |
* Each scoped copy calls boot(). All register their version in a shared |
| 48 |
* global. Actual menu/page registration is deferred to admin_menu where |
| 49 |
* only the highest version runs. |
| 50 |
*/ |
| 51 |
public static function boot(): void |
| 52 |
{ |
| 53 |
if (self::$booted) { |
| 54 |
return; |
| 55 |
} |
| 56 |
self::$booted = \true; |
| 57 |
require_once __DIR__ . '/Support/Constants.php'; |
| 58 |
$instance = self::get_instance(); |
| 59 |
// Derive our scoped prefix from the namespace (e.g. "YayMailScoped\YayCommerce\AdminShell" → "YayMailScoped") |
| 60 |
$ns = __NAMESPACE__; |
| 61 |
// YayCommerce\AdminShell or ScopedPrefix\YayCommerce\AdminShell |
| 62 |
$parts = explode('\\', $ns); |
| 63 |
self::$prefix = count($parts) > 2 ? $parts[0] : 'default'; |
| 64 |
// Register this version in the shared global for cross-scope election |
| 65 |
if (!isset($GLOBALS['yaycommerce_admin_shell_versions'])) { |
| 66 |
$GLOBALS['yaycommerce_admin_shell_versions'] = []; |
| 67 |
} |
| 68 |
$GLOBALS['yaycommerce_admin_shell_versions'][self::$prefix] = ['version' => self::VERSION, 'registry' => $instance->registry, 'boot_cb' => [static::class, 'do_shell_registration']]; |
| 69 |
// Register the version election — only once (first copy to call boot sets it up). |
| 70 |
// Bound to both admin_menu and network_admin_menu so election runs in the |
| 71 |
// Multisite Network Admin too; only the firing hook's election actually runs. |
| 72 |
if (1 === count($GLOBALS['yaycommerce_admin_shell_versions'])) { |
| 73 |
AdminContext::bind_menu([static::class, 'elect_version'], 8); |
| 74 |
// bind_menu() already wired BOTH hooks here, so mark the network election |
| 75 |
// as done. This is load-bearing: it stops the safety net below from adding |
| 76 |
// a SECOND network binding (elect_version is NOT idempotent — a double |
| 77 |
// binding would run do_shell_registration twice). Only one copy ever sees |
| 78 |
// count==1, so this path wires network at most once. |
| 79 |
$GLOBALS['yaycommerce_network_election_wired'] = \true; |
| 80 |
} |
| 81 |
// Network Admin safety net for MIXED-VERSION installs. Older copies (≤2.6.x) |
| 82 |
// wire the election to admin_menu ONLY — they predate network support — and the |
| 83 |
// first-boot guard above lets whichever copy loads first (often an old one, by |
| 84 |
// plugin folder order) own the wiring. So network_admin_menu would never get an |
| 85 |
// election binding even when a newer copy is present. This block is NOT gated by |
| 86 |
// that guard: any 2.7.1+ copy wires the network election exactly once (dedicated |
| 87 |
// flag), independent of load order, so Network Admin works whenever ≥1 updated |
| 88 |
// plugin is active. elect_version still elects the highest version as the winner. |
| 89 |
if (empty($GLOBALS['yaycommerce_network_election_wired'])) { |
| 90 |
$GLOBALS['yaycommerce_network_election_wired'] = \true; |
| 91 |
add_action('network_admin_menu', [static::class, 'elect_version'], 8); |
| 92 |
} |
| 93 |
// Legacy bridge — reads yaycommerce_licensing_plugins filter. |
| 94 |
// Runs for ALL versions (uses global WP hooks, contributes to any winning registry). |
| 95 |
$legacy_bridge = new LegacyBridge($instance->registry); |
| 96 |
$legacy_bridge->init(); |
| 97 |
// On AJAX requests admin_menu doesn't fire, so register AJAX handlers directly. |
| 98 |
// Global guard ensures only one scoped copy registers (action name is identical across copies). |
| 99 |
// DOING_AJAX must be in scoper.inc.php exclude-constants to avoid namespace prefixing. |
| 100 |
if (\defined('DOING_AJAX') && \DOING_AJAX) { |
| 101 |
$registered_ver = $GLOBALS['yaycommerce_ajax_handlers_registered'] ?? '0.0.0'; |
| 102 |
if (\version_compare(self::VERSION, $registered_ver, '>')) { |
| 103 |
$GLOBALS['yaycommerce_ajax_handlers_registered'] = self::VERSION; |
| 104 |
RecommendedPluginsPage::get_instance(); |
| 105 |
} |
| 106 |
} |
| 107 |
do_action('yaycommerce_admin_shell_booted', $instance); |
| 108 |
} |
| 109 |
/** |
| 110 |
* Version election — picks the highest version and runs its shell registration. |
| 111 |
* Called once at admin_menu priority 8 (before TopLevelMenu at 9). |
| 112 |
*/ |
| 113 |
public static function elect_version(): void |
| 114 |
{ |
| 115 |
$versions = $GLOBALS['yaycommerce_admin_shell_versions'] ?? []; |
| 116 |
if (empty($versions)) { |
| 117 |
return; |
| 118 |
} |
| 119 |
// Find highest version |
| 120 |
$winner_prefix = ''; |
| 121 |
$winner_ver = '0.0.0'; |
| 122 |
foreach ($versions as $prefix => $data) { |
| 123 |
if (version_compare($data['version'], $winner_ver, '>')) { |
| 124 |
$winner_ver = $data['version']; |
| 125 |
$winner_prefix = $prefix; |
| 126 |
} |
| 127 |
} |
| 128 |
// Call the winner's registration — may be a different scoped class |
| 129 |
$winner = $versions[$winner_prefix]; |
| 130 |
if (isset($winner['boot_cb']) && is_callable($winner['boot_cb'])) { |
| 131 |
call_user_func($winner['boot_cb']); |
| 132 |
} |
| 133 |
} |
| 134 |
/** |
| 135 |
* Register menus/pages — only called by the winning version. |
| 136 |
*/ |
| 137 |
public static function do_shell_registration(): void |
| 138 |
{ |
| 139 |
$instance = self::get_instance(); |
| 140 |
// Merge all registries into the winner's registry |
| 141 |
foreach ($GLOBALS['yaycommerce_admin_shell_versions'] ?? [] as $prefix => $data) { |
| 142 |
if ($prefix !== self::$prefix && isset($data['registry'])) { |
| 143 |
$other_registry = $data['registry']; |
| 144 |
foreach ($other_registry->all() as $info) { |
| 145 |
if (!$instance->registry->get($info->slug)) { |
| 146 |
$instance->registry->register($info); |
| 147 |
} |
| 148 |
} |
| 149 |
} |
| 150 |
} |
| 151 |
$suppressor = new MenuSuppressor(); |
| 152 |
$suppressor->init(); |
| 153 |
$top_menu = new TopLevelMenu(); |
| 154 |
$top_menu->init(); |
| 155 |
$router = new PagesRouter($instance->registry); |
| 156 |
$router->init(); |
| 157 |
// Order all submenus by declared position — runs once, only for the |
| 158 |
// winning version, so a single authority reorders every plugin's submenu. |
| 159 |
$positioner = new SubmenuPositioner(); |
| 160 |
$positioner->init(); |
| 161 |
} |
| 162 |
/** |
| 163 |
* Register a plugin with the admin shell. |
| 164 |
* Auto-detects pro vs lite via instanceof. |
| 165 |
* Runs for ALL versions (not version-gated). |
| 166 |
*/ |
| 167 |
public static function register_plugin(PluginMenuAdapter $adapter): void |
| 168 |
{ |
| 169 |
self::validate_adapter($adapter); |
| 170 |
// Publish this plugin's intended submenu position into a shared, |
| 171 |
// cross-scope map (keyed by menu slug). The version-elected winner reads |
| 172 |
// this in SubmenuPositioner to order ALL submenus after registration. |
| 173 |
// Request-scoped: not pruned (globals don't persist between requests), |
| 174 |
// and stale/unknown slugs are harmless — reorder() sorts them last. |
| 175 |
$menu_slug = $adapter->get_menu_slug(); |
| 176 |
if (!empty($menu_slug)) { |
| 177 |
if (!isset($GLOBALS[SubmenuPositioner::POSITION_KEY])) { |
| 178 |
$GLOBALS[SubmenuPositioner::POSITION_KEY] = []; |
| 179 |
} |
| 180 |
$GLOBALS[SubmenuPositioner::POSITION_KEY][$menu_slug] = $adapter->get_settings_page_position(); |
| 181 |
} |
| 182 |
// Submenu registration — per-plugin, all versions |
| 183 |
$submenu = new PluginSubmenu($adapter); |
| 184 |
$submenu->init(); |
| 185 |
// Action links + row meta — per-plugin, all versions |
| 186 |
if (is_admin()) { |
| 187 |
self::register_plugin_links($adapter); |
| 188 |
} |
| 189 |
// License subsystem — per-plugin, all versions |
| 190 |
if ($adapter instanceof LicenseConfigAdapter) { |
| 191 |
self::enable_license($adapter); |
| 192 |
} |
| 193 |
// Addon host — bridge plugin-specific addon filter into registry. |
| 194 |
if ($adapter instanceof AddonHostAdapter) { |
| 195 |
$filter_name = $adapter->get_addon_licensing_filter(); |
| 196 |
if (!empty($filter_name)) { |
| 197 |
$bridge = new AddonBridge($filter_name, self::get_instance()->registry); |
| 198 |
$bridge->init(); |
| 199 |
} |
| 200 |
} |
| 201 |
} |
| 202 |
/** |
| 203 |
* Register an "Other Plugins" submenu under an external plugin's own top-level menu. |
| 204 |
* Use for plugins with their own menu hierarchy (e.g. whatsapp, filester) that should |
| 205 |
* not appear under the shared YayCommerce menu. |
| 206 |
* |
| 207 |
* @param array{parent_menu: string, menu_title: string, menu_capability: string, menu_slug: string} $config |
| 208 |
*/ |
| 209 |
public static function register_external_plugin_menu(array $config): void |
| 210 |
{ |
| 211 |
$adapter = new ExternalPluginMenuAdapter($config); |
| 212 |
$adapter->init(); |
| 213 |
} |
| 214 |
/** |
| 215 |
* Validate adapter values upfront. |
| 216 |
*/ |
| 217 |
private static function validate_adapter(PluginMenuAdapter $adapter): void |
| 218 |
{ |
| 219 |
$menu_title = $adapter->get_menu_title(); |
| 220 |
if (empty($menu_title)) { |
| 221 |
trigger_error('[YayCommerce AdminShell] get_menu_title() must return a non-empty string.', \E_USER_WARNING); |
| 222 |
} |
| 223 |
$basename = $adapter->get_plugin_basename(); |
| 224 |
if (empty($basename)) { |
| 225 |
trigger_error('[YayCommerce AdminShell] get_plugin_basename() must return a non-empty string.', \E_USER_WARNING); |
| 226 |
} |
| 227 |
$callback = $adapter->get_settings_page_callback(); |
| 228 |
if (null !== $callback && !is_callable($callback)) { |
| 229 |
trigger_error('[YayCommerce AdminShell] get_settings_page_callback() returned a non-callable value.', \E_USER_WARNING); |
| 230 |
} |
| 231 |
if ($adapter instanceof LicenseConfigAdapter) { |
| 232 |
$slug = $adapter->get_plugin_slug(); |
| 233 |
if (empty($slug)) { |
| 234 |
trigger_error('[YayCommerce AdminShell] get_plugin_slug() must return a non-empty string.', \E_USER_WARNING); |
| 235 |
} |
| 236 |
$item_id = $adapter->get_item_id(); |
| 237 |
if ($item_id <= 0) { |
| 238 |
trigger_error('[YayCommerce AdminShell] get_item_id() must return a positive integer.', \E_USER_WARNING); |
| 239 |
} |
| 240 |
$store_url = $adapter->get_store_url(); |
| 241 |
if (empty($store_url)) { |
| 242 |
trigger_error('[YayCommerce AdminShell] get_store_url() must return a non-empty URL.', \E_USER_WARNING); |
| 243 |
} |
| 244 |
$plugin_file = $adapter->get_plugin_file(); |
| 245 |
if (empty($plugin_file)) { |
| 246 |
trigger_error('[YayCommerce AdminShell] get_plugin_file() must return a non-empty path.', \E_USER_WARNING); |
| 247 |
} |
| 248 |
} |
| 249 |
} |
| 250 |
/** |
| 251 |
* Enable the license subsystem for a plugin. |
| 252 |
* Runs for ALL versions (not version-gated). |
| 253 |
*/ |
| 254 |
public static function enable_license(LicenseConfigAdapter $adapter): void |
| 255 |
{ |
| 256 |
$slug = $adapter->get_plugin_slug(); |
| 257 |
if (isset(self::$enabled_slugs[$slug])) { |
| 258 |
return; |
| 259 |
} |
| 260 |
self::$enabled_slugs[$slug] = \true; |
| 261 |
$instance = self::get_instance(); |
| 262 |
new LicenseHandler($adapter); |
| 263 |
$info = PluginInfoFactory::from_adapter($adapter); |
| 264 |
$instance->registry->register($info); |
| 265 |
do_action('yaycommerce_admin_shell_license_enabled', $adapter); |
| 266 |
} |
| 267 |
/** |
| 268 |
* Register plugin action links + row meta. |
| 269 |
*/ |
| 270 |
private static function register_plugin_links(PluginMenuAdapter $adapter): void |
| 271 |
{ |
| 272 |
$basename = $adapter->get_plugin_basename(); |
| 273 |
add_filter('plugin_action_links_' . $basename, function (array $links) use ($adapter) { |
| 274 |
$new = []; |
| 275 |
$menu_slug = $adapter->get_menu_slug(); |
| 276 |
if (!empty($menu_slug)) { |
| 277 |
$url = admin_url('admin.php?page=' . $menu_slug); |
| 278 |
$new['settings'] = '<a href="' . esc_url($url) . '">' . esc_html($adapter->get_settings_label()) . '</a>'; |
| 279 |
} |
| 280 |
$pro_url = $adapter->get_pro_url(); |
| 281 |
if (!empty($pro_url)) { |
| 282 |
$new['go-pro'] = '<a href="' . esc_url($pro_url) . '" target="_blank" style="color:#00a32a;font-weight:700;">' . esc_html__('Go Pro', 'yaycommerce') . '</a>'; |
| 283 |
} |
| 284 |
return array_merge($new, $links); |
| 285 |
}); |
| 286 |
add_filter('plugin_row_meta', function (array $meta, string $file) use ($adapter, $basename) { |
| 287 |
if ($file !== $basename) { |
| 288 |
return $meta; |
| 289 |
} |
| 290 |
$docs_url = $adapter->get_docs_url(); |
| 291 |
if (!empty($docs_url)) { |
| 292 |
$meta[] = '<a href="' . esc_url($docs_url) . '" target="_blank">' . esc_html__('Docs', 'yaycommerce') . '</a>'; |
| 293 |
} |
| 294 |
$meta[] = '<a href="https://yaycommerce.com/support" target="_blank">' . esc_html__('Support', 'yaycommerce') . '</a>'; |
| 295 |
return $meta; |
| 296 |
}, 10, 2); |
| 297 |
} |
| 298 |
/** |
| 299 |
* Return the shared registry. |
| 300 |
*/ |
| 301 |
public static function registry(): LicenseRegistry |
| 302 |
{ |
| 303 |
return self::get_instance()->registry; |
| 304 |
} |
| 305 |
private static function get_instance(): self |
| 306 |
{ |
| 307 |
if (null === self::$instance) { |
| 308 |
self::$instance = new self(); |
| 309 |
} |
| 310 |
return self::$instance; |
| 311 |
} |
| 312 |
/** |
| 313 |
* Reset state — for unit tests only. |
| 314 |
*/ |
| 315 |
public static function reset(): void |
| 316 |
{ |
| 317 |
self::$instance = null; |
| 318 |
self::$booted = \false; |
| 319 |
self::$enabled_slugs = []; |
| 320 |
self::$prefix = ''; |
| 321 |
unset($GLOBALS['yaycommerce_admin_shell_versions']); |
| 322 |
unset($GLOBALS['yaycommerce_ajax_handlers_registered']); |
| 323 |
unset($GLOBALS['yaycommerce_network_election_wired']); |
| 324 |
unset($GLOBALS[SubmenuPositioner::POSITION_KEY]); |
| 325 |
} |
| 326 |
} |
| 327 |
|