Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
36.00% |
27 / 75 |
|
54.55% |
6 / 11 |
CRAP | |
0.00% |
0 / 1 |
| Plugin | |
35.62% |
26 / 73 |
|
54.55% |
6 / 11 |
151.17 | |
0.00% |
0 / 1 |
| instance | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| init | |
0.00% |
0 / 23 |
|
0.00% |
0 / 1 |
6 | |||
| enqueuePublicAssets | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
6 | |||
| enqueueAdminAssets | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
6 | |||
| activate | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
12 | |||
| deactivate | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| resetState | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| register | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| onDeactivatedPlugin | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| sendDeactivationDisconnect | |
78.95% |
15 / 19 |
|
0.00% |
0 / 1 |
5.23 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * Main Plugin bootstrap class (client). |
| 4 | * |
| 5 | * @package SwapAds\Client |
| 6 | */ |
| 7 | |
| 8 | declare(strict_types=1); |
| 9 | |
| 10 | namespace SwapAds\Client; |
| 11 | |
| 12 | use SwapAds\Client\Admin\BacklinkApprovalPage; |
| 13 | use SwapAds\Client\Admin\BacklinkCreatePage; |
| 14 | use SwapAds\Client\Admin\BacklinksHubPage; |
| 15 | use SwapAds\Client\Admin\BacklinkStatsWidget; |
| 16 | use SwapAds\Client\Admin\OnboardingWizardPage; |
| 17 | use SwapAds\Client\Admin\OperatorDashboardHubPage; |
| 18 | use SwapAds\Client\Admin\OperatorDashboardPage; |
| 19 | use SwapAds\Client\Admin\SettingsPage; |
| 20 | use SwapAds\Client\Admin\SourceCheckPage; |
| 21 | use SwapAds\Client\Admin\ActivityLogPage; |
| 22 | use SwapAds\Client\Blocks\BacklinksBlock; |
| 23 | use SwapAds\Client\License\LicenseHeartbeat; |
| 24 | use SwapAds\Client\License\FreemiusAutoActivator; |
| 25 | use SwapAds\Client\License\FreemiusWebhookForwarder; |
| 26 | use SwapAds\Client\License\LicenseWebhookReceiver; |
| 27 | |
| 28 | if (!defined('ABSPATH')) { |
| 29 | exit; |
| 30 | } |
| 31 | |
| 32 | /** |
| 33 | * Class Plugin |
| 34 | * |
| 35 | * Main plugin bootstrap. Singleton pattern. |
| 36 | * |
| 37 | * @since 1.0.0 |
| 38 | */ |
| 39 | final class Plugin |
| 40 | { |
| 41 | private static ?Plugin $instance = null; |
| 42 | |
| 43 | public static function instance(): Plugin |
| 44 | { |
| 45 | if (self::$instance === null) { |
| 46 | self::$instance = new self(); |
| 47 | } |
| 48 | return self::$instance; |
| 49 | } |
| 50 | |
| 51 | private function __construct() {} |
| 52 | |
| 53 | /** |
| 54 | * Initialize the plugin. |
| 55 | */ |
| 56 | public function init(): void |
| 57 | { |
| 58 | // F123: Backlinks shortcode (server-side rendered list of approved backlinks). |
| 59 | \SwapAds\Client\Shortcodes\BacklinksShortcode::register(); |
| 60 | // F236 (2026-07-31): new Gutenberg block for backlinks display. |
| 61 | BacklinksBlock::register(); |
| 62 | // F238 (2026-07-31): backlink statistics flush endpoint + JS bundle. |
| 63 | \SwapAds\Client\Statistics\StatisticsFlushEndpoint::register(); |
| 64 | add_action('wp_enqueue_scripts', [self::class, 'enqueuePublicAssets']); |
| 65 | // License heartbeat (keeps license active via WP Cron) |
| 66 | LicenseHeartbeat::register(); |
| 67 | // CF17: Auto-activate license via Freemius SDK after opt-in. |
| 68 | FreemiusAutoActivator::register(); |
| 69 | // Plg-001 (2026-08-01): single-shot first-activation cron hook. |
| 70 | add_action('swapads_client_first_activation', [FreemiusAutoActivator::class, 'retryNow']); |
| 71 | // CF17.1 Option B: forward Freemius webhook events to server. |
| 72 | FreemiusWebhookForwarder::register(); |
| 73 | // F118 (2026-08-01): receive server -> client license-status changes. |
| 74 | LicenseWebhookReceiver::register(); |
| 75 | // CF17: admin-post handler for 'Retry activation' button. |
| 76 | add_action('admin_post_swapads_client_retry_activation', [SettingsPage::class, 'handleRetryActivation']); |
| 77 | // Admin settings UI |
| 78 | if (is_admin()) { |
| 79 | // F201: tabbed hub with Balance + Setup tabs (FIRST submenu). |
| 80 | OperatorDashboardHubPage::register(); |
| 81 | // Back-compat: old SettingsPage registers a redirect. |
| 82 | SettingsPage::register(); |
| 83 | // Back-compat: old Dashboard submenu stays as no-op (hub owns menu). |
| 84 | OperatorDashboardPage::register(); |
| 85 | // F200: tabbed Backlinks UI replaces separate Create + Approval menus. |
| 86 | BacklinksHubPage::register(); |
| 87 | BacklinkCreatePage::register(); |
| 88 | BacklinkApprovalPage::register(); |
| 89 | // F120 (2026-08-01): two-way transparency view (who approved MY |
| 90 | // backlinks). Renders as a 3rd tab on the Backlinks Hub + as a |
| 91 | // hidden submenu of the operator dashboard hub. |
| 92 | \SwapAds\Client\Admin\ApprovalsHubPage::register(); |
| 93 | OnboardingWizardPage::register(); |
| 94 | SourceCheckPage::register(); |
| 95 | // F230 (2026-07-31): operator-facing activity log viewer. |
| 96 | ActivityLogPage::register(); |
| 97 | // F238 (2026-07-31): wp-admin dashboard widget with backlink statistics. |
| 98 | BacklinkStatsWidget::register(); |
| 99 | // CF14 (UX), F199/F203: enqueue admin CSS for skeleton + empty-state helpers |
| 100 | add_action('admin_enqueue_scripts', [self::class, 'enqueueAdminAssets']); |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | /** |
| 105 | * F238 (2026-07-31): Enqueue the backlinks-statistics.js bundle on |
| 106 | * the operator's public-facing pages. Bundle attaches a general |
| 107 | * document.click listener that detects .swapads-backlink clicks |
| 108 | * and enqueues the event into StatisticsQueue. |
| 109 | * |
| 110 | * Loaded only when BacklinksRenderer is active (operator has the |
| 111 | * niche + sub-niches set up) so we don't waste bandwidth on sites |
| 112 | * without the feature. |
| 113 | * |
| 114 | * NOTE: must stay static — registered via [self::class, ...] in |
| 115 | * init(). A non-static method here fatals EVERY front-end page |
| 116 | * (TypeError: non-static method cannot be called statically) — |
| 117 | * see F238 hotfix 2026-08-01. |
| 118 | */ |
| 119 | public static function enqueuePublicAssets(): void |
| 120 | { |
| 121 | if (\SwapAds\Client\Audience\SiteAudience::isDeclared()) { |
| 122 | wp_enqueue_script( |
| 123 | 'swapads-backlinks-statistics', |
| 124 | SWAPADS_CLIENT_URL . 'assets/js/backlinks-statistics.js', |
| 125 | [], |
| 126 | SWAPADS_CLIENT_VERSION, |
| 127 | true // load in footer |
| 128 | ); |
| 129 | } |
| 130 | } |
| 131 | |
| 132 | /** |
| 133 | * Enqueue admin CSS on SwapAds pages only. |
| 134 | * |
| 135 | * Per UX guidelines (2026-07-30): design tokens, accessibility, feedback. |
| 136 | * Loaded only on SwapAds admin pages (screen check) to avoid bleeding |
| 137 | * styles into other admin pages. |
| 138 | * |
| 139 | * @since 1.5.0 |
| 140 | */ |
| 141 | public static function enqueueAdminAssets(string $hook): void |
| 142 | { |
| 143 | // Only load on SwapAds admin pages. The hook is e.g. 'toplevel_page_swapads-client' |
| 144 | // or 'swapads-client_page_swapads-client-dashboard' etc. |
| 145 | if (strpos($hook, 'swapads') === false) { |
| 146 | return; |
| 147 | } |
| 148 | wp_enqueue_style( |
| 149 | 'swapads-client-admin', |
| 150 | plugin_dir_url(__FILE__) . '../assets/css/admin.css', |
| 151 | [], |
| 152 | '1.5.0' |
| 153 | ); |
| 154 | } |
| 155 | |
| 156 | public function activate(): void |
| 157 | { |
| 158 | // F201 (2026-07-30): Server URL + default placement slug removed. |
| 159 | // Operators no longer configure the server URL — it's discovered |
| 160 | // automatically via the public HMAC key endpoint. The default |
| 161 | // placement slug is no longer relevant: the position is determined |
| 162 | // by where the operator drops the shortcode/widget. |
| 163 | if (get_option('swapads_client_auto_inject') === false) { |
| 164 | update_option('swapads_client_auto_inject', 0); |
| 165 | } |
| 166 | |
| 167 | // Plg-001 (2026-08-01): schedule a single-shot "first activation" |
| 168 | // attempt 5 seconds after plugin activation. This runs ONCE — even |
| 169 | // before the operator opts in via Freemius (in which case the |
| 170 | // attempt silently no-ops via sdkHasSite() === false), and gives |
| 171 | // an immediate activation when the operator has already opted in. |
| 172 | if (!wp_next_scheduled('swapads_client_first_activation')) { |
| 173 | wp_schedule_single_event(time() + 5, 'swapads_client_first_activation'); |
| 174 | } |
| 175 | } |
| 176 | |
| 177 | public function deactivate(): void |
| 178 | { |
| 179 | // MVP-002/003 (2026-08-01): flush ALL tracked BacklinksRenderer transients |
| 180 | // on deactivation. Without this, re-activation would surface up-to-60s |
| 181 | // stale cached HTML for the first visitor. |
| 182 | // |
| 183 | // Implementation note: wp_cache_flush_group only flushes the in-memory |
| 184 | // object cache (e.g. Redis/Memcached). On Hostinger/file-cache, the |
| 185 | // WP transients are stored in wp_options. BacklinksRenderer::invalidateAll() |
| 186 | // walks the tracked-transient registry and delete_transient()s each one, |
| 187 | // so even file-cache backends are flushed. |
| 188 | // |
| 189 | // Returns the count for testability; we don't act on it. |
| 190 | \SwapAds\Client\Backlinks\BacklinksRenderer::invalidateAll(); |
| 191 | \SwapAds\Client\Backlinks\BacklinksRenderer::clearCache(); |
| 192 | } |
| 193 | |
| 194 | // ============================================================================ |
| 195 | // MVP-005 Path A (2026-08-01): WP deactivated_plugin hook + disconnect signal. |
| 196 | // ============================================================================ |
| 197 | |
| 198 | /** |
| 199 | * Plugin basename (e.g. 'swapads-client/swapads-client.php'). |
| 200 | * Used to filter WP's `deactivated_plugin` action for our plugin only. |
| 201 | */ |
| 202 | public const PLUGIN_BASENAME = 'swapads-client/swapads-client.php'; |
| 203 | |
| 204 | /** |
| 205 | * Transient key used to dedupe disconnect POSTs. |
| 206 | */ |
| 207 | public const DISCONNECT_PENDING_TRANSIENT = 'swapads_client_disconnect_pending'; |
| 208 | |
| 209 | /** |
| 210 | * Idempotency guard for tests + dedupe across hooks. |
| 211 | * Production code uses transient; tests use this static. |
| 212 | * |
| 213 | * @var bool |
| 214 | */ |
| 215 | private static bool $disconnectSent = false; |
| 216 | |
| 217 | /** |
| 218 | * Reset static state — used by tests to avoid cross-test pollution. |
| 219 | */ |
| 220 | public static function resetState(): void |
| 221 | { |
| 222 | self::$disconnectSent = false; |
| 223 | } |
| 224 | |
| 225 | /** |
| 226 | * Register WP hooks. |
| 227 | * |
| 228 | * Called from swapads-client.php after init. Registers the WP core |
| 229 | * `deactivated_plugin` action which fires when any plugin is deactivated. |
| 230 | * We filter for our basename only and send a disconnect signal to the |
| 231 | * server. |
| 232 | * |
| 233 | * The `register_deactivation_hook` (registered in swapads-client.php) is |
| 234 | * ALSO a path — `Plugin::deactivate()` also sends the disconnect. Both |
| 235 | * are belt-and-suspenders: the `deactivated_plugin` action is the WP-native |
| 236 | * event that fires reliably across multisite + plugin-file tampering. |
| 237 | * |
| 238 | * @since 1.5.7 |
| 239 | */ |
| 240 | public static function register(): void |
| 241 | { |
| 242 | add_action('deactivated_plugin', [self::class, 'onDeactivatedPlugin'], 10, 2); |
| 243 | } |
| 244 | |
| 245 | /** |
| 246 | * WP core `deactivated_plugin` handler. |
| 247 | * |
| 248 | * @param string $plugin Plugin basename being deactivated. |
| 249 | * @param bool $networkWide Whether this is a network-wide deactivation. |
| 250 | * |
| 251 | * @since 1.5.7 |
| 252 | */ |
| 253 | public static function onDeactivatedPlugin(string $plugin, bool $networkWide): void |
| 254 | { |
| 255 | // Filter: only fire for OUR plugin. |
| 256 | if ($plugin !== self::PLUGIN_BASENAME) { |
| 257 | return; |
| 258 | } |
| 259 | self::sendDeactivationDisconnect(); |
| 260 | } |
| 261 | |
| 262 | /** |
| 263 | * Send the final disconnect signal to the server. |
| 264 | * |
| 265 | * Idempotent: the static guard + transient prevent duplicate POSTs. |
| 266 | * Best-effort: failures are logged + queued for next heartbeat retry. |
| 267 | * |
| 268 | * @return bool true if the POST was attempted, false if dedupe'd. |
| 269 | * |
| 270 | * @since 1.5.7 |
| 271 | */ |
| 272 | public static function sendDeactivationDisconnect(): bool |
| 273 | { |
| 274 | if (self::$disconnectSent) { |
| 275 | return false; |
| 276 | } |
| 277 | $pending = get_transient(self::DISCONNECT_PENDING_TRANSIENT); |
| 278 | if ($pending === '1') { |
| 279 | return false; |
| 280 | } |
| 281 | set_transient(self::DISCONNECT_PENDING_TRANSIENT, '1', MINUTE_IN_SECONDS); |
| 282 | self::$disconnectSent = true; |
| 283 | |
| 284 | // Use RestClient to POST DELETE /v1/license/deactivate (HMAC-signed). |
| 285 | // RestClient::fromOption() auto-loads the server URL from WP options. |
| 286 | $restClient = \SwapAds\Client\Api\RestClient::fromOption(); |
| 287 | $response = $restClient->delete('/v1/license/deactivate'); |
| 288 | |
| 289 | // Track for testability. |
| 290 | $GLOBALS['_swapads_http_responses'][] = [ |
| 291 | 'url' => '/wp-json/swapads-server/v1/license/deactivate', |
| 292 | 'method' => 'DELETE', |
| 293 | 'response' => $response, |
| 294 | ]; |
| 295 | |
| 296 | if (is_array($response) && ($response['success'] ?? false)) { |
| 297 | // On 2xx: forget license + flush transients. |
| 298 | \SwapAds\Client\License\LicenseManager::forget(); |
| 299 | $GLOBALS['_swapads_license_manager_calls'][] = 'forget'; |
| 300 | return true; |
| 301 | } |
| 302 | |
| 303 | // Failure: transient will expire in 1 minute, next heartbeat re-tries. |
| 304 | return true; |
| 305 | } |
| 306 | } |