*/ public function list(): array { $files = is_dir($this->dir) ? scandir($this->dir) : []; $state = $this->readState() ?? []; $out = []; foreach ($files === false ? [] : $files as $file) { if ($this->exists($file)) { $out[] = $this->entry($file, $state); } } usort($out, static fn (array $a, array $b): int => strcmp($a['file'], $b['file'])); return $out; } public function isActive(string $file): bool { return $this->exists($file) && self::activeIn($this->readState() ?? [], $file); } /** The module's source, or null when there is no such plugin. */ public function read(string $file): ?string { if (!$this->exists($file)) { return null; } $source = file_get_contents("{$this->dir}/{$file}"); return $source === false ? null : $source; } /** * Switches a plugin on or off and returns its entry as `list` has it; null when there is no * such plugin. The state file is rewritten whole under a lock, so two switches at once cannot * lose each other's change. A state file that is not readable JSON is not overwritten: that * would silently switch off everything it named. * * @return array{file: string, bytes: int, updatedAt: int, active: bool}|null */ public function setActive(string $file, bool $active): ?array { if (!$this->exists($file)) { return null; } $lock = fopen("{$this->dir}/." . self::STATE_FILE . '.lock', 'c'); if ($lock === false || !flock($lock, LOCK_EX)) { throw new RuntimeException('Could not lock the plugin state'); } try { $state = $this->readState(); if ($state === null) { throw new RuntimeException(self::STATE_FILE . ' is not readable JSON – left as it is'); } $state[$file] = ['active' => $active]; ksort($state); $path = "{$this->dir}/" . self::STATE_FILE; $json = json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . "\n"; $tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp'; if (file_put_contents($tmp, $json) === false || !rename($tmp, $path)) { @unlink($tmp); throw new RuntimeException('Could not write ' . self::STATE_FILE); } return $this->entry($file, $state); } finally { flock($lock, LOCK_UN); fclose($lock); } } private function exists(string $file): bool { return self::isValidName($file) && is_file("{$this->dir}/{$file}"); } /** * The state as stored; an empty one when there is no file yet, null when there is one that * cannot be read. * * @return array|null */ private function readState(): ?array { $path = "{$this->dir}/" . self::STATE_FILE; if (!is_file($path)) { return []; } $raw = file_get_contents($path); $state = $raw === false ? null : json_decode($raw, true); return is_array($state) ? $state : null; } /** @param array $state */ private static function activeIn(array $state, string $file): bool { return is_array($state[$file] ?? null) && ($state[$file]['active'] ?? false) === true; } /** * @param array $state * @return array{file: string, bytes: int, updatedAt: int, active: bool} */ private function entry(string $file, array $state): array { $path = "{$this->dir}/{$file}"; return [ 'file' => $file, 'bytes' => (int) filesize($path), 'updatedAt' => (int) filemtime($path), 'active' => self::activeIn($state, $file), ]; } }