Files
cms-scheduler/Classes/Controller/SchedulerModuleController.php
T

510 lines
26 KiB
PHP

<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Scheduler\Controller;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Module\ModuleData;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Template\Components\ButtonBar;
use TYPO3\CMS\Backend\Template\Components\ComponentFactory;
use TYPO3\CMS\Backend\Template\ModuleTemplate;
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
use TYPO3\CMS\Core\Context\Context;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\Restriction\HiddenRestriction;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Imaging\IconSize;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Page\PageRenderer;
use TYPO3\CMS\Core\Registry;
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\View\ViewInterface;
use TYPO3\CMS\Scheduler\Domain\Repository\SchedulerTaskRepository;
use TYPO3\CMS\Scheduler\Execution;
use TYPO3\CMS\Scheduler\Scheduler;
use TYPO3\CMS\Scheduler\Service\TaskService;
/**
* Scheduler backend module.
*
* @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
*/
#[AsController]
final readonly class SchedulerModuleController
{
public function __construct(
private Scheduler $scheduler,
private SchedulerTaskRepository $taskRepository,
private IconFactory $iconFactory,
private UriBuilder $uriBuilder,
private ModuleTemplateFactory $moduleTemplateFactory,
private ComponentFactory $componentFactory,
private Context $context,
private TaskService $taskService,
private PageRenderer $pageRenderer,
private Registry $registry,
private ConnectionPool $connectionPool,
) {}
/**
* Entry dispatcher method.
*/
public function handleRequest(ServerRequestInterface $request): ResponseInterface
{
$parsedBody = $request->getParsedBody();
$view = $this->moduleTemplateFactory->create($request);
$view->assign('dateFormat', [
'day' => $GLOBALS['TYPO3_CONF_VARS']['SYS']['ddmmyy'] ?? 'd-m-y',
'time' => $GLOBALS['TYPO3_CONF_VARS']['SYS']['hhmm'] ?? 'H:i',
]);
$moduleData = $request->getAttribute('moduleData');
// Simple actions from list view.
if (!empty($parsedBody['action']['toggleHidden'])) {
$this->toggleDisabledFlag($view, (int)$parsedBody['action']['toggleHidden']);
} elseif (!empty($parsedBody['action']['stop'])) {
$this->stopTask($view, (int)$parsedBody['action']['stop']);
} elseif (!empty($parsedBody['action']['execute'])) {
$this->executeTasks($view, (string)$parsedBody['action']['execute']);
} elseif (!empty($parsedBody['action']['scheduleCron'])) {
$this->scheduleCrons($view, (string)$parsedBody['action']['scheduleCron']);
} elseif (!empty($parsedBody['action']['group']['uid'])) {
$this->groupDisable((int)$parsedBody['action']['group']['uid'], (int)($parsedBody['action']['group']['hidden'] ?? 0));
} elseif (!empty($parsedBody['action']['delete'])) {
$this->deleteTask($view, (int)$parsedBody['action']['delete']);
} elseif (!empty($parsedBody['action']['groupRemove'])) {
$rows = $this->groupRemove((int)$parsedBody['action']['groupRemove']);
if ($rows > 0) {
$view->addFlashMessage($this->getLanguageService()->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.group.deleted'));
} else {
$view->addFlashMessage($this->getLanguageService()->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.group.delete.failed'), '', ContextualFeedbackSeverity::WARNING);
}
}
return $this->renderListTasksView($view, $moduleData, $request);
}
/**
* AJAX endpoint for setup check modal content.
*/
public function setupCheckAction(ServerRequestInterface $request): ResponseInterface
{
$view = $this->moduleTemplateFactory->create($request);
$view->assign('dateFormat', [
'day' => $GLOBALS['TYPO3_CONF_VARS']['SYS']['ddmmyy'] ?? 'd-m-y',
'time' => $GLOBALS['TYPO3_CONF_VARS']['SYS']['hhmm'] ?? 'H:i',
]);
$this->addSetupCheckInformation($view);
return $view->renderResponse('CheckScreen');
}
/**
* Mark a task as deleted.
*/
private function deleteTask(ModuleTemplate $view, int $taskUid): void
{
$languageService = $this->getLanguageService();
if ($taskUid <= 0) {
throw new \RuntimeException('Expecting a valid task uid', 1641670374);
}
try {
// Try to fetch the task and delete it
$task = $this->taskRepository->findByUid($taskUid);
if ($this->taskRepository->isTaskMarkedAsRunning($task)) {
// If the task is currently running, it may not be deleted
$this->addMessage($view, $languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.canNotDeleteRunningTask'), ContextualFeedbackSeverity::ERROR);
} else {
if ($this->taskRepository->remove($task)) {
$this->addMessage($view, $languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.deleteSuccess'));
} else {
$this->addMessage($view, $languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.deleteError'));
}
}
} catch (\UnexpectedValueException) {
// The task could not be unserialized, simply update the database record setting it to deleted
$result = $this->taskRepository->remove($taskUid);
if ($result) {
$this->addMessage($view, $languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.deleteSuccess'));
} else {
$this->addMessage($view, $languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.deleteError'), ContextualFeedbackSeverity::ERROR);
}
} catch (\OutOfBoundsException) {
// The task was not found, for some reason
$this->addMessage($view, sprintf($languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.taskNotFound'), $taskUid), ContextualFeedbackSeverity::ERROR);
}
}
/**
* Clears the registered running executions from the task.
* Note this doesn't actually stop the running script. It just unmarks execution.
* @todo find a way to really kill the running task.
*/
private function stopTask(ModuleTemplate $view, int $taskUid): void
{
$languageService = $this->getLanguageService();
if ($taskUid <= 0) {
throw new \RuntimeException('Expecting a valid task uid', 1641670375);
}
try {
// Try to fetch the task and stop it
$task = $this->taskRepository->findByUid($taskUid);
if ($this->taskRepository->isTaskMarkedAsRunning($task)) {
// If the task is indeed currently running, clear marked executions
$result = $this->taskRepository->removeAllRegisteredExecutionsForTask($task);
if ($result) {
$this->addMessage($view, $languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.stopSuccess'));
} else {
$this->addMessage($view, $languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.stopError'), ContextualFeedbackSeverity::ERROR);
}
} else {
// The task is not running, nothing to unmark
$this->addMessage($view, $languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.maynotStopNonRunningTask'), ContextualFeedbackSeverity::WARNING);
}
} catch (\OutOfBoundsException $e) {
$this->addMessage($view, sprintf($languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.taskNotFound'), $taskUid), ContextualFeedbackSeverity::ERROR);
} catch (\UnexpectedValueException $e) {
$this->addMessage($view, sprintf($languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.stopTaskFailed'), $taskUid, $e->getMessage()), ContextualFeedbackSeverity::ERROR);
}
}
/**
* Toggle the disabled state of a task and register for next execution if a task is of type "single execution".
*/
private function toggleDisabledFlag(ModuleTemplate $view, int $taskUid): void
{
$languageService = $this->getLanguageService();
if ($taskUid <= 0) {
throw new \RuntimeException('Expecting a valid task uid to toggle disabled state', 1641670373);
}
try {
$task = $this->taskRepository->findByUid($taskUid);
// Toggle the task state and add a flash message
$taskName = $this->taskService->getHumanReadableTaskName($task);
$isTaskDisabled = $task->isDisabled();
// If a disabled single task is enabled again, register it for a single execution at next scheduler run.
if ($isTaskDisabled && $task->getExecution()->isSingleRun()) {
$task->setDisabled(false);
$task->setRunOnNextCronJob(true);
$execution = Execution::createSingleExecution($this->context->getAspect('date')->get('timestamp'));
$task->setExecution($execution);
$this->addMessage($view, sprintf($languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.taskEnabledAndQueuedForExecution'), $taskName, $taskUid));
} elseif ($isTaskDisabled) {
$task->setDisabled(false);
$this->addMessage($view, sprintf($languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.taskEnabled'), $taskName, $taskUid));
} else {
$task->setDisabled(true);
$this->addMessage($view, sprintf($languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.taskDisabled'), $taskName, $taskUid));
}
$this->taskRepository->updateExecution($task);
} catch (\OutOfBoundsException) {
$this->addMessage($view, sprintf($languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.taskNotFound'), $taskUid), ContextualFeedbackSeverity::ERROR);
} catch (\UnexpectedValueException $e) {
$this->addMessage($view, sprintf($languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.toggleDisableFailed'), $taskUid, $e->getMessage()), ContextualFeedbackSeverity::ERROR);
}
}
/**
* Execute a list of tasks.
*/
private function executeTasks(ModuleTemplate $view, string $taskUids): void
{
$taskUids = GeneralUtility::intExplode(',', $taskUids, true);
if (empty($taskUids)) {
throw new \RuntimeException('Expecting a list of task uids to execute', 1641715832);
}
// Loop selected tasks and execute.
$languageService = $this->getLanguageService();
foreach ($taskUids as $uid) {
try {
$task = $this->taskRepository->findByUid($uid);
$name = $this->taskService->getHumanReadableTaskName($task);
// Try to execute it and report result
$result = $this->scheduler->executeTask($task);
if ($result) {
$this->addMessage($view, sprintf($languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.executed'), $name, $uid));
} else {
$this->addMessage($view, sprintf($languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.notExecuted'), $name, $uid), ContextualFeedbackSeverity::ERROR);
}
$this->scheduler->recordLastRun('manual');
} catch (\OutOfBoundsException $e) {
$this->addMessage($view, sprintf($languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.taskNotFound'), $uid), ContextualFeedbackSeverity::ERROR);
} catch (\Exception $e) {
$this->addMessage($view, sprintf($languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.executionFailed'), $uid, $e->getMessage()), ContextualFeedbackSeverity::ERROR);
}
}
}
/**
* Schedule selected tasks to be executed on next cron run
*/
private function scheduleCrons(ModuleTemplate $view, string $taskUids): void
{
$taskUids = GeneralUtility::intExplode(',', $taskUids, true);
if (empty($taskUids)) {
throw new \RuntimeException('Expecting a list of task uids to schedule', 1641715833);
}
// Loop selected tasks and register for next cron run.
$languageService = $this->getLanguageService();
foreach ($taskUids as $uid) {
try {
$task = $this->taskRepository->findByUid($uid);
$name = $this->taskService->getHumanReadableTaskName($task);
$task->setRunOnNextCronJob(true);
if ($task->isDisabled()) {
$task->setDisabled(false);
$this->addMessage($view, sprintf($languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.taskEnabledAndQueuedForExecution'), $name, $uid));
} else {
$this->addMessage($view, sprintf($languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.taskQueuedForExecution'), $name, $uid));
}
$this->taskRepository->updateExecution($task);
} catch (\OutOfBoundsException $e) {
$this->addMessage($view, sprintf($languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.taskNotFound'), $uid), ContextualFeedbackSeverity::ERROR);
} catch (\UnexpectedValueException $e) {
$this->addMessage($view, sprintf($languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.schedulingFailed'), $uid, $e->getMessage()), ContextualFeedbackSeverity::ERROR);
}
}
}
/**
* Assemble a listing of scheduled tasks
*/
private function renderListTasksView(ModuleTemplate $view, ModuleData $moduleData, ServerRequestInterface $request): ResponseInterface
{
$languageService = $this->getLanguageService();
$data = $this->taskRepository->getGroupedTasks();
$hasAvailableTaskTypes = $this->taskService->getAllTaskTypes() !== [];
$groups = $data['taskGroupsWithTasks'] ?? [];
$groups = array_map(
static fn(int $key, array $group): array => array_merge($group, ['taskGroupCollapsed' => (bool)($moduleData->get('task-group-' . $key, false))]),
array_keys($groups),
$groups
);
// Move "not assigned to group" to the end
if (array_key_exists('uid', $groups[0] ?? []) && $groups[0]['uid'] === null) {
$groupWithoutTaskGroup = $groups[0];
unset($groups[0]);
$groups[0] = $groupWithoutTaskGroup;
}
$this->pageRenderer->loadJavaScriptModule('@typo3/scheduler/new-scheduler-task-wizard-button.js');
$this->pageRenderer->loadJavaScriptModule('@typo3/scheduler/setup-check-button.js');
$view->assignMultiple([
'groups' => $groups,
'groupsWithoutTasks' => $this->getGroupsWithoutTasks($groups),
'hasAvailableTaskTypes' => $hasAvailableTaskTypes,
'errorClasses' => $data['errorClasses'],
'returnUrl' => $this->uriBuilder->buildUriFromRoute('scheduler'),
'errorClassesCollapsed' => (bool)($moduleData->get('task-group-missing', false)),
]);
$view->setTitle(
$languageService->translate('title', 'scheduler.module'),
$languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:function.scheduler')
);
$view->makeDocHeaderModuleMenu();
if ($hasAvailableTaskTypes) {
$addTaskUrl = (string)$this->uriBuilder->buildUriFromRoute('ajax_new_scheduler_task_wizard', [
'returnUrl' => $request->getAttribute('normalizedParams')->getRequestUri(),
]);
$view->assign('addTaskUrl', $addTaskUrl);
$this->addDocHeaderAddTaskButton($view, $addTaskUrl);
$this->addDocHeaderAddTaskGroupButton($view);
$this->addDocHeaderSetupCheckButton($view);
}
$this->addDocHeaderShortcutButton($view, $languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:function.scheduler'));
return $view->renderResponse('ListTasks');
}
private function addDocHeaderAddTaskButton(ModuleTemplate $moduleTemplate, string $url): void
{
$languageService = $this->getLanguageService();
$addButton = $this->componentFactory->createGenericButton()
->setTag('typo3-scheduler-new-task-wizard-button')
->setIcon($this->iconFactory->getIcon('actions-plus', IconSize::SMALL))
->setLabel($languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:function.add'))
->setShowLabelText(true)
->setAttributes([
'url' => $url,
'subject' => $languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:function.add'),
]);
$moduleTemplate->addButtonToButtonBar($addButton, ButtonBar::BUTTON_POSITION_LEFT, 2);
}
private function addDocHeaderAddTaskGroupButton(ModuleTemplate $moduleTemplate): void
{
$languageService = $this->getLanguageService();
$addButton = $this->componentFactory->createInputButton()
->setTitle($languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:function.group.add'))
->setShowLabelText(true)
->setIcon($this->iconFactory->getIcon('actions-plus', IconSize::SMALL))
->setName('createSchedulerGroup')
->setValue('1')
->setClasses('t3js-create-group');
$moduleTemplate->addButtonToButtonBar($addButton, ButtonBar::BUTTON_POSITION_LEFT, 3);
}
private function addDocHeaderSetupCheckButton(ModuleTemplate $moduleTemplate): void
{
$languageService = $this->getLanguageService();
$setupCheckButton = $this->componentFactory->createGenericButton()
->setTag('typo3-scheduler-setup-check-button')
->setIcon($this->iconFactory->getIcon('actions-window-cog', IconSize::SMALL))
->setLabel($languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:function.check'))
->setShowLabelText(true)
->setAttributes([
'url' => (string)$this->uriBuilder->buildUriFromRoute('ajax_scheduler_setup_check'),
'subject' => $languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:function.check'),
]);
$moduleTemplate->addButtonToButtonBar($setupCheckButton, ButtonBar::BUTTON_POSITION_RIGHT, 0);
}
private function addDocHeaderShortcutButton(ModuleTemplate $moduleTemplate, string $name): void
{
$moduleTemplate->getDocHeaderComponent()->setShortcutContext(
'scheduler',
$name
);
}
/**
* Add a flash message to the flash message queue of this module.
*/
private function addMessage(ModuleTemplate $moduleTemplate, string $message, ContextualFeedbackSeverity $severity = ContextualFeedbackSeverity::OK): void
{
$moduleTemplate->addFlashMessage($message, '', $severity);
}
private function getGroupsWithoutTasks(array $taskGroupsWithTasks): array
{
$uidGroupsWithTasks = array_filter(array_column($taskGroupsWithTasks, 'uid'));
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tx_scheduler_task_group');
$queryBuilder->getRestrictions()->removeByType(HiddenRestriction::class);
$resultEmptyGroups = $queryBuilder->select('*')
->from('tx_scheduler_task_group')
->orderBy('groupName');
// Only add where statement if we have taskGroups to consider.
if (!empty($uidGroupsWithTasks)) {
$resultEmptyGroups->where($queryBuilder->expr()->notIn('uid', $uidGroupsWithTasks));
}
return $resultEmptyGroups->executeQuery()->fetchAllAssociative();
}
private function groupRemove(int $groupId): int
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tx_scheduler_task_group');
return $queryBuilder->update('tx_scheduler_task_group')
->where($queryBuilder->expr()->eq('uid', $groupId))
->set('deleted', 1)
->executeStatement();
}
private function groupDisable(int $groupId, int $hidden): void
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tx_scheduler_task_group');
$queryBuilder->update('tx_scheduler_task_group')
->where($queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($groupId)))
->set('hidden', $hidden)
->executeStatement();
}
private function addSetupCheckInformation(ViewInterface $view): void
{
$languageService = $this->getLanguageService();
// Display information about the last automated run, as stored in the system registry.
$lastRun = $this->registry->get('tx_scheduler', 'lastRun');
$lastRunMessageLabel = 'msg.noLastRun';
$lastRunMessageLabelArguments = [];
$lastRunSeverity = ContextualFeedbackSeverity::WARNING->value;
if (is_array($lastRun)) {
if (empty($lastRun['end']) || empty($lastRun['start']) || empty($lastRun['type'])) {
$lastRunMessageLabel = 'msg.incompleteLastRun';
$lastRunSeverity = ContextualFeedbackSeverity::WARNING->value;
} else {
$lastRunMessageLabelArguments = [
$lastRun['type'] === 'manual'
? $languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:label.manually')
: $languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:label.automatically'),
date($GLOBALS['TYPO3_CONF_VARS']['SYS']['ddmmyy'], $lastRun['start']),
date($GLOBALS['TYPO3_CONF_VARS']['SYS']['hhmm'], $lastRun['start']),
date($GLOBALS['TYPO3_CONF_VARS']['SYS']['ddmmyy'], $lastRun['end']),
date($GLOBALS['TYPO3_CONF_VARS']['SYS']['hhmm'], $lastRun['end']),
];
$lastRunMessageLabel = 'msg.lastRun';
$lastRunSeverity = ContextualFeedbackSeverity::INFO->value;
}
}
// Information about cli script.
$script = $this->determineExecutablePath();
$isExecutableMessageLabel = 'msg.cliScriptNotExecutable';
$isExecutableSeverity = ContextualFeedbackSeverity::ERROR->value;
$composerMode = !$script && Environment::isComposerMode();
if (!$composerMode) {
// Check if CLI script is executable or not. Skip this check if running Windows since executable detection
// is not reliable on this platform, the script will always appear as *not* executable.
$isExecutable = Environment::isWindows() ? true : ($script && is_executable($script));
if ($isExecutable) {
$isExecutableMessageLabel = 'msg.cliScriptExecutable';
$isExecutableSeverity = ContextualFeedbackSeverity::OK->value;
}
}
$view->assignMultiple([
'composerMode' => $composerMode,
'script' => $script,
'lastRunMessageLabel' => $lastRunMessageLabel,
'lastRunMessageLabelArguments' => $lastRunMessageLabelArguments,
'lastRunSeverity' => $lastRunSeverity,
'isExecutableMessageLabel' => $isExecutableMessageLabel,
'isExecutableSeverity' => $isExecutableSeverity,
]);
}
private function determineExecutablePath(): ?string
{
if (!Environment::isComposerMode()) {
return GeneralUtility::getFileAbsFileName('EXT:core/bin/typo3');
}
$composerJsonFile = getenv('TYPO3_PATH_COMPOSER_ROOT') . '/composer.json';
if (!file_exists($composerJsonFile) || !($jsonContent = file_get_contents($composerJsonFile))) {
return null;
}
$jsonConfig = @json_decode($jsonContent, true);
if (empty($jsonConfig) || !is_array($jsonConfig)) {
return null;
}
$vendorDir = trim($jsonConfig['config']['vendor-dir'] ?? 'vendor', '/');
$binDir = trim($jsonConfig['config']['bin-dir'] ?? $vendorDir . '/bin', '/');
return sprintf('%s/%s/typo3', getenv('TYPO3_PATH_COMPOSER_ROOT'), $binDir);
}
private function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}