| Code Coverage
| Lines | Functions and Methods | Classes and Traits
Total | 36.00% covered (danger) | 36.00% | 27 / 75 | 54.55% covered (warning) | 54.55% | 6 / 11 | CRAP | 0.00% covered (danger) | 0.00% | 0 / 1
Plugin | 35.62% covered (danger) | 35.62% | 26 / 73 | 54.55% covered (warning) | 54.55% | 6 / 11 | 151.17 | 0.00% covered (danger) | 0.00% | 0 / 1
instance | 100.00% covered (success) | 100.00% | 3 / 3 | 100.00% covered (success) | 100.00% | 1 / 1 | 2 |
__construct | 100.00% covered (success) | 100.00% | 1 / 1 | 100.00% covered (success) | 100.00% | 1 / 1 | 1 |
init | 0.00% covered (danger) | 0.00% | 0 / 23 | 0.00% covered (danger) | 0.00% | 0 / 1 | 6 |
enqueuePublicAssets | 0.00% covered (danger) | 0.00% | 0 / 8 | 0.00% covered (danger) | 0.00% | 0 / 1 | 6 |
enqueueAdminAssets | 0.00% covered (danger) | 0.00% | 0 / 8 | 0.00% covered (danger) | 0.00% | 0 / 1 | 6 |
activate | 0.00% covered (danger) | 0.00% | 0 / 4 | 0.00% covered (danger) | 0.00% | 0 / 1 | 12 |
deactivate | 100.00% covered (success) | 100.00% | 2 / 2 | 100.00% covered (success) | 100.00% | 1 / 1 | 1 |
resetState | 100.00% covered (success) | 100.00% | 1 / 1 | 100.00% covered (success) | 100.00% | 1 / 1 | 1 |
register | 100.00% covered (success) | 100.00% | 1 / 1 | 100.00% covered (success) | 100.00% | 1 / 1 | 1 |
onDeactivatedPlugin | 100.00% covered (success) | 100.00% | 3 / 3 | 100.00% covered (success) | 100.00% | 1 / 1 | 2 |
sendDeactivationDisconnect | 78.95% covered (warning) | 78.95% | 15 / 19 | 0.00% covered (danger) | 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 | }