'ids' ) ); foreach ( $site_ids as $site_id ) { switch_to_blog( (int) $site_id ); self::cleanup(); restore_current_blog(); } } else { self::cleanup(); } // The paid version also removes what only its own tools leave behind, // once rather than per site. if ( class_exists( Paid_Writes::class ) ) { Paid_Writes::uninstall(); } } /** * Whether the copy of GripMCP whose code is running is active. * * WordPress deletes only inactive plugins (the Plugins screen, its bulk * action, the REST API and `wp plugin uninstall` all refuse an active * one). So when the running copy is active, the copy being deleted is * the other edition, and the settings, connections and log are the * active copy's. This holds whichever hook called run(), including * Freemius's after_uninstall, which can fire for the inactive copy when * Freemius's record of which file is the paid one is out of date. */ private static function running_copy_active(): bool { if ( ! defined( 'GRIPMCP_BASENAME' ) ) { return false; } if ( ! function_exists( 'is_plugin_active' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } return is_plugin_active( GRIPMCP_BASENAME ); } /** * Whether WordPress is deleting one copy of GripMCP while the other * edition (free or paid) is active and uses the same data. The code * running is then the active copy's, so its basename is not the one in * the uninstall action's name. Freemius makes the same check before * after_uninstall. */ private static function other_edition_loaded(): bool { $filter = (string) current_filter(); if ( 0 !== strpos( $filter, 'uninstall_' ) || ! defined( 'GRIPMCP_BASENAME' ) ) { return false; } return GRIPMCP_BASENAME !== substr( $filter, strlen( 'uninstall_' ) ); } /** * Make sure WordPress will call run() when the plugin is deleted: when * the Freemius SDK (which otherwise owns that hook) is not loaded, and on * deactivation when Freemius did not register its own. The hook is stored * in an option, so this writes only when it is missing, as on a site that * was running the plugin before it moved here. */ public static function ensure_registered(): void { $hooks = (array) get_option( 'uninstall_plugins', array() ); if ( ! isset( $hooks[ GRIPMCP_BASENAME ] ) ) { register_uninstall_hook( GRIPMCP_FILE, array( self::class, 'run' ) ); } } /** * Delete options and the custom tables for the current site. */ private static function cleanup(): void { global $wpdb; delete_option( 'gripmcp_settings' ); delete_option( 'gripmcp_tokens' ); delete_option( 'gripmcp_version' ); delete_option( 'gripmcp_oauth_db_version' ); delete_option( 'gripmcp_rewrite_stamp' ); delete_option( 'gripmcp_approvals_index' ); // Approval requests and what is remembered of them (Approvals\Approval_Request). // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s", $wpdb->esc_like( 'gripmcp_apr_' ) . '%', $wpdb->esc_like( 'gripmcp_apt_' ) . '%' ) ); delete_option( 'gripmcp_log_keep' ); // OAuth connection log: times, app names and IP addresses. delete_option( 'gripmcp_oauth_log' ); delete_option( 'gripmcp_last_signed_in' ); delete_option( 'gripmcp_last_token_issued' ); // The list of theme drafts. The drafts themselves are left in place: each // is an ordinary theme under Appearance → Themes that may hold unpublished // work, may have been activated, and on multisite is shared by every site. delete_option( 'gripmcp_theme_drafts' ); // Transients (pending approvals, preview links and other short-lived // state). They expire on their own; this removes them straight away. // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s", $wpdb->esc_like( '_transient_gripmcp_' ) . '%', $wpdb->esc_like( '_transient_timeout_gripmcp_' ) . '%' ) ); // The crash recorder, and the paid version's temporary debug logging. delete_option( 'gripmcp_debug_logging' ); delete_option( 'gripmcp_crashes' ); self::debug_logs(); // A claim an in-chat upload's finalize left when its request stopped // (Tools\Media\Upload_Session::claim()). // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", $wpdb->esc_like( 'gripmcp_upll_' ) . '%' ) ); // Progress-card tasks and each user's pointer to their current one. // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", $wpdb->esc_like( 'gripmcp_task_' ) . '%' ) ); // Search-and-replace progress: where an unfinished run stopped, and which // finished runs must not be repeated by mistake. // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", $wpdb->esc_like( 'gripmcp_sr_run_' ) . '%' ) ); // Page-builder restore points (the previous layout kept before each // Elementor, WPBakery or Divi edit). The pages themselves are untouched. foreach ( array( '_gripmcp_builder_backup', '_gripmcp_builder_backup_time', '_gripmcp_builder_written', '_gripmcp_elementor_backup', '_gripmcp_elementor_backup_time', '_gripmcp_elementor_backup_settings', '_gripmcp_elementor_written' ) as $meta_key ) { delete_post_meta_by_key( $meta_key ); } // When plugin-dependent tool groups appeared, and each user's record of // when (and under which plan) their AI app loaded the tool list, and // whether it can show panels (per-site user options). delete_option( 'gripmcp_plugin_groups' ); foreach ( array( 'gripmcp_tools_listed_at', 'gripmcp_tools_listed_groups', 'gripmcp_tools_told', 'gripmcp_tools_notice_dismissed', 'gripmcp_tools_listed_plan', 'gripmcp_tools_told_plan', 'gripmcp_client_ui' ) as $user_option ) { delete_metadata( 'user', 0, $wpdb->get_blog_prefix() . $user_option, '', true ); } // Remove the scheduled OAuth cleanup event. $timestamp = wp_next_scheduled( 'gripmcp/oauth_gc' ); if ( $timestamp ) { wp_unschedule_event( $timestamp, 'gripmcp/oauth_gc' ); } $tables = array( $wpdb->prefix . 'gripmcp_logs', $wpdb->prefix . 'gripmcp_oauth_tokens', $wpdb->prefix . 'gripmcp_oauth_clients', ); foreach ( $tables as $table ) { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery, WordPress.DB.DirectDatabaseQuery.SchemaChange $wpdb->query( "DROP TABLE IF EXISTS {$table}" ); } // Remove stored file backups. These are the plugin's own data, so leaving // them behind after an explicit delete would be surprising. $uploads = wp_upload_dir(); if ( empty( $uploads['error'] ) && ! empty( $uploads['basedir'] ) ) { self::rmdir( trailingslashit( $uploads['basedir'] ) . 'gripmcp-backups', $uploads['basedir'] ); // The salt that names their folders (Filesystem\Backup_Manager). delete_option( 'gripmcp_backup_salt' ); // Files staged for an in-chat upload that was never finalized. self::rmdir( trailingslashit( $uploads['basedir'] ) . 'gripmcp-staging', $uploads['basedir'] ); } } /** * Recursively delete a directory, staying inside $base. Also used by the * paid version's part of the uninstall. * * @param string $dir Absolute directory path. * @param string $base Folder $dir must be inside. */ public static function rmdir( string $dir, string $base ): void { // Safety: never recurse outside $base, nor delete $base itself. $real_dir = realpath( $dir ); $real_base = realpath( $base ); if ( false === $real_dir || false === $real_base || 0 !== strpos( $real_dir, $real_base . DIRECTORY_SEPARATOR ) ) { return; } $fs = self::fs(); $items = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( $real_dir, \FilesystemIterator::SKIP_DOTS ), \RecursiveIteratorIterator::CHILD_FIRST ); // Symbolic links are removed as links and never followed. foreach ( $items as $item ) { if ( $item->isDir() && ! $item->isLink() ) { $fs->rmdir( $item->getPathname() ); } else { $fs->delete( $item->getPathname(), false, 'f' ); } } $fs->rmdir( $real_dir ); } /** * Delete this site's temporary debug logs folder (in its uploads folder, * as Support\Debug_Logging keeps it). Only the files the plugin itself * writes there are removed, so anything else placed in it is left alone. */ private static function debug_logs(): void { $uploads = wp_upload_dir( null, false ); if ( ! empty( $uploads['error'] ) || empty( $uploads['basedir'] ) ) { return; } $dir = untrailingslashit( (string) $uploads['basedir'] ) . '/gripmcp-logs'; if ( ! is_dir( $dir ) ) { return; } $files = array_merge( (array) glob( $dir . '/debug-*.log' ), array( $dir . '/.htaccess', $dir . '/index.php', $dir . '/web.config' ) ); foreach ( $files as $file ) { if ( is_string( $file ) && is_file( $file ) ) { wp_delete_file( $file ); } } // Only succeeds once the folder is empty, so other files keep it in place. self::fs()->rmdir( $dir ); } /** * WordPress's direct filesystem class. It needs no credentials, which an * uninstall run from the Plugins screen could not prompt for. */ private static function fs(): \WP_Filesystem_Direct { static $fs = null; if ( null === $fs ) { require_once ABSPATH . 'wp-admin/includes/class-wp-filesystem-base.php'; require_once ABSPATH . 'wp-admin/includes/class-wp-filesystem-direct.php'; $fs = new \WP_Filesystem_Direct( null ); } return $fs; } }