registry = new LicenseRegistry(); } /** * Bootstrap the shared admin shell. * * Each scoped copy calls boot(). All register their version in a shared * global. Actual menu/page registration is deferred to admin_menu where * only the highest version runs. */ public static function boot(): void { if (self::$booted) { return; } self::$booted = \true; require_once __DIR__ . '/Support/Constants.php'; $instance = self::get_instance(); // Derive our scoped prefix from the namespace (e.g. "YayMailScoped\YayCommerce\AdminShell" → "YayMailScoped") $ns = __NAMESPACE__; // YayCommerce\AdminShell or ScopedPrefix\YayCommerce\AdminShell $parts = explode('\\', $ns); self::$prefix = count($parts) > 2 ? $parts[0] : 'default'; // Register this version in the shared global for cross-scope election if (!isset($GLOBALS['yaycommerce_admin_shell_versions'])) { $GLOBALS['yaycommerce_admin_shell_versions'] = []; } $GLOBALS['yaycommerce_admin_shell_versions'][self::$prefix] = ['version' => self::VERSION, 'registry' => $instance->registry, 'boot_cb' => [static::class, 'do_shell_registration']]; // Register the version election — only once (first copy to call boot sets it up). // Bound to both admin_menu and network_admin_menu so election runs in the // Multisite Network Admin too; only the firing hook's election actually runs. if (1 === count($GLOBALS['yaycommerce_admin_shell_versions'])) { AdminContext::bind_menu([static::class, 'elect_version'], 8); // bind_menu() already wired BOTH hooks here, so mark the network election // as done. This is load-bearing: it stops the safety net below from adding // a SECOND network binding (elect_version is NOT idempotent — a double // binding would run do_shell_registration twice). Only one copy ever sees // count==1, so this path wires network at most once. $GLOBALS['yaycommerce_network_election_wired'] = \true; } // Network Admin safety net for MIXED-VERSION installs. Older copies (≤2.6.x) // wire the election to admin_menu ONLY — they predate network support — and the // first-boot guard above lets whichever copy loads first (often an old one, by // plugin folder order) own the wiring. So network_admin_menu would never get an // election binding even when a newer copy is present. This block is NOT gated by // that guard: any 2.7.1+ copy wires the network election exactly once (dedicated // flag), independent of load order, so Network Admin works whenever ≥1 updated // plugin is active. elect_version still elects the highest version as the winner. if (empty($GLOBALS['yaycommerce_network_election_wired'])) { $GLOBALS['yaycommerce_network_election_wired'] = \true; add_action('network_admin_menu', [static::class, 'elect_version'], 8); } // Legacy bridge — reads yaycommerce_licensing_plugins filter. // Runs for ALL versions (uses global WP hooks, contributes to any winning registry). $legacy_bridge = new LegacyBridge($instance->registry); $legacy_bridge->init(); // On AJAX requests admin_menu doesn't fire, so register AJAX handlers directly. // Global guard ensures only one scoped copy registers (action name is identical across copies). // DOING_AJAX must be in scoper.inc.php exclude-constants to avoid namespace prefixing. if (\defined('DOING_AJAX') && \DOING_AJAX) { $registered_ver = $GLOBALS['yaycommerce_ajax_handlers_registered'] ?? '0.0.0'; if (\version_compare(self::VERSION, $registered_ver, '>')) { $GLOBALS['yaycommerce_ajax_handlers_registered'] = self::VERSION; RecommendedPluginsPage::get_instance(); } } do_action('yaycommerce_admin_shell_booted', $instance); } /** * Version election — picks the highest version and runs its shell registration. * Called once at admin_menu priority 8 (before TopLevelMenu at 9). */ public static function elect_version(): void { $versions = $GLOBALS['yaycommerce_admin_shell_versions'] ?? []; if (empty($versions)) { return; } // Find highest version $winner_prefix = ''; $winner_ver = '0.0.0'; foreach ($versions as $prefix => $data) { if (version_compare($data['version'], $winner_ver, '>')) { $winner_ver = $data['version']; $winner_prefix = $prefix; } } // Call the winner's registration — may be a different scoped class $winner = $versions[$winner_prefix]; if (isset($winner['boot_cb']) && is_callable($winner['boot_cb'])) { call_user_func($winner['boot_cb']); } } /** * Register menus/pages — only called by the winning version. */ public static function do_shell_registration(): void { $instance = self::get_instance(); // Merge all registries into the winner's registry foreach ($GLOBALS['yaycommerce_admin_shell_versions'] ?? [] as $prefix => $data) { if ($prefix !== self::$prefix && isset($data['registry'])) { $other_registry = $data['registry']; foreach ($other_registry->all() as $info) { if (!$instance->registry->get($info->slug)) { $instance->registry->register($info); } } } } $suppressor = new MenuSuppressor(); $suppressor->init(); $top_menu = new TopLevelMenu(); $top_menu->init(); $router = new PagesRouter($instance->registry); $router->init(); // Order all submenus by declared position — runs once, only for the // winning version, so a single authority reorders every plugin's submenu. $positioner = new SubmenuPositioner(); $positioner->init(); } /** * Register a plugin with the admin shell. * Auto-detects pro vs lite via instanceof. * Runs for ALL versions (not version-gated). */ public static function register_plugin(PluginMenuAdapter $adapter): void { self::validate_adapter($adapter); // Publish this plugin's intended submenu position into a shared, // cross-scope map (keyed by menu slug). The version-elected winner reads // this in SubmenuPositioner to order ALL submenus after registration. // Request-scoped: not pruned (globals don't persist between requests), // and stale/unknown slugs are harmless — reorder() sorts them last. $menu_slug = $adapter->get_menu_slug(); if (!empty($menu_slug)) { if (!isset($GLOBALS[SubmenuPositioner::POSITION_KEY])) { $GLOBALS[SubmenuPositioner::POSITION_KEY] = []; } $GLOBALS[SubmenuPositioner::POSITION_KEY][$menu_slug] = $adapter->get_settings_page_position(); } // Submenu registration — per-plugin, all versions $submenu = new PluginSubmenu($adapter); $submenu->init(); // Action links + row meta — per-plugin, all versions if (is_admin()) { self::register_plugin_links($adapter); } // License subsystem — per-plugin, all versions if ($adapter instanceof LicenseConfigAdapter) { self::enable_license($adapter); } // Addon host — bridge plugin-specific addon filter into registry. if ($adapter instanceof AddonHostAdapter) { $filter_name = $adapter->get_addon_licensing_filter(); if (!empty($filter_name)) { $bridge = new AddonBridge($filter_name, self::get_instance()->registry); $bridge->init(); } } } /** * Register an "Other Plugins" submenu under an external plugin's own top-level menu. * Use for plugins with their own menu hierarchy (e.g. whatsapp, filester) that should * not appear under the shared YayCommerce menu. * * @param array{parent_menu: string, menu_title: string, menu_capability: string, menu_slug: string} $config */ public static function register_external_plugin_menu(array $config): void { $adapter = new ExternalPluginMenuAdapter($config); $adapter->init(); } /** * Validate adapter values upfront. */ private static function validate_adapter(PluginMenuAdapter $adapter): void { $menu_title = $adapter->get_menu_title(); if (empty($menu_title)) { trigger_error('[YayCommerce AdminShell] get_menu_title() must return a non-empty string.', \E_USER_WARNING); } $basename = $adapter->get_plugin_basename(); if (empty($basename)) { trigger_error('[YayCommerce AdminShell] get_plugin_basename() must return a non-empty string.', \E_USER_WARNING); } $callback = $adapter->get_settings_page_callback(); if (null !== $callback && !is_callable($callback)) { trigger_error('[YayCommerce AdminShell] get_settings_page_callback() returned a non-callable value.', \E_USER_WARNING); } if ($adapter instanceof LicenseConfigAdapter) { $slug = $adapter->get_plugin_slug(); if (empty($slug)) { trigger_error('[YayCommerce AdminShell] get_plugin_slug() must return a non-empty string.', \E_USER_WARNING); } $item_id = $adapter->get_item_id(); if ($item_id <= 0) { trigger_error('[YayCommerce AdminShell] get_item_id() must return a positive integer.', \E_USER_WARNING); } $store_url = $adapter->get_store_url(); if (empty($store_url)) { trigger_error('[YayCommerce AdminShell] get_store_url() must return a non-empty URL.', \E_USER_WARNING); } $plugin_file = $adapter->get_plugin_file(); if (empty($plugin_file)) { trigger_error('[YayCommerce AdminShell] get_plugin_file() must return a non-empty path.', \E_USER_WARNING); } } } /** * Enable the license subsystem for a plugin. * Runs for ALL versions (not version-gated). */ public static function enable_license(LicenseConfigAdapter $adapter): void { $slug = $adapter->get_plugin_slug(); if (isset(self::$enabled_slugs[$slug])) { return; } self::$enabled_slugs[$slug] = \true; $instance = self::get_instance(); new LicenseHandler($adapter); $info = PluginInfoFactory::from_adapter($adapter); $instance->registry->register($info); do_action('yaycommerce_admin_shell_license_enabled', $adapter); } /** * Register plugin action links + row meta. */ private static function register_plugin_links(PluginMenuAdapter $adapter): void { $basename = $adapter->get_plugin_basename(); add_filter('plugin_action_links_' . $basename, function (array $links) use ($adapter) { $new = []; $menu_slug = $adapter->get_menu_slug(); if (!empty($menu_slug)) { $url = admin_url('admin.php?page=' . $menu_slug); $new['settings'] = '' . esc_html($adapter->get_settings_label()) . ''; } $pro_url = $adapter->get_pro_url(); if (!empty($pro_url)) { $new['go-pro'] = '' . esc_html__('Go Pro', 'yaycommerce') . ''; } return array_merge($new, $links); }); add_filter('plugin_row_meta', function (array $meta, string $file) use ($adapter, $basename) { if ($file !== $basename) { return $meta; } $docs_url = $adapter->get_docs_url(); if (!empty($docs_url)) { $meta[] = '' . esc_html__('Docs', 'yaycommerce') . ''; } $meta[] = '' . esc_html__('Support', 'yaycommerce') . ''; return $meta; }, 10, 2); } /** * Return the shared registry. */ public static function registry(): LicenseRegistry { return self::get_instance()->registry; } private static function get_instance(): self { if (null === self::$instance) { self::$instance = new self(); } return self::$instance; } /** * Reset state — for unit tests only. */ public static function reset(): void { self::$instance = null; self::$booted = \false; self::$enabled_slugs = []; self::$prefix = ''; unset($GLOBALS['yaycommerce_admin_shell_versions']); unset($GLOBALS['yaycommerce_ajax_handlers_registered']); unset($GLOBALS['yaycommerce_network_election_wired']); unset($GLOBALS[SubmenuPositioner::POSITION_KEY]); } }