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