' . esc_html__( 'Watchfox AI checks the plugins, themes and WordPress version of this site against a public list of known security problems. The check runs on your server. Nothing is sent until the site owner finishes setup.', 'watchfox-ai' ) . '
';
$html .= '
' . esc_html__( 'What is sent to api.watchfox.site', 'watchfox-ai' ) . '
';
$html .= '
' . esc_html__( 'When setup is finished, the email address and language the site owner chose, the site address, and the Watchfox AI, WordPress and PHP versions are sent to api.watchfox.site, so we can email warnings.', 'watchfox-ai' ) . '
';
$html .= '
' . esc_html__( 'After each check, a report with only the problems found (an ID, the item folder name, its version, a status, how serious it is and the item name) is sent, together with the email and warning settings. The full list of plugins and themes is never sent. When the plugin is deleted, the site is removed from the service.', 'watchfox-ai' ) . '
';
$html .= '
' . esc_html__( 'The address of your wp-admin is sent too, so the Fix it now button in a warning email opens the right page. If you use Fix it now or Auto-fix, the next report also says what was done: the warning ID, the item folder name and name, the versions before and after, and whether it worked or was put back. Never file contents or backups.', 'watchfox-ai' ) . '
';
$html .= '
' . esc_html__( 'Reports also say whether Auto-fix is on or off. When the plugin is deactivated on the Plugins screen, the site owner can choose to send one reason and a short note. Nothing is sent if they skip it.', 'watchfox-ai' ) . '
';
$html .= '
' . esc_html__( 'Safety ratings: when an update is ready, your site downloads a small public file from api.watchfox.site to see how that update went on other sites. Nothing about your site is sent with that request. When Fix it now or Auto-fix updates something, whether the update was kept or put back is pooled anonymously with the same result from other sites. Only rounded totals (such as "100+ sites") are published, and only once at least 10 different site owners reported the same update.', 'watchfox-ai' ) . '
';
$html .= '
' . wp_kses_post(
sprintf(
/* translators: 1: link to the Watchfox AI privacy policy. */
__( 'Privacy policy: %1$s', 'watchfox-ai' ),
'https://watchfox.site/privacy'
)
) . '
' . wp_kses_post(
sprintf(
/* translators: 1: link to the Brevo privacy policy. */
__( 'Emails are delivered by Brevo. The email address and the name and address of the site are passed to Brevo only to deliver them. Brevo privacy policy: %1$s', 'watchfox-ai' ),
'https://www.brevo.com/legal/privacypolicy/'
)
) . '
';
$html .= '
' . wp_kses_post(
sprintf(
/* translators: 1: link to the Dodo Payments privacy policy. */
__( 'If a paid plan is chosen, payment is handled by Dodo Payments on its own page. Card details never pass through this site. Dodo Payments privacy policy: %1$s', 'watchfox-ai' ),
'https://dodopayments.com/legal/privacy-policy'
)
) . '
';
wp_add_privacy_policy_content( 'Watchfox AI', $html );
}
/** A Settings link on the Plugins screen row, the WordPress way. */
public static function action_links( $links ) {
$done = Watchfox_Settings::is_done();
$extra = array( '' . esc_html( $done ? __( 'Open Watchfox', 'watchfox-ai' ) : __( 'Set up', 'watchfox-ai' ) ) . '' );
if ( $done ) {
$extra[] = '' . esc_html__( 'Settings', 'watchfox-ai' ) . '';
}
return array_merge( $extra, $links );
}
/** Help links under the description on the Plugins screen, all on watchfox.site. */
public static function row_meta( $meta, $file ) {
if ( plugin_basename( WATCHFOX_FILE ) !== $file ) {
return $meta;
}
$es = 0 === strpos( determine_locale(), 'es' ) ? 'es/' : '';
$link = static function ( $path, $label ) use ( $es ) {
return '' . esc_html( $label ) . '';
};
$meta[] = $link( 'help', __( 'Help', 'watchfox-ai' ) );
$meta[] = $link( 'pricing', __( 'Plans', 'watchfox-ai' ) );
$meta[] = $link( 'levels', __( 'Warning levels', 'watchfox-ai' ) );
return $meta;
}
/** Styles and script load only on the two Watchfox screens. */
public static function assets( $hook ) {
if ( ! in_array( (string) $hook, self::$hooks, true ) ) {
return;
}
// Printed inline, not linked: some hosts serve wp-admin over https but build plugin URLs as http, and the browser
// then blocks the files as mixed content, leaving the screens unstyled. Inline styles and script always load.
$dir = plugin_dir_path( WATCHFOX_FILE ) . 'assets/';
$css = self::read_asset( $dir . 'admin.css' );
$css = preg_replace_callback(
'/url\(([a-z-]+\.svg)\)/',
static function ( $m ) use ( $dir ) {
return 'url("data:image/svg+xml,' . rawurlencode( self::read_asset( $dir . $m[1] ) ) . '")';
},
$css
);
wp_register_style( 'watchfox-admin', false, array(), WATCHFOX_VERSION );
wp_enqueue_style( 'watchfox-admin' );
wp_add_inline_style( 'watchfox-admin', $css );
wp_register_script( 'watchfox-admin', false, array(), WATCHFOX_VERSION, true );
wp_enqueue_script( 'watchfox-admin' );
wp_add_inline_script( 'watchfox-admin', self::read_asset( $dir . 'admin.js' ) );
}
/** The screen hooks of the Watchfox screens (filled on admin_menu). */
public static function screens() {
return self::$hooks;
}
/** Reads one of the plugin's own bundled asset files; never a remote URL. */
public static function read_asset( $file ) {
if ( ! is_readable( $file ) ) {
return '';
}
$text = file_get_contents( $file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- a local file shipped with the plugin.
return false === $text ? '' : $text;
}
private static function icon() {
$svg = '';
// Base64 on purpose: this is the format WordPress documents for add_menu_page(), and only a base64 data URI makes
// core's svg-painter script recolour the icon to match the admin colour scheme (a plain data URI is shown as a fixed image).
return 'data:image/svg+xml;base64,' . base64_encode( $svg ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- WP-documented menu icon format.
}
public static function today_count() {
$n = 0;
foreach ( Watchfox_Checker::findings() as $f ) {
if ( 'urgent' === $f['level'] ) {
$n++;
}
}
return $n;
}
private static function lang() {
return Watchfox_Words::lang_from_locale( function_exists( 'get_user_locale' ) ? get_user_locale() : get_locale() );
}
/* ---------- the shared frame ---------- */
/** The Watchfox header on every screen: the fox mark, the name, a status chip and the two screens. */
private static function header( $current, $setup = false ) {
list( $chip, $tone ) = self::status_chip( $setup );
echo '';
echo '
';
echo '' . esc_html( $chip ) . '';
if ( Watchfox_Settings::is_done() ) {
echo '';
}
echo '';
}
/** The words and tone of the header chip: Setting up, Needs you, Trial: X days left or Protected. */
public static function status_chip( $setup = false ) {
if ( ! Watchfox_Settings::is_done() ) {
return array( __( 'Setting up', 'watchfox-ai' ), 'calm' );
}
$state = Watchfox_Hub::is_registered() ? Watchfox_Hub::state() : array( 'confirmed' => true );
$unconfirmed = true !== $state['confirmed'];
if ( $setup ) {
// The last setup step: still setting up, or waiting for the confirm click.
return $unconfirmed ? array( __( 'Confirm your email', 'watchfox-ai' ), 'calm' ) : array( __( 'Setting up', 'watchfox-ai' ), 'calm' );
}
if ( self::today_count() > 0 || Watchfox_Hub::copy_detected() || Watchfox_Hub::connection_lost() ) {
return array( __( 'Needs you', 'watchfox-ai' ), 'alert' );
}
if ( $unconfirmed ) {
return array( __( 'Confirm your email', 'watchfox-ai' ), 'alert' );
}
$p = Watchfox_Hub::plan();
if ( null !== $p && 'trial' === $p['name'] && ! empty( $p['confirmed'] ) ) {
return array( self::plan_sentence( $p ), 'calm' );
}
return array( __( 'Protected', 'watchfox-ai' ), 'ok' );
}
/** A level pill as in the emails: shape and word, never colour alone. */
private static function pill( $level, $lang ) {
$shape = array( 'urgent' => '●', 'possible' => '▲', 'low' => '■' );
return '' . esc_html( $shape[ $level ] ) . ' ' . esc_html( Watchfox_Words::level( $level, $lang ) ) . '';
}
private static function form_open( $action, $class = '' ) {
echo '';
return;
}
$s = Watchfox_Settings::get();
$email = null !== $state['pending_email'] ? $state['pending_email'] : $s['email'];
// The same calm line for everyone: it never says whether a purchase is waiting.
echo '
✉
';
echo '
' . esc_html__( 'Confirm your email', 'watchfox-ai' ) . '
';
/* translators: %s: email address */
echo '
' . esc_html( sprintf( __( 'Check your inbox and click Confirm. We sent it to %s. If you\'ve bought Pro, it switches on then.', 'watchfox-ai' ), $email ) ) . '
';
if ( ! empty( $state['limited'] ) ) {
echo '
' . esc_html__( 'Too many emails today. Try again tomorrow.', 'watchfox-ai' ) . '
';
}
public static function top_line( array $shown, $unknown_count ) {
$by = array( 'urgent' => 0, 'possible' => 0, 'low' => 0 );
foreach ( self::group_findings( $shown ) as $g ) {
$by[ $g[0]['level'] ]++;
}
if ( $by['urgent'] > 0 ) {
/* translators: %d: number of things */
return sprintf( _n( '%d thing to do today', '%d things to do today', $by['urgent'], 'watchfox-ai' ), $by['urgent'] );
}
if ( $by['possible'] > 0 ) {
/* translators: %d: number of things */
return sprintf( _n( '%d thing to do this week', '%d things to do this week', $by['possible'], 'watchfox-ai' ), $by['possible'] );
}
if ( $by['low'] > 0 ) {
/* translators: %d: number of things */
return sprintf( _n( '%d thing to do when you can', '%d things to do when you can', $by['low'], 'watchfox-ai' ), $by['low'] );
}
return __( 'All quiet', 'watchfox-ai' );
}
/**
* Groups findings by component (level, ecosystem, slug, version), in the order first seen: one group is one card.
*
* @return array[] Each group is a non-empty list of findings.
*/
public static function group_findings( array $findings ) {
$groups = array();
foreach ( $findings as $f ) {
$key = ( isset( $f['level'] ) ? (string) $f['level'] : '' ) . '|' . ( isset( $f['ecosystem'] ) ? $f['ecosystem'] : '' ) . '|' . ( isset( $f['slug'] ) ? strtolower( $f['slug'] ) : '' ) . '|' . ( isset( $f['version'] ) ? $f['version'] : '' );
$groups[ $key ][] = $f;
}
return array_values( $groups );
}
/** Findings the page lists: a level, not muted. */
public static function split_findings( array $all ) {
$shown = array();
$unknown = array();
foreach ( $all as $f ) {
if ( 'unknown' === $f['status'] ) {
$unknown[] = $f;
} elseif ( null !== $f['level'] && ( 'urgent' === $f['level'] || ! Watchfox_Settings::is_muted( $f['threat_id'] ) ) ) {
$shown[] = $f;
}
}
return array( $shown, $unknown );
}
/** All problem notices in one place, one style; the raw code sits behind a toggle. */
private static function render_notices() {
$items = array();
if ( Watchfox_Hub::copy_detected() || Watchfox_Hub::connection_lost() ) {
$items[] = array( 'warn', Watchfox_Hub::copy_detected() ? __( 'This looks like a copy of your site. Set it up again to watch it.', 'watchfox-ai' ) : __( 'Watchfox lost its connection. Set it up again.', 'watchfox-ai' ), 'reset', '' );
}
$net = Watchfox_Hub::last_net_error();
if ( '' !== $net ) {
$items[] = array( 'info', __( 'Something went wrong reaching Watchfox. We will try again soon.', 'watchfox-ai' ), '', $net );
}
if ( Watchfox_List::is_stale() ) {
$items[] = array( 'info', __( "We couldn't get the latest list. Your last check is still shown.", 'watchfox-ai' ), '', '' );
}
if ( ! $items ) {
return;
}
echo '
✓' . esc_html__( 'No known problems', 'watchfox-ai' ) . '
';
}
echo '
' . ( $quiet ? '' : '' ) . '';
}
/** The right rail of the main page: status with Check again, the confirm card, the levels link and the privacy line. */
private static function render_rail( $lang ) {
$checked = Watchfox_Checker::checked_at();
$listed = Watchfox_List::last_updated();
echo '';
}
/** One empty state for every screen: a short heading, one helpful line, an optional small note, and the fox only where asked. */
private static function empty_state( $fox, $title, $text, $note = '' ) {
echo '
';
if ( '' !== $fox ) {
echo '';
}
echo '
' . esc_html( $title ) . '
' . esc_html( $text ) . '
';
if ( '' !== $note ) {
echo '
' . esc_html( $note ) . '
';
}
echo '
';
}
/** One quiet line about the plan, with a link to the Plans tab. */
private static function render_plan_line() {
$p = Watchfox_Hub::plan();
if ( null === $p ) {
return;
}
echo '
'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- pill() escapes.
}
if ( $count > 1 ) {
/* translators: %d: number of security problems found in one item */
echo '
';
self::empty_state( '', __( 'Nothing is muted', 'watchfox-ai' ), __( 'When you stop a warning, it shows up here so you can undo it.', 'watchfox-ai' ) );
echo '