aboutsummaryrefslogtreecommitdiffstats
path: root/apps/theming/lib
diff options
context:
space:
mode:
Diffstat (limited to 'apps/theming/lib')
-rw-r--r--apps/theming/lib/AppInfo/Application.php28
-rw-r--r--apps/theming/lib/Capabilities.php117
-rw-r--r--apps/theming/lib/Command/UpdateConfig.php56
-rw-r--r--apps/theming/lib/Controller/IconController.php126
-rw-r--r--apps/theming/lib/Controller/ThemingController.php276
-rw-r--r--apps/theming/lib/Controller/UserThemeController.php124
-rw-r--r--apps/theming/lib/ITheme.php33
-rw-r--r--apps/theming/lib/IconBuilder.php122
-rw-r--r--apps/theming/lib/ImageManager.php215
-rw-r--r--apps/theming/lib/Jobs/MigrateBackgroundImages.php44
-rw-r--r--apps/theming/lib/Jobs/RestoreBackgroundImageColor.php205
-rw-r--r--apps/theming/lib/Listener/BeforePreferenceListener.php88
-rw-r--r--apps/theming/lib/Listener/BeforeTemplateRenderedListener.php87
-rw-r--r--apps/theming/lib/Migration/InitBackgroundImagesMigration.php31
-rw-r--r--apps/theming/lib/Migration/MigrateAdminConfig.php101
-rw-r--r--apps/theming/lib/Migration/MigrateUserConfig.php123
-rw-r--r--apps/theming/lib/Migration/Version2006Date20240905111627.php127
-rw-r--r--apps/theming/lib/ResponseDefinitions.php21
-rw-r--r--apps/theming/lib/Service/BackgroundService.php342
-rw-r--r--apps/theming/lib/Service/JSDataService.php51
-rw-r--r--apps/theming/lib/Service/ThemeInjectionService.php103
-rw-r--r--apps/theming/lib/Service/ThemesService.php113
-rw-r--r--apps/theming/lib/Settings/Admin.php78
-rw-r--r--apps/theming/lib/Settings/AdminSection.php39
-rw-r--r--apps/theming/lib/Settings/Personal.php88
-rw-r--r--apps/theming/lib/Settings/PersonalSection.php47
-rw-r--r--apps/theming/lib/SetupChecks/PhpImagickModule.php46
-rw-r--r--apps/theming/lib/Themes/CommonThemeTrait.php131
-rw-r--r--apps/theming/lib/Themes/DarkHighContrastTheme.php67
-rw-r--r--apps/theming/lib/Themes/DarkTheme.php79
-rw-r--r--apps/theming/lib/Themes/DefaultTheme.php192
-rw-r--r--apps/theming/lib/Themes/DyslexiaFont.php38
-rw-r--r--apps/theming/lib/Themes/HighContrastTheme.php75
-rw-r--r--apps/theming/lib/Themes/LightTheme.php42
-rw-r--r--apps/theming/lib/ThemingDefaults.php236
-rw-r--r--apps/theming/lib/Util.php160
36 files changed, 2102 insertions, 1749 deletions
diff --git a/apps/theming/lib/AppInfo/Application.php b/apps/theming/lib/AppInfo/Application.php
index 48bf42252c7..d08a1903265 100644
--- a/apps/theming/lib/AppInfo/Application.php
+++ b/apps/theming/lib/AppInfo/Application.php
@@ -1,36 +1,20 @@
<?php
+
/**
- * @copyright Copyright (c) 2019 Robin Appelman <robin@icewind.nl>
- *
- * @author Morris Jobke <hey@morrisjobke.de>
- * @author Robin Appelman <robin@icewind.nl>
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Theming\AppInfo;
use OCA\Theming\Capabilities;
use OCA\Theming\Listener\BeforePreferenceListener;
use OCA\Theming\Listener\BeforeTemplateRenderedListener;
+use OCA\Theming\SetupChecks\PhpImagickModule;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap;
use OCP\AppFramework\Bootstrap\IRegistrationContext;
+use OCP\AppFramework\Http\Events\BeforeLoginTemplateRenderedEvent;
use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent;
use OCP\Config\BeforePreferenceDeletedEvent;
use OCP\Config\BeforePreferenceSetEvent;
@@ -45,8 +29,10 @@ class Application extends App implements IBootstrap {
public function register(IRegistrationContext $context): void {
$context->registerCapability(Capabilities::class);
$context->registerEventListener(BeforeTemplateRenderedEvent::class, BeforeTemplateRenderedListener::class);
+ $context->registerEventListener(BeforeLoginTemplateRenderedEvent::class, BeforeTemplateRenderedListener::class);
$context->registerEventListener(BeforePreferenceSetEvent::class, BeforePreferenceListener::class);
$context->registerEventListener(BeforePreferenceDeletedEvent::class, BeforePreferenceListener::class);
+ $context->registerSetupCheck(PhpImagickModule::class);
}
public function boot(IBootContext $context): void {
diff --git a/apps/theming/lib/Capabilities.php b/apps/theming/lib/Capabilities.php
index 10828619e60..d5d6e415e75 100644
--- a/apps/theming/lib/Capabilities.php
+++ b/apps/theming/lib/Capabilities.php
@@ -1,34 +1,18 @@
<?php
+
/**
- * @copyright Copyright (c) 2016, Joas Schilling <coding@schilljs.com>
- *
- * @author Guillaume COMPAGNON <gcompagnon@outlook.com>
- * @author Joas Schilling <coding@schilljs.com>
- * @author Julien Veyssier <eneiluj@posteo.net>
- * @author Julius Härtl <jus@bitgrid.net>
- * @author Morris Jobke <hey@morrisjobke.de>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Theming;
+use OCA\Theming\AppInfo\Application;
+use OCA\Theming\Service\BackgroundService;
use OCP\Capabilities\IPublicCapability;
use OCP\IConfig;
use OCP\IURLGenerator;
+use OCP\IUser;
+use OCP\IUserSession;
/**
* Class Capabilities
@@ -37,54 +21,93 @@ use OCP\IURLGenerator;
*/
class Capabilities implements IPublicCapability {
- /** @var ThemingDefaults */
- protected $theming;
-
- /** @var Util */
- protected $util;
-
- /** @var IURLGenerator */
- protected $url;
-
- /** @var IConfig */
- protected $config;
-
/**
* @param ThemingDefaults $theming
* @param Util $util
* @param IURLGenerator $url
* @param IConfig $config
*/
- public function __construct(ThemingDefaults $theming, Util $util, IURLGenerator $url, IConfig $config) {
- $this->theming = $theming;
- $this->util = $util;
- $this->url = $url;
- $this->config = $config;
+ public function __construct(
+ protected ThemingDefaults $theming,
+ protected Util $util,
+ protected IURLGenerator $url,
+ protected IConfig $config,
+ protected IUserSession $userSession,
+ ) {
}
/**
* Return this classes capabilities
*
- * @return array
+ * @return array{
+ * theming: array{
+ * name: string,
+ * productName: string,
+ * url: string,
+ * slogan: string,
+ * color: string,
+ * color-text: string,
+ * color-element: string,
+ * color-element-bright: string,
+ * color-element-dark: string,
+ * logo: string,
+ * background: string,
+ * background-text: string,
+ * background-plain: bool,
+ * background-default: bool,
+ * logoheader: string,
+ * favicon: string,
+ * },
+ * }
*/
public function getCapabilities() {
+ $color = $this->theming->getDefaultColorPrimary();
+ $colorText = $this->util->invertTextColor($color) ? '#000000' : '#ffffff';
+
$backgroundLogo = $this->config->getAppValue('theming', 'backgroundMime', '');
- $color = $this->theming->getColorPrimary();
+ $backgroundColor = $this->theming->getColorBackground();
+ $backgroundText = $this->theming->getTextColorBackground();
+ $backgroundPlain = $backgroundLogo === 'backgroundColor' || ($backgroundLogo === '' && $backgroundColor !== BackgroundService::DEFAULT_COLOR);
+ $background = $backgroundPlain ? $backgroundColor : $this->url->getAbsoluteURL($this->theming->getBackground());
+
+ $user = $this->userSession->getUser();
+ if ($user instanceof IUser) {
+ /**
+ * Mimics the logic of generateUserBackgroundVariables() that generates the CSS variables.
+ * Also needs to be updated if the logic changes.
+ * @see \OCA\Theming\Themes\CommonThemeTrait::generateUserBackgroundVariables()
+ */
+ $color = $this->theming->getColorPrimary();
+ $colorText = $this->theming->getTextColorPrimary();
+
+ $backgroundImage = $this->config->getUserValue($user->getUID(), Application::APP_ID, 'background_image', BackgroundService::BACKGROUND_DEFAULT);
+ if ($backgroundImage === BackgroundService::BACKGROUND_CUSTOM) {
+ $backgroundPlain = false;
+ $background = $this->url->linkToRouteAbsolute('theming.userTheme.getBackground');
+ } elseif (isset(BackgroundService::SHIPPED_BACKGROUNDS[$backgroundImage])) {
+ $backgroundPlain = false;
+ $background = $this->url->linkTo(Application::APP_ID, "img/background/$backgroundImage");
+ } elseif ($backgroundImage !== BackgroundService::BACKGROUND_DEFAULT) {
+ $backgroundPlain = true;
+ $background = $backgroundColor;
+ }
+ }
+
return [
'theming' => [
'name' => $this->theming->getName(),
+ 'productName' => $this->theming->getProductName(),
'url' => $this->theming->getBaseUrl(),
'slogan' => $this->theming->getSlogan(),
'color' => $color,
- 'color-text' => $this->theming->getTextColorPrimary(),
+ 'color-text' => $colorText,
'color-element' => $this->util->elementColor($color),
'color-element-bright' => $this->util->elementColor($color),
'color-element-dark' => $this->util->elementColor($color, false),
'logo' => $this->url->getAbsoluteURL($this->theming->getLogo()),
- 'background' => $backgroundLogo === 'backgroundColor' || ($backgroundLogo === '' && $this->theming->getColorPrimary() !== '#0082c9') ?
- $this->theming->getColorPrimary() :
- $this->url->getAbsoluteURL($this->theming->getBackground()),
- 'background-plain' => $backgroundLogo === 'backgroundColor' || ($backgroundLogo === '' && $this->theming->getColorPrimary() !== '#0082c9'),
+ 'background' => $background,
+ 'background-text' => $backgroundText,
+ 'background-plain' => $backgroundPlain,
'background-default' => !$this->util->isBackgroundThemed(),
'logoheader' => $this->url->getAbsoluteURL($this->theming->getLogo()),
'favicon' => $this->url->getAbsoluteURL($this->theming->getLogo()),
diff --git a/apps/theming/lib/Command/UpdateConfig.php b/apps/theming/lib/Command/UpdateConfig.php
index 58dfcff8a8e..6236f866445 100644
--- a/apps/theming/lib/Command/UpdateConfig.php
+++ b/apps/theming/lib/Command/UpdateConfig.php
@@ -1,24 +1,8 @@
<?php
+
/**
- * @copyright Copyright (c) 2020 Julius Härtl <jus@bitgrid.net>
- *
- * @author Julius Härtl <jus@bitgrid.net>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Theming\Command;
@@ -33,19 +17,15 @@ use Symfony\Component\Console\Output\OutputInterface;
class UpdateConfig extends Command {
public const SUPPORTED_KEYS = [
- 'name', 'url', 'imprintUrl', 'privacyUrl', 'slogan', 'color', 'disable-user-theming'
+ 'name', 'url', 'imprintUrl', 'privacyUrl', 'slogan', 'color', 'primary_color', 'background_color', 'disable-user-theming'
];
- private $themingDefaults;
- private $imageManager;
- private $config;
-
- public function __construct(ThemingDefaults $themingDefaults, ImageManager $imageManager, IConfig $config) {
+ public function __construct(
+ private ThemingDefaults $themingDefaults,
+ private ImageManager $imageManager,
+ private IConfig $config,
+ ) {
parent::__construct();
-
- $this->themingDefaults = $themingDefaults;
- $this->imageManager = $imageManager;
- $this->config = $config;
}
protected function configure() {
@@ -55,8 +35,8 @@ class UpdateConfig extends Command {
->addArgument(
'key',
InputArgument::OPTIONAL,
- 'Key to update the theming app configuration (leave empty to get a list of all configured values)' . PHP_EOL .
- 'One of: ' . implode(', ', self::SUPPORTED_KEYS)
+ 'Key to update the theming app configuration (leave empty to get a list of all configured values)' . PHP_EOL
+ . 'One of: ' . implode(', ', self::SUPPORTED_KEYS)
)
->addArgument(
'value',
@@ -111,8 +91,13 @@ class UpdateConfig extends Command {
return 0;
}
+ if ($key === 'background' && $value === 'backgroundColor') {
+ $this->themingDefaults->undo($key);
+ $key = $key . 'Mime';
+ }
+
if (in_array($key, ImageManager::SUPPORTED_IMAGE_KEYS, true)) {
- if (strpos($value, '/') !== 0) {
+ if (!str_starts_with($value, '/')) {
$output->writeln('<error>The image file needs to be provided as an absolute path: ' . $value . '.</error>');
return 1;
}
@@ -124,7 +109,12 @@ class UpdateConfig extends Command {
$key = $key . 'Mime';
}
- if ($key === 'color' && !preg_match('/^\#([0-9a-f]{3}|[0-9a-f]{6})$/i', $value)) {
+ if ($key === 'color') {
+ $output->writeln('<comment>Using "color" is deprecated, use "primary_color" instead</comment>');
+ $key = 'primary_color';
+ }
+
+ if ($key === 'primary_color' && !preg_match('/^\#([0-9a-f]{3}|[0-9a-f]{6})$/i', $value)) {
$output->writeln('<error>The given color is invalid: ' . $value . '</error>');
return 1;
}
diff --git a/apps/theming/lib/Controller/IconController.php b/apps/theming/lib/Controller/IconController.php
index 08ee71ac660..e82faf78a79 100644
--- a/apps/theming/lib/Controller/IconController.php
+++ b/apps/theming/lib/Controller/IconController.php
@@ -1,29 +1,8 @@
<?php
+
/**
- * @copyright Copyright (c) 2016 Julius Haertl <jus@bitgrid.net>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author Joas Schilling <coding@schilljs.com>
- * @author Julius Haertl <jus@bitgrid.net>
- * @author Julius Härtl <jus@bitgrid.net>
- * @author Michael Weimann <mail@michael-weimann.eu>
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Theming\Controller;
@@ -31,8 +10,12 @@ use OC\IntegrityCheck\Helpers\FileAccessHelper;
use OCA\Theming\IconBuilder;
use OCA\Theming\ImageManager;
use OCA\Theming\ThemingDefaults;
+use OCP\App\IAppManager;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
+use OCP\AppFramework\Http\Attribute\OpenAPI;
+use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\DataDisplayResponse;
use OCP\AppFramework\Http\FileDisplayResponse;
use OCP\AppFramework\Http\NotFoundResponse;
@@ -41,51 +24,42 @@ use OCP\Files\NotFoundException;
use OCP\IRequest;
class IconController extends Controller {
- /** @var ThemingDefaults */
- private $themingDefaults;
- /** @var IconBuilder */
- private $iconBuilder;
- /** @var ImageManager */
- private $imageManager;
/** @var FileAccessHelper */
private $fileAccessHelper;
- /**
- * IconController constructor.
- *
- * @param string $appName
- * @param IRequest $request
- * @param ThemingDefaults $themingDefaults
- * @param IconBuilder $iconBuilder
- * @param ImageManager $imageManager
- * @param FileAccessHelper $fileAccessHelper
- */
public function __construct(
$appName,
IRequest $request,
- ThemingDefaults $themingDefaults,
- IconBuilder $iconBuilder,
- ImageManager $imageManager,
- FileAccessHelper $fileAccessHelper
+ private ThemingDefaults $themingDefaults,
+ private IconBuilder $iconBuilder,
+ private ImageManager $imageManager,
+ FileAccessHelper $fileAccessHelper,
+ private IAppManager $appManager,
) {
parent::__construct($appName, $request);
-
- $this->themingDefaults = $themingDefaults;
- $this->iconBuilder = $iconBuilder;
- $this->imageManager = $imageManager;
$this->fileAccessHelper = $fileAccessHelper;
}
/**
- * @PublicPage
- * @NoCSRFRequired
+ * Get a themed icon
*
- * @param $app string app name
- * @param $image string image file name (svg required)
- * @return FileDisplayResponse|NotFoundResponse
+ * @param string $app ID of the app
+ * @param string $image image file name (svg required)
+ * @return FileDisplayResponse<Http::STATUS_OK, array{Content-Type: 'image/svg+xml'}>|NotFoundResponse<Http::STATUS_NOT_FOUND, array{}>
* @throws \Exception
+ *
+ * 200: Themed icon returned
+ * 404: Themed icon not found
*/
+ #[PublicPage]
+ #[NoCSRFRequired]
+ #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)]
public function getThemedIcon(string $app, string $image): Response {
+ if ($app !== 'core' && !$this->appManager->isEnabledForUser($app)) {
+ $app = 'core';
+ $image = 'favicon.png';
+ }
+
$color = $this->themingDefaults->getColorPrimary();
try {
$iconFileName = $this->imageManager->getCachedImage('icon-' . $app . '-' . $color . str_replace('/', '_', $image));
@@ -94,7 +68,7 @@ class IconController extends Controller {
if ($icon === false || $icon === '') {
return new NotFoundResponse();
}
- $iconFileName = $this->imageManager->setCachedImage('icon-' . $app . '-' . $color . str_replace('/', '_', $image), $icon);
+ $iconFileName = $this->imageManager->setCachedImage('icon-' . $app . '-' . $color . str_replace('/', '_', $image), $icon);
}
$response = new FileDisplayResponse($iconFileName, Http::STATUS_OK, ['Content-Type' => 'image/svg+xml']);
$response->cacheFor(86400, false, true);
@@ -104,14 +78,21 @@ class IconController extends Controller {
/**
* Return a 32x32 favicon as png
*
- * @PublicPage
- * @NoCSRFRequired
- *
- * @param $app string app name
- * @return FileDisplayResponse|DataDisplayResponse|NotFoundResponse
+ * @param string $app ID of the app
+ * @return DataDisplayResponse<Http::STATUS_OK, array{Content-Type: 'image/x-icon'}>|FileDisplayResponse<Http::STATUS_OK, array{Content-Type: 'image/x-icon'}>|NotFoundResponse<Http::STATUS_NOT_FOUND, array{}>
* @throws \Exception
+ *
+ * 200: Favicon returned
+ * 404: Favicon not found
*/
+ #[PublicPage]
+ #[NoCSRFRequired]
+ #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)]
public function getFavicon(string $app = 'core'): Response {
+ if ($app !== 'core' && !$this->appManager->isEnabledForUser($app)) {
+ $app = 'core';
+ }
+
$response = null;
$iconFile = null;
try {
@@ -120,14 +101,15 @@ class IconController extends Controller {
} catch (NotFoundException $e) {
}
if ($iconFile === null && $this->imageManager->shouldReplaceIcons()) {
+ $color = $this->themingDefaults->getColorPrimary();
try {
- $iconFile = $this->imageManager->getCachedImage('favIcon-' . $app);
+ $iconFile = $this->imageManager->getCachedImage('favIcon-' . $app . $color);
} catch (NotFoundException $exception) {
$icon = $this->iconBuilder->getFavicon($app);
if ($icon === false || $icon === '') {
return new NotFoundResponse();
}
- $iconFile = $this->imageManager->setCachedImage('favIcon-' . $app, $icon);
+ $iconFile = $this->imageManager->setCachedImage('favIcon-' . $app . $color, $icon);
}
$response = new FileDisplayResponse($iconFile, Http::STATUS_OK, ['Content-Type' => 'image/x-icon']);
}
@@ -142,14 +124,21 @@ class IconController extends Controller {
/**
* Return a 512x512 icon for touch devices
*
- * @PublicPage
- * @NoCSRFRequired
- *
- * @param $app string app name
- * @return DataDisplayResponse|FileDisplayResponse|NotFoundResponse
+ * @param string $app ID of the app
+ * @return DataDisplayResponse<Http::STATUS_OK, array{Content-Type: 'image/png'}>|FileDisplayResponse<Http::STATUS_OK, array{Content-Type: 'image/x-icon'|'image/png'}>|NotFoundResponse<Http::STATUS_NOT_FOUND, array{}>
* @throws \Exception
+ *
+ * 200: Touch icon returned
+ * 404: Touch icon not found
*/
+ #[PublicPage]
+ #[NoCSRFRequired]
+ #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)]
public function getTouchIcon(string $app = 'core'): Response {
+ if ($app !== 'core' && !$this->appManager->isEnabledForUser($app)) {
+ $app = 'core';
+ }
+
$response = null;
try {
$iconFile = $this->imageManager->getImage('favicon');
@@ -157,14 +146,15 @@ class IconController extends Controller {
} catch (NotFoundException $e) {
}
if ($this->imageManager->shouldReplaceIcons()) {
+ $color = $this->themingDefaults->getColorPrimary();
try {
- $iconFile = $this->imageManager->getCachedImage('touchIcon-' . $app);
+ $iconFile = $this->imageManager->getCachedImage('touchIcon-' . $app . $color);
} catch (NotFoundException $exception) {
$icon = $this->iconBuilder->getTouchIcon($app);
if ($icon === false || $icon === '') {
return new NotFoundResponse();
}
- $iconFile = $this->imageManager->setCachedImage('touchIcon-' . $app, $icon);
+ $iconFile = $this->imageManager->setCachedImage('touchIcon-' . $app . $color, $icon);
}
$response = new FileDisplayResponse($iconFile, Http::STATUS_OK, ['Content-Type' => 'image/png']);
}
diff --git a/apps/theming/lib/Controller/ThemingController.php b/apps/theming/lib/Controller/ThemingController.php
index a323bac180b..e5cee254fe8 100644
--- a/apps/theming/lib/Controller/ThemingController.php
+++ b/apps/theming/lib/Controller/ThemingController.php
@@ -1,61 +1,38 @@
<?php
+
/**
- * @copyright Copyright (c) 2016 Bjoern Schiessle <bjoern@schiessle.org>
- * @copyright Copyright (c) 2016 Lukas Reschke <lukas@statuscode.ch>
- *
- * @author Arthur Schiwon <blizzz@arthur-schiwon.de>
- * @author Bjoern Schiessle <bjoern@schiessle.org>
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author Daniel Calviño Sánchez <danxuliu@gmail.com>
- * @author Jan-Christoph Borchardt <hey@jancborchardt.net>
- * @author Joas Schilling <coding@schilljs.com>
- * @author Julius Haertl <jus@bitgrid.net>
- * @author Julius Härtl <jus@bitgrid.net>
- * @author Kyle Fazzari <kyrofa@ubuntu.com>
- * @author Lukas Reschke <lukas@statuscode.ch>
- * @author nhirokinet <nhirokinet@nhiroki.net>
- * @author rakekniven <mark.ziegler@rakekniven.de>
- * @author Robin Appelman <robin@icewind.nl>
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- * @author Thomas Citharel <nextcloud@tcit.fr>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Theming\Controller;
+use InvalidArgumentException;
use OCA\Theming\ImageManager;
use OCA\Theming\Service\ThemesService;
+use OCA\Theming\Settings\Admin;
use OCA\Theming\ThemingDefaults;
use OCP\App\IAppManager;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting;
+use OCP\AppFramework\Http\Attribute\BruteForceProtection;
+use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
+use OCP\AppFramework\Http\Attribute\OpenAPI;
+use OCP\AppFramework\Http\Attribute\PublicPage;
+use OCP\AppFramework\Http\ContentSecurityPolicy;
use OCP\AppFramework\Http\DataDisplayResponse;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Http\FileDisplayResponse;
+use OCP\AppFramework\Http\JSONResponse;
use OCP\AppFramework\Http\NotFoundResponse;
-use OCP\Files\IAppData;
+use OCP\AppFramework\Services\IAppConfig;
use OCP\Files\NotFoundException;
use OCP\Files\NotPermittedException;
use OCP\IConfig;
use OCP\IL10N;
+use OCP\INavigationManager;
use OCP\IRequest;
-use OCP\ITempManager;
use OCP\IURLGenerator;
-use ScssPhp\ScssPhp\Compiler;
/**
* Class ThemingController
@@ -65,54 +42,35 @@ use ScssPhp\ScssPhp\Compiler;
* @package OCA\Theming\Controller
*/
class ThemingController extends Controller {
- const VALID_UPLOAD_KEYS = ['header', 'logo', 'logoheader', 'background', 'favicon'];
-
- private ThemingDefaults $themingDefaults;
- private IL10N $l10n;
- private IConfig $config;
- private ITempManager $tempManager;
- private IAppData $appData;
- private IURLGenerator $urlGenerator;
- private IAppManager $appManager;
- private ImageManager $imageManager;
- private ThemesService $themesService;
+ public const VALID_UPLOAD_KEYS = ['header', 'logo', 'logoheader', 'background', 'favicon'];
public function __construct(
- $appName,
+ string $appName,
IRequest $request,
- IConfig $config,
- ThemingDefaults $themingDefaults,
- IL10N $l,
- ITempManager $tempManager,
- IAppData $appData,
- IURLGenerator $urlGenerator,
- IAppManager $appManager,
- ImageManager $imageManager,
- ThemesService $themesService
+ private IConfig $config,
+ private IAppConfig $appConfig,
+ private ThemingDefaults $themingDefaults,
+ private IL10N $l10n,
+ private IURLGenerator $urlGenerator,
+ private IAppManager $appManager,
+ private ImageManager $imageManager,
+ private ThemesService $themesService,
+ private INavigationManager $navigationManager,
) {
parent::__construct($appName, $request);
-
- $this->themingDefaults = $themingDefaults;
- $this->l10n = $l;
- $this->config = $config;
- $this->tempManager = $tempManager;
- $this->appData = $appData;
- $this->urlGenerator = $urlGenerator;
- $this->appManager = $appManager;
- $this->imageManager = $imageManager;
- $this->themesService = $themesService;
}
/**
- * @AuthorizedAdminSetting(settings=OCA\Theming\Settings\Admin)
* @param string $setting
* @param string $value
* @return DataResponse
* @throws NotPermittedException
*/
+ #[AuthorizedAdminSetting(settings: Admin::class)]
public function updateStylesheet($setting, $value) {
$value = trim($value);
$error = null;
+ $saved = false;
switch ($setting) {
case 'name':
if (strlen($value) > 250) {
@@ -148,14 +106,28 @@ class ThemingController extends Controller {
$error = $this->l10n->t('The given slogan is too long');
}
break;
- case 'color':
+ case 'primary_color':
if (!preg_match('/^\#([0-9a-f]{3}|[0-9a-f]{6})$/i', $value)) {
$error = $this->l10n->t('The given color is invalid');
+ } else {
+ $this->appConfig->setAppValueString('primary_color', $value);
+ $saved = true;
+ }
+ break;
+ case 'background_color':
+ if (!preg_match('/^\#([0-9a-f]{3}|[0-9a-f]{6})$/i', $value)) {
+ $error = $this->l10n->t('The given color is invalid');
+ } else {
+ $this->appConfig->setAppValueString('background_color', $value);
+ $saved = true;
}
break;
case 'disable-user-theming':
- if ($value !== "yes" && $value !== "no") {
+ if (!in_array($value, ['yes', 'true', 'no', 'false'])) {
$error = $this->l10n->t('Disable-user-theming should be true or false');
+ } else {
+ $this->appConfig->setAppValueBool('disable-user-theming', $value === 'yes' || $value === 'true');
+ $saved = true;
}
break;
}
@@ -168,7 +140,9 @@ class ThemingController extends Controller {
], Http::STATUS_BAD_REQUEST);
}
- $this->themingDefaults->set($setting, $value);
+ if (!$saved) {
+ $this->themingDefaults->set($setting, $value);
+ }
return new DataResponse([
'data' => [
@@ -179,18 +153,61 @@ class ThemingController extends Controller {
}
/**
- * Check that a string is a valid http/https url
+ * @param string $setting
+ * @param mixed $value
+ * @return DataResponse
+ * @throws NotPermittedException
+ */
+ #[AuthorizedAdminSetting(settings: Admin::class)]
+ public function updateAppMenu($setting, $value) {
+ $error = null;
+ switch ($setting) {
+ case 'defaultApps':
+ if (is_array($value)) {
+ try {
+ $this->navigationManager->setDefaultEntryIds($value);
+ } catch (InvalidArgumentException $e) {
+ $error = $this->l10n->t('Invalid app given');
+ }
+ } else {
+ $error = $this->l10n->t('Invalid type for setting "defaultApp" given');
+ }
+ break;
+ default:
+ $error = $this->l10n->t('Invalid setting key');
+ }
+ if ($error !== null) {
+ return new DataResponse([
+ 'data' => [
+ 'message' => $error,
+ ],
+ 'status' => 'error'
+ ], Http::STATUS_BAD_REQUEST);
+ }
+
+ return new DataResponse([
+ 'data' => [
+ 'message' => $this->l10n->t('Saved'),
+ ],
+ 'status' => 'success'
+ ]);
+ }
+
+ /**
+ * Check that a string is a valid http/https url.
+ * Also validates that there is no way for XSS through HTML
*/
private function isValidUrl(string $url): bool {
- return ((strpos($url, 'http://') === 0 || strpos($url, 'https://') === 0) &&
- filter_var($url, FILTER_VALIDATE_URL) !== false);
+ return ((str_starts_with($url, 'http://') || str_starts_with($url, 'https://'))
+ && filter_var($url, FILTER_VALIDATE_URL) !== false)
+ && !str_contains($url, '"');
}
/**
- * @AuthorizedAdminSetting(settings=OCA\Theming\Settings\Admin)
* @return DataResponse
* @throws NotPermittedException
*/
+ #[AuthorizedAdminSetting(settings: Admin::class)]
public function uploadImage(): DataResponse {
$key = $this->request->getParam('key');
if (!in_array($key, self::VALID_UPLOAD_KEYS, true)) {
@@ -254,8 +271,8 @@ class ThemingController extends Controller {
return new DataResponse(
[
- 'data' =>
- [
+ 'data'
+ => [
'name' => $name,
'url' => $this->imageManager->getImageUrl($key),
'message' => $this->l10n->t('Saved'),
@@ -267,19 +284,19 @@ class ThemingController extends Controller {
/**
* Revert setting to default value
- * @AuthorizedAdminSetting(settings=OCA\Theming\Settings\Admin)
*
* @param string $setting setting which should be reverted
* @return DataResponse
* @throws NotPermittedException
*/
+ #[AuthorizedAdminSetting(settings: Admin::class)]
public function undo(string $setting): DataResponse {
$value = $this->themingDefaults->undo($setting);
return new DataResponse(
[
- 'data' =>
- [
+ 'data'
+ => [
'value' => $value,
'message' => $this->l10n->t('Saved'),
],
@@ -290,18 +307,19 @@ class ThemingController extends Controller {
/**
* Revert all theming settings to their default values
- * @AuthorizedAdminSetting(settings=OCA\Theming\Settings\Admin)
*
* @return DataResponse
* @throws NotPermittedException
*/
+ #[AuthorizedAdminSetting(settings: Admin::class)]
public function undoAll(): DataResponse {
$this->themingDefaults->undoAll();
+ $this->navigationManager->setDefaultEntryIds([]);
return new DataResponse(
[
- 'data' =>
- [
+ 'data'
+ => [
'message' => $this->l10n->t('Saved'),
],
'status' => 'success'
@@ -310,15 +328,21 @@ class ThemingController extends Controller {
}
/**
- * @PublicPage
- * @NoCSRFRequired
* @NoSameSiteCookieRequired
*
- * @param string $key
- * @param bool $useSvg
- * @return FileDisplayResponse|NotFoundResponse
+ * Get an image
+ *
+ * @param string $key Key of the image
+ * @param bool $useSvg Return image as SVG
+ * @return FileDisplayResponse<Http::STATUS_OK, array{}>|NotFoundResponse<Http::STATUS_NOT_FOUND, array{}>
* @throws NotPermittedException
+ *
+ * 200: Image returned
+ * 404: Image not found
*/
+ #[PublicPage]
+ #[NoCSRFRequired]
+ #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)]
public function getImage(string $key, bool $useSvg = true) {
try {
$file = $this->imageManager->getImage($key, $useSvg);
@@ -327,7 +351,7 @@ class ThemingController extends Controller {
}
$response = new FileDisplayResponse($file);
- $csp = new Http\ContentSecurityPolicy();
+ $csp = new ContentSecurityPolicy();
$csp->allowInlineStyle();
$response->setContentSecurityPolicy($csp);
$response->cacheFor(3600);
@@ -342,13 +366,22 @@ class ThemingController extends Controller {
}
/**
- * @NoCSRFRequired
- * @PublicPage
* @NoSameSiteCookieRequired
* @NoTwoFactorRequired
*
- * @return DataDisplayResponse|NotFoundResponse
+ * Get the CSS stylesheet for a theme
+ *
+ * @param string $themeId ID of the theme
+ * @param bool $plain Let the browser decide the CSS priority
+ * @param bool $withCustomCss Include custom CSS
+ * @return DataDisplayResponse<Http::STATUS_OK, array{Content-Type: 'text/css'}>|NotFoundResponse<Http::STATUS_NOT_FOUND, array{}>
+ *
+ * 200: Stylesheet returned
+ * 404: Theme not found
*/
+ #[PublicPage]
+ #[NoCSRFRequired]
+ #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)]
public function getThemeStylesheet(string $themeId, bool $plain = false, bool $withCustomCss = false) {
$themes = $this->themesService->getThemes();
if (!in_array($themeId, array_keys($themes))) {
@@ -356,7 +389,7 @@ class ThemingController extends Controller {
}
$theme = $themes[$themeId];
- $customCss = $theme->getCustomCss();
+ $customCss = $theme->getCustomCss();
// Generate variables
$variables = '';
@@ -369,9 +402,17 @@ class ThemingController extends Controller {
$css = ":root { $variables } " . $customCss;
} else {
// If not set, we'll rely on the body class
- $compiler = new Compiler();
- $compiledCss = $compiler->compileString("[data-theme-$themeId] { $variables $customCss }");
- $css = $compiledCss->getCss();;
+ // We need to separate @-rules from normal selectors, as they can't be nested
+ // This is a replacement for the SCSS compiler that did this automatically before f1448fcf0777db7d4254cb0a3ef94d63be9f7a24
+ // We need a better way to handle this, but for now we just remove comments and split the at-rules
+ // from the rest of the CSS.
+ $customCssWithoutComments = preg_replace('!/\*.*?\*/!s', '', $customCss);
+ $customCssWithoutComments = preg_replace('!//.*!', '', $customCssWithoutComments);
+ preg_match_all('/(@[^{]+{(?:[^{}]*|(?R))*})/', $customCssWithoutComments, $atRules);
+ $atRulesCss = implode('', $atRules[0]);
+ $scopedCss = preg_replace('/(@[^{]+{(?:[^{}]*|(?R))*})/', '', $customCssWithoutComments);
+
+ $css = "$atRulesCss [data-theme-$themeId] { $variables $scopedCss }";
}
try {
@@ -384,12 +425,20 @@ class ThemingController extends Controller {
}
/**
- * @NoCSRFRequired
- * @PublicPage
+ * Get the manifest for an app
+ *
+ * @param string $app ID of the app
+ * @psalm-suppress LessSpecificReturnStatement The content of the Manifest doesn't need to be described in the return type
+ * @return JSONResponse<Http::STATUS_OK, array{name: string, short_name: string, start_url: string, theme_color: string, background_color: string, description: string, icons: list<array{src: non-empty-string, type: string, sizes: string}>, display_override: list<string>, display: string}, array{}>|JSONResponse<Http::STATUS_NOT_FOUND, array{}, array{}>
*
- * @return Http\JSONResponse
+ * 200: Manifest returned
+ * 404: App not found
*/
- public function getManifest($app) {
+ #[PublicPage]
+ #[NoCSRFRequired]
+ #[BruteForceProtection(action: 'manifest')]
+ #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)]
+ public function getManifest(string $app): JSONResponse {
$cacheBusterValue = $this->config->getAppValue('theming', 'cachebuster', '0');
if ($app === 'core' || $app === 'settings') {
$name = $this->themingDefaults->getName();
@@ -397,16 +446,26 @@ class ThemingController extends Controller {
$startUrl = $this->urlGenerator->getBaseUrl();
$description = $this->themingDefaults->getSlogan();
} else {
+ if (!$this->appManager->isEnabledForUser($app)) {
+ $response = new JSONResponse([], Http::STATUS_NOT_FOUND);
+ $response->throttle(['action' => 'manifest', 'app' => $app]);
+ return $response;
+ }
+
$info = $this->appManager->getAppInfo($app, false, $this->l10n->getLanguageCode());
$name = $info['name'] . ' - ' . $this->themingDefaults->getName();
$shortName = $info['name'];
- if (strpos($this->request->getRequestUri(), '/index.php/') !== false) {
+ if (str_contains($this->request->getRequestUri(), '/index.php/')) {
$startUrl = $this->urlGenerator->getBaseUrl() . '/index.php/apps/' . $app . '/';
} else {
$startUrl = $this->urlGenerator->getBaseUrl() . '/apps/' . $app . '/';
}
$description = $info['summary'] ?? '';
}
+ /**
+ * @var string $description
+ * @var string $shortName
+ */
$responseJS = [
'name' => $name,
'short_name' => $shortName,
@@ -414,24 +473,25 @@ class ThemingController extends Controller {
'theme_color' => $this->themingDefaults->getColorPrimary(),
'background_color' => $this->themingDefaults->getColorPrimary(),
'description' => $description,
- 'icons' =>
- [
+ 'icons'
+ => [
[
'src' => $this->urlGenerator->linkToRoute('theming.Icon.getTouchIcon',
- ['app' => $app]) . '?v=' . $cacheBusterValue,
+ ['app' => $app]) . '?v=' . $cacheBusterValue,
'type' => 'image/png',
'sizes' => '512x512'
],
[
'src' => $this->urlGenerator->linkToRoute('theming.Icon.getFavicon',
- ['app' => $app]) . '?v=' . $cacheBusterValue,
+ ['app' => $app]) . '?v=' . $cacheBusterValue,
'type' => 'image/svg+xml',
'sizes' => '16x16'
]
],
- 'display' => 'standalone'
+ 'display_override' => [$this->config->getSystemValueBool('theming.standalone_window.enabled', true) ? 'minimal-ui' : ''],
+ 'display' => $this->config->getSystemValueBool('theming.standalone_window.enabled', true) ? 'standalone' : 'browser'
];
- $response = new Http\JSONResponse($responseJS);
+ $response = new JSONResponse($responseJS);
$response->cacheFor(3600);
return $response;
}
diff --git a/apps/theming/lib/Controller/UserThemeController.php b/apps/theming/lib/Controller/UserThemeController.php
index 6a58366c4f6..770f2ca922f 100644
--- a/apps/theming/lib/Controller/UserThemeController.php
+++ b/apps/theming/lib/Controller/UserThemeController.php
@@ -3,43 +3,26 @@
declare(strict_types=1);
/**
- * @copyright Copyright (c) 2018 John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>
- * @copyright Copyright (c) 2019 Janis Köhr <janiskoehr@icloud.com>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author Daniel Kesselberg <mail@danielkesselberg.de>
- * @author Janis Köhr <janis.koehr@novatec-gmbh.de>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Theming\Controller;
use OCA\Theming\AppInfo\Application;
use OCA\Theming\ITheme;
+use OCA\Theming\ResponseDefinitions;
use OCA\Theming\Service\BackgroundService;
use OCA\Theming\Service\ThemesService;
use OCA\Theming\ThemingDefaults;
use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\Attribute\NoAdminRequired;
+use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
+use OCP\AppFramework\Http\Attribute\OpenAPI;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Http\FileDisplayResponse;
use OCP\AppFramework\Http\JSONResponse;
use OCP\AppFramework\Http\NotFoundResponse;
+use OCP\AppFramework\Http\Response;
use OCP\AppFramework\OCS\OCSBadRequestException;
use OCP\AppFramework\OCS\OCSForbiddenException;
use OCP\AppFramework\OCSController;
@@ -48,29 +31,23 @@ use OCP\IRequest;
use OCP\IUserSession;
use OCP\PreConditionNotMetException;
+/**
+ * @psalm-import-type ThemingBackground from ResponseDefinitions
+ */
class UserThemeController extends OCSController {
protected ?string $userId = null;
-
- private IConfig $config;
- private IUserSession $userSession;
- private ThemesService $themesService;
- private ThemingDefaults $themingDefaults;
- private BackgroundService $backgroundService;
-
- public function __construct(string $appName,
- IRequest $request,
- IConfig $config,
- IUserSession $userSession,
- ThemesService $themesService,
- ThemingDefaults $themingDefaults,
- BackgroundService $backgroundService) {
+
+ public function __construct(
+ string $appName,
+ IRequest $request,
+ private IConfig $config,
+ IUserSession $userSession,
+ private ThemesService $themesService,
+ private ThemingDefaults $themingDefaults,
+ private BackgroundService $backgroundService,
+ ) {
parent::__construct($appName, $request);
- $this->config = $config;
- $this->userSession = $userSession;
- $this->themesService = $themesService;
- $this->themingDefaults = $themingDefaults;
- $this->backgroundService = $backgroundService;
$user = $userSession->getUser();
if ($user !== null) {
@@ -79,14 +56,16 @@ class UserThemeController extends OCSController {
}
/**
- * @NoAdminRequired
- *
* Enable theme
*
* @param string $themeId the theme ID
- * @return DataResponse
- * @throws OCSBadRequestException|PreConditionNotMetException
+ * @return DataResponse<Http::STATUS_OK, list<empty>, array{}>
+ * @throws OCSBadRequestException Enabling theme is not possible
+ * @throws PreConditionNotMetException
+ *
+ * 200: Theme enabled successfully
*/
+ #[NoAdminRequired]
public function enableTheme(string $themeId): DataResponse {
$theme = $this->validateTheme($themeId);
@@ -96,14 +75,16 @@ class UserThemeController extends OCSController {
}
/**
- * @NoAdminRequired
- *
* Disable theme
*
* @param string $themeId the theme ID
- * @return DataResponse
- * @throws OCSBadRequestException|PreConditionNotMetException
+ * @return DataResponse<Http::STATUS_OK, list<empty>, array{}>
+ * @throws OCSBadRequestException Disabling theme is not possible
+ * @throws PreConditionNotMetException
+ *
+ * 200: Theme disabled successfully
*/
+ #[NoAdminRequired]
public function disableTheme(string $themeId): DataResponse {
$theme = $this->validateTheme($themeId);
@@ -119,7 +100,8 @@ class UserThemeController extends OCSController {
*
* @param string $themeId the theme ID
* @return ITheme
- * @throws OCSBadRequestException|PreConditionNotMetException
+ * @throws OCSBadRequestException
+ * @throws PreConditionNotMetException
*/
private function validateTheme(string $themeId): ITheme {
if ($themeId === '' || !$themeId) {
@@ -141,10 +123,16 @@ class UserThemeController extends OCSController {
}
/**
- * @NoAdminRequired
- * @NoCSRFRequired
+ * Get the background image
+ * @return FileDisplayResponse<Http::STATUS_OK, array{Content-Type: string}>|NotFoundResponse<Http::STATUS_NOT_FOUND, array{}>
+ *
+ * 200: Background image returned
+ * 404: Background image not found
*/
- public function getBackground(): Http\Response {
+ #[NoAdminRequired]
+ #[NoCSRFRequired]
+ #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)]
+ public function getBackground(): Response {
$file = $this->backgroundService->getBackground();
if ($file !== null) {
$response = new FileDisplayResponse($file, Http::STATUS_OK, ['Content-Type' => $file->getMimeType()]);
@@ -155,22 +143,37 @@ class UserThemeController extends OCSController {
}
/**
- * @NoAdminRequired
+ * Delete the background
+ *
+ * @return JSONResponse<Http::STATUS_OK, ThemingBackground, array{}>
+ *
+ * 200: Background deleted successfully
*/
+ #[NoAdminRequired]
public function deleteBackground(): JSONResponse {
$currentVersion = (int)$this->config->getUserValue($this->userId, Application::APP_ID, 'userCacheBuster', '0');
$this->backgroundService->deleteBackgroundImage();
return new JSONResponse([
'backgroundImage' => null,
- 'backgroundColor' => $this->themingDefaults->getColorPrimary(),
+ 'backgroundColor' => $this->themingDefaults->getColorBackground(),
+ 'primaryColor' => $this->themingDefaults->getColorPrimary(),
'version' => $currentVersion,
]);
}
/**
- * @NoAdminRequired
+ * Set the background
+ *
+ * @param string $type Type of background
+ * @param string $value Path of the background image
+ * @param string|null $color Color for the background
+ * @return JSONResponse<Http::STATUS_OK, ThemingBackground, array{}>|JSONResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_INTERNAL_SERVER_ERROR, array{error: string}, array{}>
+ *
+ * 200: Background set successfully
+ * 400: Setting background is not possible
*/
- public function setBackground(string $type = BackgroundService::BACKGROUND_DEFAULT, string $value = '', string $color = null): JSONResponse {
+ #[NoAdminRequired]
+ public function setBackground(string $type = BackgroundService::BACKGROUND_DEFAULT, string $value = '', ?string $color = null): JSONResponse {
$currentVersion = (int)$this->config->getUserValue($this->userId, Application::APP_ID, 'userCacheBuster', '0');
// Set color if provided
@@ -207,7 +210,8 @@ class UserThemeController extends OCSController {
return new JSONResponse([
'backgroundImage' => $this->config->getUserValue($this->userId, Application::APP_ID, 'background_image', BackgroundService::BACKGROUND_DEFAULT),
- 'backgroundColor' => $this->themingDefaults->getColorPrimary(),
+ 'backgroundColor' => $this->themingDefaults->getColorBackground(),
+ 'primaryColor' => $this->themingDefaults->getColorPrimary(),
'version' => $currentVersion,
]);
}
diff --git a/apps/theming/lib/ITheme.php b/apps/theming/lib/ITheme.php
index a5c9cdf26a6..2e40e8e489b 100644
--- a/apps/theming/lib/ITheme.php
+++ b/apps/theming/lib/ITheme.php
@@ -1,24 +1,9 @@
<?php
+
declare(strict_types=1);
/**
- * @copyright Copyright (c) 2022 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This code is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License, version 3,
- * as published by the Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License, version 3,
- * along with this program. If not, see <http://www.gnu.org/licenses/>
- *
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Theming;
@@ -30,8 +15,8 @@ namespace OCA\Theming;
*/
interface ITheme {
- const TYPE_THEME = 1;
- const TYPE_FONT = 2;
+ public const TYPE_THEME = 1;
+ public const TYPE_FONT = 2;
/**
* Unique theme id
@@ -71,6 +56,14 @@ interface ITheme {
public function getDescription(): string;
/**
+ * Get the meta attribute matching the theme
+ * e.g. https://html.spec.whatwg.org/multipage/semantics.html#meta-color-scheme
+ * @return array{name?: string, content?: string}[]
+ * @since 29.0.0
+ */
+ public function getMeta(): array;
+
+ /**
* Get the media query triggering this theme
* Optional, ignored if falsy
*
diff --git a/apps/theming/lib/IconBuilder.php b/apps/theming/lib/IconBuilder.php
index fb6909089b5..63f4559970d 100644
--- a/apps/theming/lib/IconBuilder.php
+++ b/apps/theming/lib/IconBuilder.php
@@ -1,28 +1,8 @@
<?php
+
/**
- * @copyright Copyright (c) 2016 Julius Härtl <jus@bitgrid.net>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author Jan-Christoph Borchardt <hey@jancborchardt.net>
- * @author Julius Haertl <jus@bitgrid.net>
- * @author Julius Härtl <jus@bitgrid.net>
- * @author Morris Jobke <hey@morrisjobke.de>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Theming;
@@ -31,13 +11,6 @@ use ImagickPixel;
use OCP\Files\SimpleFS\ISimpleFile;
class IconBuilder {
- /** @var ThemingDefaults */
- private $themingDefaults;
- /** @var Util */
- private $util;
- /** @var ImageManager */
- private $imageManager;
-
/**
* IconBuilder constructor.
*
@@ -46,13 +19,10 @@ class IconBuilder {
* @param ImageManager $imageManager
*/
public function __construct(
- ThemingDefaults $themingDefaults,
- Util $util,
- ImageManager $imageManager
+ private ThemingDefaults $themingDefaults,
+ private Util $util,
+ private ImageManager $imageManager,
) {
- $this->themingDefaults = $themingDefaults;
- $this->util = $util;
- $this->imageManager = $imageManager;
}
/**
@@ -65,27 +35,27 @@ class IconBuilder {
}
try {
$favicon = new Imagick();
- $favicon->setFormat("ico");
+ $favicon->setFormat('ico');
$icon = $this->renderAppIcon($app, 128);
if ($icon === false) {
return false;
}
- $icon->setImageFormat("png32");
+ $icon->setImageFormat('png32');
$clone = clone $icon;
- $clone->scaleImage(16,0);
+ $clone->scaleImage(16, 0);
$favicon->addImage($clone);
$clone = clone $icon;
- $clone->scaleImage(32,0);
+ $clone->scaleImage(32, 0);
$favicon->addImage($clone);
$clone = clone $icon;
- $clone->scaleImage(64,0);
+ $clone->scaleImage(64, 0);
$favicon->addImage($clone);
$clone = clone $icon;
- $clone->scaleImage(128,0);
+ $clone->scaleImage(128, 0);
$favicon->addImage($clone);
$data = $favicon->getImagesBlob();
@@ -108,7 +78,7 @@ class IconBuilder {
if ($icon === false) {
return false;
}
- $icon->setImageFormat("png32");
+ $icon->setImageFormat('png32');
$data = $icon->getImageBlob();
$icon->destroy();
return $data;
@@ -121,58 +91,53 @@ class IconBuilder {
* Render app icon on themed background color
* fallback to logo
*
- * @param $app string app name
- * @param $size int size of the icon in px
+ * @param string $app app name
+ * @param int $size size of the icon in px
* @return Imagick|false
*/
public function renderAppIcon($app, $size) {
$appIcon = $this->util->getAppIcon($app);
- if ($appIcon === false) {
- return false;
- }
if ($appIcon instanceof ISimpleFile) {
$appIconContent = $appIcon->getContent();
$mime = $appIcon->getMimeType();
+ } elseif (!file_exists($appIcon)) {
+ return false;
} else {
$appIconContent = file_get_contents($appIcon);
$mime = mime_content_type($appIcon);
}
- if ($appIconContent === false || $appIconContent === "") {
+ if ($appIconContent === false || $appIconContent === '') {
return false;
}
$color = $this->themingDefaults->getColorPrimary();
// generate background image with rounded corners
- $background = '<?xml version="1.0" encoding="UTF-8"?>' .
- '<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:cc="http://creativecommons.org/ns#" width="512" height="512" xmlns:xlink="http://www.w3.org/1999/xlink">' .
- '<rect x="0" y="0" rx="100" ry="100" width="512" height="512" style="fill:' . $color . ';" />' .
- '</svg>';
+ $cornerRadius = 0.2 * $size;
+ $background = '<?xml version="1.0" encoding="UTF-8"?>'
+ . '<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:cc="http://creativecommons.org/ns#" width="' . $size . '" height="' . $size . '" xmlns:xlink="http://www.w3.org/1999/xlink">'
+ . '<rect x="0" y="0" rx="' . $cornerRadius . '" ry="' . $cornerRadius . '" width="' . $size . '" height="' . $size . '" style="fill:' . $color . ';" />'
+ . '</svg>';
// resize svg magic as this seems broken in Imagemagick
- if ($mime === "image/svg+xml" || substr($appIconContent, 0, 4) === "<svg") {
- if (substr($appIconContent, 0, 5) !== "<?xml") {
- $svg = "<?xml version=\"1.0\"?>".$appIconContent;
+ if ($mime === 'image/svg+xml' || substr($appIconContent, 0, 4) === '<svg') {
+ if (substr($appIconContent, 0, 5) !== '<?xml') {
+ $svg = '<?xml version="1.0"?>' . $appIconContent;
} else {
$svg = $appIconContent;
}
$tmp = new Imagick();
+ $tmp->setBackgroundColor(new ImagickPixel('transparent'));
+ $tmp->setResolution(72, 72);
$tmp->readImageBlob($svg);
$x = $tmp->getImageWidth();
$y = $tmp->getImageHeight();
- $res = $tmp->getImageResolution();
$tmp->destroy();
- if ($x > $y) {
- $max = $x;
- } else {
- $max = $y;
- }
-
// convert svg to resized image
$appIconFile = new Imagick();
- $resX = (int)(512 * $res['x'] / $max * 2.53);
- $resY = (int)(512 * $res['y'] / $max * 2.53);
+ $resX = (int)(72 * $size / $x);
+ $resY = (int)(72 * $size / $y);
$appIconFile->setResolution($resX, $resY);
$appIconFile->setBackgroundColor(new ImagickPixel('transparent'));
$appIconFile->readImageBlob($svg);
@@ -181,37 +146,36 @@ class IconBuilder {
* invert app icons for bright primary colors
* the default nextcloud logo will not be inverted to black
*/
- if ($this->util->invertTextColor($color)
+ if ($this->util->isBrightColor($color)
&& !$appIcon instanceof ISimpleFile
- && $app !== "core"
+ && $app !== 'core'
) {
$appIconFile->negateImage(false);
}
- $appIconFile->scaleImage(512, 512, true);
} else {
$appIconFile = new Imagick();
$appIconFile->setBackgroundColor(new ImagickPixel('transparent'));
$appIconFile->readImageBlob($appIconContent);
- $appIconFile->scaleImage(512, 512, true);
}
// offset for icon positioning
- $border_w = (int)($appIconFile->getImageWidth() * 0.05);
- $border_h = (int)($appIconFile->getImageHeight() * 0.05);
+ $padding = 0.15;
+ $border_w = (int)($appIconFile->getImageWidth() * $padding);
+ $border_h = (int)($appIconFile->getImageHeight() * $padding);
$innerWidth = ($appIconFile->getImageWidth() - $border_w * 2);
$innerHeight = ($appIconFile->getImageHeight() - $border_h * 2);
$appIconFile->adaptiveResizeImage($innerWidth, $innerHeight);
// center icon
- $offset_w = (int)(512 / 2 - $innerWidth / 2);
- $offset_h = (int)(512 / 2 - $innerHeight / 2);
+ $offset_w = (int)($size / 2 - $innerWidth / 2);
+ $offset_h = (int)($size / 2 - $innerHeight / 2);
$finalIconFile = new Imagick();
$finalIconFile->setBackgroundColor(new ImagickPixel('transparent'));
$finalIconFile->readImageBlob($background);
$finalIconFile->setImageVirtualPixelMethod(Imagick::VIRTUALPIXELMETHOD_TRANSPARENT);
- $finalIconFile->setImageArtifact('compose:args', "1,0,-0.5,0.5");
+ $finalIconFile->setImageArtifact('compose:args', '1,0,-0.5,0.5');
$finalIconFile->compositeImage($appIconFile, Imagick::COMPOSITE_ATOP, $offset_w, $offset_h);
$finalIconFile->setImageFormat('png24');
- if (defined("Imagick::INTERPOLATE_BICUBIC") === true) {
+ if (defined('Imagick::INTERPOLATE_BICUBIC') === true) {
$filter = Imagick::INTERPOLATE_BICUBIC;
} else {
$filter = Imagick::FILTER_LANCZOS;
@@ -223,17 +187,17 @@ class IconBuilder {
}
/**
- * @param $app string app name
- * @param $image string relative path to svg file in app directory
+ * @param string $app app name
+ * @param string $image relative path to svg file in app directory
* @return string|false content of a colorized svg file
*/
public function colorSvg($app, $image) {
$imageFile = $this->util->getAppImage($app, $image);
- if ($imageFile === false || $imageFile === "") {
+ if ($imageFile === false || $imageFile === '' || !file_exists($imageFile)) {
return false;
}
$svg = file_get_contents($imageFile);
- if ($svg !== false && $svg !== "") {
+ if ($svg !== false && $svg !== '') {
$color = $this->util->elementColor($this->themingDefaults->getColorPrimary());
$svg = $this->util->colorizeSvg($svg, $color);
return $svg;
diff --git a/apps/theming/lib/ImageManager.php b/apps/theming/lib/ImageManager.php
index f7b0c12844a..309bf192bc3 100644
--- a/apps/theming/lib/ImageManager.php
+++ b/apps/theming/lib/ImageManager.php
@@ -1,35 +1,8 @@
<?php
+
/**
- * @copyright Copyright (c) 2016 Julius Härtl <jus@bitgrid.net>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author Daniel Kesselberg <mail@danielkesselberg.de>
- * @author Gary Kim <gary@garykim.dev>
- * @author Jacob Neplokh <me@jacobneplokh.com>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Julien Veyssier <eneiluj@posteo.net>
- * @author Julius Haertl <jus@bitgrid.net>
- * @author Julius Härtl <jus@bitgrid.net>
- * @author Michael Weimann <mail@michael-weimann.eu>
- * @author Morris Jobke <hey@morrisjobke.de>
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- * @author ste101 <stephan_bauer@gmx.de>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Theming;
@@ -42,38 +15,22 @@ use OCP\Files\SimpleFS\ISimpleFile;
use OCP\Files\SimpleFS\ISimpleFolder;
use OCP\ICacheFactory;
use OCP\IConfig;
-use OCP\ILogger;
use OCP\ITempManager;
use OCP\IURLGenerator;
+use Psr\Log\LoggerInterface;
class ImageManager {
public const SUPPORTED_IMAGE_KEYS = ['background', 'logo', 'logoheader', 'favicon'];
- /** @var IConfig */
- private $config;
- /** @var IAppData */
- private $appData;
- /** @var IURLGenerator */
- private $urlGenerator;
- /** @var ICacheFactory */
- private $cacheFactory;
- /** @var ILogger */
- private $logger;
- /** @var ITempManager */
- private $tempManager;
-
- public function __construct(IConfig $config,
- IAppData $appData,
- IURLGenerator $urlGenerator,
- ICacheFactory $cacheFactory,
- ILogger $logger,
- ITempManager $tempManager) {
- $this->config = $config;
- $this->urlGenerator = $urlGenerator;
- $this->cacheFactory = $cacheFactory;
- $this->logger = $logger;
- $this->tempManager = $tempManager;
- $this->appData = $appData;
+ public function __construct(
+ private IConfig $config,
+ private IAppData $appData,
+ private IURLGenerator $urlGenerator,
+ private ICacheFactory $cacheFactory,
+ private LoggerInterface $logger,
+ private ITempManager $tempManager,
+ private BackgroundService $backgroundService,
+ ) {
}
/**
@@ -86,6 +43,9 @@ class ImageManager {
$cacheBusterCounter = $this->config->getAppValue(Application::APP_ID, 'cachebuster', '0');
if ($this->hasImage($key)) {
return $this->urlGenerator->linkToRoute('theming.Theming.getImage', [ 'key' => $key ]) . '?v=' . $cacheBusterCounter;
+ } elseif ($key === 'backgroundDark' && $this->hasImage('background')) {
+ // Fall back to light variant
+ return $this->urlGenerator->linkToRoute('theming.Theming.getImage', [ 'key' => 'background' ]) . '?v=' . $cacheBusterCounter;
}
switch ($key) {
@@ -93,8 +53,17 @@ class ImageManager {
case 'logoheader':
case 'favicon':
return $this->urlGenerator->imagePath('core', 'logo/logo.png') . '?v=' . $cacheBusterCounter;
+ case 'backgroundDark':
case 'background':
- return $this->urlGenerator->linkTo(Application::APP_ID, 'img/background/' . BackgroundService::DEFAULT_BACKGROUND_IMAGE);
+ // Removing the background defines its mime as 'backgroundColor'
+ $mimeSetting = $this->config->getAppValue('theming', 'backgroundMime', '');
+ if ($mimeSetting !== 'backgroundColor') {
+ $image = BackgroundService::DEFAULT_BACKGROUND_IMAGE;
+ if ($key === 'backgroundDark') {
+ $image = BackgroundService::SHIPPED_BACKGROUNDS[$image]['dark_variant'] ?? $image;
+ }
+ return $this->urlGenerator->linkTo(Application::APP_ID, "img/background/$image");
+ }
}
return '';
}
@@ -114,10 +83,10 @@ class ImageManager {
* @throws NotPermittedException
*/
public function getImage(string $key, bool $useSvg = true): ISimpleFile {
- $logo = $this->config->getAppValue('theming', $key . 'Mime', '');
+ $mime = $this->config->getAppValue('theming', $key . 'Mime', '');
$folder = $this->getRootFolder()->getFolder('images');
- if ($logo === '' || !$folder->fileExists($key)) {
+ if ($mime === '' || !$folder->fileExists($key)) {
throw new NotFoundException();
}
@@ -144,7 +113,8 @@ class ImageManager {
public function hasImage(string $key): bool {
$mimeSetting = $this->config->getAppValue('theming', $key . 'Mime', '');
- return $mimeSetting !== '';
+ // Removing the background defines its mime as 'backgroundColor'
+ return $mimeSetting !== '' && $mimeSetting !== 'backgroundColor';
}
/**
@@ -183,7 +153,7 @@ class ImageManager {
*
* @param string $filename
* @throws NotFoundException
- * @return \OCP\Files\SimpleFS\ISimpleFile
+ * @return ISimpleFile
* @throws NotPermittedException
*/
public function getCachedImage(string $filename): ISimpleFile {
@@ -196,7 +166,7 @@ class ImageManager {
*
* @param string $filename
* @param string $data
- * @return \OCP\Files\SimpleFS\ISimpleFile
+ * @return ISimpleFile
* @throws NotFoundException
* @throws NotPermittedException
*/
@@ -225,6 +195,10 @@ class ImageManager {
} catch (NotFoundException $e) {
} catch (NotPermittedException $e) {
}
+
+ if ($key === 'logo') {
+ $this->config->deleteAppValue('theming', 'logoDimensions');
+ }
}
public function updateImage(string $key, string $tmpFile): string {
@@ -240,43 +214,118 @@ class ImageManager {
$supportedFormats = $this->getSupportedUploadImageFormats($key);
$detectedMimeType = mime_content_type($tmpFile);
if (!in_array($detectedMimeType, $supportedFormats, true)) {
- throw new \Exception('Unsupported image type');
+ throw new \Exception('Unsupported image type: ' . $detectedMimeType);
}
- if ($key === 'background' && strpos($detectedMimeType, 'image/svg') === false && strpos($detectedMimeType, 'image/gif') === false) {
- // Optimize the image since some people may upload images that will be
- // either to big or are not progressive rendering.
- $newImage = @imagecreatefromstring(file_get_contents($tmpFile));
-
- // Preserve transparency
- imagesavealpha($newImage, true);
- imagealphablending($newImage, true);
-
- $tmpFile = $this->tempManager->getTemporaryFile();
- $newWidth = (int)(imagesx($newImage) < 4096 ? imagesx($newImage) : 4096);
- $newHeight = (int)(imagesy($newImage) / (imagesx($newImage) / $newWidth));
- $outputImage = imagescale($newImage, $newWidth, $newHeight);
+ if ($key === 'background') {
+ if ($this->shouldOptimizeBackgroundImage($detectedMimeType, filesize($tmpFile))) {
+ try {
+ // Optimize the image since some people may upload images that will be
+ // either to big or are not progressive rendering.
+ $newImage = @imagecreatefromstring(file_get_contents($tmpFile));
+ if ($newImage === false) {
+ throw new \Exception('Could not read background image, possibly corrupted.');
+ }
+
+ // Preserve transparency
+ imagesavealpha($newImage, true);
+ imagealphablending($newImage, true);
+
+ $imageWidth = imagesx($newImage);
+ $imageHeight = imagesy($newImage);
+
+ /** @var int */
+ $newWidth = min(4096, $imageWidth);
+ $newHeight = intval($imageHeight / ($imageWidth / $newWidth));
+ $outputImage = imagescale($newImage, $newWidth, $newHeight);
+ if ($outputImage === false) {
+ throw new \Exception('Could not scale uploaded background image.');
+ }
+
+ $newTmpFile = $this->tempManager->getTemporaryFile();
+ imageinterlace($outputImage, true);
+ // Keep jpeg images encoded as jpeg
+ if (str_contains($detectedMimeType, 'image/jpeg')) {
+ if (!imagejpeg($outputImage, $newTmpFile, 90)) {
+ throw new \Exception('Could not recompress background image as JPEG');
+ }
+ } else {
+ if (!imagepng($outputImage, $newTmpFile, 8)) {
+ throw new \Exception('Could not recompress background image as PNG');
+ }
+ }
+ $tmpFile = $newTmpFile;
+ imagedestroy($outputImage);
+ } catch (\Exception $e) {
+ if (isset($outputImage) && is_resource($outputImage) || $outputImage instanceof \GdImage) {
+ imagedestroy($outputImage);
+ }
+
+ $this->logger->debug($e->getMessage());
+ }
+ }
- imageinterlace($outputImage, 1);
- imagepng($outputImage, $tmpFile, 8);
- imagedestroy($outputImage);
+ // For background images we need to announce it
+ $this->backgroundService->setGlobalBackground($tmpFile);
+ }
- $target->putContent(file_get_contents($tmpFile));
- } else {
- $target->putContent(file_get_contents($tmpFile));
+ $target->putContent(file_get_contents($tmpFile));
+
+ if ($key === 'logo') {
+ $content = file_get_contents($tmpFile);
+ $newImage = @imagecreatefromstring($content);
+ if ($newImage !== false) {
+ $this->config->setAppValue('theming', 'logoDimensions', imagesx($newImage) . 'x' . imagesy($newImage));
+ } elseif (str_starts_with($detectedMimeType, 'image/svg')) {
+ $matched = preg_match('/viewbox=["\']\d* \d* (\d*\.?\d*) (\d*\.?\d*)["\']/i', $content, $matches);
+ if ($matched) {
+ $this->config->setAppValue('theming', 'logoDimensions', $matches[1] . 'x' . $matches[2]);
+ } else {
+ $this->logger->warning('Could not read logo image dimensions to optimize for mail header');
+ $this->config->deleteAppValue('theming', 'logoDimensions');
+ }
+ } else {
+ $this->logger->warning('Could not read logo image dimensions to optimize for mail header');
+ $this->config->deleteAppValue('theming', 'logoDimensions');
+ }
}
return $detectedMimeType;
}
/**
+ * Decide whether an image benefits from shrinking and reconverting
+ *
+ * @param string $mimeType the mime type of the image
+ * @param int $contentSize size of the image file
+ * @return bool
+ */
+ private function shouldOptimizeBackgroundImage(string $mimeType, int $contentSize): bool {
+ // Do not touch SVGs
+ if (str_contains($mimeType, 'image/svg')) {
+ return false;
+ }
+ // GIF does not benefit from converting
+ if (str_contains($mimeType, 'image/gif')) {
+ return false;
+ }
+ // WebP also does not benefit from converting
+ // We could possibly try to convert to progressive image, but normally webP images are quite small
+ if (str_contains($mimeType, 'image/webp')) {
+ return false;
+ }
+ // As a rule of thumb background images should be max. 150-300 KiB, small images do not benefit from converting
+ return $contentSize > 150000;
+ }
+
+ /**
* Returns a list of supported mime types for image uploads.
* "favicon" images are only allowed to be SVG when imagemagick with SVG support is available.
*
* @param string $key The image key, e.g. "favicon"
* @return string[]
*/
- private function getSupportedUploadImageFormats(string $key): array {
+ public function getSupportedUploadImageFormats(string $key): array {
$supportedFormats = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if ($key !== 'favicon' || $this->shouldReplaceIcons() === true) {
diff --git a/apps/theming/lib/Jobs/MigrateBackgroundImages.php b/apps/theming/lib/Jobs/MigrateBackgroundImages.php
index 62179e46a4b..62e58f5e722 100644
--- a/apps/theming/lib/Jobs/MigrateBackgroundImages.php
+++ b/apps/theming/lib/Jobs/MigrateBackgroundImages.php
@@ -3,25 +3,8 @@
declare(strict_types=1);
/**
- * @copyright Copyright (c) 2022 Arthur Schiwon <blizzz@arthur-schiwon.de>
- *
- * @author Arthur Schiwon <blizzz@arthur-schiwon.de>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <https://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Theming\Jobs;
@@ -47,31 +30,20 @@ class MigrateBackgroundImages extends QueuedJob {
// will be saved in appdata/theming/global/
protected const STATE_FILE_NAME = '25_dashboard_to_theming_migration_users.json';
- private IAppDataFactory $appDataFactory;
- private IJobList $jobList;
- private IDBConnection $dbc;
- private IAppData $appData;
- private LoggerInterface $logger;
-
public function __construct(
ITimeFactory $time,
- IAppDataFactory $appDataFactory,
- IJobList $jobList,
- IDBConnection $dbc,
- IAppData $appData,
- LoggerInterface $logger
+ private IAppDataFactory $appDataFactory,
+ private IJobList $jobList,
+ private IDBConnection $dbc,
+ private IAppData $appData,
+ private LoggerInterface $logger,
) {
parent::__construct($time);
- $this->appDataFactory = $appDataFactory;
- $this->jobList = $jobList;
- $this->dbc = $dbc;
- $this->appData = $appData;
- $this->logger = $logger;
}
protected function run(mixed $argument): void {
if (!is_array($argument) || !isset($argument['stage'])) {
- throw new \Exception('Job '.self::class.' called with wrong argument');
+ throw new \Exception('Job ' . self::class . ' called with wrong argument');
}
switch ($argument['stage']) {
diff --git a/apps/theming/lib/Jobs/RestoreBackgroundImageColor.php b/apps/theming/lib/Jobs/RestoreBackgroundImageColor.php
new file mode 100644
index 00000000000..42662dacef2
--- /dev/null
+++ b/apps/theming/lib/Jobs/RestoreBackgroundImageColor.php
@@ -0,0 +1,205 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\Theming\Jobs;
+
+use OCA\Theming\AppInfo\Application;
+use OCA\Theming\Service\BackgroundService;
+use OCP\AppFramework\Utility\ITimeFactory;
+use OCP\BackgroundJob\IJobList;
+use OCP\BackgroundJob\QueuedJob;
+use OCP\Files\IAppData;
+use OCP\Files\NotFoundException;
+use OCP\Files\NotPermittedException;
+use OCP\IConfig;
+use OCP\IDBConnection;
+use Psr\Log\LoggerInterface;
+
+class RestoreBackgroundImageColor extends QueuedJob {
+
+ public const STAGE_PREPARE = 'prepare';
+ public const STAGE_EXECUTE = 'execute';
+ // will be saved in appdata/theming/global/
+ protected const STATE_FILE_NAME = '30_background_image_color_restoration.json';
+
+ public function __construct(
+ ITimeFactory $time,
+ private IConfig $config,
+ private IAppData $appData,
+ private IJobList $jobList,
+ private IDBConnection $dbc,
+ private LoggerInterface $logger,
+ private BackgroundService $service,
+ ) {
+ parent::__construct($time);
+ }
+
+ protected function run(mixed $argument): void {
+ if (!is_array($argument) || !isset($argument['stage'])) {
+ throw new \Exception('Job ' . self::class . ' called with wrong argument');
+ }
+
+ switch ($argument['stage']) {
+ case self::STAGE_PREPARE:
+ $this->runPreparation();
+ break;
+ case self::STAGE_EXECUTE:
+ $this->runMigration();
+ break;
+ default:
+ break;
+ }
+ }
+
+ protected function runPreparation(): void {
+ try {
+ $qb = $this->dbc->getQueryBuilder();
+ $qb2 = $this->dbc->getQueryBuilder();
+
+ $innerSQL = $qb2->select('userid')
+ ->from('preferences')
+ ->where($qb2->expr()->eq('configkey', $qb->createNamedParameter('background_color')));
+
+ // Get those users, that have a background_image set - not the default, but no background_color.
+ $result = $qb->selectDistinct('a.userid')
+ ->from('preferences', 'a')
+ ->leftJoin('a', $qb->createFunction('(' . $innerSQL->getSQL() . ')'), 'b', 'a.userid = b.userid')
+ ->where($qb2->expr()->eq('a.configkey', $qb->createNamedParameter('background_image')))
+ ->andWhere($qb2->expr()->neq('a.configvalue', $qb->createNamedParameter(BackgroundService::BACKGROUND_DEFAULT)))
+ ->andWhere($qb2->expr()->isNull('b.userid'))
+ ->executeQuery();
+
+ $userIds = $result->fetchAll(\PDO::FETCH_COLUMN);
+ $this->logger->info('Prepare to restore background information for {users} users', ['users' => count($userIds)]);
+ $this->storeUserIdsToProcess($userIds);
+ } catch (\Throwable $t) {
+ $this->jobList->add(self::class, ['stage' => self::STAGE_PREPARE]);
+ throw $t;
+ }
+ $this->jobList->add(self::class, ['stage' => self::STAGE_EXECUTE]);
+ }
+
+ /**
+ * @throws NotPermittedException
+ * @throws NotFoundException
+ */
+ protected function runMigration(): void {
+ $allUserIds = $this->readUserIdsToProcess();
+ $notSoFastMode = count($allUserIds) > 1000;
+
+ $userIds = array_slice($allUserIds, 0, 1000);
+ foreach ($userIds as $userId) {
+ $backgroundColor = $this->config->getUserValue($userId, Application::APP_ID, 'background_color');
+ if ($backgroundColor !== '') {
+ continue;
+ }
+
+ $background = $this->config->getUserValue($userId, Application::APP_ID, 'background_image');
+ switch ($background) {
+ case BackgroundService::BACKGROUND_DEFAULT:
+ $this->service->setDefaultBackground($userId);
+ break;
+ case BackgroundService::BACKGROUND_COLOR:
+ break;
+ case BackgroundService::BACKGROUND_CUSTOM:
+ $this->service->recalculateMeanColor($userId);
+ break;
+ default:
+ // shipped backgrounds
+ // do not alter primary color
+ $primary = $this->config->getUserValue($userId, Application::APP_ID, 'primary_color');
+ if (isset(BackgroundService::SHIPPED_BACKGROUNDS[$background])) {
+ $this->service->setShippedBackground($background, $userId);
+ } else {
+ $this->service->setDefaultBackground($userId);
+ }
+ // Restore primary
+ if ($primary !== '') {
+ $this->config->setUserValue($userId, Application::APP_ID, 'primary_color', $primary);
+ }
+ }
+ }
+
+ if ($notSoFastMode) {
+ $remainingUserIds = array_slice($allUserIds, 1000);
+ $this->storeUserIdsToProcess($remainingUserIds);
+ $this->jobList->add(self::class, ['stage' => self::STAGE_EXECUTE]);
+ } else {
+ $this->deleteStateFile();
+ }
+ }
+
+ /**
+ * @throws NotPermittedException
+ * @throws NotFoundException
+ */
+ protected function readUserIdsToProcess(): array {
+ $globalFolder = $this->appData->getFolder('global');
+ if ($globalFolder->fileExists(self::STATE_FILE_NAME)) {
+ $file = $globalFolder->getFile(self::STATE_FILE_NAME);
+ try {
+ $userIds = \json_decode($file->getContent(), true);
+ } catch (NotFoundException $e) {
+ $userIds = [];
+ }
+ if ($userIds === null) {
+ $userIds = [];
+ }
+ } else {
+ $userIds = [];
+ }
+ return $userIds;
+ }
+
+ /**
+ * @throws NotFoundException
+ */
+ protected function storeUserIdsToProcess(array $userIds): void {
+ $storableUserIds = \json_encode($userIds);
+ $globalFolder = $this->appData->getFolder('global');
+ try {
+ if ($globalFolder->fileExists(self::STATE_FILE_NAME)) {
+ $file = $globalFolder->getFile(self::STATE_FILE_NAME);
+ } else {
+ $file = $globalFolder->newFile(self::STATE_FILE_NAME);
+ }
+ $file->putContent($storableUserIds);
+ } catch (NotFoundException $e) {
+ } catch (NotPermittedException $e) {
+ $this->logger->warning('Lacking permissions to create {file}',
+ [
+ 'app' => 'theming',
+ 'file' => self::STATE_FILE_NAME,
+ 'exception' => $e,
+ ]
+ );
+ }
+ }
+
+ /**
+ * @throws NotFoundException
+ */
+ protected function deleteStateFile(): void {
+ $globalFolder = $this->appData->getFolder('global');
+ if ($globalFolder->fileExists(self::STATE_FILE_NAME)) {
+ $file = $globalFolder->getFile(self::STATE_FILE_NAME);
+ try {
+ $file->delete();
+ } catch (NotPermittedException $e) {
+ $this->logger->info('Could not delete {file} due to permissions. It is safe to delete manually inside data -> appdata -> theming -> global.',
+ [
+ 'app' => 'theming',
+ 'file' => $file->getName(),
+ 'exception' => $e,
+ ]
+ );
+ }
+ }
+ }
+}
diff --git a/apps/theming/lib/Listener/BeforePreferenceListener.php b/apps/theming/lib/Listener/BeforePreferenceListener.php
index a1add86e600..048deae50ce 100644
--- a/apps/theming/lib/Listener/BeforePreferenceListener.php
+++ b/apps/theming/lib/Listener/BeforePreferenceListener.php
@@ -3,54 +3,96 @@
declare(strict_types=1);
/**
- * @copyright Copyright (c) 2022 Joas Schilling <coding@schilljs.com>
- *
- * @author Joas Schilling <coding@schilljs.com>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Theming\Listener;
use OCA\Theming\AppInfo\Application;
+use OCP\App\IAppManager;
use OCP\Config\BeforePreferenceDeletedEvent;
use OCP\Config\BeforePreferenceSetEvent;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
+/** @template-implements IEventListener<BeforePreferenceDeletedEvent|BeforePreferenceSetEvent> */
class BeforePreferenceListener implements IEventListener {
+
+ /**
+ * @var string[]
+ */
+ private const ALLOWED_KEYS = ['force_enable_blur_filter', 'shortcuts_disabled', 'primary_color'];
+
+ public function __construct(
+ private IAppManager $appManager,
+ ) {
+ }
+
public function handle(Event $event): void {
if (!$event instanceof BeforePreferenceSetEvent
&& !$event instanceof BeforePreferenceDeletedEvent) {
+ // Invalid event type
return;
}
- if ($event->getAppId() !== Application::APP_ID) {
- return;
+ switch ($event->getAppId()) {
+ case Application::APP_ID: $this->handleThemingValues($event);
+ break;
+ case 'core': $this->handleCoreValues($event);
+ break;
}
+ }
- if ($event->getConfigKey() !== 'shortcuts_disabled') {
+ private function handleThemingValues(BeforePreferenceSetEvent|BeforePreferenceDeletedEvent $event): void {
+ if (!in_array($event->getConfigKey(), self::ALLOWED_KEYS)) {
+ // Not allowed config key
return;
}
if ($event instanceof BeforePreferenceSetEvent) {
- $event->setValid($event->getConfigValue() === 'yes');
+ switch ($event->getConfigKey()) {
+ case 'force_enable_blur_filter':
+ $event->setValid($event->getConfigValue() === 'yes' || $event->getConfigValue() === 'no');
+ break;
+ case 'shortcuts_disabled':
+ $event->setValid($event->getConfigValue() === 'yes');
+ break;
+ case 'primary_color':
+ $event->setValid(preg_match('/^\#([0-9a-f]{3}|[0-9a-f]{6})$/i', $event->getConfigValue()) === 1);
+ break;
+ default:
+ $event->setValid(false);
+ }
return;
}
$event->setValid(true);
}
+
+ private function handleCoreValues(BeforePreferenceSetEvent|BeforePreferenceDeletedEvent $event): void {
+ if ($event->getConfigKey() !== 'apporder') {
+ // Not allowed config key
+ return;
+ }
+
+ if ($event instanceof BeforePreferenceDeletedEvent) {
+ $event->setValid(true);
+ return;
+ }
+
+ $value = json_decode($event->getConfigValue(), true, flags:JSON_THROW_ON_ERROR);
+ if (!is_array(($value))) {
+ // Must be an array
+ return;
+ }
+
+ foreach ($value as $id => $info) {
+ // required format: [ navigation_id: string => [ order: int, app?: string ] ]
+ if (!is_string($id) || !is_array($info) || empty($info) || !isset($info['order']) || !is_numeric($info['order']) || (isset($info['app']) && !$this->appManager->isEnabledForUser($info['app']))) {
+ // Invalid config value, refuse the change
+ return;
+ }
+ }
+ $event->setValid(true);
+ }
}
diff --git a/apps/theming/lib/Listener/BeforeTemplateRenderedListener.php b/apps/theming/lib/Listener/BeforeTemplateRenderedListener.php
index 380527ee024..18ab9392b97 100644
--- a/apps/theming/lib/Listener/BeforeTemplateRenderedListener.php
+++ b/apps/theming/lib/Listener/BeforeTemplateRenderedListener.php
@@ -3,32 +3,15 @@
declare(strict_types=1);
/**
- * @copyright Copyright (c) 2020 Morris Jobke <hey@morrisjobke.de>
- *
- * @author Morris Jobke <hey@morrisjobke.de>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Theming\Listener;
use OCA\Theming\AppInfo\Application;
-use OCA\Theming\Service\BackgroundService;
use OCA\Theming\Service\JSDataService;
use OCA\Theming\Service\ThemeInjectionService;
+use OCP\AppFramework\Http\Events\BeforeLoginTemplateRenderedEvent;
use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Services\IInitialState;
@@ -36,28 +19,19 @@ use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\IConfig;
use OCP\IUserSession;
+use OCP\Util;
use Psr\Container\ContainerInterface;
+/** @template-implements IEventListener<BeforeTemplateRenderedEvent|BeforeLoginTemplateRenderedEvent> */
class BeforeTemplateRenderedListener implements IEventListener {
- private IInitialState $initialState;
- private ContainerInterface $container;
- private ThemeInjectionService $themeInjectionService;
- private IUserSession $userSession;
- private IConfig $config;
-
public function __construct(
- IInitialState $initialState,
- ContainerInterface $container,
- ThemeInjectionService $themeInjectionService,
- IUserSession $userSession,
- IConfig $config
+ private IInitialState $initialState,
+ private ContainerInterface $container,
+ private ThemeInjectionService $themeInjectionService,
+ private IUserSession $userSession,
+ private IConfig $config,
) {
- $this->initialState = $initialState;
- $this->container = $container;
- $this->themeInjectionService = $themeInjectionService;
- $this->userSession = $userSession;
- $this->config = $config;
}
public function handle(Event $event): void {
@@ -66,7 +40,7 @@ class BeforeTemplateRenderedListener implements IEventListener {
fn () => $this->container->get(JSDataService::class),
);
- /** @var BeforeTemplateRenderedEvent $event */
+ /** @var BeforeTemplateRenderedEvent|BeforeLoginTemplateRenderedEvent $event */
if ($event->getResponse()->getRenderAs() === TemplateResponse::RENDER_AS_USER) {
$this->initialState->provideLazyInitialState('shortcutsDisabled', function () {
if ($this->userSession->getUser()) {
@@ -79,44 +53,7 @@ class BeforeTemplateRenderedListener implements IEventListener {
$this->themeInjectionService->injectHeaders();
- $user = $this->userSession->getUser();
-
- if (!empty($user)) {
- $userId = $user->getUID();
-
- /** User background */
- $this->initialState->provideInitialState(
- 'backgroundImage',
- $this->config->getUserValue($userId, Application::APP_ID, 'background_image', BackgroundService::BACKGROUND_DEFAULT),
- );
-
- /** User color */
- $this->initialState->provideInitialState(
- 'backgroundColor',
- $this->config->getUserValue($userId, Application::APP_ID, 'background_color', BackgroundService::DEFAULT_COLOR),
- );
-
- /**
- * Admin background. `backgroundColor` if disabled,
- * mime type if defined and empty by default
- */
- $this->initialState->provideInitialState(
- 'themingDefaultBackground',
- $this->config->getAppValue('theming', 'backgroundMime', ''),
- );
- $this->initialState->provideInitialState(
- 'defaultShippedBackground',
- BackgroundService::DEFAULT_BACKGROUND_IMAGE,
- );
-
- /** List of all shipped backgrounds */
- $this->initialState->provideInitialState(
- 'shippedBackgrounds',
- BackgroundService::SHIPPED_BACKGROUNDS,
- );
- }
-
// Making sure to inject just after core
- \OCP\Util::addScript('theming', 'theming', 'core');
+ Util::addScript('theming', 'theming', 'core');
}
}
diff --git a/apps/theming/lib/Migration/InitBackgroundImagesMigration.php b/apps/theming/lib/Migration/InitBackgroundImagesMigration.php
index ff8783196ac..dea1bb3aa83 100644
--- a/apps/theming/lib/Migration/InitBackgroundImagesMigration.php
+++ b/apps/theming/lib/Migration/InitBackgroundImagesMigration.php
@@ -3,25 +3,8 @@
declare(strict_types=1);
/**
- * @copyright Copyright (c) 2022 Arthur Schiwon <blizzz@arthur-schiwon.de>
- *
- * @author Arthur Schiwon <blizzz@arthur-schiwon.de>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <https://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Theming\Migration;
@@ -29,13 +12,13 @@ namespace OCA\Theming\Migration;
use OCA\Theming\Jobs\MigrateBackgroundImages;
use OCP\BackgroundJob\IJobList;
use OCP\Migration\IOutput;
+use OCP\Migration\IRepairStep;
-class InitBackgroundImagesMigration implements \OCP\Migration\IRepairStep {
+class InitBackgroundImagesMigration implements IRepairStep {
- private IJobList $jobList;
-
- public function __construct(IJobList $jobList) {
- $this->jobList = $jobList;
+ public function __construct(
+ private IJobList $jobList,
+ ) {
}
public function getName() {
diff --git a/apps/theming/lib/Migration/MigrateAdminConfig.php b/apps/theming/lib/Migration/MigrateAdminConfig.php
deleted file mode 100644
index 913b29f0061..00000000000
--- a/apps/theming/lib/Migration/MigrateAdminConfig.php
+++ /dev/null
@@ -1,101 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/**
- * @copyright 2022 Christopher Ng <chrng8@gmail.com>
- *
- * @author Christopher Ng <chrng8@gmail.com>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
- */
-
-namespace OCA\Theming\Migration;
-
-use OCP\Files\IAppData;
-use OCP\Files\NotFoundException;
-use OCP\Files\SimpleFS\ISimpleFolder;
-use OCP\IL10N;
-use OCP\Migration\IOutput;
-use OCP\Migration\IRepairStep;
-use Throwable;
-
-class MigrateAdminConfig implements IRepairStep {
- private IAppData $appData;
- private IL10N $l10n;
-
- public function __construct(
- IAppData $appData,
- IL10N $l10n
- ) {
- $this->appData = $appData;
- $this->l10n = $l10n;
- }
-
- public function getName(): string {
- return $this->l10n->t('Failed to clean up the old administration theming images folder');
- }
-
- public function run(IOutput $output): void {
- $output->info('Migrating administration images');
- $this->migrateAdminImages($output);
- $this->cleanupAdminImages($output);
- }
-
- private function migrateAdminImages(IOutput $output): void {
- try {
- $images = $this->appData->getFolder('images');
- $output->info('Migrating administration images');
-
- // get or init the global folder if any
- try {
- $global = $this->appData->getFolder('global');
- } catch (NotFoundException $e) {
- $global = $this->appData->newFolder('global');
- }
-
- // get or init the new images folder if any
- try {
- $newImages = $global->getFolder('images');
- } catch (NotFoundException $e) {
- $newImages = $global->newFolder('images');
- }
-
- $files = $images->getDirectoryListing();
- $output->startProgress(count($files));
- foreach($files as $file) {
- $newImages->newFile($file->getName(), $file->getContent());
- $output->advance();
- }
-
- $output->finishProgress();
- } catch(NotFoundException $e) {
- $output->info('No administration images to migrate');
- }
- }
-
-
- private function cleanupAdminImages(IOutput $output): void {
- try {
- $images = $this->appData->getFolder('images');
- $images->delete();
- } catch (NotFoundException $e) {
- } catch (Throwable $e) {
- $output->warning($this->l10n->t('Failed to cleanup the old administration image folder', [$e->getMessage()]));
- }
- }
-}
diff --git a/apps/theming/lib/Migration/MigrateUserConfig.php b/apps/theming/lib/Migration/MigrateUserConfig.php
deleted file mode 100644
index 0f8d982dfa7..00000000000
--- a/apps/theming/lib/Migration/MigrateUserConfig.php
+++ /dev/null
@@ -1,123 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/**
- * @copyright Copyright (c) 2019 Janis Köhr <janiskoehr@icloud.com>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author Janis Köhr <janis.koehr@novatec-gmbh.de>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
- */
-namespace OCA\Theming\Migration;
-
-use OCA\Theming\AppInfo\Application;
-use OCA\Theming\Service\ThemesService;
-use OCA\Theming\Themes\DarkHighContrastTheme;
-use OCA\Theming\Themes\DarkTheme;
-use OCA\Theming\Themes\DyslexiaFont;
-use OCA\Theming\Themes\HighContrastTheme;
-use OCP\IConfig;
-use OCP\IUser;
-use OCP\IUserManager;
-use OCP\Migration\IOutput;
-use OCP\Migration\IRepairStep;
-
-class MigrateUserConfig implements IRepairStep {
-
- protected IUserManager $userManager;
- protected IConfig $config;
- protected ThemesService $themesService;
- protected DarkTheme $darkTheme;
- protected DarkHighContrastTheme $darkHighContrastTheme;
- protected HighContrastTheme $highContrastTheme;
- protected DyslexiaFont $dyslexiaFont;
-
- /**
- * MigrateUserConfig constructor.
- */
- public function __construct(IConfig $config,
- IUserManager $userManager,
- ThemesService $themesService,
- DarkTheme $darkTheme,
- DarkHighContrastTheme $darkHighContrastTheme,
- HighContrastTheme $highContrastTheme,
- DyslexiaFont $dyslexiaFont) {
- $this->config = $config;
- $this->userManager = $userManager;
- $this->themesService = $themesService;
-
- $this->darkTheme = $darkTheme;
- $this->darkHighContrastTheme = $darkHighContrastTheme;
- $this->highContrastTheme = $highContrastTheme;
- $this->dyslexiaFont = $dyslexiaFont;
- }
-
- /**
- * Returns the step's name
- *
- * @return string
- * @since 25.0.0
- */
- public function getName() {
- return 'Migrate old user accessibility config';
- }
-
- /**
- * Run repair step.
- * Must throw exception on error.
- *
- * @param IOutput $output
- * @throws \Exception in case of failure
- * @since 25.0.0
- */
- public function run(IOutput $output) {
- $output->startProgress();
- $this->userManager->callForSeenUsers(function (IUser $user) use ($output) {
- $config = [];
-
- $font = $this->config->getUserValue($user->getUID(), 'accessibility', 'font', false);
- $highcontrast = $this->config->getUserValue($user->getUID(), 'accessibility', 'highcontrast', false);
- $theme = $this->config->getUserValue($user->getUID(), 'accessibility', 'theme', false);
-
- if ($highcontrast || $theme) {
- if ($theme === 'dark' && $highcontrast === 'highcontrast') {
- $config[] = $this->darkHighContrastTheme->getId();
- } else if ($theme === 'dark') {
- $config[] = $this->darkTheme->getId();
- } else if ($highcontrast === 'highcontrast') {
- $config[] = $this->highContrastTheme->getId();
- }
- }
-
- if ($font === 'fontdyslexic') {
- $config[] = $this->dyslexiaFont->getId();
- }
-
- if (!empty($config)) {
- $this->config->setUserValue($user->getUID(), Application::APP_ID, 'enabled-themes', json_encode(array_unique($config)));
- }
-
- $output->advance();
- });
-
- $this->config->deleteAppFromAllUsers('accessibility');
-
- $output->finishProgress();
- }
-}
diff --git a/apps/theming/lib/Migration/Version2006Date20240905111627.php b/apps/theming/lib/Migration/Version2006Date20240905111627.php
new file mode 100644
index 00000000000..8f4130cba46
--- /dev/null
+++ b/apps/theming/lib/Migration/Version2006Date20240905111627.php
@@ -0,0 +1,127 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\Theming\Migration;
+
+use Closure;
+use OCA\Theming\AppInfo\Application;
+use OCA\Theming\Jobs\RestoreBackgroundImageColor;
+use OCP\BackgroundJob\IJobList;
+use OCP\IAppConfig;
+use OCP\IDBConnection;
+use OCP\Migration\IMigrationStep;
+use OCP\Migration\IOutput;
+
+// This can only be executed once because `background_color` is again used with Nextcloud 30,
+// so this part only works when updating -> Nextcloud 29 -> 30
+class Version2006Date20240905111627 implements IMigrationStep {
+
+ public function __construct(
+ private IJobList $jobList,
+ private IAppConfig $appConfig,
+ private IDBConnection $connection,
+ ) {
+ }
+
+ public function name(): string {
+ return 'Restore custom primary color';
+ }
+
+ public function description(): string {
+ return 'Restore custom primary color after separating primary color from background color';
+ }
+
+ public function preSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
+ // nop
+ }
+
+ public function changeSchema(IOutput $output, Closure $schemaClosure, array $options) {
+ $this->restoreSystemColors($output);
+
+ $userThemingEnabled = $this->appConfig->getValueBool('theming', 'disable-user-theming') === false;
+ if ($userThemingEnabled) {
+ $this->restoreUserColors($output);
+ }
+
+ return null;
+ }
+
+ public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
+ $output->info('Initialize restoring of background colors for custom background images');
+ // This is done in a background job as this can take a lot of time for large instances
+ $this->jobList->add(RestoreBackgroundImageColor::class, ['stage' => RestoreBackgroundImageColor::STAGE_PREPARE]);
+ }
+
+ private function restoreSystemColors(IOutput $output): void {
+ $defaultColor = $this->appConfig->getValueString(Application::APP_ID, 'color', '');
+ if ($defaultColor === '') {
+ $output->info('No custom system color configured - skipping');
+ } else {
+ // Restore legacy value into new field
+ $this->appConfig->setValueString(Application::APP_ID, 'background_color', $defaultColor);
+ $this->appConfig->setValueString(Application::APP_ID, 'primary_color', $defaultColor);
+ // Delete legacy field
+ $this->appConfig->deleteKey(Application::APP_ID, 'color');
+ // give some feedback
+ $output->info('Global primary color restored');
+ }
+ }
+
+ private function restoreUserColors(IOutput $output): void {
+ $output->info('Restoring user primary color');
+ // For performance let the DB handle this
+ $qb = $this->connection->getQueryBuilder();
+ // Rename the `background_color` config to `primary_color` as this was the behavior on Nextcloud 29 and older
+ // with Nextcloud 30 `background_color` is a new option to define the background color independent of the primary color.
+ $qb->update('preferences')
+ ->set('configkey', $qb->createNamedParameter('primary_color'))
+ ->where($qb->expr()->eq('appid', $qb->createNamedParameter(Application::APP_ID)))
+ ->andWhere($qb->expr()->eq('configkey', $qb->createNamedParameter('background_color')));
+
+ try {
+ $qb->executeStatement();
+ } catch (\Exception) {
+ $output->debug('Some users already configured the background color');
+ $this->restoreUserColorsFallback($output);
+ }
+
+ $output->info('Primary color of users restored');
+ }
+
+ /**
+ * Similar to restoreUserColors but also works if some users already setup a new value.
+ * This is only called if the first approach fails as this takes much longer on the DB.
+ */
+ private function restoreUserColorsFallback(IOutput $output): void {
+ $qb = $this->connection->getQueryBuilder();
+ $qb2 = $this->connection->getQueryBuilder();
+
+ $qb2->select('userid')
+ ->from('preferences')
+ ->where($qb->expr()->eq('appid', $qb->createNamedParameter(Application::APP_ID)))
+ ->andWhere($qb->expr()->eq('configkey', $qb->createNamedParameter('primary_color')));
+
+ // MySQL does not update on select of the same table, so this is a workaround:
+ if ($this->connection->getDatabaseProvider() === IDBConnection::PLATFORM_MYSQL) {
+ $subquery = 'SELECT * from ( ' . $qb2->getSQL() . ' ) preferences_alias';
+ } else {
+ $subquery = $qb2->getSQL();
+ }
+
+ $qb->update('preferences')
+ ->set('configkey', $qb->createNamedParameter('primary_color'))
+ ->where($qb->expr()->eq('appid', $qb->createNamedParameter(Application::APP_ID)))
+ ->andWhere(
+ $qb->expr()->eq('configkey', $qb->createNamedParameter('background_color')),
+ $qb->expr()->notIn('userid', $qb->createFunction($subquery)),
+ );
+
+ $qb->executeStatement();
+ }
+}
diff --git a/apps/theming/lib/ResponseDefinitions.php b/apps/theming/lib/ResponseDefinitions.php
new file mode 100644
index 00000000000..948fc792133
--- /dev/null
+++ b/apps/theming/lib/ResponseDefinitions.php
@@ -0,0 +1,21 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\Theming;
+
+/**
+ * @psalm-type ThemingBackground = array{
+ * backgroundImage: ?string,
+ * backgroundColor: string,
+ * primaryColor: string,
+ * version: int,
+ * }
+ */
+class ResponseDefinitions {
+}
diff --git a/apps/theming/lib/Service/BackgroundService.php b/apps/theming/lib/Service/BackgroundService.php
index 4879ad1cbad..ee9466c3a36 100644
--- a/apps/theming/lib/Service/BackgroundService.php
+++ b/apps/theming/lib/Service/BackgroundService.php
@@ -3,34 +3,14 @@
declare(strict_types=1);
/**
- * @copyright Copyright (c) 2020 Julius Härtl <jus@bitgrid.net>
- *
- * @author Jan C. Borchardt <hey@jancborchardt.net>
- * @author Julius Härtl <jus@bitgrid.net>
- * @author Christopher Ng <chrng8@gmail.com>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Theming\Service;
use InvalidArgumentException;
use OC\User\NoUserException;
use OCA\Theming\AppInfo\Application;
-use OCA\Theming\ThemingDefaults;
use OCP\Files\File;
use OCP\Files\IAppData;
use OCP\Files\IRootFolder;
@@ -38,129 +18,202 @@ use OCP\Files\NotFoundException;
use OCP\Files\NotPermittedException;
use OCP\Files\SimpleFS\ISimpleFile;
use OCP\Files\SimpleFS\ISimpleFolder;
+use OCP\IAppConfig;
use OCP\IConfig;
+use OCP\Image;
use OCP\Lock\LockedException;
use OCP\PreConditionNotMetException;
+use RuntimeException;
class BackgroundService {
- // true when the background is bright and need dark icons
- public const THEMING_MODE_DARK = 'dark';
- public const DEFAULT_COLOR = '#0082c9';
- public const DEFAULT_ACCESSIBLE_COLOR = '#006aa3';
+ public const DEFAULT_COLOR = '#00679e';
+ public const DEFAULT_BACKGROUND_COLOR = '#00679e';
+ /**
+ * One of our shipped background images is used
+ */
public const BACKGROUND_SHIPPED = 'shipped';
+ /**
+ * A custom background image is used
+ */
public const BACKGROUND_CUSTOM = 'custom';
+ /**
+ * The default background image is used
+ */
public const BACKGROUND_DEFAULT = 'default';
- public const BACKGROUND_DISABLED = 'disabled';
+ /**
+ * Just a background color is used
+ */
+ public const BACKGROUND_COLOR = 'color';
+
+ public const DEFAULT_BACKGROUND_IMAGE = 'jenna-kim-the-globe.webp';
- public const DEFAULT_BACKGROUND_IMAGE = 'kamil-porembinski-clouds.jpg';
+ /**
+ * 'attribution': Name, artist and license
+ * 'description': Alternative text
+ * 'attribution_url': URL for attribution
+ * 'background_color': Cached mean color of the top part to calculate app menu colors and use as fallback
+ * 'primary_color': Recommended primary color for this theme / image
+ */
public const SHIPPED_BACKGROUNDS = [
+ 'jenna-kim-the-globe.webp' => [
+ 'attribution' => 'Globe (Jenna Kim - Nextcloud GmbH, CC-BY-SA-4.0)',
+ 'description' => 'Background picture of white clouds on in front of a blue sky',
+ 'attribution_url' => 'https://nextcloud.com/trademarks/',
+ 'dark_variant' => 'jenna-kim-the-globe-dark.webp',
+ 'background_color' => self::DEFAULT_BACKGROUND_COLOR,
+ 'primary_color' => self::DEFAULT_COLOR,
+ ],
+ 'kamil-porembinski-clouds.jpg' => [
+ 'attribution' => 'Clouds (Kamil Porembiński, CC BY-SA)',
+ 'description' => 'Background picture of white clouds on in front of a blue sky',
+ 'attribution_url' => 'https://www.flickr.com/photos/paszczak000/8715851521/',
+ 'background_color' => self::DEFAULT_BACKGROUND_COLOR,
+ 'primary_color' => self::DEFAULT_COLOR,
+ ],
+ 'hannah-maclean-soft-floral.jpg' => [
+ 'attribution' => 'Soft floral (Hannah MacLean, CC0)',
+ 'description' => 'Abstract background picture in yellow and white color whith a flower on it',
+ 'attribution_url' => 'https://stocksnap.io/photo/soft-floral-XOYWCCW5PA',
+ 'background_color' => '#e4d2c1',
+ 'primary_color' => '#9f652f',
+ ],
+ 'ted-moravec-morning-fog.jpg' => [
+ 'attribution' => 'Morning fog (Ted Moravec, Public Domain)',
+ 'description' => 'Background picture of a forest shrouded in fog',
+ 'attribution_url' => 'https://flickr.com/photos/tmoravec/52392410261',
+ 'background_color' => '#f6f7f6',
+ 'primary_color' => '#114c3b',
+ ],
+ 'stefanus-martanto-setyo-husodo-underwater-ocean.jpg' => [
+ 'attribution' => 'Underwater ocean (Stefanus Martanto Setyo Husodo, CC0)',
+ 'description' => 'Background picture of an underwater ocean',
+ 'attribution_url' => 'https://stocksnap.io/photo/underwater-ocean-TJA9LBH4WS',
+ 'background_color' => '#003351',
+ 'primary_color' => '#04577e',
+ ],
+ 'zoltan-voros-rhythm-and-blues.jpg' => [
+ 'attribution' => 'Rhythm and blues (Zoltán Vörös, CC BY)',
+ 'description' => 'Abstract background picture of sand dunes during night',
+ 'attribution_url' => 'https://flickr.com/photos/v923z/51634409289/',
+ 'background_color' => '#1c2437',
+ 'primary_color' => '#1c243c',
+ ],
'anatoly-mikhaltsov-butterfly-wing-scale.jpg' => [
'attribution' => 'Butterfly wing scale (Anatoly Mikhaltsov, CC BY-SA)',
+ 'description' => 'Background picture of a red-ish butterfly wing under microscope',
'attribution_url' => 'https://commons.wikimedia.org/wiki/File:%D0%A7%D0%B5%D1%88%D1%83%D0%B9%D0%BA%D0%B8_%D0%BA%D1%80%D1%8B%D0%BB%D0%B0_%D0%B1%D0%B0%D0%B1%D0%BE%D1%87%D0%BA%D0%B8.jpg',
+ 'background_color' => '#652e11',
'primary_color' => '#a53c17',
],
'bernie-cetonia-aurata-take-off-composition.jpg' => [
'attribution' => 'Cetonia aurata take off composition (Bernie, Public Domain)',
+ 'description' => 'Montage of a cetonia aurata bug that takes off with white background',
'attribution_url' => 'https://commons.wikimedia.org/wiki/File:Cetonia_aurata_take_off_composition_05172009.jpg',
- 'theming' => self::THEMING_MODE_DARK,
+ 'background_color' => '#dee0d3',
'primary_color' => '#56633d',
],
'dejan-krsmanovic-ribbed-red-metal.jpg' => [
'attribution' => 'Ribbed red metal (Dejan Krsmanovic, CC BY)',
+ 'description' => 'Abstract background picture of red ribbed metal with two horizontal white elements on top of it',
'attribution_url' => 'https://www.flickr.com/photos/dejankrsmanovic/42971456774/',
+ 'background_color' => '#9b171c',
'primary_color' => '#9c4236',
],
'eduardo-neves-pedra-azul.jpg' => [
'attribution' => 'Pedra azul milky way (Eduardo Neves, CC BY-SA)',
+ 'description' => 'Background picture of the milky way during night with a mountain in front of it',
'attribution_url' => 'https://commons.wikimedia.org/wiki/File:Pedra_Azul_Milky_Way.jpg',
+ 'background_color' => '#1d242d',
'primary_color' => '#4f6071',
],
'european-space-agency-barents-bloom.jpg' => [
'attribution' => 'Barents bloom (European Space Agency, CC BY-SA)',
+ 'description' => 'Abstract background picture of blooming barents in blue and green colors',
'attribution_url' => 'https://www.esa.int/ESA_Multimedia/Images/2016/08/Barents_bloom',
+ 'background_color' => '#1c383d',
'primary_color' => '#396475',
],
'hannes-fritz-flippity-floppity.jpg' => [
'attribution' => 'Flippity floppity (Hannes Fritz, CC BY-SA)',
+ 'description' => 'Abstract background picture of many pairs of flip flops hanging on a wall in multiple colors',
'attribution_url' => 'http://hannes.photos/flippity-floppity',
+ 'background_color' => '#5b2d53',
'primary_color' => '#98415a',
],
'hannes-fritz-roulette.jpg' => [
'attribution' => 'Roulette (Hannes Fritz, CC BY-SA)',
+ 'description' => 'Background picture of a rotating giant wheel during night',
'attribution_url' => 'http://hannes.photos/roulette',
+ 'background_color' => '#000000',
'primary_color' => '#845334',
],
'hannes-fritz-sea-spray.jpg' => [
'attribution' => 'Sea spray (Hannes Fritz, CC BY-SA)',
+ 'description' => 'Background picture of a stone coast with fog and sea behind it',
'attribution_url' => 'http://hannes.photos/sea-spray',
+ 'background_color' => '#333f47',
'primary_color' => '#4f6071',
],
- 'kamil-porembinski-clouds.jpg' => [
- 'attribution' => 'Clouds (Kamil Porembiński, CC BY-SA)',
- 'attribution_url' => 'https://www.flickr.com/photos/paszczak000/8715851521/',
- 'primary_color' => self::DEFAULT_COLOR,
- ],
'bernard-spragg-new-zealand-fern.jpg' => [
'attribution' => 'New zealand fern (Bernard Spragg, CC0)',
+ 'description' => 'Abstract background picture of fern leafes',
'attribution_url' => 'https://commons.wikimedia.org/wiki/File:NZ_Fern.(Blechnum_chambersii)_(11263534936).jpg',
+ 'background_color' => '#0c3c03',
'primary_color' => '#316b26',
],
'rawpixel-pink-tapioca-bubbles.jpg' => [
'attribution' => 'Pink tapioca bubbles (Rawpixel, CC BY)',
+ 'description' => 'Abstract background picture of pink tapioca bubbles',
'attribution_url' => 'https://www.flickr.com/photos/byrawpixel/27665140298/in/photostream/',
- 'theming' => self::THEMING_MODE_DARK,
+ 'background_color' => '#c56e95',
'primary_color' => '#7b4e7e',
],
'nasa-waxing-crescent-moon.jpg' => [
'attribution' => 'Waxing crescent moon (NASA, Public Domain)',
+ 'description' => 'Background picture of glowing earth in foreground and moon in the background',
'attribution_url' => 'https://www.nasa.gov/image-feature/a-waxing-crescent-moon',
+ 'background_color' => '#000002',
'primary_color' => '#005ac1',
],
'tommy-chau-already.jpg' => [
'attribution' => 'Cityscape (Tommy Chau, CC BY)',
+ 'description' => 'Background picture of a skyscraper city during night',
'attribution_url' => 'https://www.flickr.com/photos/90975693@N05/16910999368',
+ 'background_color' => '#35229f',
'primary_color' => '#6a2af4',
],
'tommy-chau-lion-rock-hill.jpg' => [
'attribution' => 'Lion rock hill (Tommy Chau, CC BY)',
+ 'description' => 'Background picture of mountains during sunset or sunrise',
'attribution_url' => 'https://www.flickr.com/photos/90975693@N05/17136440246',
- 'theming' => self::THEMING_MODE_DARK,
+ 'background_color' => '#cb92b7',
'primary_color' => '#7f4f70',
],
'lali-masriera-yellow-bricks.jpg' => [
'attribution' => 'Yellow bricks (Lali Masriera, CC BY)',
+ 'description' => 'Background picture of yellow bricks with some yellow tubes',
'attribution_url' => 'https://www.flickr.com/photos/visualpanic/3982464447',
- 'theming' => self::THEMING_MODE_DARK,
+ 'background_color' => '#c78a19',
'primary_color' => '#7f5700',
],
];
- private IRootFolder $rootFolder;
- private IAppData $appData;
- private IConfig $config;
- private string $userId;
- private ThemingDefaults $themingDefaults;
-
- public function __construct(IRootFolder $rootFolder,
- IAppData $appData,
- IConfig $config,
- ?string $userId,
- ThemingDefaults $themingDefaults) {
- if ($userId === null) {
- return;
- }
-
- $this->rootFolder = $rootFolder;
- $this->config = $config;
- $this->userId = $userId;
- $this->appData = $appData;
- $this->themingDefaults = $themingDefaults;
+ public function __construct(
+ private IRootFolder $rootFolder,
+ private IAppData $appData,
+ private IAppConfig $appConfig,
+ private IConfig $config,
+ private ?string $userId,
+ ) {
}
- public function setDefaultBackground(): void {
- $this->config->deleteUserValue($this->userId, Application::APP_ID, 'background_image');
- $this->config->deleteUserValue($this->userId, Application::APP_ID, 'background_color');
+ public function setDefaultBackground(?string $userId = null): void {
+ $userId = $userId ?? $this->getUserId();
+
+ $this->config->deleteUserValue($userId, Application::APP_ID, 'background_image');
+ $this->config->deleteUserValue($userId, Application::APP_ID, 'background_color');
+ $this->config->deleteUserValue($userId, Application::APP_ID, 'primary_color');
}
/**
@@ -171,46 +224,81 @@ class BackgroundService {
* @throws PreConditionNotMetException
* @throws NoUserException
*/
- public function setFileBackground($path): void {
- $this->config->setUserValue($this->userId, Application::APP_ID, 'background_image', self::BACKGROUND_CUSTOM);
- $userFolder = $this->rootFolder->getUserFolder($this->userId);
+ public function setFileBackground(string $path, ?string $userId = null): void {
+ $userId = $userId ?? $this->getUserId();
+ $userFolder = $this->rootFolder->getUserFolder($userId);
/** @var File $file */
$file = $userFolder->get($path);
- $image = new \OCP\Image();
+ $handle = $file->fopen('r');
+ if ($handle === false) {
+ throw new InvalidArgumentException('Invalid image file');
+ }
+ $this->getAppDataFolder()->newFile('background.jpg', $handle);
- if ($image->loadFromFileHandle($file->fopen('r')) === false) {
+ $this->recalculateMeanColor();
+ }
+
+ public function recalculateMeanColor(?string $userId = null): void {
+ $userId = $userId ?? $this->getUserId();
+
+ $image = new Image();
+ $handle = $this->getAppDataFolder($userId)->getFile('background.jpg')->read();
+ if ($handle === false || $image->loadFromFileHandle($handle) === false) {
throw new InvalidArgumentException('Invalid image file');
}
- $this->getAppDataFolder()->newFile('background.jpg', $file->fopen('r'));
+ $meanColor = $this->calculateMeanColor($image);
+ if ($meanColor !== false) {
+ $this->setColorBackground($meanColor);
+ }
+ $this->config->setUserValue($userId, Application::APP_ID, 'background_image', self::BACKGROUND_CUSTOM);
}
- public function setShippedBackground($fileName): void {
- if (!array_key_exists($fileName, self::SHIPPED_BACKGROUNDS)) {
+ /**
+ * Set background of user to a shipped background identified by the filename
+ * @param string $filename The shipped background filename
+ * @param null|string $userId The user to set - defaults to currently logged in user
+ * @throws RuntimeException If neither $userId is specified nor a user is logged in
+ * @throws InvalidArgumentException If the specified filename does not match any shipped background
+ */
+ public function setShippedBackground(string $filename, ?string $userId = null): void {
+ $userId = $userId ?? $this->getUserId();
+
+ if (!array_key_exists($filename, self::SHIPPED_BACKGROUNDS)) {
throw new InvalidArgumentException('The given file name is invalid');
}
- $this->config->setUserValue($this->userId, Application::APP_ID, 'background_image', $fileName);
- $this->setColorBackground(self::SHIPPED_BACKGROUNDS[$fileName]['primary_color']);
+ $this->setColorBackground(self::SHIPPED_BACKGROUNDS[$filename]['background_color'], $userId);
+ $this->config->setUserValue($userId, Application::APP_ID, 'background_image', $filename);
+ $this->config->setUserValue($userId, Application::APP_ID, 'primary_color', self::SHIPPED_BACKGROUNDS[$filename]['primary_color']);
}
- public function setColorBackground(string $color): void {
+ /**
+ * Set the background to color only
+ * @param string|null $userId The user to set the color - default to current logged-in user
+ */
+ public function setColorBackground(string $color, ?string $userId = null): void {
+ $userId = $userId ?? $this->getUserId();
+
if (!preg_match('/^#([0-9a-f]{3}|[0-9a-f]{6})$/i', $color)) {
throw new InvalidArgumentException('The given color is invalid');
}
- $this->config->setUserValue($this->userId, Application::APP_ID, 'background_color', $color);
+ $this->config->setUserValue($userId, Application::APP_ID, 'background_color', $color);
+ $this->config->setUserValue($userId, Application::APP_ID, 'background_image', self::BACKGROUND_COLOR);
}
- public function deleteBackgroundImage(): void {
- $this->config->setUserValue($this->userId, Application::APP_ID, 'background_image', self::BACKGROUND_DISABLED);
+ public function deleteBackgroundImage(?string $userId = null): void {
+ $userId = $userId ?? $this->getUserId();
+ $this->config->setUserValue($userId, Application::APP_ID, 'background_image', self::BACKGROUND_COLOR);
}
- public function getBackground(): ?ISimpleFile {
- $background = $this->config->getUserValue($this->userId, Application::APP_ID, 'background_image', self::BACKGROUND_DEFAULT);
+ public function getBackground(?string $userId = null): ?ISimpleFile {
+ $userId = $userId ?? $this->getUserId();
+ $background = $this->config->getUserValue($userId, Application::APP_ID, 'background_image', self::BACKGROUND_DEFAULT);
if ($background === self::BACKGROUND_CUSTOM) {
try {
return $this->getAppDataFolder()->getFile('background.jpg');
- } catch (NotFoundException | NotPermittedException $e) {
+ } catch (NotFoundException|NotPermittedException $e) {
return null;
}
}
@@ -218,21 +306,109 @@ class BackgroundService {
}
/**
+ * Called when a new global background (backgroundMime) is uploaded (admin setting)
+ * This sets all necessary app config values
+ * @param resource|string $path
+ * @return string|null The fallback background color - if any
+ */
+ public function setGlobalBackground($path): ?string {
+ $image = new Image();
+ $handle = is_resource($path) ? $path : fopen($path, 'rb');
+
+ if ($handle && $image->loadFromFileHandle($handle) !== false) {
+ $meanColor = $this->calculateMeanColor($image);
+ if ($meanColor !== false) {
+ $this->appConfig->setValueString(Application::APP_ID, 'background_color', $meanColor);
+ return $meanColor;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Calculate mean color of an given image
+ * It only takes the upper part into account so that a matching text color can be derived for the app menu
+ */
+ private function calculateMeanColor(Image $image): false|string {
+ /**
+ * Small helper to ensure one channel is returned as 8byte hex
+ */
+ function toHex(int $channel): string {
+ $hex = dechex($channel);
+ return match (strlen($hex)) {
+ 0 => '00',
+ 1 => '0' . $hex,
+ 2 => $hex,
+ default => 'ff',
+ };
+ }
+
+ $tempImage = new Image();
+
+ // Crop to only analyze top bar
+ $resource = $image->cropNew(0, 0, $image->width(), min(max(50, (int)($image->height() * 0.125)), $image->height()));
+ if ($resource === false) {
+ return false;
+ }
+
+ $tempImage->setResource($resource);
+ if (!$tempImage->preciseResize(100, 7)) {
+ return false;
+ }
+
+ $resource = $tempImage->resource();
+ if ($resource === false) {
+ return false;
+ }
+
+ $reds = [];
+ $greens = [];
+ $blues = [];
+ for ($y = 0; $y < 7; $y++) {
+ for ($x = 0; $x < 100; $x++) {
+ $value = imagecolorat($resource, $x, $y);
+ if ($value === false) {
+ continue;
+ }
+ $reds[] = ($value >> 16) & 0xFF;
+ $greens[] = ($value >> 8) & 0xFF;
+ $blues[] = $value & 0xFF;
+ }
+ }
+ $meanColor = '#' . toHex((int)(array_sum($reds) / count($reds)));
+ $meanColor .= toHex((int)(array_sum($greens) / count($greens)));
+ $meanColor .= toHex((int)(array_sum($blues) / count($blues)));
+ return $meanColor;
+ }
+
+ /**
* Storing the data in appdata/theming/users/USERID
*
- * @return ISimpleFolder
+ * @param string|null $userId The user to get the folder - default to current user
* @throws NotPermittedException
*/
- private function getAppDataFolder(): ISimpleFolder {
+ private function getAppDataFolder(?string $userId = null): ISimpleFolder {
+ $userId = $userId ?? $this->getUserId();
+
try {
$rootFolder = $this->appData->getFolder('users');
- } catch (NotFoundException $e) {
+ } catch (NotFoundException) {
$rootFolder = $this->appData->newFolder('users');
}
try {
- return $rootFolder->getFolder($this->userId);
- } catch (NotFoundException $e) {
- return $rootFolder->newFolder($this->userId);
+ return $rootFolder->getFolder($userId);
+ } catch (NotFoundException) {
+ return $rootFolder->newFolder($userId);
+ }
+ }
+
+ /**
+ * @throws RuntimeException Thrown if a method that needs a user is called without any logged-in user
+ */
+ private function getUserId(): string {
+ if ($this->userId === null) {
+ throw new RuntimeException('No currently logged-in user');
}
+ return $this->userId;
}
}
diff --git a/apps/theming/lib/Service/JSDataService.php b/apps/theming/lib/Service/JSDataService.php
index 90acd74b868..81198f8b3f5 100644
--- a/apps/theming/lib/Service/JSDataService.php
+++ b/apps/theming/lib/Service/JSDataService.php
@@ -3,64 +3,47 @@
declare(strict_types=1);
/**
- * @copyright Copyright (c) 2020, Roeland Jago Douma <roeland@famdouma.nl>
- *
- * @author Julien Veyssier <eneiluj@posteo.net>
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Theming\Service;
-use OCA\Theming\AppInfo\Application;
use OCA\Theming\ThemingDefaults;
use OCA\Theming\Util;
-use OCP\IConfig;
class JSDataService implements \JsonSerializable {
- private ThemingDefaults $themingDefaults;
- private Util $util;
- private IConfig $appConfig;
- private ThemesService $themesService;
public function __construct(
- ThemingDefaults $themingDefaults,
- Util $util,
- IConfig $appConfig,
- ThemesService $themesService
+ private ThemingDefaults $themingDefaults,
+ private Util $util,
+ private ThemesService $themesService,
) {
$this->themingDefaults = $themingDefaults;
$this->util = $util;
- $this->appConfig = $appConfig;
$this->themesService = $themesService;
}
public function jsonSerialize(): array {
return [
'name' => $this->themingDefaults->getName(),
- 'url' => $this->themingDefaults->getBaseUrl(),
'slogan' => $this->themingDefaults->getSlogan(),
- 'color' => $this->themingDefaults->getColorPrimary(),
- 'defaultColor' => $this->themingDefaults->getDefaultColorPrimary(),
+
+ 'url' => $this->themingDefaults->getBaseUrl(),
'imprintUrl' => $this->themingDefaults->getImprintUrl(),
'privacyUrl' => $this->themingDefaults->getPrivacyUrl(),
+
+ 'primaryColor' => $this->themingDefaults->getColorPrimary(),
+ 'backgroundColor' => $this->themingDefaults->getColorBackground(),
+ 'defaultPrimaryColor' => $this->themingDefaults->getDefaultColorPrimary(),
+ 'defaultBackgroundColor' => $this->themingDefaults->getDefaultColorBackground(),
'inverted' => $this->util->invertTextColor($this->themingDefaults->getColorPrimary()),
+
'cacheBuster' => $this->util->getCacheBuster(),
'enabledThemes' => $this->themesService->getEnabledThemes(),
+
+ // deprecated use primaryColor
+ 'color' => $this->themingDefaults->getColorPrimary(),
+ '' => 'color is deprecated since Nextcloud 29, use primaryColor instead'
];
}
}
diff --git a/apps/theming/lib/Service/ThemeInjectionService.php b/apps/theming/lib/Service/ThemeInjectionService.php
index 8e55f614146..873d388081c 100644
--- a/apps/theming/lib/Service/ThemeInjectionService.php
+++ b/apps/theming/lib/Service/ThemeInjectionService.php
@@ -1,28 +1,12 @@
<?php
+
/**
- * @copyright Copyright (c) 2022 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Theming\Service;
-use OCA\Theming\AppInfo\Application;
+use OCA\Theming\ITheme;
use OCA\Theming\Themes\DefaultTheme;
use OCA\Theming\Util;
use OCP\IConfig;
@@ -31,24 +15,16 @@ use OCP\IUserSession;
class ThemeInjectionService {
- private IURLGenerator $urlGenerator;
- private ThemesService $themesService;
- private DefaultTheme $defaultTheme;
- private Util $util;
- private IConfig $config;
private ?string $userId;
- public function __construct(IURLGenerator $urlGenerator,
- ThemesService $themesService,
- DefaultTheme $defaultTheme,
- Util $util,
- IConfig $config,
- IUserSession $userSession) {
- $this->urlGenerator = $urlGenerator;
- $this->themesService = $themesService;
- $this->defaultTheme = $defaultTheme;
- $this->util = $util;
- $this->config = $config;
+ public function __construct(
+ private IURLGenerator $urlGenerator,
+ private ThemesService $themesService,
+ private DefaultTheme $defaultTheme,
+ private Util $util,
+ private IConfig $config,
+ IUserSession $userSession,
+ ) {
if ($userSession->getUser() !== null) {
$this->userId = $userSession->getUser()->getUID();
} else {
@@ -56,42 +32,45 @@ class ThemeInjectionService {
}
}
- public function injectHeaders() {
+ public function injectHeaders(): void {
$themes = $this->themesService->getThemes();
$defaultTheme = $themes[$this->defaultTheme->getId()];
- $mediaThemes = array_filter($themes, function($theme) {
+ $mediaThemes = array_filter($themes, function ($theme) {
// Check if the theme provides a media query
return (bool)$theme->getMediaQuery();
});
// Default theme fallback
- $this->addThemeHeader($defaultTheme->getId());
+ $this->addThemeHeaders($defaultTheme);
// Themes applied by media queries
- foreach($mediaThemes as $theme) {
- $this->addThemeHeader($theme->getId(), true, $theme->getMediaQuery());
+ foreach ($mediaThemes as $theme) {
+ $this->addThemeHeaders($theme, true, $theme->getMediaQuery());
}
// Themes
- foreach($this->themesService->getThemes() as $theme) {
+ foreach ($this->themesService->getThemes() as $theme) {
// Ignore default theme as already processed first
if ($theme->getId() === $this->defaultTheme->getId()) {
continue;
}
- $this->addThemeHeader($theme->getId(), false);
+ $this->addThemeHeaders($theme, false);
}
+
+ // Meta headers
+ $this->addThemeMetaHeaders($themes);
}
/**
* Inject theme header into rendered page
*
- * @param string $themeId the theme ID
+ * @param ITheme $theme the theme
* @param bool $plain request the :root syntax
* @param string $media media query to use in the <link> element
*/
- private function addThemeHeader(string $themeId, bool $plain = true, string $media = null) {
+ private function addThemeHeaders(ITheme $theme, bool $plain = true, ?string $media = null): void {
$linkToCSS = $this->urlGenerator->linkToRoute('theming.Theming.getThemeStylesheet', [
- 'themeId' => $themeId,
+ 'themeId' => $theme->getId(),
'plain' => $plain,
'v' => $this->util->getCacheBuster(),
]);
@@ -102,4 +81,36 @@ class ThemeInjectionService {
'class' => 'theme'
]);
}
+
+ /**
+ * Inject meta headers into rendered page
+ *
+ * @param ITheme[] $themes the theme
+ */
+ private function addThemeMetaHeaders(array $themes): void {
+ $metaHeaders = [];
+
+ // Meta headers
+ foreach ($this->themesService->getThemes() as $theme) {
+ if (!empty($theme->getMeta())) {
+ foreach ($theme->getMeta() as $meta) {
+ if (!isset($meta['name']) || !isset($meta['content'])) {
+ continue;
+ }
+
+ if (!isset($metaHeaders[$meta['name']])) {
+ $metaHeaders[$meta['name']] = [];
+ }
+ $metaHeaders[$meta['name']][] = $meta['content'];
+ }
+ }
+ }
+
+ foreach ($metaHeaders as $name => $content) {
+ \OCP\Util::addHeader('meta', [
+ 'name' => $name,
+ 'content' => join(' ', array_unique($content)),
+ ]);
+ }
+ }
}
diff --git a/apps/theming/lib/Service/ThemesService.php b/apps/theming/lib/Service/ThemesService.php
index eabd903734a..f49524cb62c 100644
--- a/apps/theming/lib/Service/ThemesService.php
+++ b/apps/theming/lib/Service/ThemesService.php
@@ -1,24 +1,8 @@
<?php
+
/**
- * @copyright Copyright (c) 2022 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Theming\Service;
@@ -33,48 +17,69 @@ use OCA\Theming\Themes\LightTheme;
use OCP\IConfig;
use OCP\IUser;
use OCP\IUserSession;
+use Psr\Log\LoggerInterface;
class ThemesService {
- private IUserSession $userSession;
- private IConfig $config;
-
/** @var ITheme[] */
private array $themesProviders;
- public function __construct(IUserSession $userSession,
- IConfig $config,
- DefaultTheme $defaultTheme,
- LightTheme $lightTheme,
- DarkTheme $darkTheme,
- HighContrastTheme $highContrastTheme,
- DarkHighContrastTheme $darkHighContrastTheme,
- DyslexiaFont $dyslexiaFont) {
- $this->userSession = $userSession;
- $this->config = $config;
+ public function __construct(
+ private IUserSession $userSession,
+ private IConfig $config,
+ private LoggerInterface $logger,
+ private DefaultTheme $defaultTheme,
+ LightTheme $lightTheme,
+ private DarkTheme $darkTheme,
+ HighContrastTheme $highContrastTheme,
+ DarkHighContrastTheme $darkHighContrastTheme,
+ DyslexiaFont $dyslexiaFont,
+ ) {
// Register themes
$this->themesProviders = [
- $defaultTheme->getId() => $defaultTheme,
- $lightTheme->getId() => $lightTheme,
- $darkTheme->getId() => $darkTheme,
- $highContrastTheme->getId() => $highContrastTheme,
- $darkHighContrastTheme->getId() => $darkHighContrastTheme,
- $dyslexiaFont->getId() => $dyslexiaFont,
+ $defaultTheme->getId() => $defaultTheme,
+ $lightTheme->getId() => $lightTheme,
+ $darkTheme->getId() => $darkTheme,
+ $highContrastTheme->getId() => $highContrastTheme,
+ $darkHighContrastTheme->getId() => $darkHighContrastTheme,
+ $dyslexiaFont->getId() => $dyslexiaFont,
];
}
/**
* Get the list of all registered themes
- *
+ *
* @return ITheme[]
*/
public function getThemes(): array {
+ // Enforced theme if configured
+ $enforcedTheme = $this->config->getSystemValueString('enforce_theme', '');
+ if ($enforcedTheme !== '') {
+ if (!isset($this->themesProviders[$enforcedTheme])) {
+ $this->logger->error('Enforced theme not found', ['theme' => $enforcedTheme]);
+ return $this->themesProviders;
+ }
+
+ $defaultTheme = $this->themesProviders[$this->defaultTheme->getId()];
+ $darkTheme = $this->themesProviders[$this->darkTheme->getId()];
+ $theme = $this->themesProviders[$enforcedTheme];
+ return [
+ // Leave the default theme as a fallback
+ $defaultTheme->getId() => $defaultTheme,
+ // Make sure we also have the dark theme to allow apps
+ // to scope sections of their UI to the dark theme
+ $darkTheme->getId() => $darkTheme,
+ // Finally, the enforced theme
+ $theme->getId() => $theme,
+ ];
+ }
+
return $this->themesProviders;
}
/**
* Enable a theme for the logged-in user
- *
+ *
* @param ITheme $theme the theme to enable
* @return string[] the enabled themes
*/
@@ -87,18 +92,18 @@ class ThemesService {
}
/** @var ITheme[] */
- $themes = array_filter(array_map(function($themeId) {
+ $themes = array_filter(array_map(function ($themeId) {
return $this->getThemes()[$themeId];
}, $themesIds));
// Filtering all themes with the same type
- $filteredThemes = array_filter($themes, function(ITheme $t) use ($theme) {
+ $filteredThemes = array_filter($themes, function (ITheme $t) use ($theme) {
return $theme->getType() === $t->getType();
});
// Retrieve IDs only
/** @var string[] */
- $filteredThemesIds = array_map(function(ITheme $t) {
+ $filteredThemesIds = array_map(function (ITheme $t) {
return $t->getId();
}, array_values($filteredThemes));
@@ -110,7 +115,7 @@ class ThemesService {
/**
* Disable a theme for the logged-in user
- *
+ *
* @param ITheme $theme the theme to disable
* @return string[] the enabled themes
*/
@@ -123,14 +128,14 @@ class ThemesService {
$this->setEnabledThemes($enabledThemes);
return $enabledThemes;
}
-
+
return $themesIds;
}
/**
* Check whether a theme is enabled or not
* for the logged-in user
- *
+ *
* @return bool
*/
public function isEnabled(ITheme $theme): bool {
@@ -144,19 +149,21 @@ class ThemesService {
}
/**
- * Get the list of all enabled themes IDs
- * for the logged-in user
- *
+ * Get the list of all enabled themes IDs for the current user.
+ *
* @return string[]
*/
public function getEnabledThemes(): array {
+ $enforcedTheme = $this->config->getSystemValueString('enforce_theme', '');
$user = $this->userSession->getUser();
if ($user === null) {
+ if ($enforcedTheme !== '') {
+ return [$enforcedTheme];
+ }
return [];
}
- $enforcedTheme = $this->config->getSystemValueString('enforce_theme', '');
- $enabledThemes = json_decode($this->config->getUserValue($user->getUID(), Application::APP_ID, 'enabled-themes', '[]'));
+ $enabledThemes = json_decode($this->config->getUserValue($user->getUID(), Application::APP_ID, 'enabled-themes', '["default"]'));
if ($enforcedTheme !== '') {
return array_merge([$enforcedTheme], $enabledThemes);
}
@@ -169,9 +176,9 @@ class ThemesService {
}
/**
- * Set the list of enabled themes
+ * Set the list of enabled themes
* for the logged-in user
- *
+ *
* @param string[] $themes the list of enabled themes IDs
*/
private function setEnabledThemes(array $themes): void {
diff --git a/apps/theming/lib/Settings/Admin.php b/apps/theming/lib/Settings/Admin.php
index 0f0d85c147d..9fa0f2bb0e7 100644
--- a/apps/theming/lib/Settings/Admin.php
+++ b/apps/theming/lib/Settings/Admin.php
@@ -1,66 +1,37 @@
<?php
+
/**
- * @copyright Copyright (c) 2016 Arthur Schiwon <blizzz@arthur-schiwon.de>
- *
- * @author Arthur Schiwon <blizzz@arthur-schiwon.de>
- * @author Bjoern Schiessle <bjoern@schiessle.org>
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author Julius Härtl <jus@bitgrid.net>
- * @author Lukas Reschke <lukas@statuscode.ch>
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Theming\Settings;
use OCA\Theming\AppInfo\Application;
+use OCA\Theming\Controller\ThemingController;
use OCA\Theming\ImageManager;
+use OCA\Theming\Service\BackgroundService;
use OCA\Theming\ThemingDefaults;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Services\IInitialState;
use OCP\IConfig;
use OCP\IL10N;
+use OCP\INavigationManager;
use OCP\IURLGenerator;
use OCP\Settings\IDelegatedSettings;
use OCP\Util;
class Admin implements IDelegatedSettings {
- private string $appName;
- private IConfig $config;
- private IL10N $l;
- private ThemingDefaults $themingDefaults;
- private IInitialState $initialState;
- private IURLGenerator $urlGenerator;
- private ImageManager $imageManager;
- public function __construct(string $appName,
- IConfig $config,
- IL10N $l,
- ThemingDefaults $themingDefaults,
- IInitialState $initialState,
- IURLGenerator $urlGenerator,
- ImageManager $imageManager) {
- $this->appName = $appName;
- $this->config = $config;
- $this->l = $l;
- $this->themingDefaults = $themingDefaults;
- $this->initialState = $initialState;
- $this->urlGenerator = $urlGenerator;
- $this->imageManager = $imageManager;
+ public function __construct(
+ private string $appName,
+ private IConfig $config,
+ private IL10N $l,
+ private ThemingDefaults $themingDefaults,
+ private IInitialState $initialState,
+ private IURLGenerator $urlGenerator,
+ private ImageManager $imageManager,
+ private INavigationManager $navigationManager,
+ ) {
}
/**
@@ -75,14 +46,24 @@ class Admin implements IDelegatedSettings {
$errorMessage = $this->l->t('You are already using a custom theme. Theming app settings might be overwritten by that.');
}
+ $allowedMimeTypes = array_reduce(ThemingController::VALID_UPLOAD_KEYS, function ($carry, $key) {
+ $carry[$key] = $this->imageManager->getSupportedUploadImageFormats($key);
+ return $carry;
+ }, []);
+
$this->initialState->provideInitialState('adminThemingParameters', [
'isThemable' => $themable,
'notThemableErrorMessage' => $errorMessage,
'name' => $this->themingDefaults->getEntity(),
'url' => $this->themingDefaults->getBaseUrl(),
'slogan' => $this->themingDefaults->getSlogan(),
- 'color' => $this->themingDefaults->getDefaultColorPrimary(),
+ 'primaryColor' => $this->themingDefaults->getDefaultColorPrimary(),
+ 'backgroundColor' => $this->themingDefaults->getDefaultColorBackground(),
'logoMime' => $this->config->getAppValue(Application::APP_ID, 'logoMime', ''),
+ 'allowedMimeTypes' => $allowedMimeTypes,
+ 'backgroundURL' => $this->imageManager->getImageUrl('background'),
+ 'defaultBackgroundURL' => $this->urlGenerator->linkTo(Application::APP_ID, 'img/background/' . BackgroundService::DEFAULT_BACKGROUND_IMAGE),
+ 'defaultBackgroundColor' => BackgroundService::DEFAULT_BACKGROUND_COLOR,
'backgroundMime' => $this->config->getAppValue(Application::APP_ID, 'backgroundMime', ''),
'logoheaderMime' => $this->config->getAppValue(Application::APP_ID, 'logoheaderMime', ''),
'faviconMime' => $this->config->getAppValue(Application::APP_ID, 'faviconMime', ''),
@@ -92,6 +73,7 @@ class Admin implements IDelegatedSettings {
'docUrlIcons' => $this->urlGenerator->linkToDocs('admin-theming-icons'),
'canThemeIcons' => $this->imageManager->shouldReplaceIcons(),
'userThemingDisabled' => $this->themingDefaults->isUserThemingDisabled(),
+ 'defaultApps' => $this->navigationManager->getDefaultEntryIds(),
]);
Util::addScript($this->appName, 'admin-theming');
@@ -108,8 +90,8 @@ class Admin implements IDelegatedSettings {
/**
* @return int whether the form should be rather on the top or bottom of
- * the admin section. The forms are arranged in ascending order of the
- * priority values. It is required to return a value between 0 and 100.
+ * the admin section. The forms are arranged in ascending order of the
+ * priority values. It is required to return a value between 0 and 100.
*
* E.g.: 70
*/
diff --git a/apps/theming/lib/Settings/AdminSection.php b/apps/theming/lib/Settings/AdminSection.php
index 2fcc81a9279..a1ea568d9f2 100644
--- a/apps/theming/lib/Settings/AdminSection.php
+++ b/apps/theming/lib/Settings/AdminSection.php
@@ -1,24 +1,8 @@
<?php
+
/**
- * @copyright Copyright (c) 2016 Arthur Schiwon <blizzz@arthur-schiwon.de>
- *
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Theming\Settings;
@@ -27,14 +11,11 @@ use OCP\IURLGenerator;
use OCP\Settings\IIconSection;
class AdminSection implements IIconSection {
- private string $appName;
- private IL10N $l;
- private IURLGenerator $url;
-
- public function __construct(string $appName, IURLGenerator $url, IL10N $l) {
- $this->appName = $appName;
- $this->url = $url;
- $this->l = $l;
+ public function __construct(
+ private string $appName,
+ private IURLGenerator $url,
+ private IL10N $l,
+ ) {
}
/**
@@ -59,8 +40,8 @@ class AdminSection implements IIconSection {
/**
* @return int whether the form should be rather on the top or bottom of
- * the settings navigation. The sections are arranged in ascending order of
- * the priority values. It is required to return a value between 0 and 99.
+ * the settings navigation. The sections are arranged in ascending order of
+ * the priority values. It is required to return a value between 0 and 99.
*
* E.g.: 70
*/
diff --git a/apps/theming/lib/Settings/Personal.php b/apps/theming/lib/Settings/Personal.php
index 5b0dc742574..f14deeb35f0 100644
--- a/apps/theming/lib/Settings/Personal.php
+++ b/apps/theming/lib/Settings/Personal.php
@@ -1,63 +1,39 @@
<?php
+
/**
- * @copyright Copyright (c) 2018 John Molakvoæ <skjnldsv@protonmail.com>
- * @copyright Copyright (c) 2019 Janis Köhr <janiskoehr@icloud.com>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Theming\Settings;
use OCA\Theming\ITheme;
+use OCA\Theming\Service\BackgroundService;
use OCA\Theming\Service\ThemesService;
use OCA\Theming\ThemingDefaults;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Services\IInitialState;
use OCP\IConfig;
+use OCP\INavigationManager;
use OCP\Settings\ISettings;
use OCP\Util;
class Personal implements ISettings {
- protected string $appName;
- private IConfig $config;
- private ThemesService $themesService;
- private IInitialState $initialStateService;
- private ThemingDefaults $themingDefaults;
-
- public function __construct(string $appName,
- IConfig $config,
- ThemesService $themesService,
- IInitialState $initialStateService,
- ThemingDefaults $themingDefaults) {
- $this->appName = $appName;
- $this->config = $config;
- $this->themesService = $themesService;
- $this->initialStateService = $initialStateService;
- $this->themingDefaults = $themingDefaults;
+ public function __construct(
+ protected string $appName,
+ private string $userId,
+ private IConfig $config,
+ private ThemesService $themesService,
+ private IInitialState $initialStateService,
+ private ThemingDefaults $themingDefaults,
+ private INavigationManager $navigationManager,
+ ) {
}
public function getForm(): TemplateResponse {
$enforcedTheme = $this->config->getSystemValueString('enforce_theme', '');
- $themes = array_map(function($theme) {
+ $themes = array_map(function ($theme) {
return [
'id' => $theme->getId(),
'type' => $theme->getType(),
@@ -69,14 +45,42 @@ class Personal implements ISettings {
}, $this->themesService->getThemes());
if ($enforcedTheme !== '') {
- $themes = array_filter($themes, function($theme) use ($enforcedTheme) {
+ $themes = array_filter($themes, function ($theme) use ($enforcedTheme) {
return $theme['type'] !== ITheme::TYPE_THEME || $theme['id'] === $enforcedTheme;
});
}
+ // Get the default entry enforced by admin
+ $forcedDefaultEntry = $this->navigationManager->getDefaultEntryIdForUser(null, false);
+
+ /** List of all shipped backgrounds */
+ $this->initialStateService->provideInitialState('shippedBackgrounds', BackgroundService::SHIPPED_BACKGROUNDS);
+
+ /**
+ * Admin theming
+ */
+ $this->initialStateService->provideInitialState('themingDefaults', [
+ /** URL of admin configured background image */
+ 'backgroundImage' => $this->themingDefaults->getBackground(),
+ /** `backgroundColor` if disabled, mime type if defined and empty by default */
+ 'backgroundMime' => $this->config->getAppValue('theming', 'backgroundMime', ''),
+ /** Admin configured background color */
+ 'backgroundColor' => $this->themingDefaults->getDefaultColorBackground(),
+ /** Admin configured primary color */
+ 'primaryColor' => $this->themingDefaults->getDefaultColorPrimary(),
+ /** Nextcloud default background image */
+ 'defaultShippedBackground' => BackgroundService::DEFAULT_BACKGROUND_IMAGE,
+ ]);
+
+ $this->initialStateService->provideInitialState('userBackgroundImage', $this->config->getUserValue($this->userId, 'theming', 'background_image', BackgroundService::BACKGROUND_DEFAULT));
$this->initialStateService->provideInitialState('themes', array_values($themes));
$this->initialStateService->provideInitialState('enforceTheme', $enforcedTheme);
$this->initialStateService->provideInitialState('isUserThemingDisabled', $this->themingDefaults->isUserThemingDisabled());
+ $this->initialStateService->provideInitialState('enableBlurFilter', $this->config->getUserValue($this->userId, 'theming', 'force_enable_blur_filter', ''));
+ $this->initialStateService->provideInitialState('navigationBar', [
+ 'userAppOrder' => json_decode($this->config->getUserValue($this->userId, 'core', 'apporder', '[]'), true, flags:JSON_THROW_ON_ERROR),
+ 'enforcedDefaultApp' => $forcedDefaultEntry
+ ]);
Util::addScript($this->appName, 'personal-theming');
@@ -93,8 +97,8 @@ class Personal implements ISettings {
/**
* @return int whether the form should be rather on the top or bottom of
- * the admin section. The forms are arranged in ascending order of the
- * priority values. It is required to return a value between 0 and 100.
+ * the admin section. The forms are arranged in ascending order of the
+ * priority values. It is required to return a value between 0 and 100.
*
* E.g.: 70
* @since 9.1
diff --git a/apps/theming/lib/Settings/PersonalSection.php b/apps/theming/lib/Settings/PersonalSection.php
index 4165a733fb2..0a9361d5533 100644
--- a/apps/theming/lib/Settings/PersonalSection.php
+++ b/apps/theming/lib/Settings/PersonalSection.php
@@ -1,25 +1,8 @@
<?php
+
/**
- * @copyright Copyright (c) 2018 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Theming\Settings;
@@ -29,15 +12,6 @@ use OCP\Settings\IIconSection;
class PersonalSection implements IIconSection {
- /** @var string */
- protected $appName;
-
- /** @var IURLGenerator */
- private $urlGenerator;
-
- /** @var IL10N */
- private $l;
-
/**
* Personal Section constructor.
*
@@ -45,12 +19,11 @@ class PersonalSection implements IIconSection {
* @param IURLGenerator $urlGenerator
* @param IL10N $l
*/
- public function __construct(string $appName,
- IURLGenerator $urlGenerator,
- IL10N $l) {
- $this->appName = $appName;
- $this->urlGenerator = $urlGenerator;
- $this->l = $l;
+ public function __construct(
+ protected string $appName,
+ private IURLGenerator $urlGenerator,
+ private IL10N $l,
+ ) {
}
/**
@@ -88,8 +61,8 @@ class PersonalSection implements IIconSection {
/**
* @return int whether the form should be rather on the top or bottom of
- * the settings navigation. The sections are arranged in ascending order of
- * the priority values. It is required to return a value between 0 and 99.
+ * the settings navigation. The sections are arranged in ascending order of
+ * the priority values. It is required to return a value between 0 and 99.
*
* E.g.: 70
* @since 9.1
diff --git a/apps/theming/lib/SetupChecks/PhpImagickModule.php b/apps/theming/lib/SetupChecks/PhpImagickModule.php
new file mode 100644
index 00000000000..cf9e51eee33
--- /dev/null
+++ b/apps/theming/lib/SetupChecks/PhpImagickModule.php
@@ -0,0 +1,46 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\Theming\SetupChecks;
+
+use OCP\IL10N;
+use OCP\IURLGenerator;
+use OCP\SetupCheck\ISetupCheck;
+use OCP\SetupCheck\SetupResult;
+
+class PhpImagickModule implements ISetupCheck {
+ public function __construct(
+ private IL10N $l10n,
+ private IURLGenerator $urlGenerator,
+ ) {
+ }
+
+ public function getName(): string {
+ return $this->l10n->t('PHP Imagick module');
+ }
+
+ public function getCategory(): string {
+ return 'php';
+ }
+
+ public function run(): SetupResult {
+ if (!extension_loaded('imagick')) {
+ return SetupResult::info(
+ $this->l10n->t('The PHP module "imagick" is not enabled although the theming app is. For favicon generation to work correctly, you need to install and enable this module.'),
+ $this->urlGenerator->linkToDocs('admin-php-modules')
+ );
+ } elseif (count(\Imagick::queryFormats('SVG')) === 0) {
+ return SetupResult::info(
+ $this->l10n->t('The PHP module "imagick" in this instance has no SVG support. For better compatibility it is recommended to install it.'),
+ $this->urlGenerator->linkToDocs('admin-php-modules')
+ );
+ } else {
+ return SetupResult::success();
+ }
+ }
+}
diff --git a/apps/theming/lib/Themes/CommonThemeTrait.php b/apps/theming/lib/Themes/CommonThemeTrait.php
index 17591c232bb..74979770b70 100644
--- a/apps/theming/lib/Themes/CommonThemeTrait.php
+++ b/apps/theming/lib/Themes/CommonThemeTrait.php
@@ -1,47 +1,35 @@
<?php
+
declare(strict_types=1);
/**
- * @copyright Copyright (c) 2022 Joas Schilling <coding@schilljs.com>
- *
- * @author Joas Schilling <coding@schilljs.com>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Theming\Themes;
-use OCA\Theming\Util;
-use OCA\Theming\ImageManager;
use OCA\Theming\AppInfo\Application;
+use OCA\Theming\ImageManager;
use OCA\Theming\Service\BackgroundService;
+use OCA\Theming\ThemingDefaults;
+use OCA\Theming\Util;
trait CommonThemeTrait {
public Util $util;
+ public ThemingDefaults $themingDefaults;
+
+ protected bool $isDarkVariant = false;
/**
* Generate primary-related variables
* This is shared between multiple themes because colorMainBackground and colorMainText
* will change in between.
*/
- protected function generatePrimaryVariables(string $colorMainBackground, string $colorMainText): array {
- $colorPrimaryLight = $this->util->mix($this->primaryColor, $colorMainBackground, -80);
- $colorPrimaryElement = $this->util->elementColor($this->primaryColor);
- $colorPrimaryElementDefault = $this->util->elementColor($this->defaultPrimaryColor);
+ protected function generatePrimaryVariables(string $colorMainBackground, string $colorMainText, bool $highContrast = false): array {
+ $isBrightColor = $this->util->isBrightColor($colorMainBackground);
+ $colorPrimaryElement = $this->util->elementColor($this->primaryColor, $isBrightColor, $colorMainBackground, $highContrast);
+ $colorPrimaryLight = $this->util->mix($colorPrimaryElement, $colorMainBackground, -80);
$colorPrimaryElementLight = $this->util->mix($colorPrimaryElement, $colorMainBackground, -80);
+ $invertPrimaryTextColor = $this->util->invertTextColor($colorPrimaryElement);
// primary related colours
return [
@@ -52,26 +40,27 @@ trait CommonThemeTrait {
// ⚠️ Using 'no' as a value to make sure we specify an
// invalid one with no fallback. 'unset' could here fallback to some
// other theme with media queries
- '--primary-invert-if-bright' => $this->util->invertTextColor($this->primaryColor) ? 'invert(100%)' : 'no',
+ '--primary-invert-if-bright' => $this->util->invertTextColor($colorPrimaryElement) ? 'invert(100%)' : 'no',
+ '--primary-invert-if-dark' => $this->util->invertTextColor($colorPrimaryElement) ? 'no' : 'invert(100%)',
'--color-primary' => $this->primaryColor,
- '--color-primary-default' => $this->defaultPrimaryColor,
'--color-primary-text' => $this->util->invertTextColor($this->primaryColor) ? '#000000' : '#ffffff',
'--color-primary-hover' => $this->util->mix($this->primaryColor, $colorMainBackground, 60),
'--color-primary-light' => $colorPrimaryLight,
'--color-primary-light-text' => $this->util->mix($this->primaryColor, $this->util->invertTextColor($colorPrimaryLight) ? '#000000' : '#ffffff', -20),
'--color-primary-light-hover' => $this->util->mix($colorPrimaryLight, $colorMainText, 90),
- '--color-primary-text-dark' => $this->util->darken($this->util->invertTextColor($this->primaryColor) ? '#000000' : '#ffffff', 7),
// used for buttons, inputs...
'--color-primary-element' => $colorPrimaryElement,
- '--color-primary-element-default-hover' => $this->util->mix($colorPrimaryElementDefault, $colorMainBackground, 60),
- '--color-primary-element-text' => $this->util->invertTextColor($colorPrimaryElement) ? '#000000' : '#ffffff',
- '--color-primary-element-hover' => $this->util->mix($colorPrimaryElement, $colorMainBackground, 60),
+ '--color-primary-element-hover' => $invertPrimaryTextColor ? $this->util->lighten($colorPrimaryElement, 4) : $this->util->darken($colorPrimaryElement, 4),
+ '--color-primary-element-text' => $invertPrimaryTextColor ? '#000000' : '#ffffff',
+ // mostly used for disabled states
+ '--color-primary-element-text-dark' => $invertPrimaryTextColor ? $this->util->lighten('#000000', 4) : $this->util->darken('#ffffff', 4),
+
+ // used for hover/focus states
'--color-primary-element-light' => $colorPrimaryElementLight,
- '--color-primary-element-light-text' => $this->util->mix($colorPrimaryElement, $this->util->invertTextColor($colorPrimaryElementLight) ? '#000000' : '#ffffff', -20),
'--color-primary-element-light-hover' => $this->util->mix($colorPrimaryElementLight, $colorMainText, 90),
- '--color-primary-element-text-dark' => $this->util->darken($this->util->invertTextColor($colorPrimaryElement) ? '#000000' : '#ffffff', 7),
+ '--color-primary-element-light-text' => $this->util->mix($colorPrimaryElement, $this->util->invertTextColor($colorPrimaryElementLight) ? '#000000' : '#ffffff', -20),
// to use like this: background-image: var(--gradient-primary-background);
'--gradient-primary-background' => 'linear-gradient(40deg, var(--color-primary) 0%, var(--color-primary-hover) 100%)',
@@ -84,34 +73,34 @@ trait CommonThemeTrait {
protected function generateGlobalBackgroundVariables(): array {
$backgroundDeleted = $this->config->getAppValue(Application::APP_ID, 'backgroundMime', '') === 'backgroundColor';
$hasCustomLogoHeader = $this->util->isLogoThemed();
- $isDefaultPrimaryBright = $this->util->invertTextColor($this->defaultPrimaryColor);
-
- $variables = [];
+ $backgroundColor = $this->themingDefaults->getColorBackground();
// Default last fallback values
- $variables['--image-background-default'] = "url('" . $this->themingDefaults->getBackground() . "')";
- $variables['--color-background-plain'] = $this->defaultPrimaryColor;
-
- // If primary as background has been request or if we have a custom primary colour
- // let's not define the background image
- if ($backgroundDeleted) {
- $variables['--color-background-plain'] = $this->defaultPrimaryColor;
- $variables['--image-background-plain'] = 'yes';
- // If no background image is set, we need to check against the shown primary colour
- $variables['--background-image-invert-if-bright'] = $isDefaultPrimaryBright ? 'invert(100%)' : 'no';
- }
+ $variables = [
+ '--color-background-plain' => $backgroundColor,
+ '--color-background-plain-text' => $this->util->invertTextColor($backgroundColor) ? '#000000' : '#ffffff',
+ '--background-image-invert-if-bright' => $this->util->invertTextColor($backgroundColor) ? 'invert(100%)' : 'no',
+ ];
// Register image variables only if custom-defined
foreach (ImageManager::SUPPORTED_IMAGE_KEYS as $image) {
if ($this->imageManager->hasImage($image)) {
$imageUrl = $this->imageManager->getImageUrl($image);
- // --image-background is overridden by user theming
$variables["--image-$image"] = "url('" . $imageUrl . "')";
+ } elseif ($image === 'background') {
+ // Apply default background if nothing is configured
+ $variables['--image-background'] = "url('" . $this->themingDefaults->getBackground($this->isDarkVariant) . "')";
}
}
+ // If a background has been requested let's not define the background image
+ if ($backgroundDeleted) {
+ $variables['--image-background'] = 'none';
+ }
+
if ($hasCustomLogoHeader) {
- $variables["--image-logoheader-custom"] = 'true';
+ // prevent inverting the logo on bright colors if customized
+ $variables['--image-logoheader-custom'] = 'true';
}
return $variables;
@@ -126,36 +115,40 @@ trait CommonThemeTrait {
&& !$this->themingDefaults->isUserThemingDisabled()
&& $this->appManager->isEnabledForUser(Application::APP_ID)) {
$backgroundImage = $this->config->getUserValue($user->getUID(), Application::APP_ID, 'background_image', BackgroundService::BACKGROUND_DEFAULT);
+ $backgroundColor = $this->config->getUserValue($user->getUID(), Application::APP_ID, 'background_color', $this->themingDefaults->getColorBackground());
+
$currentVersion = (int)$this->config->getUserValue($user->getUID(), Application::APP_ID, 'userCacheBuster', '0');
- $isPrimaryBright = $this->util->invertTextColor($this->primaryColor);
-
- // The user removed the background
- if ($backgroundImage === BackgroundService::BACKGROUND_DISABLED) {
- return [
- '--image-background' => 'no',
- '--color-background-plain' => $this->primaryColor,
- // If no background image is set, we need to check against the shown primary colour
- '--background-image-invert-if-bright' => $isPrimaryBright ? 'invert(100%)' : 'no',
- ];
+ $isBackgroundBright = $this->util->invertTextColor($backgroundColor);
+ $backgroundTextColor = $this->util->invertTextColor($backgroundColor) ? '#000000' : '#ffffff';
+
+ $variables = [
+ '--color-background-plain' => $backgroundColor,
+ '--color-background-plain-text' => $backgroundTextColor,
+ '--background-image-invert-if-bright' => $isBackgroundBright ? 'invert(100%)' : 'no',
+ ];
+
+ // Only use a background color without an image
+ if ($backgroundImage === BackgroundService::BACKGROUND_COLOR) {
+ // Might be defined already by admin theming, needs to be overridden
+ $variables['--image-background'] = 'none';
}
// The user uploaded a custom background
if ($backgroundImage === BackgroundService::BACKGROUND_CUSTOM) {
$cacheBuster = substr(sha1($user->getUID() . '_' . $currentVersion), 0, 8);
- return [
- '--image-background' => "url('" . $this->urlGenerator->linkToRouteAbsolute('theming.userTheme.getBackground') . "?v=$cacheBuster')",
- '--color-background-plain' => $this->themingDefaults->getColorPrimary(),
- ];
+ $variables['--image-background'] = "url('" . $this->urlGenerator->linkToRouteAbsolute('theming.userTheme.getBackground') . "?v=$cacheBuster')";
}
// The user picked a shipped background
if (isset(BackgroundService::SHIPPED_BACKGROUNDS[$backgroundImage])) {
- return [
- '--image-background' => "url('" . $this->urlGenerator->linkTo(Application::APP_ID, "img/background/$backgroundImage") . "')",
- '--color-background-plain' => $this->themingDefaults->getColorPrimary(),
- '--background-image-invert-if-bright' => BackgroundService::SHIPPED_BACKGROUNDS[$backgroundImage]['theming'] ?? null === BackgroundService::THEMING_MODE_DARK ? 'invert(100%)' : 'no',
- ];
+ $shippedBackground = BackgroundService::SHIPPED_BACKGROUNDS[$backgroundImage];
+ if ($this->isDarkVariant && isset($shippedBackground['dark_variant'])) {
+ $backgroundImage = $shippedBackground['dark_variant'];
+ }
+ $variables['--image-background'] = "url('" . $this->urlGenerator->linkTo(Application::APP_ID, "img/background/$backgroundImage") . "')";
}
+
+ return $variables;
}
return [];
diff --git a/apps/theming/lib/Themes/DarkHighContrastTheme.php b/apps/theming/lib/Themes/DarkHighContrastTheme.php
index 5636dacf1d9..0c8b436d660 100644
--- a/apps/theming/lib/Themes/DarkHighContrastTheme.php
+++ b/apps/theming/lib/Themes/DarkHighContrastTheme.php
@@ -1,26 +1,9 @@
<?php
+
declare(strict_types=1);
/**
- * @copyright Copyright (c) 2022 Joas Schilling <coding@schilljs.com>
- *
- * @author Joas Schilling <coding@schilljs.com>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Theming\Themes;
@@ -32,10 +15,6 @@ class DarkHighContrastTheme extends DarkTheme implements ITheme {
return 'dark-highcontrast';
}
- public function getMediaQuery(): string {
- return '(prefers-color-scheme: dark) and (prefers-contrast: more)';
- }
-
public function getTitle(): string {
return $this->l->t('Dark theme with high contrast mode');
}
@@ -48,6 +27,10 @@ class DarkHighContrastTheme extends DarkTheme implements ITheme {
return $this->l->t('Similar to the high contrast mode, but with dark colours.');
}
+ public function getMediaQuery(): string {
+ return '(prefers-color-scheme: dark) and (prefers-contrast: more)';
+ }
+
/**
* Keep this consistent with other HighContrast Themes
*/
@@ -56,19 +39,24 @@ class DarkHighContrastTheme extends DarkTheme implements ITheme {
$colorMainText = '#ffffff';
$colorMainBackground = '#000000';
- $colorMainBackgroundRGB = join(',', $this->util->hexToRGB($colorMainBackground));
+ $colorMainBackgroundRGB = join(',', $this->util->hexToRGB($colorMainBackground));
+
+ $colorError = '#ff5252';
+ $colorWarning = '#ffcc00';
+ $colorSuccess = '#42a942';
+ $colorInfo = '#38c0ff';
return array_merge(
$defaultVariables,
- $this->generatePrimaryVariables($colorMainBackground, $colorMainText),
+ $this->generatePrimaryVariables($colorMainBackground, $colorMainText, true),
[
'--color-main-background' => $colorMainBackground,
'--color-main-background-rgb' => $colorMainBackgroundRGB,
'--color-main-background-translucent' => 'rgba(var(--color-main-background-rgb), 1)',
'--color-main-text' => $colorMainText,
- '--color-background-dark' => $this->util->lighten($colorMainBackground, 30),
- '--color-background-darker' => $this->util->lighten($colorMainBackground, 30),
+ '--color-background-dark' => $this->util->lighten($colorMainBackground, 25),
+ '--color-background-darker' => $this->util->lighten($colorMainBackground, 25),
'--color-main-background-blur' => $colorMainBackground,
'--filter-background-blur' => 'none',
@@ -81,7 +69,27 @@ class DarkHighContrastTheme extends DarkTheme implements ITheme {
'--color-text-light' => $colorMainText,
'--color-text-lighter' => $colorMainText,
- '--color-scrollbar' => $this->util->lighten($colorMainBackground, 35),
+ '--color-error' => $colorError,
+ '--color-error-rgb' => join(',', $this->util->hexToRGB($colorError)),
+ '--color-error-hover' => $this->util->lighten($colorError, 10),
+ '--color-error-text' => $this->util->lighten($colorError, 25),
+
+ '--color-warning' => $colorWarning,
+ '--color-warning-rgb' => join(',', $this->util->hexToRGB($colorWarning)),
+ '--color-warning-hover' => $this->util->lighten($colorWarning, 10),
+ '--color-warning-text' => $this->util->lighten($colorWarning, 10),
+
+ '--color-success' => $colorSuccess,
+ '--color-success-rgb' => join(',', $this->util->hexToRGB($colorSuccess)),
+ '--color-success-hover' => $this->util->lighten($colorSuccess, 10),
+ '--color-success-text' => $this->util->lighten($colorSuccess, 35),
+
+ '--color-info' => $colorInfo,
+ '--color-info-rgb' => join(',', $this->util->hexToRGB($colorInfo)),
+ '--color-info-hover' => $this->util->lighten($colorInfo, 10),
+ '--color-info-text' => $this->util->lighten($colorInfo, 20),
+
+ '--color-scrollbar' => 'auto transparent',
// used for the icon loading animation
'--color-loading-light' => '#000000',
@@ -92,6 +100,7 @@ class DarkHighContrastTheme extends DarkTheme implements ITheme {
'--color-border' => $this->util->lighten($colorMainBackground, 50),
'--color-border-dark' => $this->util->lighten($colorMainBackground, 50),
+ '--color-border-maxcontrast' => $this->util->lighten($colorMainBackground, 55),
]
);
}
diff --git a/apps/theming/lib/Themes/DarkTheme.php b/apps/theming/lib/Themes/DarkTheme.php
index 588f8f5a8a6..fd273d4697d 100644
--- a/apps/theming/lib/Themes/DarkTheme.php
+++ b/apps/theming/lib/Themes/DarkTheme.php
@@ -1,26 +1,9 @@
<?php
+
declare(strict_types=1);
/**
- * @copyright Copyright (c) 2022 Joas Schilling <coding@schilljs.com>
- *
- * @author Joas Schilling <coding@schilljs.com>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Theming\Themes;
@@ -28,14 +11,12 @@ use OCA\Theming\ITheme;
class DarkTheme extends DefaultTheme implements ITheme {
+ protected bool $isDarkVariant = true;
+
public function getId(): string {
return 'dark';
}
- public function getMediaQuery(): string {
- return '(prefers-color-scheme: dark)';
- }
-
public function getTitle(): string {
return $this->l->t('Dark theme');
}
@@ -48,17 +29,34 @@ class DarkTheme extends DefaultTheme implements ITheme {
return $this->l->t('A dark theme to ease your eyes by reducing the overall luminosity and brightness.');
}
+ public function getMediaQuery(): string {
+ return '(prefers-color-scheme: dark)';
+ }
+
+ public function getMeta(): array {
+ // https://html.spec.whatwg.org/multipage/semantics.html#meta-color-scheme
+ return [[
+ 'name' => 'color-scheme',
+ 'content' => 'dark',
+ ]];
+ }
+
public function getCSSVariables(): array {
$defaultVariables = parent::getCSSVariables();
- $colorMainText = '#D8D8D8';
+ $colorMainText = '#EBEBEB';
$colorMainBackground = '#171717';
$colorMainBackgroundRGB = join(',', $this->util->hexToRGB($colorMainBackground));
- $colorTextMaxcontrast = $this->util->darken($colorMainText, 30);
+ $colorTextMaxcontrast = $this->util->darken($colorMainText, 32);
$colorBoxShadow = $this->util->darken($colorMainBackground, 70);
$colorBoxShadowRGB = join(',', $this->util->hexToRGB($colorBoxShadow));
+ $colorError = '#FF3333';
+ $colorWarning = '#FFCC00';
+ $colorSuccess = '#3B973B';
+ $colorInfo = '#00AEFF';
+
return array_merge(
$defaultVariables,
$this->generatePrimaryVariables($colorMainBackground, $colorMainText),
@@ -66,8 +64,7 @@ class DarkTheme extends DefaultTheme implements ITheme {
'--color-main-text' => $colorMainText,
'--color-main-background' => $colorMainBackground,
'--color-main-background-rgb' => $colorMainBackgroundRGB,
-
- '--color-scrollbar' => $this->util->lighten($colorMainBackground, 15),
+ '--color-main-background-blur' => 'rgba(var(--color-main-background-rgb), .85)',
'--color-background-hover' => $this->util->lighten($colorMainBackground, 4),
'--color-background-dark' => $this->util->lighten($colorMainBackground, 7),
@@ -78,9 +75,27 @@ class DarkTheme extends DefaultTheme implements ITheme {
'--color-text-maxcontrast' => $colorTextMaxcontrast,
'--color-text-maxcontrast-default' => $colorTextMaxcontrast,
- '--color-text-maxcontrast-background-blur' => $this->util->lighten($colorTextMaxcontrast, 2),
- '--color-text-light' => $this->util->darken($colorMainText, 10),
- '--color-text-lighter' => $this->util->darken($colorMainText, 20),
+ '--color-text-maxcontrast-background-blur' => $this->util->lighten($colorTextMaxcontrast, 6),
+ '--color-text-light' => 'var(--color-main-text)', // deprecated
+ '--color-text-lighter' => 'var(--color-text-maxcontrast)', // deprecated
+
+ '--color-error' => $colorError,
+ '--color-error-rgb' => join(',', $this->util->hexToRGB($colorError)),
+ '--color-error-hover' => $this->util->lighten($colorError, 10),
+ '--color-error-text' => $this->util->lighten($colorError, 15),
+ '--color-warning' => $colorWarning,
+ '--color-warning-rgb' => join(',', $this->util->hexToRGB($colorWarning)),
+ '--color-warning-hover' => $this->util->lighten($colorWarning, 10),
+ '--color-warning-text' => $colorWarning,
+ '--color-success' => $colorSuccess,
+ '--color-success-rgb' => join(',', $this->util->hexToRGB($colorSuccess)),
+ '--color-success-hover' => $this->util->lighten($colorSuccess, 10),
+ '--color-success-text' => $this->util->lighten($colorSuccess, 15),
+ '--color-info' => $colorInfo,
+ '--color-info-rgb' => join(',', $this->util->hexToRGB($colorInfo)),
+ '--color-info-hover' => $this->util->lighten($colorInfo, 10),
+ '--color-info-text' => $colorInfo,
+ '--color-favorite' => '#ffde00',
// used for the icon loading animation
'--color-loading-light' => '#777',
@@ -91,7 +106,7 @@ class DarkTheme extends DefaultTheme implements ITheme {
'--color-border' => $this->util->lighten($colorMainBackground, 7),
'--color-border-dark' => $this->util->lighten($colorMainBackground, 14),
- '--color-border-maxcontrast' => $this->util->lighten($colorMainBackground, 30),
+ '--color-border-maxcontrast' => $this->util->lighten($colorMainBackground, 40),
'--background-invert-if-dark' => 'invert(100%)',
'--background-invert-if-bright' => 'no',
diff --git a/apps/theming/lib/Themes/DefaultTheme.php b/apps/theming/lib/Themes/DefaultTheme.php
index cb4ed510068..bdd3048a498 100644
--- a/apps/theming/lib/Themes/DefaultTheme.php
+++ b/apps/theming/lib/Themes/DefaultTheme.php
@@ -1,79 +1,43 @@
<?php
+
declare(strict_types=1);
/**
- * @copyright Copyright (c) 2022 Joas Schilling <coding@schilljs.com>
- *
- * @author Joas Schilling <coding@schilljs.com>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Theming\Themes;
+use OC\AppFramework\Http\Request;
use OCA\Theming\ImageManager;
use OCA\Theming\ITheme;
-use OCA\Theming\Service\BackgroundService;
use OCA\Theming\ThemingDefaults;
use OCA\Theming\Util;
use OCP\App\IAppManager;
use OCP\IConfig;
use OCP\IL10N;
+use OCP\IRequest;
use OCP\IURLGenerator;
use OCP\IUserSession;
class DefaultTheme implements ITheme {
use CommonThemeTrait;
- public Util $util;
- public ThemingDefaults $themingDefaults;
- public IUserSession $userSession;
- public IURLGenerator $urlGenerator;
- public ImageManager $imageManager;
- public IConfig $config;
- public IL10N $l;
- public IAppManager $appManager;
-
public string $defaultPrimaryColor;
public string $primaryColor;
- public function __construct(Util $util,
- ThemingDefaults $themingDefaults,
- IUserSession $userSession,
- IURLGenerator $urlGenerator,
- ImageManager $imageManager,
- IConfig $config,
- IL10N $l,
- IAppManager $appManager) {
- $this->util = $util;
- $this->themingDefaults = $themingDefaults;
- $this->userSession = $userSession;
- $this->urlGenerator = $urlGenerator;
- $this->imageManager = $imageManager;
- $this->config = $config;
- $this->l = $l;
- $this->appManager = $appManager;
-
+ public function __construct(
+ public Util $util,
+ public ThemingDefaults $themingDefaults,
+ public IUserSession $userSession,
+ public IURLGenerator $urlGenerator,
+ public ImageManager $imageManager,
+ public IConfig $config,
+ public IL10N $l,
+ public IAppManager $appManager,
+ private ?IRequest $request,
+ ) {
$this->defaultPrimaryColor = $this->themingDefaults->getDefaultColorPrimary();
$this->primaryColor = $this->themingDefaults->getColorPrimary();
-
- // Override default defaultPrimaryColor if set to improve accessibility
- if ($this->primaryColor === BackgroundService::DEFAULT_COLOR) {
- $this->primaryColor = BackgroundService::DEFAULT_ACCESSIBLE_COLOR;
- }
}
public function getId(): string {
@@ -100,21 +64,48 @@ class DefaultTheme implements ITheme {
return '';
}
+ public function getMeta(): array {
+ return [];
+ }
+
public function getCSSVariables(): array {
$colorMainText = '#222222';
$colorMainTextRgb = join(',', $this->util->hexToRGB($colorMainText));
- $colorTextMaxcontrast = $this->util->lighten($colorMainText, 33);
+ // Color that still provides enough contrast for text, so we need a ratio of 4.5:1 on main background AND hover
+ $colorTextMaxcontrast = '#6b6b6b'; // 4.5 : 1 for hover background and background dark
$colorMainBackground = '#ffffff';
$colorMainBackgroundRGB = join(',', $this->util->hexToRGB($colorMainBackground));
$colorBoxShadow = $this->util->darken($colorMainBackground, 70);
$colorBoxShadowRGB = join(',', $this->util->hexToRGB($colorBoxShadow));
+ $colorError = '#DB0606';
+ $colorWarning = '#A37200';
+ $colorSuccess = '#2d7b41';
+ $colorInfo = '#0071ad';
+
+ $user = $this->userSession->getUser();
+ // Chromium based browsers currently (2024) have huge performance issues with blur filters
+ $isChromium = $this->request !== null && $this->request->isUserAgent([Request::USER_AGENT_CHROME, Request::USER_AGENT_MS_EDGE]);
+ // Ignore MacOS because they always have hardware accelartion
+ $isChromium = $isChromium && !$this->request->isUserAgent(['/Macintosh/']);
+ // Allow to force the blur filter
+ $forceEnableBlur = $user === null ? false : $this->config->getUserValue(
+ $user->getUID(),
+ 'theming',
+ 'force_enable_blur_filter',
+ );
+ $workingBlur = match($forceEnableBlur) {
+ 'yes' => true,
+ 'no' => false,
+ default => !$isChromium
+ };
+
$variables = [
'--color-main-background' => $colorMainBackground,
'--color-main-background-rgb' => $colorMainBackgroundRGB,
'--color-main-background-translucent' => 'rgba(var(--color-main-background-rgb), .97)',
'--color-main-background-blur' => 'rgba(var(--color-main-background-rgb), .8)',
- '--filter-background-blur' => 'blur(25px)',
+ '--filter-background-blur' => $workingBlur ? 'blur(25px)' : 'none',
// to use like this: background-image: linear-gradient(0, var('--gradient-main-background));
'--gradient-main-background' => 'var(--color-main-background) 0%, var(--color-main-background-translucent) 85%, transparent 100%',
@@ -132,61 +123,96 @@ class DefaultTheme implements ITheme {
'--color-text-maxcontrast' => $colorTextMaxcontrast,
'--color-text-maxcontrast-default' => $colorTextMaxcontrast,
'--color-text-maxcontrast-background-blur' => $this->util->darken($colorTextMaxcontrast, 7),
- '--color-text-light' => $colorMainText,
- '--color-text-lighter' => $this->util->lighten($colorMainText, 33),
-
- '--color-scrollbar' => 'rgba(' . $colorMainTextRgb . ', .15)',
-
- // info/warning/success feedback colours
- '--color-error' => '#e9322d',
- '--color-error-rgb' => join(',', $this->util->hexToRGB('#e9322d')),
- '--color-error-hover' => $this->util->mix('#e9322d', $colorMainBackground, 60),
- '--color-warning' => '#eca700',
- '--color-warning-rgb' => join(',', $this->util->hexToRGB('#eca700')),
- '--color-warning-hover' => $this->util->mix('#eca700', $colorMainBackground, 60),
- '--color-success' => '#46ba61',
- '--color-success-rgb' => join(',', $this->util->hexToRGB('#46ba61')),
- '--color-success-hover' => $this->util->mix('#46ba61', $colorMainBackground, 60),
+ '--color-text-light' => 'var(--color-main-text)', // deprecated
+ '--color-text-lighter' => 'var(--color-text-maxcontrast)', // deprecated
+
+ '--color-scrollbar' => 'var(--color-border-maxcontrast) transparent',
+
+ // error/warning/success/info feedback colours
+ '--color-error' => $colorError,
+ '--color-error-rgb' => join(',', $this->util->hexToRGB($colorError)),
+ '--color-error-hover' => $this->util->mix($colorError, $colorMainBackground, 75),
+ '--color-error-text' => $this->util->darken($colorError, 5),
+ '--color-warning' => $colorWarning,
+ '--color-warning-rgb' => join(',', $this->util->hexToRGB($colorWarning)),
+ '--color-warning-hover' => $this->util->darken($colorWarning, 5),
+ '--color-warning-text' => $this->util->darken($colorWarning, 7),
+ '--color-success' => $colorSuccess,
+ '--color-success-rgb' => join(',', $this->util->hexToRGB($colorSuccess)),
+ '--color-success-hover' => $this->util->mix($colorSuccess, $colorMainBackground, 80),
+ '--color-success-text' => $this->util->darken($colorSuccess, 4),
+ '--color-info' => $colorInfo,
+ '--color-info-rgb' => join(',', $this->util->hexToRGB($colorInfo)),
+ '--color-info-hover' => $this->util->mix($colorInfo, $colorMainBackground, 80),
+ '--color-info-text' => $this->util->darken($colorInfo, 4),
+ '--color-favorite' => '#A37200',
// used for the icon loading animation
'--color-loading-light' => '#cccccc',
'--color-loading-dark' => '#444444',
'--color-box-shadow-rgb' => $colorBoxShadowRGB,
- '--color-box-shadow' => "rgba(var(--color-box-shadow-rgb), 0.5)",
+ '--color-box-shadow' => 'rgba(var(--color-box-shadow-rgb), 0.5)',
'--color-border' => $this->util->darken($colorMainBackground, 7),
'--color-border-dark' => $this->util->darken($colorMainBackground, 14),
- '--color-border-maxcontrast' => $this->util->darken($colorMainBackground, 42),
+ '--color-border-maxcontrast' => $this->util->darken($colorMainBackground, 51),
- '--font-face' => "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Cantarell, Ubuntu, 'Helvetica Neue', Arial, sans-serif, 'Noto Color Emoji', 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'",
+ '--font-face' => "system-ui, -apple-system, 'Segoe UI', Roboto, Oxygen-Sans, Cantarell, Ubuntu, 'Helvetica Neue', 'Noto Sans', 'Liberation Sans', Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'",
'--default-font-size' => '15px',
+ '--font-size-small' => '13px',
+ // 1.5 * font-size for accessibility
+ '--default-line-height' => '1.5',
// TODO: support "(prefers-reduced-motion)"
'--animation-quick' => '100ms',
'--animation-slow' => '300ms',
// Default variables --------------------------------------------
- '--border-radius' => '3px',
- '--border-radius-large' => '10px',
- // pill-style button, value is large so big buttons also have correct roundness
+ // Border width for input elements such as text fields and selects
+ '--border-width-input' => '1px',
+ '--border-width-input-focused' => '2px',
+
+ // Border radii (new values)
+ '--border-radius-small' => '4px', // For smaller elements
+ '--border-radius-element' => '8px', // For interactive elements such as buttons, input, navigation and list items
+ '--border-radius-container' => '12px', // For smaller containers like action menus
+ '--border-radius-container-large' => '16px', // For bigger containers like body or modals
+
+ // Border radii (deprecated)
+ '--border-radius' => 'var(--border-radius-small)',
+ '--border-radius-large' => 'var(--border-radius-element)',
+ '--border-radius-rounded' => '28px',
'--border-radius-pill' => '100px',
- '--default-clickable-area' => '44px',
- '--default-line-height' => '24px',
+ '--default-clickable-area' => '34px',
+ '--clickable-area-large' => '48px',
+ '--clickable-area-small' => '24px',
+
'--default-grid-baseline' => '4px',
- // various structure data
+ // header / navigation bar
'--header-height' => '50px',
+ '--header-menu-item-height' => '44px',
+ /* An alpha mask to be applied to all icons on the navigation bar (header menu).
+ * Icons are have a size of 20px but usually we use MDI which have a content of 16px so 2px padding top bottom,
+ * for better gradient we must at first begin at those 2px (10% of height) as start and stop positions.
+ */
+ '--header-menu-icon-mask' => 'linear-gradient(var(--color-background-plain-text) 25%, color-mix(in srgb, var(--color-background-plain-text), 55% transparent) 90%) alpha',
+
+ // various structure data
'--navigation-width' => '300px',
'--sidebar-min-width' => '300px',
'--sidebar-max-width' => '500px',
- '--list-min-width' => '200px',
- '--list-max-width' => '300px',
- '--header-menu-item-height' => '44px',
- '--header-menu-profile-item-height' => '66px',
- // mobile. Keep in sync with core/js/js.js
+ // Border radius of the body container
+ '--body-container-radius' => 'var(--border-radius-container-large)',
+ // Margin of the body container
+ '--body-container-margin' => 'calc(var(--default-grid-baseline) * 2)',
+ // Height of the body container to fully fill the view port
+ '--body-height' => 'calc(100% - env(safe-area-inset-bottom) - var(--header-height) - var(--body-container-margin))',
+
+ // mobile. Keep in sync with core/src/init.js
'--breakpoint-mobile' => '1024px',
'--background-invert-if-dark' => 'no',
'--background-invert-if-bright' => 'invert(100%)',
diff --git a/apps/theming/lib/Themes/DyslexiaFont.php b/apps/theming/lib/Themes/DyslexiaFont.php
index 2629ac588c6..2448de7b3c8 100644
--- a/apps/theming/lib/Themes/DyslexiaFont.php
+++ b/apps/theming/lib/Themes/DyslexiaFont.php
@@ -1,26 +1,9 @@
<?php
+
declare(strict_types=1);
/**
- * @copyright Copyright (c) 2022 Joas Schilling <coding@schilljs.com>
- *
- * @author Joas Schilling <coding@schilljs.com>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Theming\Themes;
@@ -60,32 +43,23 @@ class DyslexiaFont extends DefaultTheme implements ITheme {
}
public function getCustomCss(): string {
- $fontPathWoff = $this->urlGenerator->linkTo('theming', 'fonts/OpenDyslexic-Regular.woff');
$fontPathOtf = $this->urlGenerator->linkTo('theming', 'fonts/OpenDyslexic-Regular.otf');
- $fontPathTtf = $this->urlGenerator->linkTo('theming', 'fonts/OpenDyslexic-Regular.ttf');
- $boldFontPathWoff = $this->urlGenerator->linkTo('theming', 'fonts/OpenDyslexic-Bold.woff');
$boldFontPathOtf = $this->urlGenerator->linkTo('theming', 'fonts/OpenDyslexic-Bold.otf');
- $boldFontPathTtf = $this->urlGenerator->linkTo('theming', 'fonts/OpenDyslexic-Bold.ttf');
return "
@font-face {
font-family: 'OpenDyslexic';
font-style: normal;
font-weight: 400;
- src: url('$fontPathWoff') format('woff'),
- url('$fontPathOtf') format('opentype'),
- url('$fontPathTtf') format('truetype');
+ src: url('$fontPathOtf') format('opentype');
}
-
+
@font-face {
font-family: 'OpenDyslexic';
font-style: normal;
font-weight: 700;
- src: url('$boldFontPathWoff') format('woff'),
- url('$boldFontPathOtf') format('opentype'),
- url('$boldFontPathTtf') format('truetype');
+ src: url('$boldFontPathOtf') format('opentype');
}
";
}
}
-
diff --git a/apps/theming/lib/Themes/HighContrastTheme.php b/apps/theming/lib/Themes/HighContrastTheme.php
index 4bb77b8c961..5b51114a32f 100644
--- a/apps/theming/lib/Themes/HighContrastTheme.php
+++ b/apps/theming/lib/Themes/HighContrastTheme.php
@@ -1,26 +1,9 @@
<?php
+
declare(strict_types=1);
/**
- * @copyright Copyright (c) 2022 Joas Schilling <coding@schilljs.com>
- *
- * @author Joas Schilling <coding@schilljs.com>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Theming\Themes;
@@ -32,10 +15,6 @@ class HighContrastTheme extends DefaultTheme implements ITheme {
return 'light-highcontrast';
}
- public function getMediaQuery(): string {
- return '(prefers-contrast: more)';
- }
-
public function getTitle(): string {
return $this->l->t('High contrast mode');
}
@@ -48,6 +27,10 @@ class HighContrastTheme extends DefaultTheme implements ITheme {
return $this->l->t('A high contrast mode to ease your navigation. Visual quality will be reduced but clarity will be increased.');
}
+ public function getMediaQuery(): string {
+ return '(prefers-contrast: more)';
+ }
+
/**
* Keep this consistent with other HighContrast Themes
*/
@@ -56,19 +39,27 @@ class HighContrastTheme extends DefaultTheme implements ITheme {
$colorMainText = '#000000';
$colorMainBackground = '#ffffff';
- $colorMainBackgroundRGB = join(',', $this->util->hexToRGB($colorMainBackground));
+ $colorMainBackgroundRGB = join(',', $this->util->hexToRGB($colorMainBackground));
+ $colorError = '#D10000';
+ $colorWarning = '#995900';
+ $colorSuccess = '#207830';
+ $colorInfo = '#006DA8';
+
+ $primaryVariables = $this->generatePrimaryVariables($colorMainBackground, $colorMainText, true);
return array_merge(
$defaultVariables,
- $this->generatePrimaryVariables($colorMainBackground, $colorMainText),
+ $primaryVariables,
[
+ '--color-primary-element-text-dark' => $primaryVariables['--color-primary-element-text'],
+
'--color-main-background' => $colorMainBackground,
'--color-main-background-rgb' => $colorMainBackgroundRGB,
'--color-main-background-translucent' => 'rgba(var(--color-main-background-rgb), 1)',
'--color-main-text' => $colorMainText,
- '--color-background-dark' => $this->util->darken($colorMainBackground, 30),
- '--color-background-darker' => $this->util->darken($colorMainBackground, 30),
+ '--color-background-dark' => $this->util->darken($colorMainBackground, 20),
+ '--color-background-darker' => $this->util->darken($colorMainBackground, 20),
'--color-main-background-blur' => $colorMainBackground,
'--filter-background-blur' => 'none',
@@ -81,7 +72,29 @@ class HighContrastTheme extends DefaultTheme implements ITheme {
'--color-text-light' => $colorMainText,
'--color-text-lighter' => $colorMainText,
- '--color-scrollbar' => $this->util->darken($colorMainBackground, 25),
+ '--color-error' => $colorError,
+ '--color-error-rgb' => join(',', $this->util->hexToRGB($colorError)),
+ '--color-error-hover' => $this->util->darken($colorError, 8),
+ '--color-error-text' => $this->util->darken($colorError, 17),
+
+ '--color-warning' => $colorWarning,
+ '--color-warning-rgb' => join(',', $this->util->hexToRGB($colorWarning)),
+ '--color-warning-hover' => $this->util->darken($colorWarning, 7),
+ '--color-warning-text' => $this->util->darken($colorWarning, 13),
+
+ '--color-info' => $colorInfo,
+ '--color-info-rgb' => join(',', $this->util->hexToRGB($colorInfo)),
+ '--color-info-hover' => $this->util->darken($colorInfo, 7),
+ '--color-info-text' => $this->util->darken($colorInfo, 15),
+
+ '--color-success' => $colorSuccess,
+ '--color-success-rgb' => join(',', $this->util->hexToRGB($colorSuccess)),
+ '--color-success-hover' => $this->util->darken($colorSuccess, 7),
+ '--color-success-text' => $this->util->darken($colorSuccess, 14),
+
+ '--color-favorite' => '#936B06',
+
+ '--color-scrollbar' => 'auto transparent',
// used for the icon loading animation
'--color-loading-light' => '#dddddd',
@@ -92,6 +105,10 @@ class HighContrastTheme extends DefaultTheme implements ITheme {
'--color-border' => $this->util->darken($colorMainBackground, 50),
'--color-border-dark' => $this->util->darken($colorMainBackground, 50),
+ '--color-border-maxcontrast' => $this->util->darken($colorMainBackground, 56),
+
+ // remove the gradient from the app icons
+ '--header-menu-icon-mask' => 'none',
]
);
}
diff --git a/apps/theming/lib/Themes/LightTheme.php b/apps/theming/lib/Themes/LightTheme.php
index e988f612226..714156d4721 100644
--- a/apps/theming/lib/Themes/LightTheme.php
+++ b/apps/theming/lib/Themes/LightTheme.php
@@ -1,37 +1,13 @@
<?php
+
declare(strict_types=1);
/**
- * @copyright Copyright (c) 2022 Joas Schilling <coding@schilljs.com>
- *
- * @author Joas Schilling <coding@schilljs.com>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Theming\Themes;
-use OCA\Theming\ImageManager;
-use OCA\Theming\ThemingDefaults;
-use OCA\Theming\Util;
use OCA\Theming\ITheme;
-use OCA\Theming\Themes\DefaultTheme;
-use OCP\IConfig;
-use OCP\IL10N;
-use OCP\IURLGenerator;
class LightTheme extends DefaultTheme implements ITheme {
@@ -39,10 +15,6 @@ class LightTheme extends DefaultTheme implements ITheme {
return 'light';
}
- public function getType(): int {
- return ITheme::TYPE_THEME;
- }
-
public function getTitle(): string {
return $this->l->t('Light theme');
}
@@ -58,4 +30,12 @@ class LightTheme extends DefaultTheme implements ITheme {
public function getMediaQuery(): string {
return '(prefers-color-scheme: light)';
}
+
+ public function getMeta(): array {
+ // https://html.spec.whatwg.org/multipage/semantics.html#meta-color-scheme
+ return [[
+ 'name' => 'color-scheme',
+ 'content' => 'light',
+ ]];
+ }
}
diff --git a/apps/theming/lib/ThemingDefaults.php b/apps/theming/lib/ThemingDefaults.php
index 6cb204d1b1b..04f56895fa3 100644
--- a/apps/theming/lib/ThemingDefaults.php
+++ b/apps/theming/lib/ThemingDefaults.php
@@ -1,42 +1,8 @@
<?php
+
/**
- * @copyright Copyright (c) 2016 Bjoern Schiessle <bjoern@schiessle.org>
- * @copyright Copyright (c) 2017 Lukas Reschke <lukas@statuscode.ch>
- *
- * @author Arthur Schiwon <blizzz@arthur-schiwon.de>
- * @author Bjoern Schiessle <bjoern@schiessle.org>
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author Daniel Kesselberg <mail@danielkesselberg.de>
- * @author Guillaume COMPAGNON <gcompagnon@outlook.com>
- * @author Jan-Christoph Borchardt <hey@jancborchardt.net>
- * @author Joachim Bauch <bauch@struktur.de>
- * @author Joas Schilling <coding@schilljs.com>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Julien Veyssier <eneiluj@posteo.net>
- * @author Julius Haertl <jus@bitgrid.net>
- * @author Julius Härtl <jus@bitgrid.net>
- * @author Lukas Reschke <lukas@statuscode.ch>
- * @author Michael Weimann <mail@michael-weimann.eu>
- * @author Morris Jobke <hey@morrisjobke.de>
- * @author Patrik Kernstock <info@pkern.at>
- * @author Robin Appelman <robin@icewind.nl>
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Theming;
@@ -46,6 +12,7 @@ use OCP\App\AppPathNotFoundException;
use OCP\App\IAppManager;
use OCP\Files\NotFoundException;
use OCP\Files\SimpleFS\ISimpleFile;
+use OCP\IAppConfig;
use OCP\ICacheFactory;
use OCP\IConfig;
use OCP\IL10N;
@@ -55,22 +22,14 @@ use OCP\IUserSession;
class ThemingDefaults extends \OC_Defaults {
- private IConfig $config;
- private IL10N $l;
- private ImageManager $imageManager;
- private IUserSession $userSession;
- private IURLGenerator $urlGenerator;
- private ICacheFactory $cacheFactory;
- private Util $util;
- private IAppManager $appManager;
- private INavigationManager $navigationManager;
-
private string $name;
private string $title;
private string $entity;
private string $productName;
private string $url;
- private string $color;
+ private string $backgroundColor;
+ private string $primaryColor;
+ private string $docBaseUrl;
private string $iTunesAppId;
private string $iOSClientUrl;
@@ -79,47 +38,34 @@ class ThemingDefaults extends \OC_Defaults {
/**
* ThemingDefaults constructor.
- *
- * @param IConfig $config
- * @param IL10N $l
- * @param ImageManager $imageManager
- * @param IUserSession $userSession
- * @param IURLGenerator $urlGenerator
- * @param ICacheFactory $cacheFactory
- * @param Util $util
- * @param IAppManager $appManager
*/
- public function __construct(IConfig $config,
- IL10N $l,
- IUserSession $userSession,
- IURLGenerator $urlGenerator,
- ICacheFactory $cacheFactory,
- Util $util,
- ImageManager $imageManager,
- IAppManager $appManager,
- INavigationManager $navigationManager
+ public function __construct(
+ private IConfig $config,
+ private IAppConfig $appConfig,
+ private IL10N $l,
+ private IUserSession $userSession,
+ private IURLGenerator $urlGenerator,
+ private ICacheFactory $cacheFactory,
+ private Util $util,
+ private ImageManager $imageManager,
+ private IAppManager $appManager,
+ private INavigationManager $navigationManager,
+ private BackgroundService $backgroundService,
) {
parent::__construct();
- $this->config = $config;
- $this->l = $l;
- $this->imageManager = $imageManager;
- $this->userSession = $userSession;
- $this->urlGenerator = $urlGenerator;
- $this->cacheFactory = $cacheFactory;
- $this->util = $util;
- $this->appManager = $appManager;
- $this->navigationManager = $navigationManager;
$this->name = parent::getName();
$this->title = parent::getTitle();
$this->entity = parent::getEntity();
$this->productName = parent::getProductName();
$this->url = parent::getBaseUrl();
- $this->color = parent::getColorPrimary();
+ $this->primaryColor = parent::getColorPrimary();
+ $this->backgroundColor = parent::getColorBackground();
$this->iTunesAppId = parent::getiTunesAppId();
$this->iOSClientUrl = parent::getiOSClientUrl();
$this->AndroidClientUrl = parent::getAndroidClientUrl();
$this->FDroidClientUrl = parent::getFDroidClientUrl();
+ $this->docBaseUrl = parent::getDocBaseUrl();
}
public function getName() {
@@ -163,14 +109,23 @@ class ThemingDefaults extends \OC_Defaults {
return (string)$this->config->getAppValue('theming', 'privacyUrl', '');
}
+ public function getDocBaseUrl() {
+ return (string)$this->config->getAppValue('theming', 'docBaseUrl', $this->docBaseUrl);
+ }
+
public function getShortFooter() {
$slogan = $this->getSlogan();
$baseUrl = $this->getBaseUrl();
- if ($baseUrl !== '') {
- $footer = '<a href="' . $baseUrl . '" target="_blank"' .
- ' rel="noreferrer noopener" class="entity-name">' . $this->getEntity() . '</a>';
- } else {
- $footer = '<span class="entity-name">' .$this->getEntity() . '</span>';
+ $entity = $this->getEntity();
+ $footer = '';
+
+ if ($entity !== '') {
+ if ($baseUrl !== '') {
+ $footer = '<a href="' . $baseUrl . '" target="_blank"'
+ . ' rel="noreferrer noopener" class="entity-name">' . $entity . '</a>';
+ } else {
+ $footer = '<span class="entity-name">' . $entity . '</span>';
+ }
}
$footer .= ($slogan !== '' ? ' – ' . $slogan : '');
@@ -200,20 +155,21 @@ class ThemingDefaults extends \OC_Defaults {
if ($link['url'] !== ''
&& filter_var($link['url'], FILTER_VALIDATE_URL)
) {
- $legalLinks .= $divider . '<a href="' . $link['url'] . '" class="legal" target="_blank"' .
- ' rel="noreferrer noopener">' . $link['text'] . '</a>';
+ $legalLinks .= $divider . '<a href="' . $link['url'] . '" class="legal" target="_blank"'
+ . ' rel="noreferrer noopener">' . $link['text'] . '</a>';
$divider = ' · ';
}
}
if ($legalLinks !== '') {
- $footer .= '<br/>' . $legalLinks;
+ $footer .= '<br/><span class="footer__legal-links">' . $legalLinks . '</span>';
}
return $footer;
}
/**
- * Color that is used for the header as well as for mail headers
+ * Color that is used for highlighting elements like important buttons
+ * If user theming is enabled then the user defined value is returned
*/
public function getColorPrimary(): string {
$user = $this->userSession->getUser();
@@ -227,31 +183,66 @@ class ThemingDefaults extends \OC_Defaults {
// user-defined primary color
if (!empty($user)) {
- $themingBackgroundColor = $this->config->getUserValue($user->getUID(), Application::APP_ID, 'background_color', '');
- // If the user selected a specific colour
- if (preg_match('/^\#([0-9a-f]{3}|[0-9a-f]{6})$/i', $themingBackgroundColor)) {
- return $themingBackgroundColor;
+ $userPrimaryColor = $this->config->getUserValue($user->getUID(), Application::APP_ID, 'primary_color', '');
+ if (preg_match('/^\#([0-9a-f]{3}|[0-9a-f]{6})$/i', $userPrimaryColor)) {
+ return $userPrimaryColor;
}
}
- // If the default color is not valid, return the default background one
- if (!preg_match('/^\#([0-9a-f]{3}|[0-9a-f]{6})$/i', $defaultColor)) {
- return BackgroundService::DEFAULT_COLOR;
+ // Finally, return the system global primary color
+ return $defaultColor;
+ }
+
+ /**
+ * Color that is used for the page background (e.g. the header)
+ * If user theming is enabled then the user defined value is returned
+ */
+ public function getColorBackground(): string {
+ $user = $this->userSession->getUser();
+
+ // admin-defined background color
+ $defaultColor = $this->getDefaultColorBackground();
+
+ if ($this->isUserThemingDisabled()) {
+ return $defaultColor;
}
- // Finally, return the system global primary color
+ // user-defined background color
+ if (!empty($user)) {
+ $userBackgroundColor = $this->config->getUserValue($user->getUID(), Application::APP_ID, 'background_color', '');
+ if (preg_match('/^\#([0-9a-f]{3}|[0-9a-f]{6})$/i', $userBackgroundColor)) {
+ return $userBackgroundColor;
+ }
+ }
+
+ // Finally, return the system global background color
return $defaultColor;
}
/**
- * Return the default color primary
+ * Return the default primary color - only taking admin setting into account
*/
public function getDefaultColorPrimary(): string {
- $color = $this->config->getAppValue(Application::APP_ID, 'color', '');
- if (!preg_match('/^\#([0-9a-f]{3}|[0-9a-f]{6})$/i', $color)) {
- $color = '#0082c9';
+ // try admin color
+ $defaultColor = $this->appConfig->getValueString(Application::APP_ID, 'primary_color', '');
+ if (preg_match('/^\#([0-9a-f]{3}|[0-9a-f]{6})$/i', $defaultColor)) {
+ return $defaultColor;
}
- return $color;
+
+ // fall back to default primary color
+ return $this->primaryColor;
+ }
+
+ /**
+ * Default background color only taking admin setting into account
+ */
+ public function getDefaultColorBackground(): string {
+ $defaultColor = $this->appConfig->getValueString(Application::APP_ID, 'background_color');
+ if (preg_match('/^\#([0-9a-f]{3}|[0-9a-f]{6})$/i', $defaultColor)) {
+ return $defaultColor;
+ }
+
+ return $this->backgroundColor;
}
/**
@@ -296,10 +287,11 @@ class ThemingDefaults extends \OC_Defaults {
/**
* Themed background image url
*
+ * @param bool $darkVariant if the dark variant (if available) of the background should be used
* @return string
*/
- public function getBackground(): string {
- return $this->imageManager->getImageUrl('background');
+ public function getBackground(bool $darkVariant = false): string {
+ return $this->imageManager->getImageUrl('background' . ($darkVariant ? 'Dark' : ''));
}
/**
@@ -332,6 +324,7 @@ class ThemingDefaults extends \OC_Defaults {
/**
* @return array scss variables to overwrite
+ * @deprecated since Nextcloud 22 - https://github.com/nextcloud/server/issues/9940
*/
public function getScssVariables() {
$cacheBuster = $this->config->getAppValue('theming', 'cachebuster', '0');
@@ -348,13 +341,13 @@ class ThemingDefaults extends \OC_Defaults {
'theming-favicon-mime' => "'" . $this->config->getAppValue('theming', 'faviconMime') . "'"
];
- $variables['image-logo'] = "url('".$this->imageManager->getImageUrl('logo')."')";
- $variables['image-logoheader'] = "url('".$this->imageManager->getImageUrl('logoheader')."')";
- $variables['image-favicon'] = "url('".$this->imageManager->getImageUrl('favicon')."')";
- $variables['image-login-background'] = "url('".$this->imageManager->getImageUrl('background')."')";
+ $variables['image-logo'] = "url('" . $this->imageManager->getImageUrl('logo') . "')";
+ $variables['image-logoheader'] = "url('" . $this->imageManager->getImageUrl('logoheader') . "')";
+ $variables['image-favicon'] = "url('" . $this->imageManager->getImageUrl('favicon') . "')";
+ $variables['image-login-background'] = "url('" . $this->imageManager->getImageUrl('background') . "')";
$variables['image-login-plain'] = 'false';
- if ($this->config->getAppValue('theming', 'color', '') !== '') {
+ if ($this->appConfig->getValueString(Application::APP_ID, 'primary_color', '') !== '') {
$variables['color-primary'] = $this->getColorPrimary();
$variables['color-primary-text'] = $this->getTextColorPrimary();
$variables['color-primary-element'] = $this->util->elementColor($this->getColorPrimary());
@@ -404,7 +397,7 @@ class ThemingDefaults extends \OC_Defaults {
}
$route = $this->urlGenerator->linkToRoute('theming.Theming.getManifest', ['app' => $app ]);
}
- if (strpos($image, 'filetypes/') === 0 && file_exists(\OC::$SERVERROOT . '/core/img/' . $image)) {
+ if (str_starts_with($image, 'filetypes/') && file_exists(\OC::$SERVERROOT . '/core/img/' . $image)) {
$route = $this->urlGenerator->linkToRoute('theming.Icon.getThemedIcon', ['app' => $app, 'image' => $image]);
}
@@ -448,12 +441,16 @@ class ThemingDefaults extends \OC_Defaults {
* Revert all settings to the default value
*/
public function undoAll(): void {
+ // Remember the current cachebuster value, as we do not want to reset this value
+ // Otherwise this can lead to caching issues as the value might be known to a browser already
+ $cacheBusterKey = $this->config->getAppValue('theming', 'cachebuster', '0');
$this->config->deleteAppValues('theming');
+ $this->config->setAppValue('theming', 'cachebuster', $cacheBusterKey);
$this->increaseCacheBuster();
}
/**
- * Revert settings to the default value
+ * Revert admin settings to the default value
*
* @param string $setting setting which should be reverted
* @return string default value
@@ -473,14 +470,22 @@ class ThemingDefaults extends \OC_Defaults {
case 'slogan':
$returnValue = $this->getSlogan();
break;
- case 'color':
- $returnValue = $this->getDefaultColorPrimary();
+ case 'primary_color':
+ $returnValue = BackgroundService::DEFAULT_COLOR;
+ break;
+ case 'background_color':
+ // If a background image is set we revert to the mean image color
+ if ($this->imageManager->hasImage('background')) {
+ $file = $this->imageManager->getImage('background');
+ $returnValue = $this->backgroundService->setGlobalBackground($file->read()) ?? '';
+ }
break;
case 'logo':
case 'logoheader':
case 'background':
case 'favicon':
$this->imageManager->delete($setting);
+ $this->config->deleteAppValue('theming', $setting . 'Mime');
break;
}
@@ -488,7 +493,16 @@ class ThemingDefaults extends \OC_Defaults {
}
/**
- * Color of text in the header and primary buttons
+ * Color of text in the header menu
+ *
+ * @return string
+ */
+ public function getTextColorBackground() {
+ return $this->util->invertTextColor($this->getColorBackground()) ? '#000000' : '#ffffff';
+ }
+
+ /**
+ * Color of text on primary buttons and other elements
*
* @return string
*/
@@ -509,6 +523,6 @@ class ThemingDefaults extends \OC_Defaults {
* Has the admin disabled user customization
*/
public function isUserThemingDisabled(): bool {
- return $this->config->getAppValue('theming', 'disable-user-theming', 'no') === 'yes';
+ return $this->appConfig->getValueBool(Application::APP_ID, 'disable-user-theming');
}
}
diff --git a/apps/theming/lib/Util.php b/apps/theming/lib/Util.php
index a85dacd3de2..797456632fc 100644
--- a/apps/theming/lib/Util.php
+++ b/apps/theming/lib/Util.php
@@ -1,33 +1,12 @@
<?php
+
/**
- * @copyright Copyright (c) 2016 Julius Härtl <jus@bitgrid.net>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author Daniel Kesselberg <mail@danielkesselberg.de>
- * @author Joas Schilling <coding@schilljs.com>
- * @author Julien Veyssier <eneiluj@posteo.net>
- * @author Julius Haertl <jus@bitgrid.net>
- * @author Julius Härtl <jus@bitgrid.net>
- * @author Michael Weimann <mail@michael-weimann.eu>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Theming;
+use Mexitek\PHPColors\Color;
use OCP\App\AppPathNotFoundException;
use OCP\App\IAppManager;
use OCP\Files\IAppData;
@@ -35,27 +14,34 @@ use OCP\Files\NotFoundException;
use OCP\Files\SimpleFS\ISimpleFile;
use OCP\IConfig;
use OCP\IUserSession;
-use Mexitek\PHPColors\Color;
+use OCP\Server;
+use OCP\ServerVersion;
class Util {
+ public function __construct(
+ private ServerVersion $serverVersion,
+ private IConfig $config,
+ private IAppManager $appManager,
+ private IAppData $appData,
+ private ImageManager $imageManager,
+ ) {
+ }
- private IConfig $config;
- private IAppManager $appManager;
- private IAppData $appData;
- private ImageManager $imageManager;
-
- public function __construct(IConfig $config, IAppManager $appManager, IAppData $appData, ImageManager $imageManager) {
- $this->config = $config;
- $this->appManager = $appManager;
- $this->appData = $appData;
- $this->imageManager = $imageManager;
+ /**
+ * Should we invert the text on this background color?
+ * @param string $color rgb color value
+ * @return bool
+ */
+ public function invertTextColor(string $color): bool {
+ return $this->colorContrast($color, '#ffffff') < 4.5;
}
/**
+ * Is this color too bright ?
* @param string $color rgb color value
* @return bool
*/
- public function invertTextColor($color) {
+ public function isBrightColor(string $color): bool {
$l = $this->calculateLuma($color);
if ($l > 0.6) {
return true;
@@ -68,20 +54,43 @@ class Util {
* get color for on-page elements:
* theme color by default, grey if theme color is to bright
* @param string $color
- * @param bool $brightBackground
+ * @param ?bool $brightBackground
* @return string
*/
- public function elementColor($color, bool $brightBackground = true) {
+ public function elementColor($color, ?bool $brightBackground = null, ?string $backgroundColor = null, bool $highContrast = false) {
+ if ($backgroundColor !== null) {
+ $brightBackground = $brightBackground ?? $this->isBrightColor($backgroundColor);
+ // Minimal amount that is possible to change the luminance
+ $epsilon = 1.0 / 255.0;
+ // Current iteration to prevent infinite loops
+ $iteration = 0;
+ // We need to keep blurred backgrounds in mind which might be mixed with the background
+ $blurredBackground = $this->mix($backgroundColor, $brightBackground ? $color : '#ffffff', 66);
+ $contrast = $this->colorContrast($color, $blurredBackground);
+
+ // Min. element contrast is 3:1 but we need to keep hover states in mind -> min 3.2:1
+ $minContrast = $highContrast ? 5.6 : 3.2;
+
+ while ($contrast < $minContrast && $iteration++ < 100) {
+ $hsl = Color::hexToHsl($color);
+ $hsl['L'] = max(0, min(1, $hsl['L'] + ($brightBackground ? -$epsilon : $epsilon)));
+ $color = '#' . Color::hslToHex($hsl);
+ $contrast = $this->colorContrast($color, $blurredBackground);
+ }
+ return $color;
+ }
+
+ // Fallback for legacy calling
$luminance = $this->calculateLuminance($color);
- if ($brightBackground && $luminance > 0.8) {
- // If the color is too bright in bright mode, we fall back to a darker gray
- return '#aaaaaa';
+ if ($brightBackground !== false && $luminance > 0.8) {
+ // If the color is too bright in bright mode, we fall back to a darkened color
+ return $this->darken($color, 30);
}
- if (!$brightBackground && $luminance < 0.2) {
- // If the color is too dark in dark mode, we fall back to a brighter gray
- return '#555555';
+ if ($brightBackground !== true && $luminance < 0.2) {
+ // If the color is too dark in dark mode, we fall back to a brightened color
+ return $this->lighten($color, 30);
}
return $color;
@@ -129,12 +138,38 @@ class Util {
}
/**
+ * Calculate the Luma according to WCAG 2
+ * http://www.w3.org/TR/WCAG20/#relativeluminancedef
* @param string $color rgb color value
* @return float
*/
public function calculateLuma(string $color): float {
- [$red, $green, $blue] = $this->hexToRGB($color);
- return (0.2126 * $red + 0.7152 * $green + 0.0722 * $blue) / 255;
+ $rgb = $this->hexToRGB($color);
+
+ // Normalize the values by converting to float and applying the rules from WCAG2.0
+ $rgb = array_map(function (int $color) {
+ $color = $color / 255.0;
+ if ($color <= 0.03928) {
+ return $color / 12.92;
+ } else {
+ return pow((($color + 0.055) / 1.055), 2.4);
+ }
+ }, $rgb);
+
+ [$red, $green, $blue] = $rgb;
+ return (0.2126 * $red + 0.7152 * $green + 0.0722 * $blue);
+ }
+
+ /**
+ * Calculat the color contrast according to WCAG 2
+ * http://www.w3.org/TR/WCAG20/#contrast-ratiodef
+ * @param string $color1 The first color
+ * @param string $color2 The second color
+ */
+ public function colorContrast(string $color1, string $color2): float {
+ $luminance1 = $this->calculateLuma($color1) + 0.05;
+ $luminance2 = $this->calculateLuma($color2) + 0.05;
+ return max($luminance1, $luminance2) / min($luminance1, $luminance2);
}
/**
@@ -152,18 +187,18 @@ class Util {
* @return string base64 encoded radio button svg
*/
public function generateRadioButton($color) {
- $radioButtonIcon = '<svg xmlns="http://www.w3.org/2000/svg" height="16" width="16">' .
- '<path d="M8 1a7 7 0 0 0-7 7 7 7 0 0 0 7 7 7 7 0 0 0 7-7 7 7 0 0 0-7-7zm0 1a6 6 0 0 1 6 6 6 6 0 0 1-6 6 6 6 0 0 1-6-6 6 6 0 0 1 6-6zm0 2a4 4 0 1 0 0 8 4 4 0 0 0 0-8z" fill="'.$color.'"/></svg>';
+ $radioButtonIcon = '<svg xmlns="http://www.w3.org/2000/svg" height="16" width="16">'
+ . '<path d="M8 1a7 7 0 0 0-7 7 7 7 0 0 0 7 7 7 7 0 0 0 7-7 7 7 0 0 0-7-7zm0 1a6 6 0 0 1 6 6 6 6 0 0 1-6 6 6 6 0 0 1-6-6 6 6 0 0 1 6-6zm0 2a4 4 0 1 0 0 8 4 4 0 0 0 0-8z" fill="' . $color . '"/></svg>';
return base64_encode($radioButtonIcon);
}
/**
- * @param $app string app name
+ * @param string $app app name
* @return string|ISimpleFile path to app icon / file of logo
*/
public function getAppIcon($app) {
- $app = str_replace(['\0', '/', '\\', '..'], '', $app);
+ $app = $this->appManager->cleanAppId($app);
try {
$appPath = $this->appManager->getAppPath($app);
$icon = $appPath . '/img/' . $app . '.svg';
@@ -180,7 +215,7 @@ class Util {
if ($this->config->getAppValue('theming', 'logoMime', '') !== '') {
$logoFile = null;
try {
- $folder = $this->appData->getFolder('images');
+ $folder = $this->appData->getFolder('global/images');
return $folder->getFile('logo');
} catch (NotFoundException $e) {
}
@@ -189,14 +224,17 @@ class Util {
}
/**
- * @param $app string app name
- * @param $image string relative path to image in app folder
+ * @param string $app app name
+ * @param string $image relative path to image in app folder
* @return string|false absolute path to image
*/
public function getAppImage($app, $image) {
- $app = str_replace(['\0', '/', '\\', '..'], '', $app);
+ $app = $this->appManager->cleanAppId($app);
+ /**
+ * @psalm-taint-escape file
+ */
$image = str_replace(['\0', '\\', '..'], '', $image);
- if ($app === "core") {
+ if ($app === 'core') {
$icon = \OC::$SERVERROOT . '/core/img/' . $image;
if (file_exists($icon)) {
return $icon;
@@ -236,8 +274,8 @@ class Util {
/**
* replace default color with a custom one
*
- * @param $svg string content of a svg file
- * @param $color string color to match
+ * @param string $svg content of a svg file
+ * @param string $color color to match
* @return string
*/
public function colorizeSvg($svg, $color) {
@@ -269,18 +307,20 @@ class Util {
}
public function getCacheBuster(): string {
- $userSession = \OC::$server->get(IUserSession::class);
+ $userSession = Server::get(IUserSession::class);
$userId = '';
$user = $userSession->getUser();
if (!is_null($user)) {
$userId = $user->getUID();
}
+ $serverVersion = $this->serverVersion->getVersionString();
+ $themingAppVersion = $this->appManager->getAppVersion('theming');
$userCacheBuster = '';
if ($userId) {
$userCacheBusterValue = (int)$this->config->getUserValue($userId, 'theming', 'userCacheBuster', '0');
$userCacheBuster = $userId . '_' . $userCacheBusterValue;
}
$systemCacheBuster = $this->config->getAppValue('theming', 'cachebuster', '0');
- return substr(sha1($userCacheBuster . $systemCacheBuster), 0, 8);
+ return substr(sha1($serverVersion . $themingAppVersion . $userCacheBuster . $systemCacheBuster), 0, 8);
}
}