*/ private array $definitions = array(); /** * Running instances of active apps by id. * * @var array */ private array $instances = array(); /** * Apps that failed to start this request, with the reason. * * @var array */ private array $failed = array(); /** * Creates the registry. * * @param Container $container Service container. * @param Entitlements $entitlements Entitlements service. * @param Settings $settings Settings service. */ public function __construct( Container $container, Entitlements $entitlements, Settings $settings ) { $this->container = $container; $this->entitlements = $entitlements; $this->settings = $settings; } /** * Adds an app definition. A second definition with the same id is ignored. * * @param AppDefinition $definition App definition. */ public function register( AppDefinition $definition ): void { if ( isset( $this->definitions[ $definition->id ] ) ) { _doing_it_wrong( __METHOD__, esc_html( sprintf( 'App "%s" is already registered.', $definition->id ) ), '0.1.0' ); return; } $this->definitions[ $definition->id ] = $definition; foreach ( $definition->features as $feature_id => $edition ) { $this->entitlements->register_feature( $feature_id, $edition ); } } /** * Collects definitions and starts every active app whose dependencies are running. */ public function boot(): void { /** * Register apps here: $registry->register( new AppDefinition( ... ) ). * * @param AppRegistry $registry The registry. */ do_action( 'forensix_register_apps', $this ); foreach ( $this->boot_order() as $id ) { $definition = $this->definitions[ $id ]; if ( ! Edition::covers( $this->entitlements->edition(), $definition->edition ) ) { $this->failed[ $id ] = 'edition'; continue; } try { $app = $definition->create( $this->container ); $app->register(); $this->instances[ $id ] = $app; } catch ( \Throwable $e ) { $this->failed[ $id ] = $e->getMessage(); /** * Fires when an active app fails to start. The rest of the site keeps working. * * @param string $id App id. * @param \Throwable $e The error. */ do_action( 'forensix_app_failed', $id, $e ); } } } /** * All known definitions. * * @return array */ public function definitions(): array { return $this->definitions; } /** * One definition. * * @param string $id App id. */ public function get( string $id ): ?AppDefinition { return $this->definitions[ $id ] ?? null; } /** * Ids the user has activated (stored), including apps whose code is currently missing. * * @return string[] */ public function active_ids(): array { $ids = get_option( self::ACTIVE_OPTION, array() ); return is_array( $ids ) ? array_values( array_filter( $ids, 'is_string' ) ) : array(); } /** * Whether an app is activated. * * @param string $id App id. */ public function is_active( string $id ): bool { return in_array( $id, $this->active_ids(), true ); } /** * Whether an app is running in this request. * * @param string $id App id. */ public function is_running( string $id ): bool { return isset( $this->instances[ $id ] ); } /** * The running instance of an app, or null when it is not running in this request. * Lets add-ons use an app's public methods (e.g. start a scan) only while it is active. * * @param string $id App id. */ public function instance( string $id ): ?AppInterface { return $this->instances[ $id ] ?? null; } /** * Apps that failed to start this request. * * @return array */ public function failed(): array { return $this->failed; } /** * Activates an app after checking it exists, its edition and its dependencies. * * @param string $id App id. */ public function activate( string $id ): Result { $definition = $this->get( $id ); if ( null === $definition ) { return Result::failure( 'unknown_app', __( 'This app is not available.', 'forensix-digital' ) ); } if ( $this->is_active( $id ) ) { return Result::success( __( 'The app is already active.', 'forensix-digital' ) ); } if ( ! Edition::covers( $this->entitlements->edition(), $definition->edition ) ) { return Result::failure( 'edition', /* translators: %s: edition name, e.g. "Pro". */ sprintf( __( 'This app needs Forensix Digital %s.', 'forensix-digital' ), Edition::label( $definition->edition ) ), array( 'edition' => $definition->edition ) ); } $missing = array_values( array_diff( $definition->dependencies, $this->active_ids() ) ); if ( array() !== $missing ) { return Result::failure( 'missing_dependencies', /* translators: %s: comma-separated app names. */ sprintf( __( 'Activate these apps first: %s.', 'forensix-digital' ), implode( ', ', $this->names( $missing ) ) ), array( 'missing' => $missing ) ); } try { $app = $definition->create( $this->container ); $app->activate(); } catch ( \Throwable $e ) { return Result::failure( 'activation_failed', __( 'The app could not be activated.', 'forensix-digital' ), array( 'error' => $e->getMessage() ) ); } $this->store( array_merge( $this->active_ids(), array( $id ) ) ); $app->register(); $this->instances[ $id ] = $app; /** * Fires after an app was activated. * * @param string $id App id. */ do_action( 'forensix_app_activated', $id ); return Result::success( __( 'The app is active.', 'forensix-digital' ) ); } /** * What deactivating an app would affect, for the confirmation dialog. * * @param string $id App id. * @return array{dependents: string[], dependent_names: string[], effects: string[]} */ public function deactivation_impact( string $id ): array { $dependents = $this->active_dependents( $id ); $effects = array(); foreach ( array_merge( $dependents, array( $id ) ) as $app_id ) { $definition = $this->get( $app_id ); if ( null !== $definition ) { $effects = array_merge( $effects, $definition->deactivation_effects ); } } return array( 'dependents' => $dependents, 'dependent_names' => $this->names( $dependents ), 'effects' => array_values( array_unique( $effects ) ), ); } /** * Deactivates an app. Refuses when other active apps depend on it, unless $cascade is true, * in which case those apps are deactivated first. Keeps settings and history. * * @param string $id App id. * @param bool $cascade Also deactivate apps that depend on this one. */ public function deactivate( string $id, bool $cascade = false ): Result { if ( ! $this->is_active( $id ) ) { return Result::success( __( 'The app is already inactive.', 'forensix-digital' ) ); } $dependents = $this->active_dependents( $id ); if ( array() !== $dependents && ! $cascade ) { return Result::failure( 'has_dependents', /* translators: %s: comma-separated app names. */ sprintf( __( 'These apps need it and would be deactivated too: %s.', 'forensix-digital' ), implode( ', ', $this->names( $dependents ) ) ), array( 'dependents' => $dependents ) ); } // Dependents are returned deepest first, so each is stopped before what it relies on. $deactivated = array(); foreach ( array_merge( $dependents, array( $id ) ) as $app_id ) { $this->stop( $app_id ); $deactivated[] = $app_id; } return Result::success( __( 'The app is inactive. Its settings and history are kept.', 'forensix-digital' ), array( 'deactivated' => $deactivated ) ); } /** * Deletes an inactive app's data: its own data, settings, jobs and activity entries. * * @param string $id App id. */ public function delete_data( string $id ): Result { if ( $this->is_active( $id ) ) { return Result::failure( 'app_active', __( 'Deactivate the app before deleting its data.', 'forensix-digital' ) ); } $definition = $this->get( $id ); if ( null !== $definition ) { try { $definition->create( $this->container )->delete_data(); } catch ( \Throwable $e ) { return Result::failure( 'delete_failed', __( 'The app data could not be deleted.', 'forensix-digital' ), array( 'error' => $e->getMessage() ) ); } } $this->settings->delete( $id ); /** * Fires after an app's data was deleted, so shared services remove the app's rows. * * @param string $id App id. */ do_action( 'forensix_app_data_deleted', $id ); return Result::success( __( 'The app data was deleted.', 'forensix-digital' ) ); } /** * Calls activate() again on every running app, so apps re-create the schedules that plugin * deactivation removed. App activate() methods must therefore be idempotent. */ public function reactivate_running(): void { foreach ( $this->instances as $id => $app ) { try { $app->activate(); } catch ( \Throwable $e ) { do_action( 'forensix_app_failed', $id, $e ); } } } /** * Stops one app and removes it from the active list. * * @param string $id App id. */ private function stop( string $id ): void { $app = $this->instances[ $id ] ?? null; if ( null === $app ) { $definition = $this->get( $id ); $app = null !== $definition ? $definition->create( $this->container ) : null; } if ( null !== $app ) { try { $app->deactivate(); } catch ( \Throwable $e ) { do_action( 'forensix_app_failed', $id, $e ); } } unset( $this->instances[ $id ] ); $this->store( array_diff( $this->active_ids(), array( $id ) ) ); /** * Fires after an app was deactivated. Shared services cancel the app's jobs and schedules. * * @param string $id App id. */ do_action( 'forensix_app_deactivated', $id ); } /** * Active apps that depend on an app, directly or indirectly, deepest first. * * @param string $id App id. * @param string[] $visited Ids already walked, to stop on dependency cycles. * @return string[] */ private function active_dependents( string $id, array $visited = array() ): array { $visited[] = $id; $result = array(); foreach ( $this->active_ids() as $active_id ) { $definition = $this->get( $active_id ); if ( null === $definition || in_array( $active_id, $visited, true ) || ! in_array( $id, $definition->dependencies, true ) ) { continue; } foreach ( $this->active_dependents( $active_id, $visited ) as $deeper ) { $result[] = $deeper; } $result[] = $active_id; } return array_values( array_unique( $result ) ); } /** * Active, known app ids in an order where dependencies come first. Apps whose * dependencies are not active (or form a cycle) are left out. * * @return string[] */ private function boot_order(): array { $pending = array_values( array_filter( $this->active_ids(), fn( string $id ): bool => isset( $this->definitions[ $id ] ) ) ); $order = array(); do { $progress = false; foreach ( $pending as $index => $id ) { if ( array() === array_diff( $this->definitions[ $id ]->dependencies, $order ) ) { $order[] = $id; unset( $pending[ $index ] ); $progress = true; } } } while ( $progress && array() !== $pending ); foreach ( $pending as $id ) { $this->failed[ $id ] = 'dependencies'; } return $order; } /** * Display names for app ids. * * @param string[] $ids App ids. * @return string[] */ private function names( array $ids ): array { return array_map( fn( string $id ): string => null !== $this->get( $id ) ? $this->definitions[ $id ]->name : $id, $ids ); } /** * Saves the active list. * * @param string[] $ids App ids. */ private function store( array $ids ): void { update_option( self::ACTIVE_OPTION, array_values( array_unique( $ids ) ), true ); } }