*/ private static $outer = array(); /** * Whether the caller may see file and line details. * * @var bool */ private static $admin = false; /** * Whether the shutdown filter is added. * * @var bool */ private static $hooked = false; public static function boot(): void { add_action( 'admin_post_handymcp_crash_dismiss', array( __CLASS__, 'dismiss' ) ); } /** * Start watching one tool call. * * @param string $tool Tool name. * @param bool $admin Caller can manage options. */ public static function start( string $tool, bool $admin ): void { self::$outer[] = array( self::$tool, self::$admin ); self::$tool = $tool; self::$admin = $admin; if ( ! self::$hooked ) { self::$hooked = true; add_filter( 'wp_php_error_message', array( __CLASS__, 'fatal_message' ), 10, 2 ); } } public static function stop(): void { $prev = array_pop( self::$outer ); self::$tool = is_array( $prev ) ? (string) $prev[0] : ''; self::$admin = is_array( $prev ) && ! empty( $prev[1] ); } /** * Who owns a file: a plugin, a theme, WordPress itself or HandyMCP. * * @param string $file File path. * @return array{type: string, slug: string, name: string, file: string} */ public static function owner( string $file ): array { $path = wp_normalize_path( $file ); $roots = array( 'plugin' => array( wp_normalize_path( WP_PLUGIN_DIR ), wp_normalize_path( WPMU_PLUGIN_DIR ) ), 'theme' => array( wp_normalize_path( get_theme_root() ) ), ); foreach ( $roots as $type => $dirs ) { foreach ( $dirs as $dir ) { $dir = trailingslashit( $dir ); if ( '' === $dir || 0 !== strpos( $path, $dir ) ) { continue; } $rel = substr( $path, strlen( $dir ) ); $slug = (string) strtok( $rel, '/' ); if ( 'plugin' === $type && in_array( $slug, array( dirname( plugin_basename( HANDYMCP_FILE ) ), 'handymcp', 'handymcp-pro' ), true ) ) { return array( 'type' => 'self', 'slug' => $slug, 'name' => 'HandyMCP', 'file' => $rel ); } return array( 'type' => $type, 'slug' => $slug, 'name' => self::label( $type, $slug ), 'file' => $rel ); } } $abs = trailingslashit( wp_normalize_path( ABSPATH ) ); return array( 'type' => 'core', 'slug' => '', 'name' => 'WordPress', 'file' => 0 === strpos( $path, $abs ) ? substr( $path, strlen( $abs ) ) : basename( $path ) ); } /** * Readable plugin or theme name. * * @param string $type plugin or theme. * @param string $slug Folder name. */ private static function label( string $type, string $slug ): string { if ( 'theme' === $type ) { $theme = wp_get_theme( $slug ); return $theme->exists() ? (string) $theme->get( 'Name' ) : $slug; } if ( ! function_exists( 'get_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } foreach ( get_plugins() as $file => $data ) { if ( dirname( (string) $file ) === $slug && ! empty( $data['Name'] ) ) { return (string) $data['Name']; } } return $slug; } /** * The first plugin or theme in an exception's call stack (the thrower may be WordPress core). * * @param \Throwable $e Exception. * @return array{type: string, slug: string, name: string, file: string, line: int} */ public static function from_throwable( \Throwable $e ): array { $frames = array_merge( array( array( 'file' => $e->getFile(), 'line' => $e->getLine() ) ), $e->getTrace() ); $first = null; foreach ( $frames as $f ) { if ( empty( $f['file'] ) ) { continue; } $o = self::owner( (string) $f['file'] ); $o['line'] = (int) ( $f['line'] ?? 0 ); if ( null === $first ) { $first = $o; } if ( in_array( $o['type'], array( 'plugin', 'theme' ), true ) ) { return $o; } if ( 'self' === $o['type'] ) { break; } } return $first ?? array( 'type' => 'core', 'slug' => '', 'name' => 'WordPress', 'file' => '', 'line' => 0 ); } /** * Message for the AI app. File and line only for admins, never the raw error text. * * @param array $o Owner with line. * @param string $tool Tool name. */ public static function message( array $o, string $tool ): string { $where = self::$admin && '' !== (string) $o['file'] ? ' (' . $o['file'] . ( ! empty( $o['line'] ) ? ' line ' . (int) $o['line'] : '' ) . ')' : ''; if ( 'plugin' === $o['type'] || 'theme' === $o['type'] ) { $who = ( 'theme' === $o['type'] ? 'The theme ' : 'The plugin ' ) . $o['name']; return $who . ' crashed while ' . $tool . ' was running' . $where . '. The change may or may not have been saved: read the item again to check before trying again, and do not repeat the same call blindly. Tell the user that ' . $o['name'] . ' caused the error, so they can update it or contact its author.'; } return 'The tool hit an internal error while running ' . $tool . $where . '. The change may or may not have been saved: read the item again to check before trying again.'; } /** * Save the last crash for a one-time notice in the plugin screens. * * @param array $o Owner with line. * @param string $tool Tool name. */ public static function record( array $o, string $tool ): void { update_option( self::OPTION, array( 'time' => time(), 'tool' => sanitize_key( $tool ), 'type' => sanitize_key( (string) $o['type'] ), 'name' => sanitize_text_field( (string) $o['name'] ), 'file' => sanitize_text_field( (string) $o['file'] ), 'line' => (int) ( $o['line'] ?? 0 ), ), false ); } /** * Replace WordPress's fatal error text while a tool runs. * * @param string $message Default message. * @param array $error Error from error_get_last(). */ public static function fatal_message( $message, $error ) { if ( '' === self::$tool || ! is_array( $error ) || empty( $error['file'] ) ) { return $message; } $o = self::owner( (string) $error['file'] ); $o['line'] = (int) ( $error['line'] ?? 0 ); $tool = self::$tool; self::$tool = ''; self::record( $o, $tool ); return esc_html( self::message( $o, $tool ) ); } /** * One-time notice in the HandyMCP screens about the last crash. */ public static function notice(): void { $c = get_option( self::OPTION ); if ( ! is_array( $c ) || ! current_user_can( 'manage_options' ) || (int) ( $c['time'] ?? 0 ) < time() - 7 * DAY_IN_SECONDS ) { return; } $who = in_array( $c['type'] ?? '', array( 'plugin', 'theme' ), true ) ? (string) $c['name'] : __( 'WordPress or HandyMCP', 'handymcp' ); $url = wp_nonce_url( admin_url( 'admin-post.php?action=handymcp_crash_dismiss' ), 'handymcp_crash_dismiss' ); $file = '' !== (string) ( $c['file'] ?? '' ) ? sprintf( /* translators: 1: file, 2: line. */ __( 'File: %1$s, line %2$d', 'handymcp' ), (string) $c['file'], (int) $c['line'] ) : ''; $tool = (string) $c['tool']; $all = \HandyMCP\Plugin::instance()->container()->get( 'registry' )->all(); $tool = isset( $all[ $tool ]['title'] ) && '' !== (string) $all[ $tool ]['title'] ? (string) $all[ $tool ]['title'] : $tool; /* translators: 1: plugin or theme name, 2: what the AI app was doing, 3: date. */ $text = sprintf( __( '%1$s crashed while an AI app was running "%2$s" (%3$s). Update it or contact its author if this keeps happening.', 'handymcp' ), $who, $tool, wp_date( 'M j, H:i', (int) $c['time'] ) ); echo ''; } public static function dismiss(): void { check_admin_referer( 'handymcp_crash_dismiss' ); if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Sorry, you are not allowed to do that.', 'handymcp' ) ); } delete_option( self::OPTION ); wp_safe_redirect( wp_get_referer() ? wp_get_referer() : admin_url() ); exit; } }