PluginProbe ʕ •ᴥ•ʔ
WP Chat App / trunk
WP Chat App vtrunk
3.8.1 3.8.2 trunk 2.6 3.4 3.4.1 3.4.2 3.4.4 3.4.5 3.4.6 3.5 3.6 3.6.1 3.6.2 3.6.3 3.6.4 3.6.5 3.6.6 3.6.7 3.6.8 3.6.9 3.7.0 3.7.1 3.7.2 3.7.3 3.7.4 3.8.0
wp-whatsapp / vendor-prefixed / src / AdminShell.php
wp-whatsapp / vendor-prefixed / src Last commit date
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