# woocommerce-pos/1.10.21/includes/Services/Cloud_Print_Registry.php

WCPOS – Point of Sale (POS) plugin for WooCommerce, version 1.10.21. 507 lines.

- Page: https://pluginprobe.com/plugins/woocommerce-pos/1.10.21/code/includes/Services/Cloud_Print_Registry.php
- Raw: https://pluginprobe.com/plugins/woocommerce-pos/1.10.21/raw/includes/Services/Cloud_Print_Registry.php
- Modified: 2026-08-25T07:52:20+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/woocommerce-pos/1.10.21/code/includes/Services/Cloud_Print_Registry.php#L10-L20`.

```php
<?php
/**
 * Cloud printer registry (reads woocommerce_pos_settings_cloud_print).
 *
 * @package WCPOS\WooCommercePOS\Services
 */

namespace WCPOS\WooCommercePOS\Services;

/**
 * Cloud_Print_Registry class.
 */
class Cloud_Print_Registry {
	const OPTION = 'woocommerce_pos_settings_cloud_print';

	const RUNTIME_OPTION = 'woocommerce_pos_cloud_print_runtime';
	const SEEN_TTL       = 150; // Seconds; connected if seen within this window.
	const PN_STATUS_TTL  = 60;  // Seconds; PrintNode live-status cache window.

	/**
	 * Per-printer CloudPRNT client capabilities, keyed by printer id.
	 *
	 * Separate from RUNTIME_OPTION, whose values are bare last-seen timestamps.
	 */
	const CAPABILITIES_OPTION = 'woocommerce_pos_cloud_print_capabilities';

	/**
	 * Seconds before a printer's capability answers are asked for again.
	 *
	 * Star's own WooCommerce plugin re-asks every 120 seconds; matching it keeps
	 * a swapped-out printer on a reused id from serving formats the new hardware
	 * cannot decode for longer than a couple of minutes.
	 */
	const CAPABILITIES_TTL = 120;

	/**
	 * Mutex guarding read-modify-write of the shared capabilities option.
	 */
	const CAPABILITIES_LOCK = 'wcpos_cloud_print_caps_lock';

	/**
	 * Seconds after which a capabilities lock is treated as abandoned.
	 *
	 * Short on purpose: the critical section is one option write, so a lock
	 * older than this belongs to a request that died holding it.
	 */
	const CAPABILITIES_LOCK_TTL = 10;

	/**
	 * All registered cloud printers.
	 *
	 * @return array<int, array>
	 */
	public function get_printers(): array {
		$settings = get_option( self::OPTION, array() );

		return isset( $settings['printers'] ) && \is_array( $settings['printers'] ) ? $settings['printers'] : array();
	}

	/**
	 * Get a registered cloud printer by id.
	 *
	 * @param string $printer_id Printer id.
	 *
	 * @return array|null
	 */
	public function get_printer( string $printer_id ): ?array {
		foreach ( $this->get_printers() as $printer ) {
			if ( isset( $printer['id'] ) && hash_equals( (string) $printer['id'], $printer_id ) ) {
				return $printer;
			}
		}

		return null;
	}

	/**
	 * Verify a printer's poll token (constant-time).
	 *
	 * @param string $printer_id Printer id.
	 * @param string $token      Presented token.
	 */
	public function verify_token( string $printer_id, string $token ): bool {
		$printer = $this->get_printer( $printer_id );
		if ( null === $printer || empty( $printer['poll_token_hash'] ) || '' === $token ) {
			return false;
		}

		return hash_equals( (string) $printer['poll_token_hash'], self::hash_token( $token ) );
	}

	/**
	 * Generate a cryptographically strong poll token (returned to the admin once).
	 */
	public static function generate_token(): string {
		return bin2hex( random_bytes( 24 ) );
	}

	/**
	 * Hash a poll token for at-rest storage (we never persist the plaintext).
	 *
	 * @param string $token Token.
	 */
	public static function hash_token( string $token ): string {
		return hash( 'sha256', $token );
	}

	/**
	 * Derive a stable, URL-safe printer id from a display name, unique against existing ids.
	 *
	 * @param string        $name         Display name.
	 * @param array<string> $existing_ids Already-used ids.
	 *
	 * @return string
	 */
	public static function derive_id( string $name, array $existing_ids ): string {
		$base = sanitize_title( $name );
		if ( '' === $base ) {
			$base = 'printer';
		}
		$candidate = $base;
		$suffix    = 2;
		while ( \in_array( $candidate, $existing_ids, true ) ) {
			$candidate = $base . '-' . $suffix;
			++$suffix;
		}

		return $candidate;
	}

	/**
	 * Record that a printer polled just now.
	 *
	 * @param string $printer_id Printer id.
	 */
	public function record_seen( string $printer_id ): void {
		$runtime                = get_option( self::RUNTIME_OPTION, array() );
		$runtime                = \is_array( $runtime ) ? $runtime : array();
		$runtime[ $printer_id ] = time();
		update_option( self::RUNTIME_OPTION, $runtime, false ); // Autoload no.
	}

	/**
	 * Get a printer's last-seen unix timestamp (0 if never).
	 *
	 * @param string $printer_id Printer id.
	 *
	 * @return int
	 */
	public function get_seen( string $printer_id ): int {
		$runtime = get_option( self::RUNTIME_OPTION, array() );

		return ( \is_array( $runtime ) && isset( $runtime[ $printer_id ] ) ) ? (int) $runtime[ $printer_id ] : 0;
	}

	/**
	 * Drop runtime last-seen entries for printer ids that no longer exist.
	 *
	 * Prevents the runtime option from growing unbounded as printers are
	 * removed, and stops a recreated id (slug reuse) from inheriting a deleted
	 * printer's stale status.
	 *
	 * @param array<string> $keep_ids Printer ids to retain.
	 */
	public function prune_seen( array $keep_ids ): void {
		$runtime = get_option( self::RUNTIME_OPTION, array() );
		if ( ! \is_array( $runtime ) ) {
			return;
		}
		$pruned = array_intersect_key( $runtime, array_flip( $keep_ids ) );
		if ( $pruned !== $runtime ) {
			update_option( self::RUNTIME_OPTION, $pruned, false );
		}
	}

	/**
	 * A printer's cached CloudPRNT client capabilities.
	 *
	 * Answers are returned however stale they are: a printer's decodable format
	 * list does not change while it sits on a shelf, and serving the last known
	 * answer beats falling back to "offer everything" on every cold cache. The
	 * TTL governs how often we ask again, not how long an answer is believed.
	 *
	 * @param string $printer_id Printer id.
	 *
	 * @return array{client_type:string, client_version:string, encodings:array<int, string>, status_code:string, updated:int, asked:int}
	 */
	public function get_capabilities( string $printer_id ): array {
		$all = get_option( self::CAPABILITIES_OPTION, array() );

		return self::hydrate( \is_array( $all ) ? ( $all[ $printer_id ] ?? array() ) : array() );
	}

	/**
	 * Normalize a stored capability entry into the full record shape.
	 *
	 * @param mixed $stored The raw stored entry, or anything at all — the option
	 *                      is hand-editable and predates this shape.
	 *
	 * @return array{client_type:string, client_version:string, encodings:array<int, string>, status_code:string, updated:int, asked:int}
	 */
	private static function hydrate( $stored ): array {
		$stored = \is_array( $stored ) ? $stored : array();

		return array(
			'client_type'    => (string) ( $stored['client_type'] ?? '' ),
			'client_version' => (string) ( $stored['client_version'] ?? '' ),
			'encodings'      => \is_array( $stored['encodings'] ?? null ) ? array_values( array_map( 'strval', $stored['encodings'] ) ) : array(),
			'status_code'    => (string) ( $stored['status_code'] ?? '' ),
			'updated'        => (int) ( $stored['updated'] ?? 0 ),
			'asked'          => (int) ( $stored['asked'] ?? 0 ),
		);
	}

	/**
	 * Store the answers a printer gave to our `clientAction` questions.
	 *
	 * Only the fields the printer actually answered are overwritten — a poll
	 * that carries `ClientType` but not `Encodings` must not wipe an encodings
	 * list we already know.
	 *
	 * @param string                $printer_id  Printer id.
	 * @param array<string, string> $answers     Answers keyed by request name.
	 * @param string                $status_code The printer's reported status code.
	 *
	 * @return bool Whether anything was stored. False also when a concurrent
	 *              poll held the write lock; see mutate_capabilities().
	 */
	public function record_capabilities( string $printer_id, array $answers, string $status_code = '' ): bool {
		return $this->mutate_printer_record(
			$printer_id,
			static function ( array $record ) use ( $answers, $status_code ): ?array {
				$changed = false;

				foreach ( $answers as $name => $value ) {
					switch ( $name ) {
						case 'ClientType':
							$record['client_type'] = $value;
							$changed               = true;
							break;
						case 'ClientVersion':
							$record['client_version'] = $value;
							$changed                  = true;
							break;
						case 'Encodings':
							$record['encodings'] = self::parse_encodings( $value );
							$changed             = true;
							break;
					}
				}

				if ( '' !== $status_code && $status_code !== $record['status_code'] ) {
					$record['status_code'] = $status_code;
					$changed               = true;
				}

				if ( ! $changed ) {
					return null;
				}

				$record['updated'] = time();

				return $record;
			}
		);
	}

	/**
	 * Whether the printer should be asked for its capabilities on this poll.
	 *
	 * @param string $printer_id Printer id.
	 *
	 * @return bool
	 */
	public function should_request_capabilities( string $printer_id ): bool {
		$record = $this->get_capabilities( $printer_id );

		return ( time() - $record['asked'] ) >= self::CAPABILITIES_TTL;
	}

	/**
	 * Record that this poll response carried capability questions.
	 *
	 * Written even when the printer never answers, so firmware that ignores
	 * `clientAction` is asked once per TTL rather than on every poll.
	 *
	 * @param string $printer_id Printer id.
	 */
	public function record_capability_request( string $printer_id ): void {
		$this->mutate_printer_record(
			$printer_id,
			static function ( array $record ): array {
				$record['asked'] = time();

				return $record;
			}
		);
	}

	/**
	 * Drop cached capabilities for printer ids that no longer exist.
	 *
	 * @param array<string> $keep_ids Printer ids to retain.
	 */
	public function prune_capabilities( array $keep_ids ): void {
		$this->mutate_capabilities(
			static function ( array $all ) use ( $keep_ids ): ?array {
				$pruned = array_intersect_key( $all, array_flip( $keep_ids ) );

				return $pruned === $all ? null : $pruned;
			}
		);
	}

	/**
	 * Read-modify-write one printer's capability record under the mutex.
	 *
	 * The callback receives the record as it stands *inside* the lock, never a
	 * snapshot taken before it. Assembling the record first and handing the
	 * finished thing to a locked setter would leave same-printer polls racing:
	 * two overlapping polls would each build from the same pre-lock read, and
	 * the second write would undo the first — restoring a stale `asked` stamp or
	 * erasing encodings that had just arrived.
	 *
	 * @param string   $printer_id Printer id.
	 * @param callable $mutate     Receives the current record, returns the new
	 *                             one, or null to write nothing.
	 *
	 * @return bool Whether the record was written.
	 */
	private function mutate_printer_record( string $printer_id, callable $mutate ): bool {
		return $this->mutate_capabilities(
			static function ( array $all ) use ( $printer_id, $mutate ): ?array {
				$next = $mutate( self::hydrate( $all[ $printer_id ] ?? array() ) );
				if ( null === $next ) {
					return null;
				}

				$all[ $printer_id ] = $next;

				return $all;
			}
		);
	}

	/**
	 * Read-modify-write the shared capabilities option under a mutex.
	 *
	 * Every registered printer polls into the same option, so an unguarded
	 * read-modify-write lets one printer's write discard another's: both read
	 * the same snapshot, each sets its own key, and the second write wins.
	 * The mutex plus a cache-bypassing read inside it makes the sequence
	 * atomic across concurrent requests.
	 *
	 * A poll that cannot take the lock skips its write rather than queueing.
	 * The record is a cache with a CAPABILITIES_TTL refresh, so the cost of a
	 * skipped write is one refresh cycle, never a wrong answer — and blocking
	 * a printer's poll to persist a cache would be the worse trade.
	 *
	 * @param callable $mutate Receives the current map, returns the new map, or
	 *                         null to write nothing.
	 *
	 * @return bool Whether the option was written.
	 */
	private function mutate_capabilities( callable $mutate ): bool {
		if ( ! self::acquire_capabilities_lock() ) {
			return false;
		}

		try {
			// Drop this request's cached copy so the read reflects writes other
			// requests made while we were waiting for the lock.
			wp_cache_delete( self::CAPABILITIES_OPTION, 'options' );

			$all = get_option( self::CAPABILITIES_OPTION, array() );
			$all = \is_array( $all ) ? $all : array();

			$next = $mutate( $all );
			if ( null === $next ) {
				return false;
			}

			update_option( self::CAPABILITIES_OPTION, $next, false ); // Autoload no.
		} finally {
			delete_option( self::CAPABILITIES_LOCK );
		}

		return true;
	}

	/**
	 * Take the capabilities mutex.
	 *
	 * The atomic primitive is add_option(): the options table's unique index on
	 * option_name means exactly one concurrent caller can create the row. This
	 * mirrors Print_Job_Service's lifecycle lock.
	 *
	 * @return bool Whether the lock was taken.
	 */
	private static function acquire_capabilities_lock(): bool {
		$now = time();

		if ( add_option( self::CAPABILITIES_LOCK, (string) $now, '', false ) ) {
			return true;
		}

		$locked_at = (int) get_option( self::CAPABILITIES_LOCK, 0 );
		if ( $locked_at > 0 && ( $now - $locked_at ) > self::CAPABILITIES_LOCK_TTL ) {
			delete_option( self::CAPABILITIES_LOCK );

			return add_option( self::CAPABILITIES_LOCK, (string) $now, '', false );
		}

		return false;
	}

	/**
	 * Read a printer's `Encodings` answer into a list of media types.
	 *
	 * The answer is a delimited string of the media types the client can decode
	 * (Star's own plugin substring-matches it). Splitting on both `,` and `;`
	 * and then keeping only `type/subtype` tokens drops MIME parameters such as
	 * `charset=utf-8` without needing to know which delimiter the firmware used.
	 *
	 * @param string $value The raw `Encodings` answer.
	 *
	 * @return array<int, string> Lower-cased media types, in the printer's order.
	 */
	private static function parse_encodings( string $value ): array {
		$tokens = preg_split( '/[,;\r\n\t ]+/', strtolower( $value ) );
		if ( false === $tokens ) {
			return array();
		}

		$types = array();
		foreach ( $tokens as $token ) {
			$token = trim( $token );
			if ( 1 === preg_match( '#^[a-z0-9][a-z0-9!\#$&^_.+-]*/[a-z0-9][a-z0-9!\#$&^_.+-]*$#', $token ) ) {
				$types[] = $token;
			}
		}

		return array_values( array_unique( $types ) );
	}

	/**
	 * Connection status for a printer.
	 *
	 * For PrintNode printers this returns PrintNode's live vocabulary
	 * ('online'|'offline'|'unknown'), cached briefly. For polling printers
	 * (Star/Epson) it returns 'waiting' (never polled), 'connected' (polled
	 * within SEEN_TTL), or 'offline' (polled, but stale).
	 *
	 * @param string $printer_id Printer id.
	 *
	 * @return string
	 */
	public function status_for( string $printer_id ): string {
		$printer = $this->get_printer( $printer_id );
		if ( null !== $printer ) {
			$provider = Provider::normalize( \is_string( $printer['provider'] ?? null ) ? $printer['provider'] : null );
			$adapter  = Provider::adapter( $provider );
			if ( null !== $adapter ) {
				return $adapter->status(
					$printer,
					array(
						'now'          => time(),
						'seen'         => $this->get_seen( $printer_id ),
						'seen_ttl'     => self::SEEN_TTL,
						'cache_ttl'    => self::PN_STATUS_TTL,
						'relay_status' => Provider::is_polling( $provider ) ? Cloud_Print_Relay_Service::status( $printer_id ) : null,
					)
				);
			}
		}

		/*
		 * Shared fallthrough, kept from the pre-adapter implementation: a
		 * printer_id with a recorded poll but no registry row — or a row whose
		 * provider is unrecognised — is still reported from its last-seen
		 * timestamp, not as 'waiting'. Returning early here instead reported a
		 * recently-polled printer as never-seen.
		 * `test_status_connected_when_recently_seen` pins this.
		 */
		$seen = $this->get_seen( $printer_id );
		if ( 0 === $seen ) {
			return 'waiting';
		}

		return ( time() - $seen ) <= self::SEEN_TTL ? 'connected' : 'offline';
	}

	/**
	 * The relay's block signal for a printer, when it reports one.
	 *
	 * Delegates to the relay service's transient-cached status, so this is
	 * safe to call in any order relative to status_for().
	 *
	 * @param string $printer_id Printer ID.
	 *
	 * @return string|null
	 */
	public function status_detail_for( string $printer_id ): ?string {
		return Cloud_Print_Relay_Service::status_detail( $printer_id );
	}
}

```
