Pro-feature slug => metadata. */ private $features = []; /** @var array Pro-feature slug => enabled flag. */ private $enabled = []; public static function instance(): self { if ( self::$instance === null ) { self::$instance = new self(); } return self::$instance; } private function __construct() {} /** * Is a given Pro add-on version new enough for this core? * * Pure on purpose — the two versions come in as strings, so the rule can * be tested without a site and the caller decides where they came from. * * @param string $pro_version Installed add-on version, '' when no add-on. * @param string $min_pro Oldest add-on this core works with, '' to skip. * @return string 'absent' | 'outdated' | 'ok' */ public static function pro_version_status( string $pro_version, string $min_pro ): string { if ( $pro_version === '' ) { return 'absent'; } if ( $min_pro === '' ) { return 'ok'; } return version_compare( $pro_version, $min_pro, '<' ) ? 'outdated' : 'ok'; } /** * The same question about the site this is running on. * * GUARDFORGE_Pro_Loader is declared only by the add-on, so its absence is the * authoritative "no Pro here" — a stale GUARDFORGE_PRO_VERSION left behind in a * half-deleted file cannot fake an install. */ public static function installed_pro_status(): string { if ( ! class_exists( 'GUARDFORGE_Pro_Loader' ) ) { return 'absent'; } return self::pro_version_status( defined( 'GUARDFORGE_PRO_VERSION' ) ? (string) GUARDFORGE_PRO_VERSION : '0', defined( 'GUARDFORGE_MIN_PRO' ) ? (string) GUARDFORGE_MIN_PRO : '' ); } /** * Is the add-on on this site too old to be believed? * * True for exactly one of the three states. 'absent' is not something to * act on — there is nothing installed to disbelieve, and class_exists * already answers it everywhere else — and 'ok' is the normal case. * * WHAT THE FREE SIDE DOES WITH A STALE ADD-ON, and why: it reports its * features as ABSENT, not as present-but-warned-about. * * "Warn and carry on" leaves a feature that is present and lies. An add-on * that predates the contract it is being asked to honour is not working * code for that contract; it is code that answers the wrong question * confidently. Here it is the worst case there is, * because the feature acts without being asked: the alerts, the Cloudflare * push and the AI credit spend are the three things staging mode exists to * stop, and a 1.1.0 add-on cannot stop them while the banner says it has. * * Carrying on also inverts the product's own arithmetic: a CURRENT add-on * whose licence lapsed is switched off, and a STALE one is left running, * because the stale one is precisely the one that cannot be asked. The * more out of date it is, the more of the product it gets. * * Absent is the right shape because it is a state every screen and every * caller here already renders correctly (Guideline 5 — availability means * working code is on disk), and one the operator leaves by pressing * Update. Nothing already stored is touched and the settings the add-on * wrote are kept. * * The whole add-on is taken, not a subset of its features: nothing in this * repository maps a feature slug to the add-on release that introduced its * half of the contract, so there is no honest way to say which of them an * older add-on still holds. */ public static function pro_addon_is_too_old(): bool { return self::installed_pro_status() === 'outdated'; } /** Whether GuardForge Pro is loaded and new enough for this core, which is all the core knows about it. */ public static function addon_active(): bool { return self::installed_pro_status() === 'ok'; } /** * An add-on older than GUARDFORGE_MIN_PRO reaches for the licence class this * core no longer carries (it moved into the add-on with Pro 1.4.0) and * would fatal on boot, so its plugins_loaded callbacks are removed before * they run; GUARDFORGE_Core::pro_addon_outdated_notice asks for an * update. Callbacks that already ran, and callbacks that are not closures * (the add-on's update client is registered by name at priority 1), are * left alone, so the site can still take the update that fixes it. */ public static function keep_outdated_addon_from_booting(): void { global $wp_filter; if ( ! self::pro_addon_is_too_old() || ! defined( 'GUARDFORGE_PRO_DIR' ) || ! isset( $wp_filter['plugins_loaded'] ) ) { return; } $addon_dir = wp_normalize_path( (string) GUARDFORGE_PRO_DIR ); foreach ( $wp_filter['plugins_loaded']->callbacks as $priority => $callbacks ) { foreach ( $callbacks as $callback ) { if ( ! ( $callback['function'] instanceof Closure ) ) { continue; } $file = wp_normalize_path( (string) ( new ReflectionFunction( $callback['function'] ) )->getFileName() ); if ( strpos( $file, $addon_dir ) === 0 ) { remove_action( 'plugins_loaded', $callback['function'], $priority ); } } } } /** * Is an add-on feature both installed and switched on right now? * * The free package can answer exactly one half of that for itself: whether * the add-on's class is on disk. That is the default, and on a free-only * site it is the whole answer — availability decided by the presence of * code, never by a check standing over code that ships in this download * (wp.org Guideline 5, and the rejection of 2026-08-03 behind it). * * The other half only the add-on knows. Its loader require_once's every * module whatever it has switched on, so the class can be present while * the feature is off — and a screen that trusted class_exists alone * handed that customer a live button that saved happily and then did * nothing. The add-on narrows the answer through this filter. * * The filter may only narrow. class_exists is the floor and it is * enforced here rather than trusted to every listener: a screen that drew * a form because something said "available" about a class that is not on * disk does not produce a notice, it produces a fatal. * * @param string $slug Feature slug the add-on registers. * @param string $class Class that ships only in the add-on. * @return bool */ public static function feature_available( $slug, $class ) { if ( ! class_exists( $class ) ) { return false; } // An add-on too old for this core is reported absent, not merely // warned about — it hooks the filter below only if it has heard of // it, so a stale one would leave this answering "available" for a // contract it cannot hold, which is the one failure this helper // exists to prevent. See pro_addon_is_too_old. if ( self::pro_addon_is_too_old() ) { return false; } return (bool) apply_filters( 'guardforge/pro_feature_available', true, (string) $slug ); } /** * Give the add-on somewhere to say why one of its features is not running. * * The free package knows the feature is off and nothing more, and says * nothing here itself; the add-on prints the reason it actually knows. * * @param string $slug Feature slug. */ public static function feature_notice( $slug ) { do_action( 'guardforge/pro_feature_notice', (string) $slug ); } /** Register that a Pro feature exists in this site's Pro add-on. Idempotent. */ public function register_feature( string $slug, array $meta = [] ): void { $this->features[ $slug ] = $meta; } /** Is this Pro feature shipped by the currently-installed Pro add-on? */ public function has_feature( string $slug ): bool { return isset( $this->features[ $slug ] ); } /** Mark a Pro feature as enabled for this environment (the add-on switched it on). */ public function enable_feature( string $slug ): void { $this->enabled[ $slug ] = true; } /** Mark a Pro feature as disabled (the add-on switched it off). */ public function disable_feature( string $slug ): void { unset( $this->enabled[ $slug ] ); } /** * True only when the feature is BOTH registered by Pro AND currently enabled. * Returns false on Free-only sites because Pro never registers anything. */ public function is_enabled( string $slug ): bool { // …and false on a site whose add-on is too old for this core, whatever // that add-on registered and enabled: this is the one chokepoint every // callsite reaches, so a stale add-on is reported absent here rather // than at each caller. if ( self::pro_addon_is_too_old() ) { return false; } return ! empty( $this->enabled[ $slug ] ); } /** Full feature registry — for admin UI listing. */ public function all_features(): array { $out = []; foreach ( $this->features as $slug => $meta ) { $out[] = array_merge( [ 'slug' => $slug, 'enabled' => $this->is_enabled( $slug ) ], $meta ); } return $out; } /** Free-core version — Pro reads this to gate by compatibility. */ public function core_version(): string { return defined( 'GUARDFORGE_VERSION' ) ? GUARDFORGE_VERSION : '0.0.0'; } }