PluginProbe
YayMail – WooCommerce Email Customizer / trunk
YayMail – WooCommerce Email Customizer vtrunk
4.4.4 4.4.3 4.4.2 4.4.1 trunk 1.9.6 2.1.4 2.1.5 3.2.2 3.2.6 3.2.7.1 3.2.8.1 3.2.9 3.3 3.3.1 3.3.4 3.3.5 3.3.6 3.3.7 3.3.8 3.3.9 3.4 3.4.1 3.4.2 3.4.3 All 55 releases
yaymail / vendor-prefixed / src / AdminShell.php

AdminShell.php in YayMail – WooCommerce Email Customizer trunk, at vendor-prefixed/src/AdminShell.php

327 lines 15.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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