PluginProbe
YayMail – WooCommerce Email Customizer / 4.4.2
YayMail – WooCommerce Email Customizer v4.4.2
4.4.6 4.4.5 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 All 57 releases
yaymail / vendor-prefixed / src / AdminShell.php

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

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