Compare commits

...

12 Commits

Author SHA1 Message Date
Ferdinand Thiessen e9a6556e44 fixup 2026-04-07 04:06:33 +02:00
Ferdinand Thiessen 2a61e0bd98 refactor: migrate app level badge to css modules
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
2026-04-07 04:06:33 +02:00
Ferdinand Thiessen fc0fc06124 refactor: migrate app bundles to Vue 3
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
2026-04-07 04:06:33 +02:00
Ferdinand Thiessen 4cb22ad89f refactor: migrated app management to Vue 3
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
2026-04-07 04:06:09 +02:00
Ferdinand Thiessen 6a674f449d refactor(appstore): migrate app management views to Vue 3 and Typescript
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
2026-04-07 04:06:09 +02:00
Ferdinand Thiessen 67afe77f1c refactor(appstore): migrate app discover section to Vue 3
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
2026-04-07 04:06:09 +02:00
Ferdinand Thiessen 7482a3a0f3 refactor(appstore): migrate daemon selection dialog to Typescript and Vue 3
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
2026-04-07 04:06:09 +02:00
Ferdinand Thiessen 89fe742a83 refactor(appstore): prepare Vue 3 migration
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
2026-04-07 04:06:09 +02:00
Ferdinand Thiessen c320f30268 refactor(appstore): split controllers and use proper root
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
2026-04-07 04:06:09 +02:00
Ferdinand Thiessen 7c0d70dc98 refactor(appstore): migrate markdown component to Typescript
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
2026-04-07 04:06:09 +02:00
Ferdinand Thiessen 3ad1cfd623 refactor: split appstore from settings app
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
2026-04-07 04:06:08 +02:00
Ferdinand Thiessen 2a3321f1ff build: harden module name generation
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
2026-04-07 00:04:05 +02:00
127 changed files with 7333 additions and 3177 deletions
+5 -4
View File
@@ -17,16 +17,17 @@ node_modules/
# ignore all apps except core ones
/apps*/*
!/apps/admin_audit
!/apps/appstore
!/apps/cloud_federation_api
!/apps/comments
!/apps/contactsinteraction
!/apps/dashboard
!/apps/dav
!/apps/files
!/apps/encryption
!/apps/federation
!/apps/federatedfilesharing
!/apps/sharebymail
!/apps/encryption
!/apps/files
!/apps/files_external
!/apps/files_reminders
!/apps/files_sharing
@@ -38,9 +39,9 @@ node_modules/
!/apps/profile
!/apps/provisioning_api
!/apps/settings
!/apps/sharebymail
!/apps/systemtags
!/apps/testing
!/apps/admin_audit
!/apps/updatenotification
!/apps/theming
!/apps/twofactor_backupcodes
+6
View File
@@ -8,6 +8,12 @@ source_file = translationfiles/templates/admin_audit.pot
source_lang = en
type = PO
[o:nextcloud:p:nextcloud:r:appstore]
file_filter = translationfiles/<lang>/appstore.po
source_file = translationfiles/templates/appstore.pot
source_lang = en
type = PO
[o:nextcloud:p:nextcloud:r:cloud_federation_api]
file_filter = translationfiles/<lang>/cloud_federation_api.po
source_file = translationfiles/templates/cloud_federation_api.pot
+16
View File
@@ -0,0 +1,16 @@
version = 1
SPDX-PackageName = "nextcloud"
SPDX-PackageSupplier = "Nextcloud <info@nextcloud.com>"
SPDX-PackageDownloadLocation = "https://github.com/nextcloud/server"
[[annotations]]
path = ["tests/fixtures/categories.json", "tests/fixtures/categories-api-response.json"]
precedence = "aggregate"
SPDX-FileCopyrightText = "2026 Nextcloud GmbH and Nextcloud contributors"
SPDX-License-Identifier = "CC-BY-SA-4.0"
[[annotations]]
path = ["img/app.svg"]
precedence = "aggregate"
SPDX-FileCopyrightText = "2018-2024 Google LLC"
SPDX-License-Identifier = "Apache-2.0"
+22
View File
@@ -0,0 +1,22 @@
<?xml version="1.0"?>
<!--
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<info xmlns:xsi= "http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://apps.nextcloud.com/schema/apps/info.xsd">
<id>settings</id>
<name>Nextcloud Appstore</name>
<summary>Nextcloud Appstore</summary>
<description>Nextcloud Appstore</description>
<version>1.0.0</version>
<licence>agpl</licence>
<author>Nextcloud</author>
<namespace>Appstore</namespace>
<category>customization</category>
<bugs>https://github.com/nextcloud/server/issues</bugs>
<dependencies>
<nextcloud min-version="33" max-version="33"/>
</dependencies>
</info>
+22
View File
@@ -0,0 +1,22 @@
<?php
// autoload.php @generated by Composer
if (PHP_VERSION_ID < 50600) {
if (!headers_sent()) {
header('HTTP/1.1 500 Internal Server Error');
}
$err = 'Composer 2.3.0 dropped support for autoloading on PHP <5.6 and you are running '.PHP_VERSION.', please upgrade PHP or use Composer 2.2 LTS via "composer self-update --2.2". Aborting.'.PHP_EOL;
if (!ini_get('display_errors')) {
if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {
fwrite(STDERR, $err);
} elseif (!headers_sent()) {
echo $err;
}
}
throw new RuntimeException($err);
}
require_once __DIR__ . '/composer/autoload_real.php';
return ComposerAutoloaderInitAppstore::getLoader();
+13
View File
@@ -0,0 +1,13 @@
{
"config" : {
"vendor-dir": ".",
"optimize-autoloader": true,
"classmap-authoritative": true,
"autoloader-suffix": "Appstore"
},
"autoload" : {
"psr-4": {
"OCA\\Appstore\\": "../lib/"
}
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"_readme": [
"This file locks the dependencies of your project to a known state",
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "d751713988987e9331980363e24189ce",
"packages": [],
"packages-dev": [],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": {},
"prefer-stable": false,
"prefer-lowest": false,
"platform": {},
"platform-dev": {},
"plugin-api-version": "2.6.0"
}
@@ -0,0 +1,579 @@
<?php
/*
* This file is part of Composer.
*
* (c) Nils Adermann <naderman@naderman.de>
* Jordi Boggiano <j.boggiano@seld.be>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Composer\Autoload;
/**
* ClassLoader implements a PSR-0, PSR-4 and classmap class loader.
*
* $loader = new \Composer\Autoload\ClassLoader();
*
* // register classes with namespaces
* $loader->add('Symfony\Component', __DIR__.'/component');
* $loader->add('Symfony', __DIR__.'/framework');
*
* // activate the autoloader
* $loader->register();
*
* // to enable searching the include path (eg. for PEAR packages)
* $loader->setUseIncludePath(true);
*
* In this example, if you try to use a class in the Symfony\Component
* namespace or one of its children (Symfony\Component\Console for instance),
* the autoloader will first look for the class under the component/
* directory, and it will then fallback to the framework/ directory if not
* found before giving up.
*
* This class is loosely based on the Symfony UniversalClassLoader.
*
* @author Fabien Potencier <fabien@symfony.com>
* @author Jordi Boggiano <j.boggiano@seld.be>
* @see https://www.php-fig.org/psr/psr-0/
* @see https://www.php-fig.org/psr/psr-4/
*/
class ClassLoader
{
/** @var \Closure(string):void */
private static $includeFile;
/** @var string|null */
private $vendorDir;
// PSR-4
/**
* @var array<string, array<string, int>>
*/
private $prefixLengthsPsr4 = array();
/**
* @var array<string, list<string>>
*/
private $prefixDirsPsr4 = array();
/**
* @var list<string>
*/
private $fallbackDirsPsr4 = array();
// PSR-0
/**
* List of PSR-0 prefixes
*
* Structured as array('F (first letter)' => array('Foo\Bar (full prefix)' => array('path', 'path2')))
*
* @var array<string, array<string, list<string>>>
*/
private $prefixesPsr0 = array();
/**
* @var list<string>
*/
private $fallbackDirsPsr0 = array();
/** @var bool */
private $useIncludePath = false;
/**
* @var array<string, string>
*/
private $classMap = array();
/** @var bool */
private $classMapAuthoritative = false;
/**
* @var array<string, bool>
*/
private $missingClasses = array();
/** @var string|null */
private $apcuPrefix;
/**
* @var array<string, self>
*/
private static $registeredLoaders = array();
/**
* @param string|null $vendorDir
*/
public function __construct($vendorDir = null)
{
$this->vendorDir = $vendorDir;
self::initializeIncludeClosure();
}
/**
* @return array<string, list<string>>
*/
public function getPrefixes()
{
if (!empty($this->prefixesPsr0)) {
return call_user_func_array('array_merge', array_values($this->prefixesPsr0));
}
return array();
}
/**
* @return array<string, list<string>>
*/
public function getPrefixesPsr4()
{
return $this->prefixDirsPsr4;
}
/**
* @return list<string>
*/
public function getFallbackDirs()
{
return $this->fallbackDirsPsr0;
}
/**
* @return list<string>
*/
public function getFallbackDirsPsr4()
{
return $this->fallbackDirsPsr4;
}
/**
* @return array<string, string> Array of classname => path
*/
public function getClassMap()
{
return $this->classMap;
}
/**
* @param array<string, string> $classMap Class to filename map
*
* @return void
*/
public function addClassMap(array $classMap)
{
if ($this->classMap) {
$this->classMap = array_merge($this->classMap, $classMap);
} else {
$this->classMap = $classMap;
}
}
/**
* Registers a set of PSR-0 directories for a given prefix, either
* appending or prepending to the ones previously set for this prefix.
*
* @param string $prefix The prefix
* @param list<string>|string $paths The PSR-0 root directories
* @param bool $prepend Whether to prepend the directories
*
* @return void
*/
public function add($prefix, $paths, $prepend = false)
{
$paths = (array) $paths;
if (!$prefix) {
if ($prepend) {
$this->fallbackDirsPsr0 = array_merge(
$paths,
$this->fallbackDirsPsr0
);
} else {
$this->fallbackDirsPsr0 = array_merge(
$this->fallbackDirsPsr0,
$paths
);
}
return;
}
$first = $prefix[0];
if (!isset($this->prefixesPsr0[$first][$prefix])) {
$this->prefixesPsr0[$first][$prefix] = $paths;
return;
}
if ($prepend) {
$this->prefixesPsr0[$first][$prefix] = array_merge(
$paths,
$this->prefixesPsr0[$first][$prefix]
);
} else {
$this->prefixesPsr0[$first][$prefix] = array_merge(
$this->prefixesPsr0[$first][$prefix],
$paths
);
}
}
/**
* Registers a set of PSR-4 directories for a given namespace, either
* appending or prepending to the ones previously set for this namespace.
*
* @param string $prefix The prefix/namespace, with trailing '\\'
* @param list<string>|string $paths The PSR-4 base directories
* @param bool $prepend Whether to prepend the directories
*
* @throws \InvalidArgumentException
*
* @return void
*/
public function addPsr4($prefix, $paths, $prepend = false)
{
$paths = (array) $paths;
if (!$prefix) {
// Register directories for the root namespace.
if ($prepend) {
$this->fallbackDirsPsr4 = array_merge(
$paths,
$this->fallbackDirsPsr4
);
} else {
$this->fallbackDirsPsr4 = array_merge(
$this->fallbackDirsPsr4,
$paths
);
}
} elseif (!isset($this->prefixDirsPsr4[$prefix])) {
// Register directories for a new namespace.
$length = strlen($prefix);
if ('\\' !== $prefix[$length - 1]) {
throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator.");
}
$this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length;
$this->prefixDirsPsr4[$prefix] = $paths;
} elseif ($prepend) {
// Prepend directories for an already registered namespace.
$this->prefixDirsPsr4[$prefix] = array_merge(
$paths,
$this->prefixDirsPsr4[$prefix]
);
} else {
// Append directories for an already registered namespace.
$this->prefixDirsPsr4[$prefix] = array_merge(
$this->prefixDirsPsr4[$prefix],
$paths
);
}
}
/**
* Registers a set of PSR-0 directories for a given prefix,
* replacing any others previously set for this prefix.
*
* @param string $prefix The prefix
* @param list<string>|string $paths The PSR-0 base directories
*
* @return void
*/
public function set($prefix, $paths)
{
if (!$prefix) {
$this->fallbackDirsPsr0 = (array) $paths;
} else {
$this->prefixesPsr0[$prefix[0]][$prefix] = (array) $paths;
}
}
/**
* Registers a set of PSR-4 directories for a given namespace,
* replacing any others previously set for this namespace.
*
* @param string $prefix The prefix/namespace, with trailing '\\'
* @param list<string>|string $paths The PSR-4 base directories
*
* @throws \InvalidArgumentException
*
* @return void
*/
public function setPsr4($prefix, $paths)
{
if (!$prefix) {
$this->fallbackDirsPsr4 = (array) $paths;
} else {
$length = strlen($prefix);
if ('\\' !== $prefix[$length - 1]) {
throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator.");
}
$this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length;
$this->prefixDirsPsr4[$prefix] = (array) $paths;
}
}
/**
* Turns on searching the include path for class files.
*
* @param bool $useIncludePath
*
* @return void
*/
public function setUseIncludePath($useIncludePath)
{
$this->useIncludePath = $useIncludePath;
}
/**
* Can be used to check if the autoloader uses the include path to check
* for classes.
*
* @return bool
*/
public function getUseIncludePath()
{
return $this->useIncludePath;
}
/**
* Turns off searching the prefix and fallback directories for classes
* that have not been registered with the class map.
*
* @param bool $classMapAuthoritative
*
* @return void
*/
public function setClassMapAuthoritative($classMapAuthoritative)
{
$this->classMapAuthoritative = $classMapAuthoritative;
}
/**
* Should class lookup fail if not found in the current class map?
*
* @return bool
*/
public function isClassMapAuthoritative()
{
return $this->classMapAuthoritative;
}
/**
* APCu prefix to use to cache found/not-found classes, if the extension is enabled.
*
* @param string|null $apcuPrefix
*
* @return void
*/
public function setApcuPrefix($apcuPrefix)
{
$this->apcuPrefix = function_exists('apcu_fetch') && filter_var(ini_get('apc.enabled'), FILTER_VALIDATE_BOOLEAN) ? $apcuPrefix : null;
}
/**
* The APCu prefix in use, or null if APCu caching is not enabled.
*
* @return string|null
*/
public function getApcuPrefix()
{
return $this->apcuPrefix;
}
/**
* Registers this instance as an autoloader.
*
* @param bool $prepend Whether to prepend the autoloader or not
*
* @return void
*/
public function register($prepend = false)
{
spl_autoload_register(array($this, 'loadClass'), true, $prepend);
if (null === $this->vendorDir) {
return;
}
if ($prepend) {
self::$registeredLoaders = array($this->vendorDir => $this) + self::$registeredLoaders;
} else {
unset(self::$registeredLoaders[$this->vendorDir]);
self::$registeredLoaders[$this->vendorDir] = $this;
}
}
/**
* Unregisters this instance as an autoloader.
*
* @return void
*/
public function unregister()
{
spl_autoload_unregister(array($this, 'loadClass'));
if (null !== $this->vendorDir) {
unset(self::$registeredLoaders[$this->vendorDir]);
}
}
/**
* Loads the given class or interface.
*
* @param string $class The name of the class
* @return true|null True if loaded, null otherwise
*/
public function loadClass($class)
{
if ($file = $this->findFile($class)) {
$includeFile = self::$includeFile;
$includeFile($file);
return true;
}
return null;
}
/**
* Finds the path to the file where the class is defined.
*
* @param string $class The name of the class
*
* @return string|false The path if found, false otherwise
*/
public function findFile($class)
{
// class map lookup
if (isset($this->classMap[$class])) {
return $this->classMap[$class];
}
if ($this->classMapAuthoritative || isset($this->missingClasses[$class])) {
return false;
}
if (null !== $this->apcuPrefix) {
$file = apcu_fetch($this->apcuPrefix.$class, $hit);
if ($hit) {
return $file;
}
}
$file = $this->findFileWithExtension($class, '.php');
// Search for Hack files if we are running on HHVM
if (false === $file && defined('HHVM_VERSION')) {
$file = $this->findFileWithExtension($class, '.hh');
}
if (null !== $this->apcuPrefix) {
apcu_add($this->apcuPrefix.$class, $file);
}
if (false === $file) {
// Remember that this class does not exist.
$this->missingClasses[$class] = true;
}
return $file;
}
/**
* Returns the currently registered loaders keyed by their corresponding vendor directories.
*
* @return array<string, self>
*/
public static function getRegisteredLoaders()
{
return self::$registeredLoaders;
}
/**
* @param string $class
* @param string $ext
* @return string|false
*/
private function findFileWithExtension($class, $ext)
{
// PSR-4 lookup
$logicalPathPsr4 = strtr($class, '\\', DIRECTORY_SEPARATOR) . $ext;
$first = $class[0];
if (isset($this->prefixLengthsPsr4[$first])) {
$subPath = $class;
while (false !== $lastPos = strrpos($subPath, '\\')) {
$subPath = substr($subPath, 0, $lastPos);
$search = $subPath . '\\';
if (isset($this->prefixDirsPsr4[$search])) {
$pathEnd = DIRECTORY_SEPARATOR . substr($logicalPathPsr4, $lastPos + 1);
foreach ($this->prefixDirsPsr4[$search] as $dir) {
if (file_exists($file = $dir . $pathEnd)) {
return $file;
}
}
}
}
}
// PSR-4 fallback dirs
foreach ($this->fallbackDirsPsr4 as $dir) {
if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr4)) {
return $file;
}
}
// PSR-0 lookup
if (false !== $pos = strrpos($class, '\\')) {
// namespaced class name
$logicalPathPsr0 = substr($logicalPathPsr4, 0, $pos + 1)
. strtr(substr($logicalPathPsr4, $pos + 1), '_', DIRECTORY_SEPARATOR);
} else {
// PEAR-like class name
$logicalPathPsr0 = strtr($class, '_', DIRECTORY_SEPARATOR) . $ext;
}
if (isset($this->prefixesPsr0[$first])) {
foreach ($this->prefixesPsr0[$first] as $prefix => $dirs) {
if (0 === strpos($class, $prefix)) {
foreach ($dirs as $dir) {
if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) {
return $file;
}
}
}
}
}
// PSR-0 fallback dirs
foreach ($this->fallbackDirsPsr0 as $dir) {
if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) {
return $file;
}
}
// PSR-0 include paths.
if ($this->useIncludePath && $file = stream_resolve_include_path($logicalPathPsr0)) {
return $file;
}
return false;
}
/**
* @return void
*/
private static function initializeIncludeClosure()
{
if (self::$includeFile !== null) {
return;
}
/**
* Scope isolated include.
*
* Prevents access to $this/self from included files.
*
* @param string $file
* @return void
*/
self::$includeFile = \Closure::bind(static function($file) {
include $file;
}, null, null);
}
}
@@ -0,0 +1,396 @@
<?php
/*
* This file is part of Composer.
*
* (c) Nils Adermann <naderman@naderman.de>
* Jordi Boggiano <j.boggiano@seld.be>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Composer;
use Composer\Autoload\ClassLoader;
use Composer\Semver\VersionParser;
/**
* This class is copied in every Composer installed project and available to all
*
* See also https://getcomposer.org/doc/07-runtime.md#installed-versions
*
* To require its presence, you can require `composer-runtime-api ^2.0`
*
* @final
*/
class InstalledVersions
{
/**
* @var string|null if set (by reflection by Composer), this should be set to the path where this class is being copied to
* @internal
*/
private static $selfDir = null;
/**
* @var mixed[]|null
* @psalm-var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}|array{}|null
*/
private static $installed;
/**
* @var bool
*/
private static $installedIsLocalDir;
/**
* @var bool|null
*/
private static $canGetVendors;
/**
* @var array[]
* @psalm-var array<string, array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
*/
private static $installedByVendor = array();
/**
* Returns a list of all package names which are present, either by being installed, replaced or provided
*
* @return string[]
* @psalm-return list<string>
*/
public static function getInstalledPackages()
{
$packages = array();
foreach (self::getInstalled() as $installed) {
$packages[] = array_keys($installed['versions']);
}
if (1 === \count($packages)) {
return $packages[0];
}
return array_keys(array_flip(\call_user_func_array('array_merge', $packages)));
}
/**
* Returns a list of all package names with a specific type e.g. 'library'
*
* @param string $type
* @return string[]
* @psalm-return list<string>
*/
public static function getInstalledPackagesByType($type)
{
$packagesByType = array();
foreach (self::getInstalled() as $installed) {
foreach ($installed['versions'] as $name => $package) {
if (isset($package['type']) && $package['type'] === $type) {
$packagesByType[] = $name;
}
}
}
return $packagesByType;
}
/**
* Checks whether the given package is installed
*
* This also returns true if the package name is provided or replaced by another package
*
* @param string $packageName
* @param bool $includeDevRequirements
* @return bool
*/
public static function isInstalled($packageName, $includeDevRequirements = true)
{
foreach (self::getInstalled() as $installed) {
if (isset($installed['versions'][$packageName])) {
return $includeDevRequirements || !isset($installed['versions'][$packageName]['dev_requirement']) || $installed['versions'][$packageName]['dev_requirement'] === false;
}
}
return false;
}
/**
* Checks whether the given package satisfies a version constraint
*
* e.g. If you want to know whether version 2.3+ of package foo/bar is installed, you would call:
*
* Composer\InstalledVersions::satisfies(new VersionParser, 'foo/bar', '^2.3')
*
* @param VersionParser $parser Install composer/semver to have access to this class and functionality
* @param string $packageName
* @param string|null $constraint A version constraint to check for, if you pass one you have to make sure composer/semver is required by your package
* @return bool
*/
public static function satisfies(VersionParser $parser, $packageName, $constraint)
{
$constraint = $parser->parseConstraints((string) $constraint);
$provided = $parser->parseConstraints(self::getVersionRanges($packageName));
return $provided->matches($constraint);
}
/**
* Returns a version constraint representing all the range(s) which are installed for a given package
*
* It is easier to use this via isInstalled() with the $constraint argument if you need to check
* whether a given version of a package is installed, and not just whether it exists
*
* @param string $packageName
* @return string Version constraint usable with composer/semver
*/
public static function getVersionRanges($packageName)
{
foreach (self::getInstalled() as $installed) {
if (!isset($installed['versions'][$packageName])) {
continue;
}
$ranges = array();
if (isset($installed['versions'][$packageName]['pretty_version'])) {
$ranges[] = $installed['versions'][$packageName]['pretty_version'];
}
if (array_key_exists('aliases', $installed['versions'][$packageName])) {
$ranges = array_merge($ranges, $installed['versions'][$packageName]['aliases']);
}
if (array_key_exists('replaced', $installed['versions'][$packageName])) {
$ranges = array_merge($ranges, $installed['versions'][$packageName]['replaced']);
}
if (array_key_exists('provided', $installed['versions'][$packageName])) {
$ranges = array_merge($ranges, $installed['versions'][$packageName]['provided']);
}
return implode(' || ', $ranges);
}
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
}
/**
* @param string $packageName
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as version, use satisfies or getVersionRanges if you need to know if a given version is present
*/
public static function getVersion($packageName)
{
foreach (self::getInstalled() as $installed) {
if (!isset($installed['versions'][$packageName])) {
continue;
}
if (!isset($installed['versions'][$packageName]['version'])) {
return null;
}
return $installed['versions'][$packageName]['version'];
}
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
}
/**
* @param string $packageName
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as version, use satisfies or getVersionRanges if you need to know if a given version is present
*/
public static function getPrettyVersion($packageName)
{
foreach (self::getInstalled() as $installed) {
if (!isset($installed['versions'][$packageName])) {
continue;
}
if (!isset($installed['versions'][$packageName]['pretty_version'])) {
return null;
}
return $installed['versions'][$packageName]['pretty_version'];
}
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
}
/**
* @param string $packageName
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as reference
*/
public static function getReference($packageName)
{
foreach (self::getInstalled() as $installed) {
if (!isset($installed['versions'][$packageName])) {
continue;
}
if (!isset($installed['versions'][$packageName]['reference'])) {
return null;
}
return $installed['versions'][$packageName]['reference'];
}
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
}
/**
* @param string $packageName
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as install path. Packages of type metapackages also have a null install path.
*/
public static function getInstallPath($packageName)
{
foreach (self::getInstalled() as $installed) {
if (!isset($installed['versions'][$packageName])) {
continue;
}
return isset($installed['versions'][$packageName]['install_path']) ? $installed['versions'][$packageName]['install_path'] : null;
}
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
}
/**
* @return array
* @psalm-return array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}
*/
public static function getRootPackage()
{
$installed = self::getInstalled();
return $installed[0]['root'];
}
/**
* Returns the raw installed.php data for custom implementations
*
* @deprecated Use getAllRawData() instead which returns all datasets for all autoloaders present in the process. getRawData only returns the first dataset loaded, which may not be what you expect.
* @return array[]
* @psalm-return array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}
*/
public static function getRawData()
{
@trigger_error('getRawData only returns the first dataset loaded, which may not be what you expect. Use getAllRawData() instead which returns all datasets for all autoloaders present in the process.', E_USER_DEPRECATED);
if (null === self::$installed) {
// only require the installed.php file if this file is loaded from its dumped location,
// and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937
if (substr(__DIR__, -8, 1) !== 'C') {
self::$installed = include __DIR__ . '/installed.php';
} else {
self::$installed = array();
}
}
return self::$installed;
}
/**
* Returns the raw data of all installed.php which are currently loaded for custom implementations
*
* @return array[]
* @psalm-return list<array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
*/
public static function getAllRawData()
{
return self::getInstalled();
}
/**
* Lets you reload the static array from another file
*
* This is only useful for complex integrations in which a project needs to use
* this class but then also needs to execute another project's autoloader in process,
* and wants to ensure both projects have access to their version of installed.php.
*
* A typical case would be PHPUnit, where it would need to make sure it reads all
* the data it needs from this class, then call reload() with
* `require $CWD/vendor/composer/installed.php` (or similar) as input to make sure
* the project in which it runs can then also use this class safely, without
* interference between PHPUnit's dependencies and the project's dependencies.
*
* @param array[] $data A vendor/composer/installed.php data set
* @return void
*
* @psalm-param array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $data
*/
public static function reload($data)
{
self::$installed = $data;
self::$installedByVendor = array();
// when using reload, we disable the duplicate protection to ensure that self::$installed data is
// always returned, but we cannot know whether it comes from the installed.php in __DIR__ or not,
// so we have to assume it does not, and that may result in duplicate data being returned when listing
// all installed packages for example
self::$installedIsLocalDir = false;
}
/**
* @return string
*/
private static function getSelfDir()
{
if (self::$selfDir === null) {
self::$selfDir = strtr(__DIR__, '\\', '/');
}
return self::$selfDir;
}
/**
* @return array[]
* @psalm-return list<array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
*/
private static function getInstalled()
{
if (null === self::$canGetVendors) {
self::$canGetVendors = method_exists('Composer\Autoload\ClassLoader', 'getRegisteredLoaders');
}
$installed = array();
$copiedLocalDir = false;
if (self::$canGetVendors) {
$selfDir = self::getSelfDir();
foreach (ClassLoader::getRegisteredLoaders() as $vendorDir => $loader) {
$vendorDir = strtr($vendorDir, '\\', '/');
if (isset(self::$installedByVendor[$vendorDir])) {
$installed[] = self::$installedByVendor[$vendorDir];
} elseif (is_file($vendorDir.'/composer/installed.php')) {
/** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $required */
$required = require $vendorDir.'/composer/installed.php';
self::$installedByVendor[$vendorDir] = $required;
$installed[] = $required;
if (self::$installed === null && $vendorDir.'/composer' === $selfDir) {
self::$installed = $required;
self::$installedIsLocalDir = true;
}
}
if (self::$installedIsLocalDir && $vendorDir.'/composer' === $selfDir) {
$copiedLocalDir = true;
}
}
}
if (null === self::$installed) {
// only require the installed.php file if this file is loaded from its dumped location,
// and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937
if (substr(__DIR__, -8, 1) !== 'C') {
/** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $required */
$required = require __DIR__ . '/installed.php';
self::$installed = $required;
} else {
self::$installed = array();
}
}
if (self::$installed !== array() && !$copiedLocalDir) {
$installed[] = self::$installed;
}
return $installed;
}
}
+21
View File
@@ -0,0 +1,21 @@
Copyright (c) Nils Adermann, Jordi Boggiano
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,16 @@
<?php
// autoload_classmap.php @generated by Composer
$vendorDir = dirname(__DIR__);
$baseDir = $vendorDir;
return array(
'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php',
'OCA\\Appstore\\AppInfo\\Application' => $baseDir . '/../lib/AppInfo/Application.php',
'OCA\\Appstore\\Controller\\ApiController' => $baseDir . '/../lib/Controller/ApiController.php',
'OCA\\Appstore\\Controller\\AppSettingsController' => $baseDir . '/../lib/Controller/AppSettingsController.php',
'OCA\\Appstore\\Controller\\DiscoverController' => $baseDir . '/../lib/Controller/DiscoverController.php',
'OCA\\Appstore\\Controller\\PageController' => $baseDir . '/../lib/Controller/PageController.php',
'OCA\\Appstore\\Search\\AppSearch' => $baseDir . '/../lib/Search/AppSearch.php',
);
@@ -0,0 +1,9 @@
<?php
// autoload_namespaces.php @generated by Composer
$vendorDir = dirname(__DIR__);
$baseDir = $vendorDir;
return array(
);
@@ -0,0 +1,10 @@
<?php
// autoload_psr4.php @generated by Composer
$vendorDir = dirname(__DIR__);
$baseDir = $vendorDir;
return array(
'OCA\\Appstore\\' => array($baseDir . '/../lib'),
);
@@ -0,0 +1,37 @@
<?php
// autoload_real.php @generated by Composer
class ComposerAutoloaderInitAppstore
{
private static $loader;
public static function loadClassLoader($class)
{
if ('Composer\Autoload\ClassLoader' === $class) {
require __DIR__ . '/ClassLoader.php';
}
}
/**
* @return \Composer\Autoload\ClassLoader
*/
public static function getLoader()
{
if (null !== self::$loader) {
return self::$loader;
}
spl_autoload_register(array('ComposerAutoloaderInitAppstore', 'loadClassLoader'), true, true);
self::$loader = $loader = new \Composer\Autoload\ClassLoader(\dirname(__DIR__));
spl_autoload_unregister(array('ComposerAutoloaderInitAppstore', 'loadClassLoader'));
require __DIR__ . '/autoload_static.php';
call_user_func(\Composer\Autoload\ComposerStaticInitAppstore::getInitializer($loader));
$loader->setClassMapAuthoritative(true);
$loader->register(true);
return $loader;
}
}
@@ -0,0 +1,42 @@
<?php
// autoload_static.php @generated by Composer
namespace Composer\Autoload;
class ComposerStaticInitAppstore
{
public static $prefixLengthsPsr4 = array (
'O' =>
array (
'OCA\\Appstore\\' => 13,
),
);
public static $prefixDirsPsr4 = array (
'OCA\\Appstore\\' =>
array (
0 => __DIR__ . '/..' . '/../lib',
),
);
public static $classMap = array (
'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php',
'OCA\\Appstore\\AppInfo\\Application' => __DIR__ . '/..' . '/../lib/AppInfo/Application.php',
'OCA\\Appstore\\Controller\\ApiController' => __DIR__ . '/..' . '/../lib/Controller/ApiController.php',
'OCA\\Appstore\\Controller\\AppSettingsController' => __DIR__ . '/..' . '/../lib/Controller/AppSettingsController.php',
'OCA\\Appstore\\Controller\\DiscoverController' => __DIR__ . '/..' . '/../lib/Controller/DiscoverController.php',
'OCA\\Appstore\\Controller\\PageController' => __DIR__ . '/..' . '/../lib/Controller/PageController.php',
'OCA\\Appstore\\Search\\AppSearch' => __DIR__ . '/..' . '/../lib/Search/AppSearch.php',
);
public static function getInitializer(ClassLoader $loader)
{
return \Closure::bind(function () use ($loader) {
$loader->prefixLengthsPsr4 = ComposerStaticInitAppstore::$prefixLengthsPsr4;
$loader->prefixDirsPsr4 = ComposerStaticInitAppstore::$prefixDirsPsr4;
$loader->classMap = ComposerStaticInitAppstore::$classMap;
}, null, ClassLoader::class);
}
}
@@ -0,0 +1,5 @@
{
"packages": [],
"dev": false,
"dev-package-names": []
}
@@ -0,0 +1,23 @@
<?php return array(
'root' => array(
'name' => '__root__',
'pretty_version' => 'dev-master',
'version' => 'dev-master',
'reference' => '3efb1d80e9851e0c33311a7722f523e020654691',
'type' => 'library',
'install_path' => __DIR__ . '/../',
'aliases' => array(),
'dev' => false,
),
'versions' => array(
'__root__' => array(
'pretty_version' => 'dev-master',
'version' => 'dev-master',
'reference' => '3efb1d80e9851e0c33311a7722f523e020654691',
'type' => 'library',
'install_path' => __DIR__ . '/../',
'aliases' => array(),
'dev_requirement' => false,
),
),
);

Before

Width:  |  Height:  |  Size: 321 B

After

Width:  |  Height:  |  Size: 321 B

View File
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Appstore\AppInfo;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap;
use OCP\AppFramework\Bootstrap\IRegistrationContext;
class Application extends App implements IBootstrap {
public const APP_ID = 'appstore';
/**
* @param array $urlParams
*/
public function __construct(array $urlParams = []) {
parent::__construct(self::APP_ID, $urlParams);
}
public function register(IRegistrationContext $context): void {
}
public function boot(IBootContext $context): void {
}
}
@@ -0,0 +1,440 @@
<?php
declare(strict_types=1);
/*!
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Appstore\Controller;
use OC\App\AppManager;
use OC\App\AppStore\Bundles\BundleFetcher;
use OC\App\AppStore\Fetcher\AppFetcher;
use OC\App\AppStore\Fetcher\CategoryFetcher;
use OC\App\AppStore\Version\VersionParser;
use OC\App\DependencyAnalyzer;
use OC\Installer;
use OCA\Appstore\AppInfo\Application;
use OCP\App\AppPathNotFoundException;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\ApiRoute;
use OCP\AppFramework\Http\Attribute\OpenAPI;
use OCP\AppFramework\Http\Attribute\PasswordConfirmationRequired;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCS\OCSException;
use OCP\AppFramework\OCSController;
use OCP\IAppConfig;
use OCP\IConfig;
use OCP\IGroup;
use OCP\IGroupManager;
use OCP\IRequest;
use OCP\L10N\IFactory;
use OCP\Server;
use OCP\Support\Subscription\IRegistry;
use Psr\Log\LoggerInterface;
#[OpenAPI(scope: OpenAPI::SCOPE_ADMINISTRATION)]
class ApiController extends OCSController {
/** @var array */
private $allApps = [];
public function __construct(
IRequest $request,
private IConfig $config,
private IAppConfig $appConfig,
private AppManager $appManager,
private DependencyAnalyzer $dependencyAnalyzer,
private CategoryFetcher $categoryFetcher,
private AppFetcher $appFetcher,
private IFactory $l10nFactory,
private BundleFetcher $bundleFetcher,
private Installer $installer,
private IRegistry $subscriptionRegistry,
private LoggerInterface $logger,
) {
parent::__construct(Application::APP_ID, $request);
}
/**
* Get all available categories
*/
#[ApiRoute('GET', '/api/v1/apps/categories')]
public function listCategories(): DataResponse {
$currentLanguage = substr($this->l10nFactory->findLanguage(), 0, 2);
$categories = $this->categoryFetcher->get();
$categories = array_map(fn ($category) => [
'id' => $category['id'],
'displayName' => $category['translations'][$currentLanguage]['name'] ?? $category['translations']['en']['name'],
], $categories);
return new DataResponse(array_values($categories));
}
/**
* Get all available apps
*/
#[ApiRoute('GET', '/api/v1/apps')]
public function listApps(): DataResponse {
$apps = $this->getAllApps();
$ignoreMaxApps = $this->config->getSystemValue('app_install_overwrite', []);
if (!is_array($ignoreMaxApps)) {
$this->logger->warning('The value given for app_install_overwrite is not an array. Ignoring...');
$ignoreMaxApps = [];
}
// Extend existing app details
$apps = array_map(function (array $appData) use ($ignoreMaxApps) {
if (isset($appData['appstoreData'])) {
$appstoreData = $appData['appstoreData'];
$appData['screenshot'] = $this->createProxyPreviewUrl($appstoreData['screenshots'][0]['url'] ?? '');
$appData['category'] = $appstoreData['categories'];
$appData['releases'] = $appstoreData['releases'];
}
$newVersion = $this->installer->isUpdateAvailable($appData['id']);
if ($newVersion) {
$appData['update'] = $newVersion;
}
// fix groups to be an array
$groups = [];
if (is_string($appData['groups'])) {
$groups = json_decode($appData['groups']);
// ensure 'groups' is an array
if (!is_array($groups)) {
$groups = [$groups];
}
}
$appData['groups'] = $groups;
// analyze dependencies
$ignoreMax = in_array($appData['id'], $ignoreMaxApps);
$missing = $this->dependencyAnalyzer->analyze($appData, $ignoreMax);
$appData['missingDependencies'] = $missing;
$appData['missingMinNextcloudVersion'] = !isset($appData['dependencies']['nextcloud']['@attributes']['min-version']);
$appData['missingMaxNextcloudVersion'] = !isset($appData['dependencies']['nextcloud']['@attributes']['max-version']);
$appData['isCompatible'] = $this->dependencyAnalyzer->isMarkedCompatible($appData);
$appData['internal'] = in_array($appData['id'], $this->appManager->getAlwaysEnabledApps());
return $appData;
}, $apps);
usort($apps, $this->sortApps(...));
return new DataResponse(array_values($apps));
}
/**
* Enable one apps
*
* App will be enabled for specific groups only if $groups is defined
*
* @param string $appId - The app to enable
* @param array $groups - The groups to enable the app for
* @return DataResponse
*/
#[PasswordConfirmationRequired]
#[ApiRoute('POST', '/api/v1/apps/enable')]
public function enableApp(string $appId, array $groups = []): DataResponse {
try {
$updateRequired = false;
$appId = $this->appManager->cleanAppId($appId);
// Check if app is already downloaded
if (!$this->installer->isDownloaded($appId)) {
$this->installer->downloadApp($appId);
}
$this->installer->installApp($appId);
if (count($groups) > 0) {
$this->appManager->enableAppForGroups($appId, $this->getGroupList($groups));
} else {
$this->appManager->enableApp($appId);
}
$updateRequired = $updateRequired || $this->appManager->isUpgradeRequired($appId);
return new DataResponse(['update_required' => $updateRequired]);
} catch (\Throwable $e) {
$this->logger->error('could not enable app', ['exception' => $e]);
throw new OCSException('could not enable app', Http::STATUS_INTERNAL_SERVER_ERROR, $e);
}
}
/**
* Disable an app
*/
#[PasswordConfirmationRequired]
#[ApiRoute('POST', '/api/v1/apps/disable')]
public function disableApp(string $appId): DataResponse {
try {
$appId = $this->appManager->cleanAppId($appId);
$this->appManager->disableApp($appId);
return new DataResponse([]);
} catch (\Exception $e) {
$this->logger->error('could not disable app', ['exception' => $e]);
throw new OCSException('could not disable app', Http::STATUS_INTERNAL_SERVER_ERROR, $e);
}
}
/**
* Uninstall an app
*/
#[PasswordConfirmationRequired]
#[ApiRoute('POST', '/api/v1/apps/uninstall')]
public function uninstallApp(string $appId): DataResponse {
$appId = $this->appManager->cleanAppId($appId);
$result = $this->installer->removeApp($appId);
if ($result !== false) {
// If this app was force enabled, remove the force-enabled-state
$this->appManager->removeOverwriteNextcloudRequirement($appId);
$this->appManager->clearAppsCache();
return new DataResponse([]);
}
throw new OCSException('could not remove app', Http::STATUS_INTERNAL_SERVER_ERROR);
}
/**
* Update an app
*/
#[PasswordConfirmationRequired]
#[ApiRoute('POST', '/api/v1/apps/update')]
public function updateApp(string $appId): DataResponse {
$appId = $this->appManager->cleanAppId($appId);
$this->config->setSystemValue('maintenance', true);
try {
$result = $this->installer->updateAppstoreApp($appId);
$this->config->setSystemValue('maintenance', false);
if ($result === false) {
throw new \Exception('Update failed');
}
} catch (\Exception $ex) {
$this->config->setSystemValue('maintenance', false);
throw new OCSException('could not update app', Http::STATUS_INTERNAL_SERVER_ERROR, $ex);
}
return new DataResponse([]);
}
/**
* Force enable an app.
*
* @return JSONResponse
*/
#[PasswordConfirmationRequired]
#[ApiRoute('POST', '/api/v1/apps/force')]
public function force(string $appId): DataResponse {
$appId = $this->appManager->cleanAppId($appId);
$this->appManager->overwriteNextcloudRequirement($appId);
return new DataResponse([]);
}
/**
* Convert URL to proxied URL so CSP is no problem
*/
private function createProxyPreviewUrl(string $url): string {
if ($url === '') {
return '';
}
return 'https://usercontent.apps.nextcloud.com/' . base64_encode($url);
}
private function fetchApps() {
$appClass = new \OC_App();
$apps = $appClass->listAllApps();
foreach ($apps as $app) {
$app['installed'] = true;
if (isset($app['screenshot'][0])) {
$appScreenshot = $app['screenshot'][0] ?? null;
if (is_array($appScreenshot)) {
// Screenshot with thumbnail
$appScreenshot = $appScreenshot['@value'];
}
$app['screenshot'] = $this->createProxyPreviewUrl($appScreenshot);
}
$this->allApps[$app['id']] = $app;
}
$apps = $this->getAppsForCategory('');
$supportedApps = $this->subscriptionRegistry->delegateGetSupportedApps();
foreach ($apps as $app) {
$app['appstore'] = true;
if (!array_key_exists($app['id'], $this->allApps)) {
$this->allApps[$app['id']] = $app;
} else {
$this->allApps[$app['id']] = array_merge($app, $this->allApps[$app['id']]);
}
if (in_array($app['id'], $supportedApps)) {
$this->allApps[$app['id']]['level'] = \OC_App::supportedApp;
}
}
// add bundle information
$bundles = $this->bundleFetcher->getBundles();
foreach ($bundles as $bundle) {
foreach ($bundle->getAppIdentifiers() as $identifier) {
foreach ($this->allApps as &$app) {
if ($app['id'] === $identifier) {
$app['bundleIds'][] = $bundle->getIdentifier();
continue;
}
}
}
}
}
private function getAllApps() {
if (empty($this->allApps)) {
$this->fetchApps();
}
return $this->allApps;
}
/**
* Get all apps for a category from the app store
*
* @param string $requestedCategory
* @return array
* @throws \Exception
*/
private function getAppsForCategory($requestedCategory = ''): array {
$versionParser = new VersionParser();
$formattedApps = [];
$apps = $this->appFetcher->get();
foreach ($apps as $app) {
// Skip all apps not in the requested category
if ($requestedCategory !== '') {
$isInCategory = false;
foreach ($app['categories'] as $category) {
if ($category === $requestedCategory) {
$isInCategory = true;
}
}
if (!$isInCategory) {
continue;
}
}
if (!isset($app['releases'][0]['rawPlatformVersionSpec'])) {
continue;
}
$nextcloudVersion = $versionParser->getVersion($app['releases'][0]['rawPlatformVersionSpec']);
$nextcloudVersionDependencies = [];
if ($nextcloudVersion->getMinimumVersion() !== '') {
$nextcloudVersionDependencies['nextcloud']['@attributes']['min-version'] = $nextcloudVersion->getMinimumVersion();
}
if ($nextcloudVersion->getMaximumVersion() !== '') {
$nextcloudVersionDependencies['nextcloud']['@attributes']['max-version'] = $nextcloudVersion->getMaximumVersion();
}
$phpVersion = $versionParser->getVersion($app['releases'][0]['rawPhpVersionSpec']);
try {
$this->appManager->getAppPath($app['id']);
$existsLocally = true;
} catch (AppPathNotFoundException) {
$existsLocally = false;
}
$phpDependencies = [];
if ($phpVersion->getMinimumVersion() !== '') {
$phpDependencies['php']['@attributes']['min-version'] = $phpVersion->getMinimumVersion();
}
if ($phpVersion->getMaximumVersion() !== '') {
$phpDependencies['php']['@attributes']['max-version'] = $phpVersion->getMaximumVersion();
}
if (isset($app['releases'][0]['minIntSize'])) {
$phpDependencies['php']['@attributes']['min-int-size'] = $app['releases'][0]['minIntSize'];
}
$authors = '';
foreach ($app['authors'] as $key => $author) {
$authors .= $author['name'];
if ($key !== count($app['authors']) - 1) {
$authors .= ', ';
}
}
$currentLanguage = substr($this->l10nFactory->findLanguage(), 0, 2);
$enabledValue = $this->appConfig->getValueString($app['id'], 'enabled', 'no');
$groups = null;
if ($enabledValue !== 'no' && $enabledValue !== 'yes') {
$groups = $enabledValue;
}
$currentVersion = '';
if ($this->appManager->isEnabledForAnyone($app['id'])) {
$currentVersion = $this->appManager->getAppVersion($app['id']);
} else {
$currentVersion = $app['releases'][0]['version'];
}
$formattedApps[] = [
'id' => $app['id'],
'app_api' => false,
'name' => $app['translations'][$currentLanguage]['name'] ?? $app['translations']['en']['name'],
'description' => $app['translations'][$currentLanguage]['description'] ?? $app['translations']['en']['description'],
'summary' => $app['translations'][$currentLanguage]['summary'] ?? $app['translations']['en']['summary'],
'license' => $app['releases'][0]['licenses'],
'author' => $authors,
'shipped' => $this->appManager->isShipped($app['id']),
'internal' => in_array($app['id'], $this->appManager->getAlwaysEnabledApps()),
'version' => $currentVersion,
'types' => [],
'documentation' => [
'admin' => $app['adminDocs'],
'user' => $app['userDocs'],
'developer' => $app['developerDocs']
],
'website' => $app['website'],
'bugs' => $app['issueTracker'],
'dependencies' => array_merge(
$nextcloudVersionDependencies,
$phpDependencies
),
'level' => ($app['isFeatured'] === true) ? 200 : 100,
'missingMaxNextcloudVersion' => false,
'missingMinNextcloudVersion' => false,
'screenshot' => isset($app['screenshots'][0]['url']) ? 'https://usercontent.apps.nextcloud.com/' . base64_encode($app['screenshots'][0]['url']) : '',
'score' => $app['ratingOverall'],
'ratingNumOverall' => $app['ratingNumOverall'],
'ratingNumThresholdReached' => $app['ratingNumOverall'] > 5,
'removable' => $existsLocally,
'active' => $this->appManager->isEnabledForUser($app['id']),
'needsDownload' => !$existsLocally,
'groups' => $groups,
'fromAppStore' => true,
'appstoreData' => $app,
];
}
return $formattedApps;
}
private function getGroupList(array $groups) {
$groupManager = Server::get(IGroupManager::class);
$groupsList = [];
foreach ($groups as $group) {
$groupItem = $groupManager->get($group);
if ($groupItem instanceof IGroup) {
$groupsList[] = $groupManager->get($group);
}
}
return $groupsList;
}
private function sortApps($a, $b) {
$a = (string)$a['name'];
$b = (string)$b['name'];
if ($a === $b) {
return 0;
}
return ($a < $b) ? -1 : 1;
}
}
@@ -0,0 +1,181 @@
<?php
declare(strict_types=1);
/*!
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Appstore\Controller;
use OC\App\AppStore\Fetcher\AppDiscoverFetcher;
use OCA\Appstore\AppInfo\Application;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\FrontpageRoute;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\Attribute\OpenAPI;
use OCP\AppFramework\Http\FileDisplayResponse;
use OCP\AppFramework\Http\JSONResponse;
use OCP\AppFramework\Http\NotFoundResponse;
use OCP\Files\AppData\IAppDataFactory;
use OCP\Files\IAppData;
use OCP\Files\NotFoundException;
use OCP\Files\NotPermittedException;
use OCP\Files\SimpleFS\ISimpleFile;
use OCP\Files\SimpleFS\ISimpleFolder;
use OCP\Http\Client\IClientService;
use OCP\IRequest;
use OCP\IUserSession;
use OCP\Security\RateLimiting\ILimiter;
use Psr\Log\LoggerInterface;
#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)]
class DiscoverController extends Controller {
private IAppData $appData;
public function __construct(
IRequest $request,
IAppDataFactory $appDataFactory,
private IClientService $clientService,
private AppDiscoverFetcher $discoverFetcher,
private LoggerInterface $logger,
) {
parent::__construct(Application::APP_ID, $request);
$this->appData = $appDataFactory->get(Application::APP_ID);
}
/**
* Get all active entries for the app discover section
*/
#[NoCSRFRequired]
#[FrontpageRoute('GET', '/api/v1/discover')]
public function getAppDiscoverJSON(): JSONResponse {
$data = $this->discoverFetcher->get(true);
return new JSONResponse(array_values($data));
}
/**
* Get a image for the app discover section - this is proxied for privacy and CSP reasons
*
* @param string $fileName - The image file name
*/
#[NoCSRFRequired]
#[FrontpageRoute('GET', '/api/v1/discover/media')]
public function getAppDiscoverMedia(string $fileName, ILimiter $limiter, IUserSession $session): FileDisplayResponse|NotFoundResponse {
$getEtag = $this->discoverFetcher->getETag() ?? date('Y-m');
$etag = trim($getEtag, '"');
$folder = null;
try {
$folder = $this->appData->getFolder('app-discover-cache');
$this->cleanUpImageCache($folder, $etag);
} catch (\Throwable $e) {
$folder = $this->appData->newFolder('app-discover-cache');
}
// Get the current cache folder
try {
$folder = $folder->getFolder($etag);
} catch (NotFoundException $e) {
$folder = $folder->newFolder($etag);
}
$info = pathinfo($fileName);
$hashName = md5($fileName);
$allFiles = $folder->getDirectoryListing();
// Try to find the file
$file = array_filter($allFiles, function (ISimpleFile $file) use ($hashName) {
return str_starts_with($file->getName(), $hashName);
});
// Get the first entry
$file = reset($file);
// If not found request from Web
if ($file === false) {
$user = $session->getUser();
// this route is not public thus we can assume a user is logged-in
assert($user !== null);
// Register a user request to throttle fetching external data
// this will prevent using the server for DoS of other systems.
$limiter->registerUserRequest(
'settings-discover-media',
// allow up to 24 media requests per hour
// this should be a sane default when a completely new section is loaded
// keep in mind browsers request all files from a source-set
24,
60 * 60,
$user,
);
if (!$this->checkCanDownloadMedia($fileName)) {
$this->logger->warning('Tried to load media files for app discover section from untrusted source');
return new NotFoundResponse(Http::STATUS_BAD_REQUEST);
}
try {
$client = $this->clientService->newClient();
$fileResponse = $client->get($fileName);
$contentType = $fileResponse->getHeader('Content-Type');
$extension = $info['extension'] ?? '';
$file = $folder->newFile($hashName . '.' . base64_encode($contentType) . '.' . $extension, $fileResponse->getBody());
} catch (\Throwable $e) {
$this->logger->warning('Could not load media file for app discover section', ['media_src' => $fileName, 'exception' => $e]);
return new NotFoundResponse();
}
} else {
// File was found so we can get the content type from the file name
$contentType = base64_decode(explode('.', $file->getName())[1] ?? '');
}
$response = new FileDisplayResponse($file, Http::STATUS_OK, ['Content-Type' => $contentType]);
// cache for 7 days
$response->cacheFor(604800, false, true);
return $response;
}
private function checkCanDownloadMedia(string $filename): bool {
$urlInfo = parse_url($filename);
if (!isset($urlInfo['host']) || !isset($urlInfo['path'])) {
return false;
}
// Always allowed hosts
if ($urlInfo['host'] === 'nextcloud.com') {
return true;
}
// Hosts that need further verification
// Github is only allowed if from our organization
$ALLOWED_HOSTS = ['github.com', 'raw.githubusercontent.com'];
if (!in_array($urlInfo['host'], $ALLOWED_HOSTS)) {
return false;
}
if (str_starts_with($urlInfo['path'], '/nextcloud/') || str_starts_with($urlInfo['path'], '/nextcloud-gmbh/')) {
return true;
}
return false;
}
/**
* Remove orphaned folders from the image cache that do not match the current etag
* @param ISimpleFolder $folder The folder to clear
* @param string $etag The etag (directory name) to keep
*/
private function cleanUpImageCache(ISimpleFolder $folder, string $etag): void {
// Cleanup old cache folders
$allFiles = $folder->getDirectoryListing();
foreach ($allFiles as $dir) {
try {
if ($dir->getName() !== $etag) {
$dir->delete();
}
} catch (NotPermittedException $e) {
// ignore folder for now
}
}
}
}
@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
/*!
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Appstore\Controller;
use OC\App\AppManager;
use OC\App\AppStore\Bundles\BundleFetcher;
use OC\Installer;
use OCA\AppAPI\Service\ExAppsPageService;
use OCA\Appstore\AppInfo\Application;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http\Attribute\FrontpageRoute;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\Attribute\OpenAPI;
use OCP\AppFramework\Http\ContentSecurityPolicy;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Services\IInitialState;
use OCP\IConfig;
use OCP\IL10N;
use OCP\INavigationManager;
use OCP\IRequest;
use OCP\IURLGenerator;
use OCP\Server;
use OCP\Util;
#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)]
class PageController extends Controller {
public function __construct(
IRequest $request,
private IL10N $l10n,
private IConfig $config,
private Installer $installer,
private AppManager $appManager,
private IURLGenerator $urlGenerator,
private IInitialState $initialState,
private BundleFetcher $bundleFetcher,
private INavigationManager $navigationManager,
) {
parent::__construct(Application::APP_ID, $request);
}
/**
* @psalm-suppress UndefinedClass AppAPI is shipped since 30.0.1
*
* @return TemplateResponse
*/
#[NoCSRFRequired]
#[FrontpageRoute('GET', '/settings/apps', root: '')]
#[FrontpageRoute('GET', '/settings/apps/{category}', defaults: ['category' => ''], root: '')]
#[FrontpageRoute('GET', '/settings/apps/{category}/{id}', defaults: ['category' => '', 'id' => ''], root: '')]
public function viewApps(): TemplateResponse {
$this->navigationManager->setActiveEntry('core_apps');
$this->initialState->provideInitialState('appstoreEnabled', $this->config->getSystemValueBool('appstoreenabled', true));
$this->initialState->provideInitialState('appstoreBundles', $this->getBundles());
$this->initialState->provideInitialState('appstoreDeveloperDocs', $this->urlGenerator->linkToDocs('developer-manual'));
$this->initialState->provideInitialState('appstoreUpdateCount', count($this->getAppsWithUpdates()));
if ($this->appManager->isEnabledForAnyone('app_api')) {
try {
Server::get(ExAppsPageService::class)->provideAppApiState($this->initialState);
} catch (\Psr\Container\NotFoundExceptionInterface|\Psr\Container\ContainerExceptionInterface $e) {
}
}
$policy = new ContentSecurityPolicy();
$policy->addAllowedImageDomain('https://usercontent.apps.nextcloud.com');
$templateResponse = new TemplateResponse(Application::APP_ID, 'empty', ['pageTitle' => $this->l10n->t('App store')]);
$templateResponse->setContentSecurityPolicy($policy);
Util::addStyle(Application::APP_ID, 'main');
Util::addScript(Application::APP_ID, 'main');
return $templateResponse;
}
private function getAppsWithUpdates() {
$appClass = new \OC_App();
$apps = $appClass->listAllApps();
foreach ($apps as $key => $app) {
$newVersion = $this->installer->isUpdateAvailable($app['id']);
if ($newVersion === false) {
unset($apps[$key]);
}
}
return $apps;
}
private function getBundles() {
$result = [];
$bundles = $this->bundleFetcher->getBundles();
foreach ($bundles as $bundle) {
$result[] = [
'name' => $bundle->getName(),
'id' => $bundle->getIdentifier(),
'appIdentifiers' => $bundle->getAppIdentifiers()
];
}
return $result;
}
}
@@ -6,8 +6,9 @@ declare(strict_types=1);
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Settings\Search;
namespace OCA\Appstore\Search;
use OCA\Appstore\AppInfo\Application;
use OCP\IL10N;
use OCP\INavigationManager;
use OCP\IUser;
@@ -24,7 +25,7 @@ class AppSearch implements IProvider {
}
public function getId(): string {
return 'settings_apps';
return Application::APP_ID;
}
public function getName(): string {
@@ -32,7 +33,7 @@ class AppSearch implements IProvider {
}
public function getOrder(string $route, array $routeParameters): int {
return $route === 'settings.AppSettings.viewApps' ? -50 : 100;
return $route === 'appstore.AppSettings.viewApps' ? -50 : 100;
}
public function search(IUser $user, ISearchQuery $query): SearchResult {
+104
View File
@@ -0,0 +1,104 @@
{
"openapi": "3.0.3",
"info": {
"title": "settings-administration",
"version": "0.0.1",
"description": "Nextcloud settings",
"license": {
"name": "agpl"
}
},
"components": {
"securitySchemes": {
"basic_auth": {
"type": "http",
"scheme": "basic"
},
"bearer_auth": {
"type": "http",
"scheme": "bearer"
}
},
"schemas": {}
},
"paths": {
"/index.php/settings/admin/log/download": {
"get": {
"operationId": "log_settings-download",
"summary": "download logfile",
"description": "This endpoint requires admin access",
"tags": [
"log_settings"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"responses": {
"200": {
"description": "Logfile returned",
"headers": {
"Content-Disposition": {
"schema": {
"type": "string",
"enum": [
"attachment; filename=\"nextcloud.log\""
]
}
}
},
"content": {
"application/octet-stream": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"message"
],
"properties": {
"message": {
"type": "string"
}
}
}
}
}
},
"403": {
"description": "Logged in account must be an admin",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"message"
],
"properties": {
"message": {
"type": "string"
}
}
}
}
}
}
}
}
}
},
"tags": []
}
@@ -0,0 +1,2 @@
SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
SPDX-License-Identifier: AGPL-3.0-or-later
+709
View File
@@ -0,0 +1,709 @@
{
"openapi": "3.0.3",
"info": {
"title": "settings-full",
"version": "0.0.1",
"description": "Nextcloud settings",
"license": {
"name": "agpl"
}
},
"components": {
"securitySchemes": {
"basic_auth": {
"type": "http",
"scheme": "basic"
},
"bearer_auth": {
"type": "http",
"scheme": "bearer"
}
},
"schemas": {
"DeclarativeForm": {
"type": "object",
"required": [
"id",
"priority",
"section_type",
"section_id",
"storage_type",
"title",
"app",
"fields"
],
"properties": {
"id": {
"type": "string"
},
"priority": {
"type": "integer",
"format": "int64"
},
"section_type": {
"type": "string",
"enum": [
"admin",
"personal"
]
},
"section_id": {
"type": "string"
},
"storage_type": {
"type": "string",
"enum": [
"internal",
"external"
]
},
"title": {
"type": "string"
},
"description": {
"type": "string"
},
"doc_url": {
"type": "string"
},
"app": {
"type": "string"
},
"fields": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DeclarativeFormField"
}
}
}
},
"DeclarativeFormField": {
"type": "object",
"required": [
"id",
"title",
"type",
"default",
"value"
],
"properties": {
"id": {
"type": "string"
},
"title": {
"type": "string"
},
"description": {
"type": "string"
},
"type": {
"type": "string",
"enum": [
"text",
"password",
"email",
"tel",
"url",
"number",
"checkbox",
"multi-checkbox",
"radio",
"select",
"multi-select"
]
},
"placeholder": {
"type": "string"
},
"label": {
"type": "string"
},
"default": {
"type": "object"
},
"options": {
"type": "array",
"items": {
"oneOf": [
{
"type": "string"
},
{
"type": "object",
"required": [
"name",
"value"
],
"properties": {
"name": {
"type": "string"
},
"value": {
"type": "object"
}
}
}
]
}
},
"value": {
"anyOf": [
{
"type": "string"
},
{
"type": "integer",
"format": "int64"
},
{
"type": "number",
"format": "double"
},
{
"type": "boolean"
},
{
"type": "array",
"items": {
"type": "string"
}
}
]
},
"sensitive": {
"type": "boolean"
}
}
},
"OCSMeta": {
"type": "object",
"required": [
"status",
"statuscode"
],
"properties": {
"status": {
"type": "string"
},
"statuscode": {
"type": "integer"
},
"message": {
"type": "string"
},
"totalitems": {
"type": "string"
},
"itemsperpage": {
"type": "string"
}
}
}
}
},
"paths": {
"/index.php/settings/admin/log/download": {
"get": {
"operationId": "log_settings-download",
"summary": "download logfile",
"description": "This endpoint requires admin access",
"tags": [
"log_settings"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"responses": {
"200": {
"description": "Logfile returned",
"headers": {
"Content-Disposition": {
"schema": {
"type": "string",
"enum": [
"attachment; filename=\"nextcloud.log\""
]
}
}
},
"content": {
"application/octet-stream": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"message"
],
"properties": {
"message": {
"type": "string"
}
}
}
}
}
},
"403": {
"description": "Logged in account must be an admin",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"message"
],
"properties": {
"message": {
"type": "string"
}
}
}
}
}
}
}
}
},
"/ocs/v2.php/settings/api/declarative/value": {
"post": {
"operationId": "declarative_settings-set-value",
"summary": "Sets a declarative settings value",
"tags": [
"declarative_settings"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"app",
"formId",
"fieldId",
"value"
],
"properties": {
"app": {
"type": "string",
"description": "ID of the app"
},
"formId": {
"type": "string",
"description": "ID of the form"
},
"fieldId": {
"type": "string",
"description": "ID of the field"
},
"value": {
"type": "object",
"description": "Value to be saved"
}
}
}
}
}
},
"parameters": [
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "Value set successfully",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"nullable": true
}
}
}
}
}
}
}
},
"500": {
"description": "Not logged in or not an admin user",
"content": {
"text/plain": {
"schema": {
"type": "string"
}
}
}
},
"400": {
"description": "Invalid arguments to save value",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
}
}
}
},
"/ocs/v2.php/settings/api/declarative/value-sensitive": {
"post": {
"operationId": "declarative_settings-set-sensitive-value",
"summary": "Sets a declarative settings value. Password confirmation is required for sensitive values.",
"description": "This endpoint requires password confirmation",
"tags": [
"declarative_settings"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"app",
"formId",
"fieldId",
"value"
],
"properties": {
"app": {
"type": "string",
"description": "ID of the app"
},
"formId": {
"type": "string",
"description": "ID of the form"
},
"fieldId": {
"type": "string",
"description": "ID of the field"
},
"value": {
"type": "object",
"description": "Value to be saved"
}
}
}
}
}
},
"parameters": [
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "Value set successfully",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"nullable": true
}
}
}
}
}
}
}
},
"500": {
"description": "Not logged in or not an admin user",
"content": {
"text/plain": {
"schema": {
"type": "string"
}
}
}
},
"400": {
"description": "Invalid arguments to save value",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
}
}
}
},
"/ocs/v2.php/settings/api/declarative/forms": {
"get": {
"operationId": "declarative_settings-get-forms",
"summary": "Gets all declarative forms with the values prefilled.",
"tags": [
"declarative_settings"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"parameters": [
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "Forms returned",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DeclarativeForm"
}
}
}
}
}
}
}
}
},
"500": {
"description": "",
"content": {
"text/plain": {
"schema": {
"type": "string"
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
}
}
}
}
},
"tags": []
}
+2
View File
@@ -0,0 +1,2 @@
SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
SPDX-License-Identifier: AGPL-3.0-or-later
+632
View File
@@ -0,0 +1,632 @@
{
"openapi": "3.0.3",
"info": {
"title": "settings",
"version": "0.0.1",
"description": "Nextcloud settings",
"license": {
"name": "agpl"
}
},
"components": {
"securitySchemes": {
"basic_auth": {
"type": "http",
"scheme": "basic"
},
"bearer_auth": {
"type": "http",
"scheme": "bearer"
}
},
"schemas": {
"DeclarativeForm": {
"type": "object",
"required": [
"id",
"priority",
"section_type",
"section_id",
"storage_type",
"title",
"app",
"fields"
],
"properties": {
"id": {
"type": "string"
},
"priority": {
"type": "integer",
"format": "int64"
},
"section_type": {
"type": "string",
"enum": [
"admin",
"personal"
]
},
"section_id": {
"type": "string"
},
"storage_type": {
"type": "string",
"enum": [
"internal",
"external"
]
},
"title": {
"type": "string"
},
"description": {
"type": "string"
},
"doc_url": {
"type": "string"
},
"app": {
"type": "string"
},
"fields": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DeclarativeFormField"
}
}
}
},
"DeclarativeFormField": {
"type": "object",
"required": [
"id",
"title",
"type",
"default",
"value"
],
"properties": {
"id": {
"type": "string"
},
"title": {
"type": "string"
},
"description": {
"type": "string"
},
"type": {
"type": "string",
"enum": [
"text",
"password",
"email",
"tel",
"url",
"number",
"checkbox",
"multi-checkbox",
"radio",
"select",
"multi-select"
]
},
"placeholder": {
"type": "string"
},
"label": {
"type": "string"
},
"default": {
"type": "object"
},
"options": {
"type": "array",
"items": {
"oneOf": [
{
"type": "string"
},
{
"type": "object",
"required": [
"name",
"value"
],
"properties": {
"name": {
"type": "string"
},
"value": {
"type": "object"
}
}
}
]
}
},
"value": {
"anyOf": [
{
"type": "string"
},
{
"type": "integer",
"format": "int64"
},
{
"type": "number",
"format": "double"
},
{
"type": "boolean"
},
{
"type": "array",
"items": {
"type": "string"
}
}
]
},
"sensitive": {
"type": "boolean"
}
}
},
"OCSMeta": {
"type": "object",
"required": [
"status",
"statuscode"
],
"properties": {
"status": {
"type": "string"
},
"statuscode": {
"type": "integer"
},
"message": {
"type": "string"
},
"totalitems": {
"type": "string"
},
"itemsperpage": {
"type": "string"
}
}
}
}
},
"paths": {
"/ocs/v2.php/settings/api/declarative/value": {
"post": {
"operationId": "declarative_settings-set-value",
"summary": "Sets a declarative settings value",
"tags": [
"declarative_settings"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"app",
"formId",
"fieldId",
"value"
],
"properties": {
"app": {
"type": "string",
"description": "ID of the app"
},
"formId": {
"type": "string",
"description": "ID of the form"
},
"fieldId": {
"type": "string",
"description": "ID of the field"
},
"value": {
"type": "object",
"description": "Value to be saved"
}
}
}
}
}
},
"parameters": [
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "Value set successfully",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"nullable": true
}
}
}
}
}
}
}
},
"500": {
"description": "Not logged in or not an admin user",
"content": {
"text/plain": {
"schema": {
"type": "string"
}
}
}
},
"400": {
"description": "Invalid arguments to save value",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
}
}
}
},
"/ocs/v2.php/settings/api/declarative/value-sensitive": {
"post": {
"operationId": "declarative_settings-set-sensitive-value",
"summary": "Sets a declarative settings value. Password confirmation is required for sensitive values.",
"description": "This endpoint requires password confirmation",
"tags": [
"declarative_settings"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"app",
"formId",
"fieldId",
"value"
],
"properties": {
"app": {
"type": "string",
"description": "ID of the app"
},
"formId": {
"type": "string",
"description": "ID of the form"
},
"fieldId": {
"type": "string",
"description": "ID of the field"
},
"value": {
"type": "object",
"description": "Value to be saved"
}
}
}
}
}
},
"parameters": [
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "Value set successfully",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"nullable": true
}
}
}
}
}
}
}
},
"500": {
"description": "Not logged in or not an admin user",
"content": {
"text/plain": {
"schema": {
"type": "string"
}
}
}
},
"400": {
"description": "Invalid arguments to save value",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
}
}
}
},
"/ocs/v2.php/settings/api/declarative/forms": {
"get": {
"operationId": "declarative_settings-get-forms",
"summary": "Gets all declarative forms with the values prefilled.",
"tags": [
"declarative_settings"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"parameters": [
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "Forms returned",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DeclarativeForm"
}
}
}
}
}
}
}
}
},
"500": {
"description": "",
"content": {
"text/plain": {
"schema": {
"type": "string"
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
}
}
}
}
},
"tags": []
}
+2
View File
@@ -0,0 +1,2 @@
SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
SPDX-License-Identifier: AGPL-3.0-or-later
+61
View File
@@ -0,0 +1,61 @@
<!--
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import { t } from '@nextcloud/l10n'
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import NcAppContent from '@nextcloud/vue/components/NcAppContent'
import NcContent from '@nextcloud/vue/components/NcContent'
import AppstoreNavigation from './views/AppstoreNavigation.vue'
import AppstoreSidebar from './views/AppstoreSidebar.vue'
import { APPSTORE_CATEGORY_NAMES } from './constants.ts'
const route = useRoute()
const currentCategory = computed(() => {
if (route.params.category) {
return [route.params.category].flat()[0]!
}
if (route.name === 'apps-bundles') {
return 'bundles'
}
return 'discover'
})
const heading = computed(() => APPSTORE_CATEGORY_NAMES[currentCategory.value] ?? currentCategory.value)
const pageTitle = computed(() => `${heading.value} - ${t('appstore', 'App store')}`)
const showSidebar = computed(() => !!route.params.id)
</script>
<template>
<NcContent appName="appstore">
<AppstoreNavigation />
<NcAppContent
:class="$style.appstoreApp__content"
:pageHeading="t('appstore', 'App store')"
:pageTitle>
<h2 v-if="heading" :class="$style.appstoreApp__heading">
{{ heading }}
</h2>
<router-view />
</NcAppContent>
<AppstoreSidebar v-if="showSidebar" />
</NcContent>
</template>
<style module>
.appstoreApp__content {
padding-inline-end: var(--body-container-margin);
}
.appstoreApp__heading {
margin-block-start: var(--app-navigation-padding);
margin-inline-start: calc(var(--default-clickable-area) + var(--app-navigation-padding) * 2);
min-height: var(--default-clickable-area);
line-height: var(--default-clickable-area);
vertical-align: center;
}
</style>
+112
View File
@@ -0,0 +1,112 @@
/*!
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
/**
* Helper for localized values
*/
export type ILocalizedValue<T> = Record<string, T | undefined> & { en: T }
export interface IAppDiscoverElement {
/**
* Type of the element
*/
type: typeof APP_DISCOVER_KNOWN_TYPES[number]
/**
* Identifier for this element
*/
id: string
/**
* Order of this element to pin elements (smaller = shown on top)
*/
order?: number
/**
* Optional, localized, headline for the element
*/
headline?: ILocalizedValue<string>
/**
* Optional link target for the element
*/
link?: string
/**
* Optional date when this element will get valid (only show since then)
*/
date?: number
/**
* Optional date when this element will be invalid (only show until then)
*/
expiryDate?: number
}
/** Wrapper for media source and MIME type */
type MediaSource = { src: string, mime: string }
/**
* Media content type for posts
*/
interface IAppDiscoverMediaContent {
/**
* The media source to show - either one or a list of sources with their MIME type for fallback options
*/
src: MediaSource | MediaSource[]
/**
* Alternative text for the media
*/
alt: string
/**
* Optional link target for the media (e.g. to the full video)
*/
link?: string
}
/**
* Wrapper for post media
*/
interface IAppDiscoverMedia {
/**
* The alignment of the media element
*/
alignment?: 'start' | 'end' | 'center'
/**
* The (localized) content
*/
content: ILocalizedValue<IAppDiscoverMediaContent>
}
/**
* An app element only used for the showcase type
*/
export interface IAppDiscoverApp {
/** The App ID */
type: 'app'
appId: string
}
export interface IAppDiscoverPost extends IAppDiscoverElement {
type: 'post'
text?: ILocalizedValue<string>
media?: IAppDiscoverMedia
}
export interface IAppDiscoverShowcase extends IAppDiscoverElement {
type: 'showcase'
content: (IAppDiscoverPost | IAppDiscoverApp)[]
}
export interface IAppDiscoverCarousel extends IAppDiscoverElement {
type: 'carousel'
text?: ILocalizedValue<string>
content: IAppDiscoverPost[]
}
export type IAppDiscoverElements = IAppDiscoverPost | IAppDiscoverCarousel | IAppDiscoverShowcase
@@ -27,7 +27,16 @@ export interface IAppstoreAppRelease {
}
}
export interface IAppstoreApp {
export interface IAppstoreAppData extends Record<string, unknown> {
ratingOverall: number
ratingNumOverall: number
ratingRecent: number
ratingNumRecent: number
releases: IAppstoreAppRelease[]
}
export interface IAppstoreAppResponse {
id: string
name: string
summary: string
@@ -38,24 +47,36 @@ export interface IAppstoreApp {
version: string
category: string | string[]
preview?: string
icon?: string
screenshot?: string
app_api: boolean
/**
* Groups this app is limited to.
* (only available if app is already installed)
*/
groups?: string[]
score: number
ratingNumThresholdReached: boolean
app_api: false
active: boolean
internal: boolean
removable: boolean
installed: boolean
canInstall: boolean
canUnInstall: boolean
isCompatible: boolean
needsDownload: boolean
missingDependencies?: string[]
update?: string
appstoreData: Record<string, never>
appstoreData?: IAppstoreAppData
releases?: IAppstoreAppRelease[]
}
export interface IAppstoreApp extends IAppstoreAppResponse {
loading?: boolean
}
export interface IComputeDevice {
id: string
label: string
@@ -81,10 +102,10 @@ export interface IDeployDaemon {
export interface IExAppStatus {
action: string
deploy: number
deploy_start_time: number
error: string
deploy_start_time?: number
error?: string
init: number
init_start_time: number
init_start_time?: number
type: string
}
@@ -111,8 +132,9 @@ export interface IAppstoreExAppRelease extends IAppstoreAppRelease {
}
export interface IAppstoreExApp extends IAppstoreApp {
app_api: true
daemon: IDeployDaemon | null | undefined
status: IExAppStatus | Record<string, never>
error: string
error?: string
releases: IAppstoreExAppRelease[]
}
+64
View File
@@ -0,0 +1,64 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import type { IAppstoreApp, IAppstoreExApp } from '../apps.d.ts'
import { mdiCogOutline } from '@mdi/js'
import { computed, ref, watch } from 'vue'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
const { app, noFallback, size = 20 } = defineProps<{
app: IAppstoreApp | IAppstoreExApp
noFallback?: boolean
size?: number
}>()
const isSvg = computed(() => app.icon?.endsWith('.svg'))
const svgIcon = ref<string>('')
watch(() => app.icon, async () => {
svgIcon.value = ''
if (app.icon?.endsWith('.svg')) {
const response = await fetch(app.icon)
if (response.ok) {
svgIcon.value = await response.text()
}
}
}, { immediate: true })
</script>
<template>
<span :class="$style.appIcon">
<NcIconSvgWrapper
v-if="svgIcon"
:size
:svg="svgIcon" />
<img
v-else-if="app.icon && !isSvg"
:class="$style.appIcon__image"
alt=""
:src="app.icon"
:height="size"
:width="size">
<NcIconSvgWrapper
v-else-if="!noFallback"
:path="mdiCogOutline"
:size />
</span>
</template>
<style module>
.appIcon {
display: inline-flex;
justify-content: center;
}
.appImage__image {
filter: var(--invert-if-dark);
object-fit: cover;
height: 100%;
width: 100%;
}
</style>
+66
View File
@@ -0,0 +1,66 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import type { IAppstoreApp, IAppstoreExApp } from '../apps.d.ts'
import { mdiCogOutline } from '@mdi/js'
import { NcLoadingIcon } from '@nextcloud/vue'
import { ref, watchEffect } from 'vue'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
const props = defineProps<{
app: IAppstoreApp | IAppstoreExApp
}>()
const isError = ref(false)
const isLoading = ref(true)
watchEffect(() => {
if (props.app.screenshot) {
isError.value = false
isLoading.value = true
const image = new Image()
image.onload = () => {
isLoading.value = false
}
image.onerror = () => {
isError.value = true
isLoading.value = false
}
image.src = props.app.screenshot
} else {
isLoading.value = false
isError.value = false
}
})
</script>
<template>
<div :class="$style.appImage">
<NcIconSvgWrapper
v-if="isError || !props.app.screenshot"
:size="80"
:path="mdiCogOutline" />
<NcLoadingIcon v-else-if="isLoading" :size="80" />
<img :class="$style.appImage__image" :src="props.app.screenshot" alt="">
</div>
</template>
<style module>
.appImage {
display: flex;
justify-content: center;
width: 100%;
height: 100%;
}
.appImage__image {
object-fit: cover;
height: 100%;
width: 100%;
}
</style>
@@ -2,19 +2,9 @@
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<span
v-if="isSupported || isFeatured"
class="app-level-badge"
:class="{ 'app-level-badge--supported': isSupported }"
:title="badgeTitle">
<NcIconSvgWrapper :path="badgeIcon" :size="20" inline />
{{ badgeText }}
</span>
</template>
<script setup lang="ts">
import { mdiCheck, mdiStarShootingOutline } from '@mdi/js'
import { mdiStar, mdiStarShootingOutline } from '@mdi/js'
import { translate as t } from '@nextcloud/l10n'
import { computed } from 'vue'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
@@ -28,15 +18,27 @@ const props = defineProps<{
const isSupported = computed(() => props.level === 300)
const isFeatured = computed(() => props.level === 200)
const badgeIcon = computed(() => isSupported.value ? mdiStarShootingOutline : mdiCheck)
const badgeText = computed(() => isSupported.value ? t('settings', 'Supported') : t('settings', 'Featured'))
const badgeIcon = computed(() => isSupported.value
? mdiStarShootingOutline
: mdiStar)
const badgeText = computed(() => isSupported.value ? t('appstore', 'Supported') : t('appstore', 'Featured'))
const badgeTitle = computed(() => isSupported.value
? t('settings', 'This app is supported via your current Nextcloud subscription.')
: t('settings', 'Featured apps are developed by and within the community. They offer central functionality and are ready for production use.'))
? t('appstore', 'This app is supported via your current Nextcloud subscription.')
: t('appstore', 'Featured apps are developed by and within the community. They offer central functionality and are ready for production use.'))
</script>
<style scoped lang="scss">
.app-level-badge {
<template>
<span
v-if="isSupported || isFeatured"
:class="[ $style.appLevelBadge, { [$style.appLevelBadge__supported]: isSupported } ]"
:title="badgeTitle">
<NcIconSvgWrapper :path="badgeIcon" :size="20" inline />
{{ badgeText }}
</span>
</template>
<style module>
.appLevelBadge {
color: var(--color-text-maxcontrast);
background-color: transparent;
border: 1px solid var(--color-text-maxcontrast);
@@ -44,14 +46,14 @@ const badgeTitle = computed(() => isSupported.value
display: flex;
flex-direction: row;
gap: 6px;
gap: var(--default-grid-baseline);
padding: 3px 6px;
width: fit-content;
}
&--supported {
background-color: var(--color-success);
border-color: var(--color-border-success);
color: var(--color-success-text);
}
.appLevelBadge__supported {
background-color: var(--color-success);
border-color: var(--color-border-success);
color: var(--color-success-text);
}
</style>
+83
View File
@@ -0,0 +1,83 @@
<!--
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
/**
* This component either shows a native link to the installed app or external size
* or a router link to the appstore page of the app if not installed
*/
import type { RouterLinkProps } from 'vue-router'
import type { INavigationEntry } from '../../../../core/src/types/navigation.d.ts'
import { loadState } from '@nextcloud/initial-state'
import { generateUrl } from '@nextcloud/router'
import { ref, watchEffect } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
const props = defineProps<{
href: string
}>()
const route = useRoute()
const knownRoutes = Object.fromEntries(loadState<INavigationEntry[]>('core', 'apps').map((app) => [app.app ?? app.id, app.href]))
const routerProps = ref<RouterLinkProps>()
const linkProps = ref<Record<string, string>>()
watchEffect(() => {
const match = props.href.match(/^app:(\/\/)?([^/]+)(\/.+)?$/)
routerProps.value = undefined
linkProps.value = undefined
// not an app url
if (match === null) {
linkProps.value = {
href: props.href,
target: '_blank',
rel: 'noreferrer noopener',
}
return
}
const appId = match[2]!
// Check if specific route was requested
if (match[3]) {
// we do no know anything about app internal path so we only allow generic app paths
linkProps.value = {
href: generateUrl(`/apps/${appId}${match[3]}`),
}
return
}
// If we know any route for that app we open it
if (appId in knownRoutes) {
linkProps.value = {
href: knownRoutes[appId]!,
}
return
}
// Fallback to show the app store entry
routerProps.value = {
to: {
name: 'apps-details',
params: {
category: route.params?.category ?? 'discover',
id: appId,
},
},
}
})
</script>
<template>
<a v-if="linkProps" v-bind="linkProps">
<slot />
</a>
<RouterLink v-else-if="routerProps" v-bind="routerProps">
<slot />
</RouterLink>
</template>
@@ -19,36 +19,36 @@
}">
<template v-if="useListView">
<div v-if="showUpdateAll" class="apps-list__toolbar">
{{ n('settings', '%n app has an update available', '%n apps have an update available', counter) }}
{{ n('appstore', '%n app has an update available', '%n apps have an update available', counter) }}
<NcButton
v-if="showUpdateAll"
id="app-list-update-all"
variant="primary"
@click="updateAll">
{{ n('settings', 'Update', 'Update all', counter) }}
{{ n('appstore', 'Update', 'Update all', counter) }}
</NcButton>
</div>
<div v-if="!showUpdateAll" class="apps-list__toolbar">
{{ t('settings', 'All apps are up-to-date.') }}
{{ t('appstore', 'All apps are up-to-date.') }}
</div>
<TransitionGroup name="apps-list" tag="table" class="apps-list__list-container">
<tr key="app-list-view-header">
<th>
<span class="hidden-visually">{{ t('settings', 'Icon') }}</span>
<span class="hidden-visually">{{ t('appstore', 'Icon') }}</span>
</th>
<th>
<span class="hidden-visually">{{ t('settings', 'Name') }}</span>
<span class="hidden-visually">{{ t('appstore', 'Name') }}</span>
</th>
<th>
<span class="hidden-visually">{{ t('settings', 'Version') }}</span>
<span class="hidden-visually">{{ t('appstore', 'Version') }}</span>
</th>
<th>
<span class="hidden-visually">{{ t('settings', 'Level') }}</span>
<span class="hidden-visually">{{ t('appstore', 'Level') }}</span>
</th>
<th>
<span class="hidden-visually">{{ t('settings', 'Actions') }}</span>
<span class="hidden-visually">{{ t('appstore', 'Actions') }}</span>
</th>
</tr>
<AppItem
@@ -64,30 +64,30 @@
class="apps-list__list-container">
<tr key="app-list-view-header">
<th id="app-table-col-icon">
<span class="hidden-visually">{{ t('settings', 'Icon') }}</span>
<span class="hidden-visually">{{ t('appstore', 'Icon') }}</span>
</th>
<th id="app-table-col-name">
<span class="hidden-visually">{{ t('settings', 'Name') }}</span>
<span class="hidden-visually">{{ t('appstore', 'Name') }}</span>
</th>
<th id="app-table-col-version">
<span class="hidden-visually">{{ t('settings', 'Version') }}</span>
<span class="hidden-visually">{{ t('appstore', 'Version') }}</span>
</th>
<th id="app-table-col-level">
<span class="hidden-visually">{{ t('settings', 'Level') }}</span>
<span class="hidden-visually">{{ t('appstore', 'Level') }}</span>
</th>
<th id="app-table-col-actions">
<span class="hidden-visually">{{ t('settings', 'Actions') }}</span>
<span class="hidden-visually">{{ t('appstore', 'Actions') }}</span>
</th>
</tr>
<template v-for="bundle in bundles">
<tr :key="bundle.id">
<template v-for="bundle in bundles" :key="bundle.id">
<tr>
<th :id="`app-table-rowgroup-${bundle.id}`" colspan="5" scope="rowgroup">
<div class="apps-list__bundle-heading">
<span class="apps-list__bundle-header">
{{ bundle.name }}
</span>
<NcButton variant="secondary" @click="toggleBundle(bundle.id)">
{{ t('settings', bundleToggleText(bundle.id)) }}
{{ t('appstore', bundleToggleText(bundle.id)) }}
</NcButton>
</div>
</th>
@@ -115,23 +115,23 @@
<div class="apps-list__list-container">
<table v-if="search !== '' && searchApps.length > 0" class="apps-list__list-container">
<caption class="apps-list__bundle-header">
{{ t('settings', 'Results from other categories') }}
{{ t('appstore', 'Results from other categories') }}
</caption>
<tr key="app-list-view-header">
<th>
<span class="hidden-visually">{{ t('settings', 'Icon') }}</span>
<span class="hidden-visually">{{ t('appstore', 'Icon') }}</span>
</th>
<th>
<span class="hidden-visually">{{ t('settings', 'Name') }}</span>
<span class="hidden-visually">{{ t('appstore', 'Name') }}</span>
</th>
<th>
<span class="hidden-visually">{{ t('settings', 'Version') }}</span>
<span class="hidden-visually">{{ t('appstore', 'Version') }}</span>
</th>
<th>
<span class="hidden-visually">{{ t('settings', 'Level') }}</span>
<span class="hidden-visually">{{ t('appstore', 'Level') }}</span>
</th>
<th>
<span class="hidden-visually">{{ t('settings', 'Actions') }}</span>
<span class="hidden-visually">{{ t('appstore', 'Actions') }}</span>
</th>
</tr>
<AppItem
@@ -145,7 +145,7 @@
<div v-if="search !== '' && !loading && searchApps.length === 0 && apps.length === 0" id="apps-list-empty" class="emptycontent emptycontent-search">
<div id="app-list-empty-icon" class="icon-settings-dark" />
<h2>{{ t('settings', 'No apps found for your version') }}</h2>
<h2>{{ t('appstore', 'No apps found for your version') }}</h2>
</div>
</div>
</template>
@@ -157,7 +157,7 @@ import NcButton from '@nextcloud/vue/components/NcButton'
import AppItem from './AppList/AppItem.vue'
import OfficeSuiteSwitcher from './AppList/OfficeSuiteSwitcher.vue'
import { getOfficeSuiteById, OFFICE_SUITES } from '../constants/OfficeSuites.js'
import logger from '../logger.ts'
import logger from '../utils/logger.ts'
import AppManagement from '../mixins/AppManagement.js'
import { useAppApiStore } from '../store/app-api-store.ts'
import { useAppsStore } from '../store/apps-store.ts'
@@ -314,9 +314,9 @@ export default {
bundleToggleText() {
return (id) => {
if (this.allBundlesEnabled(id)) {
return t('settings', 'Disable all')
return t('appstore', 'Disable all')
}
return t('settings', 'Download and enable all')
return t('appstore', 'Download and enable all')
}
},
},
@@ -0,0 +1,108 @@
<!--
- SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<li
:class="[$style.appListItem, {
[$style.appListItem_selected]: isSelected,
}]">
<div class="app-image app-image-icon">
<div v-if="!app?.app_api && !props.app.preview" class="icon-settings-dark" />
<NcIconSvgWrapper
v-else-if="app.app_api && !props.app.preview"
:path="mdiCogOutline"
:size="24"
style="min-width: auto; min-height: auto; height: 100%;" />
<svg
v-else-if="app.preview && !app.app_api"
width="32"
height="32"
viewBox="0 0 32 32">
<image
x="0"
y="0"
width="32"
height="32"
preserveAspectRatio="xMinYMin meet"
:xlink:href="app.preview"
class="app-icon" />
</svg>
</div>
<div class="app-name">
<router-link
class="app-name--link"
:to="{
name: 'apps-details',
params: {
category: category,
id: app.id,
},
}"
:aria-label="t('appstore', 'Show details for {appName} app', { appName: app.name })">
{{ app.name }}
</router-link>
</div>
<AppListVersion :app />
<div class="app-level">
<AppLevelBadge :level="app.level" />
</div>
</li>
</template>
<script setup lang="ts">
import type { IAppstoreApp } from '../../apps.d.ts'
import { mdiCogOutline } from '@mdi/js'
import { t } from '@nextcloud/l10n'
import { ref, watchEffect } from 'vue'
import { useRoute } from 'vue-router'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import AppLevelBadge from './AppLevelBadge.vue'
import AppListVersion from './AppListVersion.vue'
const props = defineProps<{
app: IAppstoreApp
category: string
}>()
const route = useRoute()
const isSelected = ref(false)
watchEffect(() => {
isSelected.value = props.app.id === route.params.id
})
const screenshotLoaded = ref(false)
watchEffect(() => {
if (props.app.screenshot) {
const image = new Image()
image.onload = () => {
screenshotLoaded.value = true
}
image.src = props.app.screenshot
}
})
</script>
<style module>
.appListItem {
--app-item-padding: calc(var(--default-grid-baseline) * 2);
--app-item-height: calc(var(--default-clickable-area) + var(--app-item-padding) * 2);
> * {
vertical-align: middle;
border-bottom: 1px solid var(--color-border);
padding: var(--app-item-padding);
height: var(--app-item-height);
}
}
.appListItem:hover {
background-color: var(--color-background-dark);
}
.appListItem_selected {
background-color: var(--color-background-dark);
}
</style>
@@ -0,0 +1,27 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import type { IAppstoreApp } from '../../apps.d.ts'
defineProps<{
app: IAppstoreApp
}>()
</script>
<template>
<div :class="$style.appListVersion">
<span v-if="app.version">{{ app.version }}</span>
<span v-else-if="app.appstoreData?.releases[0]?.version">
{{ app.appstoreData.releases[0].version }}
</span>
</div>
</template>
<style module>
.appListVersion {
color: var(--color-text-maxcontrast);
}
</style>
@@ -53,7 +53,7 @@
id: app.id,
},
}"
:aria-label="t('settings', 'Show details for {appName} app', { appName: app.name })">
:aria-label="t('appstore', 'Show details for {appName} app', { appName: app.name })">
{{ app.name }}
</router-link>
</component>
@@ -92,7 +92,7 @@
:disabled="installing || isLoading || !defaultDeployDaemonAccessible || isManualInstall"
:title="updateButtonText"
@click.stop="update(app.id)">
{{ t('settings', 'Update to {update}', { update: app.update }) }}
{{ t('appstore', 'Update to {update}', { update: app.update }) }}
</NcButton>
<NcButton
v-if="app.canUnInstall"
@@ -100,7 +100,7 @@
variant="tertiary"
:disabled="installing || isLoading"
@click.stop="remove(app.id)">
{{ t('settings', 'Remove') }}
{{ t('appstore', 'Remove') }}
</NcButton>
<NcButton
v-if="app.active"
@@ -129,7 +129,7 @@
<DaemonSelectionDialog
v-if="app?.app_api && showSelectDaemonModal"
:show.sync="showSelectDaemonModal"
v-model:show="showSelectDaemonModal"
:app="app" />
</component>
</component>
@@ -139,13 +139,12 @@
import { mdiCogOutline } from '@mdi/js'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import DaemonSelectionDialog from '../AppAPI/DaemonSelectionDialog.vue'
import SvgFilterMixin from '../SvgFilterMixin.vue'
import DaemonSelectionDialog from '../DaemonSelectionDialog/DaemonSelectionDialog.vue'
import AppLevelBadge from './AppLevelBadge.vue'
import AppScore from './AppScore.vue'
import AppManagement from '../../mixins/AppManagement.js'
import { useAppApiStore } from '../../store/app-api-store.ts'
import { useAppsStore } from '../../store/apps-store.js'
import { useAppsStore } from '../../store/apps.ts'
import { useAppApiStore } from '../../store/exApps.ts'
export default {
name: 'AppItem',
@@ -157,7 +156,7 @@ export default {
DaemonSelectionDialog,
},
mixins: [AppManagement, SvgFilterMixin],
mixins: [AppManagement],
props: {
app: {
type: Object,
@@ -281,7 +280,6 @@ export default {
</script>
<style scoped lang="scss">
@use '../../../../../core/css/variables.scss' as variables;
@use 'sass:math';
.app-item {
@@ -364,7 +362,7 @@ export default {
}
/* Hide actions on a small screen. Click on app opens fill-screen sidebar with the buttons */
@media only screen and (max-width: math.div(variables.$breakpoint-mobile, 2)) {
@media only screen and (max-width: 512px) {
.app-actions {
display: none;
}
@@ -437,7 +435,7 @@ export default {
}
}
@media only screen and (max-width: variables.$breakpoint-mobile) {
@media only screen and (max-width: 1024px) {
width: 50%;
}
@@ -3,6 +3,53 @@
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import { loadState } from '@nextcloud/initial-state'
import { t } from '@nextcloud/l10n'
import IconArrowRight from 'vue-material-design-icons/ArrowRight.vue'
import IconCheckCircle from 'vue-material-design-icons/CheckCircle.vue'
import { OFFICE_SUITES } from '../../constants/OfficeSuites.ts'
import { shallowRef } from 'vue'
const { installedApps = [] } = defineProps<{
installedApps: { id: string, active: boolean }[],
}>()
const emit = defineEmits<{
'suite-selected': [suiteId: string | null]
}>()
const isAllInOne = loadState('settings', 'isAllInOne', false)
const selectedSuite = shallowRef<string | null>(getInitialSuite())
function getInitialSuite() {
for (const suite of OFFICE_SUITES) {
const app = installedApps.find((a) => a.id === suite.appId)
if (app && app.active) {
return suite.id
}
}
return null
}
function selectSuite(suiteId: string) {
if (selectedSuite.value === suiteId) {
// already selected keep selection; use the disable button to clear
return
}
selectedSuite.value = suiteId
emit('suite-selected', suiteId)
}
function disableSuites() {
if (!selectedSuite.value) {
return
}
selectedSuite.value = null
emit('suite-selected', null)
}
</script>
<template>
<div class="office-suite-switcher">
<div v-if="isAllInOne" class="office-suite-switcher__aio-message">
@@ -13,7 +60,7 @@
<p>{{ t('settings', 'Select your preferred office suite. Please note that installing requires manual server setup.') }}</p>
<div class="office-suite-cards">
<div
v-for="suite in officeSuites"
v-for="suite in OFFICE_SUITES"
:key="suite.id"
class="office-suite-card"
:class="{
@@ -56,71 +103,6 @@
</div>
</template>
<script>
import { loadState } from '@nextcloud/initial-state'
import { translate as t } from '@nextcloud/l10n'
import IconArrowRight from 'vue-material-design-icons/ArrowRight.vue'
import IconCheckCircle from 'vue-material-design-icons/CheckCircle.vue'
import { OFFICE_SUITES } from '../../constants/OfficeSuites.js'
export default {
name: 'OfficeSuiteSwitcher',
components: {
IconCheckCircle,
IconArrowRight,
},
props: {
installedApps: {
type: Array,
default: () => [],
},
},
emits: ['suite-selected'],
data() {
return {
isAllInOne: loadState('settings', 'isAllInOne', false),
selectedSuite: this.getInitialSuite(),
officeSuites: OFFICE_SUITES,
}
},
methods: {
t,
getInitialSuite() {
for (const suite of OFFICE_SUITES) {
const app = this.installedApps.find((a) => a.id === suite.appId)
if (app && app.active) {
return suite.id
}
}
return null
},
selectSuite(suiteId) {
if (this.selectedSuite === suiteId) {
// already selected keep selection; use the disable button to clear
return
}
this.selectedSuite = suiteId
this.$emit('suite-selected', suiteId)
},
disableSuites() {
if (this.selectedSuite === null) {
return
}
this.selectedSuite = null
this.$emit('suite-selected', null)
},
},
}
</script>
<style lang="scss" scoped>
.office-suite-switcher {
padding: 20px;
@@ -134,13 +116,13 @@ export default {
text-align: center;
}
p {
margin: 8px 0;
p {
margin: 8px 0;
&:first-child {
font-weight: 600;
}
}
&:first-child {
font-weight: 600;
}
}
}
.office-suite-cards {
@@ -53,7 +53,7 @@ export default defineComponent({
computed: {
title() {
const appScore = (this.score * 5).toFixed(1)
return t('settings', 'Community rating: {score}/5', { score: appScore })
return t('appstore', 'Community rating: {score}/5', { score: appScore })
},
fullStars() {
@@ -7,18 +7,18 @@
<NcAppSidebarTab
v-if="app?.daemon"
id="daemon"
:name="t('settings', 'Daemon')"
:name="t('appstore', 'Daemon')"
:order="3">
<template #icon>
<NcIconSvgWrapper :path="mdiFileChart" :size="24" />
</template>
<div class="daemon">
<h4>{{ t('settings', 'Deploy Daemon') }}</h4>
<p><b>{{ t('settings', 'Type') }}</b>: {{ app?.daemon.accepts_deploy_id }}</p>
<p><b>{{ t('settings', 'Name') }}</b>: {{ app?.daemon.name }}</p>
<p><b>{{ t('settings', 'Display Name') }}</b>: {{ app?.daemon.display_name }}</p>
<p><b>{{ t('settings', 'GPUs support') }}</b>: {{ gpuSupport }}</p>
<p><b>{{ t('settings', 'Compute device') }}</b>: {{ app?.daemon?.deploy_config?.computeDevice?.label }}</p>
<h4>{{ t('appstore', 'Deploy Daemon') }}</h4>
<p><b>{{ t('appstore', 'Type') }}</b>: {{ app?.daemon.accepts_deploy_id }}</p>
<p><b>{{ t('appstore', 'Name') }}</b>: {{ app?.daemon.name }}</p>
<p><b>{{ t('appstore', 'Display Name') }}</b>: {{ app?.daemon.display_name }}</p>
<p><b>{{ t('appstore', 'GPUs support') }}</b>: {{ gpuSupport }}</p>
<p><b>{{ t('appstore', 'Compute device') }}</b>: {{ app?.daemon?.deploy_config?.computeDevice?.label }}</p>
</div>
</NcAppSidebarTab>
</template>
@@ -27,6 +27,7 @@
import type { IAppstoreExApp } from '../../app-types.ts'
import { mdiFileChart } from '@mdi/js'
import { t } from '@nextcloud/l10n'
import { ref } from 'vue'
import NcAppSidebarTab from '@nextcloud/vue/components/NcAppSidebarTab'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
@@ -7,17 +7,17 @@
<NcDialog
:open="show"
size="normal"
:name="t('settings', 'Advanced deploy options')"
:name="t('appstore', 'Advanced deploy options')"
@update:open="$emit('update:show', $event)">
<div class="modal__content">
<p class="deploy-option__hint">
{{ configuredDeployOptions === null ? t('settings', 'Edit ExApp deploy options before installation') : t('settings', 'Configured ExApp deploy options. Can be set only during installation') }}.
{{ configuredDeployOptions === null ? t('appstore', 'Edit ExApp deploy options before installation') : t('appstore', 'Configured ExApp deploy options. Can be set only during installation') }}.
<a v-if="deployOptionsDocsUrl" :href="deployOptionsDocsUrl">
{{ t('settings', 'Learn more') }}
{{ t('appstore', 'Learn more') }}
</a>
</p>
<h3 v-if="environmentVariables.length > 0 || (configuredDeployOptions !== null && configuredDeployOptions.environment_variables.length > 0)">
{{ t('settings', 'Environment variables') }}
{{ t('appstore', 'Environment variables') }}
</h3>
<template v-if="configuredDeployOptions === null">
<div
@@ -34,7 +34,7 @@
v-else-if="Object.keys(configuredDeployOptions).length > 0"
class="envs">
<legend class="deploy-option__hint">
{{ t('settings', 'ExApp container environment variables') }}
{{ t('appstore', 'ExApp container environment variables') }}
</legend>
<NcTextField
v-for="(value, key) in configuredDeployOptions.environment_variables"
@@ -46,28 +46,28 @@
</fieldset>
<template v-else>
<p class="deploy-option__hint">
{{ t('settings', 'No environment variables defined') }}
{{ t('appstore', 'No environment variables defined') }}
</p>
</template>
<h3>{{ t('settings', 'Mounts') }}</h3>
<h3>{{ t('appstore', 'Mounts') }}</h3>
<template v-if="configuredDeployOptions === null">
<p class="deploy-option__hint">
{{ t('settings', 'Define host folder mounts to bind to the ExApp container') }}
{{ t('appstore', 'Define host folder mounts to bind to the ExApp container') }}
</p>
<NcNoteCard type="info" :text="t('settings', 'Must exist on the Deploy daemon host prior to installing the ExApp')" />
<NcNoteCard type="info" :text="t('appstore', 'Must exist on the Deploy daemon host prior to installing the ExApp')" />
<div
v-for="mount in deployOptions.mounts"
:key="mount.hostPath"
class="deploy-option"
style="display: flex; align-items: center; justify-content: space-between; flex-direction: row;">
<NcTextField v-model="mount.hostPath" :label="t('settings', 'Host path')" />
<NcTextField v-model="mount.containerPath" :label="t('settings', 'Container path')" />
<NcTextField v-model="mount.hostPath" :label="t('appstore', 'Host path')" />
<NcTextField v-model="mount.containerPath" :label="t('appstore', 'Container path')" />
<NcCheckboxRadioSwitch v-model="mount.readonly">
{{ t('settings', 'Read-only') }}
{{ t('appstore', 'Read-only') }}
</NcCheckboxRadioSwitch>
<NcButton
:aria-label="t('settings', 'Remove mount')"
:aria-label="t('appstore', 'Remove mount')"
style="margin-top: 6px;"
@click="removeMount(mount)">
<template #icon>
@@ -77,73 +77,73 @@
</div>
<div v-if="addingMount" class="deploy-option">
<h4>
{{ t('settings', 'New mount') }}
{{ t('appstore', 'New mount') }}
</h4>
<div style="display: flex; align-items: center; justify-content: space-between; flex-direction: row;">
<NcTextField
ref="newMountHostPath"
v-model="newMountPoint.hostPath"
:label="t('settings', 'Host path')"
:aria-label="t('settings', 'Enter path to host folder')" />
:label="t('appstore', 'Host path')"
:aria-label="t('appstore', 'Enter path to host folder')" />
<NcTextField
v-model="newMountPoint.containerPath"
:label="t('settings', 'Container path')"
:aria-label="t('settings', 'Enter path to container folder')" />
:label="t('appstore', 'Container path')"
:aria-label="t('appstore', 'Enter path to container folder')" />
<NcCheckboxRadioSwitch
v-model="newMountPoint.readonly"
:aria-label="t('settings', 'Toggle read-only mode')">
{{ t('settings', 'Read-only') }}
:aria-label="t('appstore', 'Toggle read-only mode')">
{{ t('appstore', 'Read-only') }}
</NcCheckboxRadioSwitch>
</div>
<div style="display: flex; align-items: center; margin-top: 4px;">
<NcButton
:aria-label="t('settings', 'Confirm adding new mount')"
:aria-label="t('appstore', 'Confirm adding new mount')"
@click="addMountPoint">
<template #icon>
<NcIconSvgWrapper :path="mdiCheck" />
</template>
{{ t('settings', 'Confirm') }}
{{ t('appstore', 'Confirm') }}
</NcButton>
<NcButton
:aria-label="t('settings', 'Cancel adding mount')"
:aria-label="t('appstore', 'Cancel adding mount')"
style="margin-left: 4px;"
@click="cancelAddMountPoint">
<template #icon>
<NcIconSvgWrapper :path="mdiClose" />
</template>
{{ t('settings', 'Cancel') }}
{{ t('appstore', 'Cancel') }}
</NcButton>
</div>
</div>
<NcButton
v-if="!addingMount"
:aria-label="t('settings', 'Add mount')"
:aria-label="t('appstore', 'Add mount')"
style="margin-top: 5px;"
@click="startAddingMount">
<template #icon>
<NcIconSvgWrapper :path="mdiPlus" />
</template>
{{ t('settings', 'Add mount') }}
{{ t('appstore', 'Add mount') }}
</NcButton>
</template>
<template v-else-if="configuredDeployOptions.mounts.length > 0">
<p class="deploy-option__hint">
{{ t('settings', 'ExApp container mounts') }}
{{ t('appstore', 'ExApp container mounts') }}
</p>
<div
v-for="mount in configuredDeployOptions.mounts"
:key="mount.hostPath"
class="deploy-option"
style="display: flex; align-items: center; justify-content: space-between; flex-direction: row;">
<NcTextField v-model="mount.hostPath" :label="t('settings', 'Host path')" readonly />
<NcTextField v-model="mount.containerPath" :label="t('settings', 'Container path')" readonly />
<NcTextField v-model="mount.hostPath" :label="t('appstore', 'Host path')" readonly />
<NcTextField v-model="mount.containerPath" :label="t('appstore', 'Container path')" readonly />
<NcCheckboxRadioSwitch v-model="mount.readonly" disabled>
{{ t('settings', 'Read-only') }}
{{ t('appstore', 'Read-only') }}
</NcCheckboxRadioSwitch>
</div>
</template>
<p v-else class="deploy-option__hint">
{{ t('settings', 'No mounts defined') }}
{{ t('appstore', 'No mounts defined') }}
</p>
</div>
@@ -165,6 +165,7 @@ import { mdiCheck, mdiClose, mdiDeleteOutline, mdiPlus } from '@mdi/js'
import axios from '@nextcloud/axios'
import { emit } from '@nextcloud/event-bus'
import { loadState } from '@nextcloud/initial-state'
import { t } from '@nextcloud/l10n'
import { generateUrl } from '@nextcloud/router'
import { computed, ref } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
@@ -222,6 +223,8 @@ export default {
})
return {
t,
environmentVariables,
deployOptions,
store,
@@ -244,7 +247,7 @@ export default {
addingPortBinding: false,
configuredDeployOptions: null,
deployOptionsDocsUrl: loadState('settings', 'deployOptionsDocsUrl', null),
deployOptionsDocsUrl: loadState('appstore', 'deployOptionsDocsUrl', null),
}
},
@@ -6,7 +6,7 @@
<template>
<NcAppSidebarTab
id="desc"
:name="t('settings', 'Description')"
:name="t('appstore', 'Description')"
:order="0">
<template #icon>
<NcIconSvgWrapper :path="mdiTextShort" />
@@ -24,7 +24,7 @@ import { mdiTextShort } from '@mdi/js'
import { translate as t } from '@nextcloud/l10n'
import NcAppSidebarTab from '@nextcloud/vue/components/NcAppSidebarTab'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import Markdown from '../Markdown.vue'
import MarkdownPreview from '../MarkdownPreview.vue'
defineProps<{
app: IAppstoreApp
@@ -6,7 +6,7 @@
<template>
<NcAppSidebarTab
id="details"
:name="t('settings', 'Details')"
:name="t('appstore', 'Details')"
:order="1">
<template #icon>
<NcIconSvgWrapper :path="mdiTextBoxOutline" />
@@ -21,15 +21,15 @@
:value="app.id"
class="groups-enable__checkbox checkbox"
@change="setGroupLimit">
<label :for="`groups_enable_${app.id}`">{{ t('settings', 'Limit to groups') }}</label>
<label :for="`groups_enable_${app.id}`">{{ t('appstore', 'Limit to groups') }}</label>
<input
type="hidden"
class="group_select"
:title="t('settings', 'All')"
:title="t('appstore', 'All')"
value="">
<br>
<label for="limitToGroups">
<span>{{ t('settings', 'Limit app usage to groups') }}</span>
<span>{{ t('appstore', 'Limit app usage to groups') }}</span>
</label>
<NcSelect
v-if="isLimitedToGroups(app)"
@@ -43,7 +43,7 @@
@option:selected="addGroupLimitation"
@option:deselected="removeGroupLimitation"
@search="asyncFindGroup">
<span slot="noResult">{{ t('settings', 'No results') }}</span>
<span slot="noResult">{{ t('appstore', 'No results') }}</span>
</NcSelect>
</div>
<div class="app-details__actions-manage">
@@ -51,14 +51,14 @@
v-if="app.update"
class="update primary"
type="button"
:value="t('settings', 'Update to {version}', { version: app.update })"
:value="t('appstore', 'Update to {version}', { version: app.update })"
:disabled="installing || isLoading || isManualInstall"
@click="update(app.id)">
<input
v-if="app.canUnInstall"
class="uninstall"
type="button"
:value="t('settings', 'Remove')"
:value="t('appstore', 'Remove')"
:disabled="installing || isLoading"
@click="remove(app.id, removeData)">
<input
@@ -88,36 +88,36 @@
@click="forceEnable(app.id)">
<NcButton
v-if="app?.app_api && (app.canInstall || app.isCompatible)"
:aria-label="t('settings', 'Advanced deploy options')"
:aria-label="t('appstore', 'Advanced deploy options')"
variant="secondary"
@click="() => showDeployOptionsModal = true">
<template #icon>
<NcIconSvgWrapper :path="mdiToyBrickPlusOutline" />
</template>
{{ t('settings', 'Deploy options') }}
{{ t('appstore', 'Deploy options') }}
</NcButton>
</div>
<p v-if="!defaultDeployDaemonAccessible" class="warning">
{{ t('settings', 'Default Deploy daemon is not accessible') }}
{{ t('appstore', 'Default Deploy daemon is not accessible') }}
</p>
<NcCheckboxRadioSwitch
v-if="app.canUnInstall"
:model-value="removeData"
:disabled="installing || isLoading || !defaultDeployDaemonAccessible"
@update:modelValue="toggleRemoveData">
{{ t('settings', 'Delete data on remove') }}
{{ t('appstore', 'Delete data on remove') }}
</NcCheckboxRadioSwitch>
</div>
<ul class="app-details__dependencies">
<li v-if="app.missingMinOwnCloudVersion">
{{ t('settings', 'This app has no minimum {productName} version assigned. This will be an error in the future.', { productName }) }}
{{ t('appstore', 'This app has no minimum {productName} version assigned. This will be an error in the future.', { productName }) }}
</li>
<li v-if="app.missingMaxOwnCloudVersion">
{{ t('settings', 'This app has no maximum {productName} version assigned. This will be an error in the future.', { productName }) }}
{{ t('appstore', 'This app has no maximum {productName} version assigned. This will be an error in the future.', { productName }) }}
</li>
<li v-if="!app.canInstall">
{{ t('settings', 'This app cannot be installed because the following dependencies are not fulfilled:') }}
{{ t('appstore', 'This app cannot be installed because the following dependencies are not fulfilled:') }}
<ul class="missing-dependencies">
<li v-for="(dep, index) in app.missingDependencies" :key="index">
{{ dep }}
@@ -128,14 +128,14 @@
<div v-if="lastModified && !app.shipped" class="app-details__section">
<h4>
{{ t('settings', 'Latest updated') }}
{{ t('appstore', 'Latest updated') }}
</h4>
<NcDateTime :timestamp="lastModified" />
</div>
<div class="app-details__section">
<h4>
{{ t('settings', 'Author') }}
{{ t('appstore', 'Author') }}
</h4>
<p class="app-details__authors">
{{ appAuthors }}
@@ -144,7 +144,7 @@
<div class="app-details__section">
<h4>
{{ t('settings', 'Categories') }}
{{ t('appstore', 'Categories') }}
</h4>
<p>
{{ appCategories }}
@@ -152,8 +152,8 @@
</div>
<div v-if="externalResources.length > 0" class="app-details__section">
<h4>{{ t('settings', 'Resources') }}</h4>
<ul class="app-details__documentation" :aria-label="t('settings', 'Documentation')">
<h4>{{ t('appstore', 'Resources') }}</h4>
<ul class="app-details__documentation" :aria-label="t('appstore', 'Documentation')">
<li v-for="resource of externalResources" :key="resource.id">
<a
class="appslink"
@@ -167,13 +167,13 @@
</div>
<div class="app-details__section">
<h4>{{ t('settings', 'Interact') }}</h4>
<h4>{{ t('appstore', 'Interact') }}</h4>
<div class="app-details__interact">
<NcButton
:disabled="!app.bugs"
:href="app.bugs ?? '#'"
:aria-label="t('settings', 'Report a bug')"
:title="t('settings', 'Report a bug')">
:aria-label="t('appstore', 'Report a bug')"
:title="t('appstore', 'Report a bug')">
<template #icon>
<NcIconSvgWrapper :path="mdiBugOutline" />
</template>
@@ -181,8 +181,8 @@
<NcButton
:disabled="!app.bugs"
:href="app.bugs ?? '#'"
:aria-label="t('settings', 'Request feature')"
:title="t('settings', 'Request feature')">
:aria-label="t('appstore', 'Request feature')"
:title="t('appstore', 'Request feature')">
<template #icon>
<NcIconSvgWrapper :path="mdiFeatureSearchOutline" />
</template>
@@ -190,8 +190,8 @@
<NcButton
v-if="app.appstoreData?.discussion"
:href="app.appstoreData.discussion"
:aria-label="t('settings', 'Ask questions or discuss')"
:title="t('settings', 'Ask questions or discuss')">
:aria-label="t('appstore', 'Ask questions or discuss')"
:title="t('appstore', 'Ask questions or discuss')">
<template #icon>
<NcIconSvgWrapper :path="mdiTooltipQuestionOutline" />
</template>
@@ -199,8 +199,8 @@
<NcButton
v-if="!app.internal"
:href="rateAppUrl"
:aria-label="t('settings', 'Rate the app')"
:title="t('settings', 'Rate')">
:aria-label="t('appstore', 'Rate the app')"
:title="t('appstore', 'Rate')">
<template #icon>
<NcIconSvgWrapper :path="mdiStar" />
</template>
@@ -335,14 +335,14 @@ export default {
resources.push({
id: 'appstore',
href: this.appstoreUrl,
label: t('settings', 'View in store'),
label: t('appstore', 'View in store'),
})
}
if (this.app.website) {
resources.push({
id: 'website',
href: this.app.website,
label: t('settings', 'Visit website'),
label: t('appstore', 'Visit website'),
})
}
if (this.app.documentation) {
@@ -350,21 +350,21 @@ export default {
resources.push({
id: 'doc-user',
href: this.app.documentation.user,
label: t('settings', 'Usage documentation'),
label: t('appstore', 'Usage documentation'),
})
}
if (this.app.documentation.admin) {
resources.push({
id: 'doc-admin',
href: this.app.documentation.admin,
label: t('settings', 'Admin documentation'),
label: t('appstore', 'Admin documentation'),
})
}
if (this.app.documentation.developer) {
resources.push({
id: 'doc-developer',
href: this.app.documentation.developer,
label: t('settings', 'Developer documentation'),
label: t('appstore', 'Developer documentation'),
})
}
}
@@ -6,14 +6,14 @@
<NcAppSidebarTab
v-if="hasChangelog"
id="changelog"
:name="t('settings', 'Changelog')"
:name="t('appstore', 'Changelog')"
:order="2">
<template #icon>
<NcIconSvgWrapper :path="mdiClockFast" :size="24" />
</template>
<div v-for="release in app.releases" :key="release.version" class="app-sidebar-tabs__release">
<h2>{{ release.version }}</h2>
<Markdown
<MarkdownPreview
class="app-sidebar-tabs__release-text"
:text="createChangelogFromRelease(release)" />
</div>
@@ -28,7 +28,7 @@ import { getLanguage, translate as t } from '@nextcloud/l10n'
import { computed } from 'vue'
import NcAppSidebarTab from '@nextcloud/vue/components/NcAppSidebarTab'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import Markdown from '../Markdown.vue'
import MarkdownPreview from '../MarkdownPreview.vue'
const props = defineProps<{ app: IAppstoreApp }>()
@@ -0,0 +1,50 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import type { IAppstoreApp, IAppstoreExApp } from '../../apps.d.ts'
import { t } from '@nextcloud/l10n'
import { useElementSize } from '@vueuse/core'
import { computed, useTemplateRef } from 'vue'
import AppTableRow from './AppTableRow.vue'
defineProps<{
apps: (IAppstoreApp | IAppstoreExApp)[]
}>()
const tableElement = useTemplateRef('table')
const { width: tableWidth } = useElementSize(tableElement)
const isNarrow = computed(() => tableWidth.value < 768)
</script>
<template>
<table ref="table" :class="$style.appTable">
<thead hidden>
<tr>
<th>{{ t('appstore', 'App name') }}</th>
<th>{{ t('appstore', 'Version') }}</th>
<th v-if="!isNarrow">
{{ t('appstore', 'Support level') }}
</th>
<th>{{ t('appstore', 'Actions') }}</th>
</tr>
</thead>
<tbody>
<AppTableRow
v-for="app in apps"
:key="app.id"
:app
:isNarrow />
</tbody>
</table>
</template>
<style module>
.appTable {
width: 100%;
}
</style>
@@ -0,0 +1,149 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import type { IAppstoreApp, IAppstoreExApp } from '../../apps.d.ts'
import { mdiInformationOutline } from '@mdi/js'
import { t } from '@nextcloud/l10n'
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
import NcActionRouter from '@nextcloud/vue/components/NcActionRouter'
import NcActions from '@nextcloud/vue/components/NcActions'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import AppDaemonBadge from '../AppDaemonBadge.vue'
import AppIcon from '../AppIcon.vue'
import AppLevelBadge from '../AppLevelBadge.vue'
import { useActions } from '../../composables/useActions.ts'
const { app, isNarrow } = defineProps<{
app: IAppstoreApp | IAppstoreExApp
isNarrow?: boolean
}>()
const actions = useActions(() => app)
const inlineActions = computed(() => {
if (actions.value.length === 1) {
return [...actions.value]
}
if (isNarrow) {
return []
}
return actions.value.slice(0, 1)
.filter((action) => action.inline !== false)
})
const menuActions = computed(() => actions.value.slice(inlineActions.value.length))
const route = useRoute()
const detailsRoute = computed(() => ({
name: route.name!,
params: {
...route.params,
id: app.id,
},
}))
</script>
<template>
<tr :class="$style.appTableRow">
<td :class="$style.appTableRow__nameCell">
<NcButton
alignment="start"
:title="t('appstore', 'Show details')"
:to="detailsRoute"
variant="tertiary-no-background"
wide>
<template #icon>
<NcLoadingIcon v-if="app.loading" :size="24" />
<AppIcon v-else :app :size="24" />
</template>
{{ app.name }}
<span v-if="app.loading" class="hidden-visually">({{ t('appstore', 'is loading') }})</span>
<span class="hidden-visually">({{ t('appstore', 'Show details') }})</span>
</NcButton>
</td>
<td>
<span :class="$style.appTableRow__versionCell">{{ app.version }}</span>
</td>
<td v-if="!isNarrow">
<div :class="$style.appTableRow__levelCell">
<AppLevelBadge v-if="app.level" :level="app.level" />
<AppDaemonBadge v-if="'daemon' in app && app.daemon" :daemon="app.daemon" />
</div>
</td>
<td>
<div :class="$style.appTableRow__actionsCell">
<NcButton
v-for="action in inlineActions"
:key="action.id"
:ariaLabel="isNarrow ? action.label(app) : undefined"
:title="isNarrow ? action.label(app) : undefined"
:variant="action.variant"
@click="action.callback(app)">
<template #icon>
<NcIconSvgWrapper :path="action.icon" />
</template>
<template v-if="!isNarrow" #default>
{{ action.label(app) }}
</template>
</NcButton>
<NcActions forceMenu>
<NcActionButton
v-for="action in menuActions"
:key="action.id"
closeAfterClick
:variant="action.variant"
@click="action.callback(app)">
<template #icon>
<NcIconSvgWrapper :path="action.icon" />
</template>
{{ action.label(app) }}
</NcActionButton>
<NcActionRouter closeAfterClick :to="detailsRoute">
<template #icon>
<NcIconSvgWrapper :path="mdiInformationOutline" />
</template>
{{ t('appstore', 'Show details') }}
</NcActionRouter>
</NcActions>
</div>
</td>
</tr>
</template>
<style module>
.appTableRow {
height: calc(var(--default-clickable-area) + var(--default-grid-baseline));
}
.appTableRow td {
padding-block: var(--default-grid-baseline);
vertical-align: middle;
}
.appTableRow__nameCell {
/* Padding is needed to have proper focus-visible */
padding-inline: var(--default-grid-baseline);
}
.appTableRow__levelCell {
display: flex;
align-items: center;
gap: var(--default-grid-baseline)
}
.appTableRow__versionCell {
color: var(--color-text-maxcontrast);
}
.appTableRow__actionsCell {
display: flex;
gap: var(--default-grid-baseline);
justify-content: end;
}
</style>
@@ -0,0 +1,59 @@
<!--
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import type { IAppstoreExApp, IDeployDaemon } from '../../apps.d.ts'
import { mdiFormatListBulleted } from '@mdi/js'
import { t } from '@nextcloud/l10n'
import { generateUrl } from '@nextcloud/router'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcDialog from '@nextcloud/vue/components/NcDialog'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import DaemonSelectionDialogList from './DaemonSelectionDialogList.vue'
import { useExAppsStore } from '../../store/exApps.ts'
defineProps<{
/**
* The app to enable
*/
app: IAppstoreExApp
}>()
defineEmits<{
close: [daemon?: IDeployDaemon]
}>()
const store = useExAppsStore()
const appApiAdminPage = generateUrl('/settings/admin/app_api')
</script>
<template>
<NcDialog
:name="t('appstore', 'Choose Deploy Daemon for {appName}', { appName: app.name })"
size="normal"
@update:open="$event || $emit('close')">
<NcEmptyContent
v-if="store.dockerDaemons.length > 0"
class="daemon-selection-list__empty-content"
:name="t('appstore', 'No Deploy daemons configured')"
:description="t('appstore', 'Register a custom one or setup from available templates')">
<template #icon>
<NcIconSvgWrapper :path="mdiFormatListBulleted" />
</template>
<template #action>
<NcButton :href="appApiAdminPage">
{{ t('appstore', 'Manage Deploy daemons') }}
</NcButton>
</template>
</NcEmptyContent>
<DaemonSelectionDialogList
v-else
:app="app"
@selected="$emit('close', $event)" />
</NcDialog>
</template>
@@ -0,0 +1,39 @@
<!--
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import type { IDeployDaemon } from '../../apps.d.ts'
import { t } from '@nextcloud/l10n'
import DaemonSelectionDialogListEntry from './DaemonSelectionDialogListEntry.vue'
import { useExAppsStore } from '../../store/exApps.ts'
defineEmits<{
selected: [daemon: IDeployDaemon]
}>()
const store = useExAppsStore()
</script>
<template>
<ul
:class="$style.DaemonSelectionDialogList"
:aria-label="t('appstore', 'Registered Deploy daemons list')">
<DaemonSelectionDialogListEntry
v-for="daemon in store.dockerDaemons"
:key="daemon.id"
:daemon
:isDefault="store.defaultDaemon!.name === daemon.name"
@selected="$emit('selected', daemon)" />
</ul>
</template>
<style module>
.DaemonSelectionDialogList {
max-height: 350px;
overflow-y: scroll;
padding: 2rem;
}
</style>
@@ -0,0 +1,44 @@
<!--
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import type { IDeployDaemon } from '../../apps.d.ts'
import { t } from '@nextcloud/l10n'
import { computed } from 'vue'
import NcListItem from '@nextcloud/vue/components/NcListItem'
const props = defineProps<{
/**
* The daemon to use
*/
daemon: IDeployDaemon
/**
* Whether this daemon is the default one
*/
isDefault: boolean
}>()
const emit = defineEmits<{
selected: []
}>()
const itemTitle = computed(() => `${props.daemon.name} - ${props.daemon.display_name}`)
</script>
<template>
<NcListItem
:active="isDefault"
:counterNumber="daemon.exAppsCount"
counterType="highlighted"
:details="isDefault ? t('appstore', 'Default') : ''"
forceDisplayActions
:name="itemTitle"
@click.stop="emit('selected')">
<template #subname>
{{ daemon.accepts_deploy_id }}
</template>
</NcListItem>
</template>
@@ -3,15 +3,8 @@
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<AppItem
v-if="app"
:app="app"
category="discover"
class="app-discover-app"
inline
:list-view="false" />
<a
v-else
v-if="!app"
class="app-discover-app app-discover-app__skeleton"
:href="appStoreLink"
target="_blank"
@@ -24,14 +17,32 @@
<span class="skeleton-element" />
<span class="skeleton-element" />
</a>
<article v-else class="app-discover-app">
<AppImage class="app-discover-app__image" :app="app" />
<div class="app-discover-app__wrapper">
<h3 class="app-discover-app__name">
<AppLink :href="`app:${app.id}`">
{{ app.name }}
</AppLink>
</h3>
<p>{{ app.summary }}</p>
<AppScore
v-if="app.ratingNumThresholdReached"
class="app-discover-app__score"
:score="app.score" />
</div>
</article>
</template>
<script setup lang="ts">
import type { IAppDiscoverApp } from '../../constants/AppDiscoverTypes.ts'
import type { IAppDiscoverApp } from '../../apps-discover.d.ts'
import { computed } from 'vue'
import AppItem from '../AppList/AppItem.vue'
import { useAppsStore } from '../../store/apps-store.ts'
import AppImage from '../AppImage.vue'
import AppLink from '../AppLink.vue'
import AppScore from '../AppScore.vue'
import { useAppsStore } from '../../store/apps.ts'
const props = defineProps<{
modelValue: IAppDiscoverApp
@@ -40,16 +51,42 @@ const props = defineProps<{
const store = useAppsStore()
const app = computed(() => store.getAppById(props.modelValue.appId))
const appStoreLink = computed(() => props.modelValue.appId ? `https://apps.nextcloud.com/apps/${props.modelValue.appId}` : '#')
const appStoreLink = computed(() => props.modelValue.appId
? `https://apps.nextcloud.com/apps/${props.modelValue.appId}`
: '#')
</script>
<style scoped lang="scss">
.app-discover-app {
width: 100% !important; // full with of the showcase item
border-radius: var(--border-radius-element);
display: flex;
flex-direction: column;
overflow: hidden;
width: 100% !important;
&:hover {
background: var(--color-background-hover);
border-radius: var(--border-radius-rounded);
}
&__image {
height: 96px;
width: 100%;
}
&__name {
margin-block: 0.5rem;
font-size: 1.2rem;
}
&__score {
margin-top: auto;
}
&__wrapper {
display: flex;
flex-direction: column;
padding: calc(2 * var(--default-grid-baseline));
padding-top: 0px;
}
&__skeleton {
@@ -3,7 +3,7 @@
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<section :aria-roledescription="t('settings', 'Carousel')" :aria-labelledby="headingId ? `${headingId}` : undefined">
<section :aria-roledescription="t('appstore', 'Carousel')" :aria-labelledby="headingId ? `${headingId}` : undefined">
<h3 v-if="headline" :id="headingId">
{{ translatedHeadline }}
</h3>
@@ -12,7 +12,7 @@
<NcButton
class="app-discover-carousel__button app-discover-carousel__button--previous"
variant="tertiary-no-background"
:aria-label="t('settings', 'Previous slide')"
:aria-label="t('appstore', 'Previous slide')"
:disabled="!hasPrevious"
@click="currentIndex -= 1">
<template #icon>
@@ -22,7 +22,7 @@
</div>
<Transition :name="transitionName" mode="out-in">
<PostType
<DiscoverTypePost
v-bind="shownElement"
:key="shownElement.id ?? currentIndex"
:aria-labelledby="`${internalId}-tab-${currentIndex}`"
@@ -35,7 +35,7 @@
<NcButton
class="app-discover-carousel__button app-discover-carousel__button--next"
variant="tertiary-no-background"
:aria-label="t('settings', 'Next slide')"
:aria-label="t('appstore', 'Next slide')"
:disabled="!hasNext"
@click="currentIndex += 1">
<template #icon>
@@ -44,12 +44,12 @@
</NcButton>
</div>
</div>
<div class="app-discover-carousel__tabs" role="tablist" :aria-label="t('settings', 'Choose slide to display')">
<div class="app-discover-carousel__tabs" role="tablist" :aria-label="t('appstore', 'Choose slide to display')">
<NcButton
v-for="index of content.length"
:id="`${internalId}-tab-${index}`"
:key="index"
:aria-label="t('settings', '{index} of {total}', { index, total: content.length })"
:aria-label="t('appstore', '{index} of {total}', { index, total: content.length })"
:aria-controls="`${internalId}-tabpanel-${index}`"
:aria-selected="`${currentIndex === (index - 1)}`"
role="tab"
@@ -63,85 +63,53 @@
</section>
</template>
<script lang="ts">
<script setup lang="ts">
import type { PropType } from 'vue'
import type { IAppDiscoverCarousel } from '../../constants/AppDiscoverTypes.ts'
import type { IAppDiscoverCarousel } from '../../apps-discover.d.ts'
import { mdiChevronLeft, mdiChevronRight, mdiCircleOutline, mdiCircleSlice8 } from '@mdi/js'
import { translate as t } from '@nextcloud/l10n'
import { computed, defineComponent, nextTick, ref, watch } from 'vue'
import { t } from '@nextcloud/l10n'
import { computed, nextTick, ref, watch } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import PostType from './PostType.vue'
import DiscoverTypePost from './DiscoverTypePost.vue'
import { useLocalizedValue } from '../../composables/useGetLocalizedValue.ts'
import { commonAppDiscoverProps } from './common.ts'
export default defineComponent({
name: 'CarouselType',
const props = defineProps({
...commonAppDiscoverProps,
components: {
NcButton,
NcIconSvgWrapper,
PostType,
/**
* The content of the carousel
*/
content: {
type: Array as PropType<IAppDiscoverCarousel['content']>,
required: true,
},
})
props: {
...commonAppDiscoverProps,
const translatedHeadline = useLocalizedValue(computed(() => props.headline))
/**
* The content of the carousel
*/
content: {
type: Array as PropType<IAppDiscoverCarousel['content']>,
required: true,
},
},
const currentIndex = ref(Math.min(1, props.content.length - 1))
const shownElement = ref(props.content[currentIndex.value]!)
const hasNext = computed(() => currentIndex.value < (props.content.length - 1))
const hasPrevious = computed(() => currentIndex.value > 0)
setup(props) {
const translatedHeadline = useLocalizedValue(computed(() => props.headline))
const internalId = computed(() => props.id ?? (Math.random() + 1).toString(36).substring(7))
const headingId = computed(() => `${internalId.value}-h`)
const currentIndex = ref(Math.min(1, props.content.length - 1))
const shownElement = ref(props.content[currentIndex.value])
const hasNext = computed(() => currentIndex.value < (props.content.length - 1))
const hasPrevious = computed(() => currentIndex.value > 0)
const transitionName = ref('slide-in')
watch(() => currentIndex.value, (o, n) => {
if (o < n) {
transitionName.value = 'slide-in'
} else {
transitionName.value = 'slide-out'
}
const internalId = computed(() => props.id ?? (Math.random() + 1).toString(36).substring(7))
const headingId = computed(() => `${internalId.value}-h`)
const transitionName = ref('slide-in')
watch(() => currentIndex.value, (o, n) => {
if (o < n) {
transitionName.value = 'slide-in'
} else {
transitionName.value = 'slide-out'
}
// Wait next tick
nextTick(() => {
shownElement.value = props.content[currentIndex.value]
})
})
return {
t,
internalId,
headingId,
hasNext,
hasPrevious,
currentIndex,
shownElement,
transitionName,
translatedHeadline,
mdiChevronLeft,
mdiChevronRight,
mdiCircleOutline,
mdiCircleSlice8,
}
},
// Wait next tick
nextTick(() => {
shownElement.value = props.content[currentIndex.value]!
})
})
</script>
@@ -47,7 +47,7 @@
:type="source.mime">
<img
v-if="isImage"
:src="generatePrivacyUrl(mediaSources[0].src)"
:src="generatePrivacyUrl(mediaSources[0]!.src)"
:alt="mediaAlt">
</component>
<div class="app-discover-post__play-icon-wrapper">
@@ -61,137 +61,109 @@
</article>
</template>
<script lang="ts">
<script setup lang="ts">
import type { PropType } from 'vue'
import type { IAppDiscoverPost } from '../../constants/AppDiscoverTypes.ts'
import type { IAppDiscoverPost } from '../../apps-discover.d.ts'
import { mdiPlayCircleOutline } from '@mdi/js'
import { generateUrl } from '@nextcloud/router'
import { useElementSize, useElementVisibility } from '@vueuse/core'
import { computed, defineComponent, ref, watchEffect } from 'vue'
import { computed, ref, watchEffect } from 'vue'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import AppLink from './AppLink.vue'
import { useLocalizedValue } from '../../composables/useGetLocalizedValue.ts'
import { commonAppDiscoverProps } from './common.ts'
export default defineComponent({
components: {
AppLink,
NcIconSvgWrapper,
const props = defineProps({
...commonAppDiscoverProps,
text: {
type: Object as PropType<IAppDiscoverPost['text']>,
required: false,
default: () => null,
},
props: {
...commonAppDiscoverProps,
text: {
type: Object as PropType<IAppDiscoverPost['text']>,
required: false,
default: () => null,
},
media: {
type: Object as PropType<IAppDiscoverPost['media']>,
required: false,
default: () => null,
},
inline: {
type: Boolean,
required: false,
default: false,
},
domId: {
type: String,
required: false,
default: null,
},
media: {
type: Object as PropType<IAppDiscoverPost['media']>,
required: false,
default: () => null,
},
setup(props) {
const translatedHeadline = useLocalizedValue(computed(() => props.headline))
const translatedText = useLocalizedValue(computed(() => props.text))
const localizedMedia = useLocalizedValue(computed(() => props.media?.content))
inline: {
type: Boolean,
required: false,
default: false,
},
const mediaSources = computed(() => localizedMedia.value !== null ? [localizedMedia.value.src].flat() : undefined)
const mediaAlt = computed(() => localizedMedia.value?.alt ?? '')
domId: {
type: String,
required: false,
default: null,
},
})
const isImage = computed(() => mediaSources?.value?.[0].mime.startsWith('image/') === true)
/**
* Is the media is shown full width
*/
const isFullWidth = computed(() => !translatedHeadline.value && !translatedText.value)
const translatedHeadline = useLocalizedValue(computed(() => props.headline))
const translatedText = useLocalizedValue(computed(() => props.text))
const localizedMedia = useLocalizedValue(computed(() => props.media?.content))
/**
* Link on the media
* Fallback to post link to prevent link inside link (which is invalid HTML)
*/
const mediaLink = computed(() => localizedMedia.value?.link ?? props.link)
const mediaSources = computed(() => localizedMedia.value !== null ? [localizedMedia.value.src].flat() : undefined)
const mediaAlt = computed(() => localizedMedia.value?.alt ?? '')
const hasPlaybackEnded = ref(false)
const showPlayVideo = computed(() => localizedMedia.value?.link && hasPlaybackEnded.value)
const isImage = computed(() => mediaSources.value?.[0]?.mime.startsWith('image/') === true)
/**
* Is the media is shown full width
*/
const isFullWidth = computed(() => !translatedHeadline.value && !translatedText.value)
/**
* The content is sized / styles are applied based on the container width
* To make it responsive even for inline usage and when opening / closing the sidebar / navigation
*/
const container = ref<HTMLElement>()
const { width: containerWidth } = useElementSize(container)
const isSmallWidth = computed(() => containerWidth.value < 600)
/**
* Link on the media
* Fallback to post link to prevent link inside link (which is invalid HTML)
*/
const mediaLink = computed(() => localizedMedia.value?.link ?? props.link)
/**
* Generate URL for cached media to prevent user can be tracked
*
* @param url The URL to resolve
*/
const generatePrivacyUrl = (url: string) => url.startsWith('/') ? url : generateUrl('/settings/api/apps/media?fileName={fileName}', { fileName: url })
const hasPlaybackEnded = ref(false)
const showPlayVideo = computed(() => localizedMedia.value?.link && hasPlaybackEnded.value)
const mediaElement = ref<HTMLVideoElement | HTMLPictureElement>()
const mediaIsVisible = useElementVisibility(mediaElement, { threshold: 0.3 })
watchEffect(() => {
// Only if media is video
if (!isImage.value && mediaElement.value) {
const video = mediaElement.value as HTMLVideoElement
/**
* The content is sized / styles are applied based on the container width
* To make it responsive even for inline usage and when opening / closing the sidebar / navigation
*/
const container = ref<HTMLElement>()
const { width: containerWidth } = useElementSize(container)
const isSmallWidth = computed(() => containerWidth.value < 600)
if (mediaIsVisible.value) {
// Ensure video is muted - otherwise .play() will be blocked by browsers
video.muted = true
// If visible start playback
video.play()
} else {
// If not visible pause the playback
video.pause()
// If the animation has ended reset
if (video.ended) {
video.currentTime = 0
hasPlaybackEnded.value = false
}
}
/**
* Generate URL for cached media to prevent user can be tracked
*
* @param url The URL to resolve
*/
function generatePrivacyUrl(url: string) {
return url.startsWith('/')
? url
: generateUrl('/apps/appstore/api/v1/discover/media?fileName={fileName}', { fileName: url })
}
const mediaElement = ref<HTMLVideoElement | HTMLPictureElement>()
const mediaIsVisible = useElementVisibility(mediaElement, { threshold: 0.3 })
watchEffect(() => {
// Only if media is video
if (!isImage.value && mediaElement.value) {
const video = mediaElement.value as HTMLVideoElement
if (mediaIsVisible.value) {
// Ensure video is muted - otherwise .play() will be blocked by browsers
video.muted = true
// If visible start playback
video.play()
} else {
// If not visible pause the playback
video.pause()
// If the animation has ended reset
if (video.ended) {
video.currentTime = 0
hasPlaybackEnded.value = false
}
})
return {
mdiPlayCircleOutline,
container,
translatedText,
translatedHeadline,
mediaElement,
mediaSources,
mediaAlt,
mediaLink,
hasPlaybackEnded,
showPlayVideo,
isFullWidth,
isSmallWidth,
isImage,
generatePrivacyUrl,
}
},
}
})
</script>
@@ -16,71 +16,50 @@
<ul class="app-discover-showcase__list">
<li
v-for="(item, index) of content"
:key="item.id ?? index"
:key="'id' in item ? item.id : index"
class="app-discover-showcase__item">
<PostType
<DiscoverTypePost
v-if="item.type === 'post'"
v-bind="item"
inline />
<AppType v-else-if="item.type === 'app'" :model-value="item" />
<DiscoverTypeApp v-else-if="item.type === 'app'" :model-value="item" />
</li>
</ul>
</section>
</template>
<script lang="ts">
<script setup lang="ts">
import type { PropType } from 'vue'
import type { IAppDiscoverShowcase } from '../../constants/AppDiscoverTypes.ts'
import type { IAppDiscoverShowcase } from '../../apps-discover.d.ts'
import { translate as t } from '@nextcloud/l10n'
import { useElementSize } from '@vueuse/core'
import { computed, defineComponent, ref } from 'vue'
import AppType from './AppType.vue'
import PostType from './PostType.vue'
import { computed, ref } from 'vue'
import DiscoverTypeApp from './DiscoverTypeApp.vue'
import DiscoverTypePost from './DiscoverTypePost.vue'
import { useLocalizedValue } from '../../composables/useGetLocalizedValue.ts'
import { commonAppDiscoverProps } from './common.ts'
export default defineComponent({
name: 'ShowcaseType',
const props = defineProps({
...commonAppDiscoverProps,
components: {
AppType,
PostType,
},
props: {
...commonAppDiscoverProps,
/**
* The content of the carousel
*/
content: {
type: Array as PropType<IAppDiscoverShowcase['content']>,
required: true,
},
},
setup(props) {
const translatedHeadline = useLocalizedValue(computed(() => props.headline))
/**
* Make the element responsive based on the container width to also handle open navigation or sidebar
*/
const container = ref<HTMLElement>()
const { width: containerWidth } = useElementSize(container)
const isSmallWidth = computed(() => containerWidth.value < 768)
const isExtraSmallWidth = computed(() => containerWidth.value < 512)
return {
t,
container,
isSmallWidth,
isExtraSmallWidth,
translatedHeadline,
}
/**
* The content of the carousel
*/
content: {
type: Array as PropType<IAppDiscoverShowcase['content']>,
required: true,
},
})
const translatedHeadline = useLocalizedValue(computed(() => props.headline))
/**
* Make the element responsive based on the container width to also handle open navigation or sidebar
*/
const container = ref<HTMLElement>()
const { width: containerWidth } = useElementSize(container)
const isSmallWidth = computed(() => containerWidth.value < 768)
const isExtraSmallWidth = computed(() => containerWidth.value < 512)
</script>
<style scoped lang="scss">
@@ -3,9 +3,9 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { PropType } from 'vue'
import type { IAppDiscoverElement } from '../../constants/AppDiscoverTypes.ts'
import type { IAppDiscoverElement } from '../../apps-discover.d.ts'
import { APP_DISCOVER_KNOWN_TYPES } from '../../constants/AppDiscoverTypes.ts'
import { APP_DISCOVER_KNOWN_TYPES } from '../../constants.ts'
/**
* Common Props for all app discover types
@@ -0,0 +1,33 @@
/*!
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { cleanup, render } from '@testing-library/vue'
import { beforeEach, describe, expect, it } from 'vitest'
import MarkdownPreview from './MarkdownPreview.vue'
describe('MarkdownPreview component', () => {
beforeEach(cleanup)
it('renders', () => {
const component = render(MarkdownPreview, {
props: {
minHeadingLevel: 2,
text: `# Heading one
This is [a link](http://example.com)!
## Heading two
> This is a block quote
![](http://example.com/image.jpg "Title")`,
},
})
expect(component.getByRole('heading', { level: 2, name: 'Heading one' })).toBeTruthy()
expect(component.getByRole('heading', { level: 3, name: 'Heading two' })).toBeTruthy()
expect(component.getByText('This is a block quote')).toBeInstanceOf(HTMLQuoteElement)
expect(component.getByRole('link', { name: 'a link' })).toBeInstanceOf(HTMLAnchorElement)
expect(component.getByRole('link', { name: 'a link' }).getAttribute('href')).toBe('http://example.com')
expect(() => component.getByRole('img')).toThrow() // its a text
})
})
@@ -0,0 +1,84 @@
<!--
- SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import { useMarkdown } from '../composables/useMarkdown.ts'
const {
text,
minHeadingLevel = 1,
} = defineProps<{
/**
* The markdown text to render
*/
text: string
/**
* Limit the minimum heading level
*/
minHeadingLevel?: number
}>()
const renderMarkdown = useMarkdown(() => text, { minHeadingLevel })
</script>
<template>
<!-- eslint-disable-next-line vue/no-v-html -->
<div class="settings-markdown" v-html="renderMarkdown" />
</template>
<style scoped lang="scss">
.settings-markdown :deep {
a {
text-decoration: underline;
&::after {
content: '↗';
padding-inline: calc(var(--default-grid-baseline) / 2);
}
}
pre {
white-space: pre;
overflow-x: auto;
background-color: var(--color-background-dark);
border-radius: var(--border-radius);
padding: 1em 1.3em;
margin-bottom: 1em;
}
p code {
background-color: var(--color-background-dark);
border-radius: var(--border-radius);
padding: .1em .3em;
}
li {
position: relative;
}
ul, ol {
padding-inline-start: 10px;
margin-inline-start: 10px;
}
ul li {
list-style-type: disc;
}
ul > li > ul > li {
list-style-type: circle;
}
ul > li > ul > li ul li {
list-style-type: square;
}
blockquote {
padding-inline-start: 1em;
border-inline-start: 4px solid var(--color-primary-element);
color: var(--color-text-maxcontrast);
margin-inline: 0;
}
}
</style>
+125
View File
@@ -0,0 +1,125 @@
/*!
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { MaybeRefOrGetter } from 'vue'
import type { IAppstoreApp, IAppstoreExApp } from '../apps.d.ts'
import { mdiAlertCircleCheckOutline, mdiCheck, mdiClose, mdiDownload, mdiTrashCanOutline, mdiUpdate } from '@mdi/js'
import { t } from '@nextcloud/l10n'
import { computed, toValue } from 'vue'
import { useAppsStore } from '../store/apps.ts'
import { useUpdatesStore } from '../store/updates.ts'
import { canDisable, canEnable, canInstall, canUninstall, canUpdate, needForceEnable } from '../utils/appStatus.ts'
type AppAction = {
id: string
icon: string
label: (app: IAppstoreApp | IAppstoreExApp) => string
callback: (app: IAppstoreApp | IAppstoreExApp) => Promise<void>
variant?: 'primary' | 'error' | 'warning'
inline?: boolean
}
const AppAction = Object.freeze({
INSTALL: {
id: 'install',
icon: mdiDownload,
label: (app: IAppstoreApp | IAppstoreExApp) => {
if (app.app_api) {
return t('appstore', 'Deploy and enable')
}
if (app.needsDownload) {
return t('appstore', 'Download and enable')
}
return t('appstore', 'Install and enable')
},
async callback(app: IAppstoreApp | IAppstoreExApp) {
const store = useAppsStore()
await store.enableApp(app.id)
},
} as AppAction,
ENABLE: {
id: 'enable',
icon: mdiCheck,
variant: 'primary',
label: () => t('appstore', 'Enable'),
async callback(app: IAppstoreApp | IAppstoreExApp) {
const store = useAppsStore()
await store.enableApp(app.id)
},
} as AppAction,
FORCE_ENABLE: {
id: 'force-enable',
icon: mdiAlertCircleCheckOutline,
inline: false,
label: () => t('appstore', 'Force enable'),
variant: 'warning',
async callback(app: IAppstoreApp | IAppstoreExApp) {
const store = useAppsStore()
await store.forceEnableApp(app.id)
},
} as AppAction,
DISABLE: {
id: 'disable',
icon: mdiClose,
label: () => t('appstore', 'Disable'),
async callback(app: IAppstoreApp | IAppstoreExApp) {
const store = useAppsStore()
await store.disableApp(app.id)
},
} as AppAction,
REMOVE: {
id: 'remove',
icon: mdiTrashCanOutline,
variant: 'error',
inline: false,
label: () => t('appstore', 'Remove'),
async callback(app: IAppstoreApp | IAppstoreExApp) {
const store = useAppsStore()
await store.uninstallApp(app.id)
},
} as AppAction,
UPDATE: {
id: 'update',
icon: mdiUpdate,
variant: 'primary',
label: (app: IAppstoreApp | IAppstoreExApp) => t('appstore', 'Update to {version}', { version: app.update! }),
async callback(app: IAppstoreApp | IAppstoreExApp) {
const store = useUpdatesStore()
await store.updateApp(app.id)
},
} as AppAction,
})
/**
* Get the available actions for an app
*
* @param app - The app to get the actions for
*/
export function useActions(app: MaybeRefOrGetter<IAppstoreApp | IAppstoreExApp>) {
return computed(() => {
const actions: typeof AppAction[keyof typeof AppAction][] = []
if (canUpdate(toValue(app))) {
actions.push(AppAction.UPDATE)
}
if (canDisable(toValue(app))) {
actions.push(AppAction.DISABLE)
}
if (needForceEnable(toValue(app))) {
actions.push(AppAction.FORCE_ENABLE)
} else if (canInstall(toValue(app))) {
actions.push(AppAction.INSTALL)
} else if (canEnable(toValue(app))) {
actions.push(AppAction.ENABLE)
}
if (canUninstall(toValue(app))) {
actions.push(AppAction.REMOVE)
}
return actions
})
}
@@ -1,14 +1,15 @@
/**
/*!
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { Ref } from 'vue'
import type { IAppstoreApp } from '../app-types.ts'
import type { IAppstoreApp, IAppstoreExApp } from '../apps.d.ts'
import { mdiCog, mdiCogOutline } from '@mdi/js'
import { computed, ref, watchEffect } from 'vue'
import AppstoreCategoryIcons from '../constants/AppstoreCategoryIcons.ts'
import logger from '../logger.ts'
import { APPSTORE_CATEGORY_ICONS } from '../constants.ts'
import logger from '../utils/logger.ts'
/**
* Get the app icon raw SVG for use with `NcIconSvgWrapper` (do never use without sanitizing)
@@ -16,7 +17,7 @@ import logger from '../logger.ts'
*
* @param app The app to get the icon for
*/
export function useAppIcon(app: Ref<IAppstoreApp>) {
export function useAppIcon(app: Ref<IAppstoreApp | IAppstoreExApp | null>) {
const appIcon = ref<string | null>(null)
/**
@@ -29,7 +30,7 @@ export function useAppIcon(app: Ref<IAppstoreApp>) {
path = mdiCogOutline
} else {
path = [app.value?.category ?? []].flat()
.map((name) => AppstoreCategoryIcons[name])
.map((name) => APPSTORE_CATEGORY_ICONS[name])
.filter((icon) => !!icon)
.at(0)
?? (!app.value?.app_api ? mdiCog : mdiCogOutline)
@@ -39,13 +40,13 @@ export function useAppIcon(app: Ref<IAppstoreApp>) {
watchEffect(async () => {
// Note: Only variables until the first `await` will be watched!
if (!app.value?.preview) {
if (!app.value?.icon) {
appIcon.value = categoryIcon.value
} else {
appIcon.value = null
// Now try to load the real app icon
try {
const response = await window.fetch(app.value.preview)
const response = await window.fetch(app.value.icon)
const blob = await response.blob()
const rawSvg = await blob.text()
appIcon.value = rawSvg.replaceAll(/fill="#(fff|ffffff)([a-z0-9]{1,2})?"/ig, 'fill="currentColor"')
@@ -1,23 +1,13 @@
/**
/*!
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { ILocalizedValue } from '../constants/AppDiscoverTypes.ts'
import type { Ref } from 'vue'
import type { ILocalizedValue } from '../apps-discover.d.ts'
import { getLanguage } from '@nextcloud/l10n'
import {
type Ref,
computed,
} from 'vue'
/**
* Helper to get the localized value for the current users language
*
* @param dict The dictionary to get the value from
* @param language The language to use
*/
const getLocalizedValue = <T>(dict: ILocalizedValue<T>, language: string) => dict[language] ?? dict[language.split('_')[0]] ?? dict.en ?? null
import { computed } from 'vue'
/**
* Get the localized value of the dictionary provided
@@ -33,3 +23,13 @@ export function useLocalizedValue<T>(dict: Ref<ILocalizedValue<T | undefined> |
return computed(() => !dict?.value ? null : getLocalizedValue<T>(dict.value as ILocalizedValue<T>, language))
}
/**
* Helper to get the localized value for the current users language
*
* @param dict The dictionary to get the value from
* @param language The language to use
*/
function getLocalizedValue<T>(dict: ILocalizedValue<T>, language: string) {
return dict[language] ?? dict[language.split('_')[0]!] ?? dict.en ?? null
}
@@ -0,0 +1,52 @@
/*!
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { expect, test } from 'vitest'
import { useMarkdown } from './useMarkdown.ts'
test('renders links', () => {
const rendered = useMarkdown('This is [a link](http://example.com)!')
expect(rendered.value).toMatchInlineSnapshot('"<p>This is <a href="http://example.com" rel="noreferrer noopener">a link</a>!</p>\n"')
})
test('removes links with invalid URL', () => {
const rendered = useMarkdown('This is [a link](ftp://example.com)!')
expect(rendered.value).toMatchInlineSnapshot('"<p>This is !</p>\n"')
})
test('renders images', () => {
const rendered = useMarkdown('![alt text](http://example.com/image.jpg)')
expect(rendered.value).toMatchInlineSnapshot('"<p>alt text</p>\n"')
})
test('renders images with title', () => {
const rendered = useMarkdown('![](http://example.com/image.jpg "Title")')
expect(rendered.value).toMatchInlineSnapshot('"<p>Title</p>\n"')
})
test('renders images with alt text and title', () => {
const rendered = useMarkdown('![alt text](http://example.com/image.jpg "Title")')
expect(rendered.value).toMatchInlineSnapshot(`
"<p>alt text</p>\n"
`)
})
test('renders block quotes', () => {
const rendered = useMarkdown('> This is a block quote')
expect(rendered.value).toMatchInlineSnapshot('"<blockquote>This is a block quote</blockquote>"')
})
test('renders headings', () => {
const rendered = useMarkdown('# level 1\n## level 2\n### level 3\n#### level 4\n##### level 5\n###### level 6\n')
expect(rendered.value).toMatchInlineSnapshot('"<h1>level 1</h1><h2>level 2</h2><h3>level 3</h3><h4>level 4</h4><h5>level 5</h5><h6>level 6</h6>"')
})
test('renders headings with minHeadingLevel', () => {
const rendered = useMarkdown(
'# level 1\n## level 2\n### level 3\n#### level 4\n##### level 5\n###### level 6\n',
{ minHeadingLevel: 4 },
)
expect(rendered.value).toMatchInlineSnapshot('"<h4>level 1</h4><h5>level 2</h5><h6>level 3</h6><h6>level 4</h6><h6>level 5</h6><h6>level 6</h6>"')
})
@@ -0,0 +1,134 @@
/*!
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { Tokens } from 'marked'
import type { MaybeRefOrGetter } from 'vue'
import dompurify from 'dompurify'
import { marked } from 'marked'
import { computed, toValue } from 'vue'
export interface MarkdownOptions {
minHeadingLevel?: number
}
/**
* Render Markdown to HTML
*
* @param text - The Markdown source
* @param options - Markdown options
*/
export function useMarkdown(text: MaybeRefOrGetter<string>, options?: MarkdownOptions) {
const renderer = new marked.Renderer()
renderer.blockquote = markedBlockquote
renderer.link = markedLink
renderer.image = markedImage
return computed(() => {
const minHeading = options?.minHeadingLevel ?? 1
renderer.heading = getMarkedHeading(minHeading)
const markdown = toValue(text).trim()
return dompurify.sanitize(
marked(markdown, {
async: false,
renderer,
gfm: false,
breaks: false,
pedantic: false,
}),
{
ALLOWED_TAGS: [
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'strong',
'p',
'a',
'ul',
'ol',
'li',
'em',
'del',
'blockquote',
],
},
)
})
}
/**
* Custom link renderer that only allows http and https links
*
* @param ctx - The render context
* @param ctx.href - The link href
* @param ctx.title - The link title
* @param ctx.text - The link text
*/
function markedLink({ href, title, text }: Tokens.Link) {
let url: URL
try {
url = new URL(href)
} catch {
return ''
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return ''
}
let out = '<a href="' + href + '" rel="noreferrer noopener"'
if (title) {
out += ' title="' + title + '"'
}
out += '>' + text + '</a>'
return out
}
/**
* Only render image alt text or title
*
* @param ctx - The render context
* @param ctx.title - The image title
* @param ctx.text - The image alt text
*/
function markedImage({ title, text }: Tokens.Image): string {
if (text) {
return text
}
return title ?? ''
}
/**
* Render block quotes without any special styling
*
* @param ctx - The render context
* @param ctx.text - The blockquote text
*/
function markedBlockquote({ text }: Tokens.Blockquote): string {
return `<blockquote>${text}</blockquote>`
}
/**
* Get a custom heading renderer that clamps heading levels
*
* @param minHeading - The heading to clamp to
*/
function getMarkedHeading(minHeading: number) {
/**
* Custom heading renderer that adjusts heading levels
*
* @param ctx - The render context
* @param ctx.text - The heading text
* @param ctx.depth - The heading depth
*/
return ({ text, depth }: Tokens.Heading): string => {
depth = Math.min(6, depth + (minHeading - 1))
return `<h${depth}>${text}</h${depth}>`
}
}
+84
View File
@@ -0,0 +1,84 @@
/**
* SPDX-FileCopyrightText: Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import {
mdiAccountMultipleOutline,
mdiAccountOutline,
mdiArchiveOutline,
mdiCheck,
mdiClipboardFlowOutline,
mdiClose,
mdiCogOutline,
mdiControllerClassicOutline,
mdiCreationOutline,
mdiDownload,
mdiFileDocumentEdit,
mdiFolder,
mdiKeyOutline,
mdiMagnify,
mdiMonitorEye,
mdiMultimedia,
mdiOfficeBuildingOutline,
mdiOpenInApp,
mdiSecurity,
mdiStar,
mdiStarCircleOutline,
mdiStarShootingOutline,
mdiTools,
mdiViewColumnOutline,
} from '@mdi/js'
import { t } from '@nextcloud/l10n'
/**
* The names of the special appstore sections
*/
export const APPSTORE_CATEGORY_NAMES = Object.freeze({
discover: t('appstore', 'Discover'),
installed: t('appstore', 'Your apps'),
enabled: t('appstore', 'Active apps'),
disabled: t('appstore', 'Disabled apps'),
updates: t('appstore', 'Updates'),
bundles: t('appstore', 'App bundles'),
featured: t('appstore', 'Featured apps'),
supported: t('appstore', 'Supported apps'), // From subscription
})
/**
* SVG paths used for appstore category icons
*/
export const APPSTORE_CATEGORY_ICONS = Object.freeze({
// system special categories
discover: mdiStarCircleOutline,
installed: mdiAccountOutline,
enabled: mdiCheck,
disabled: mdiClose,
bundles: mdiArchiveOutline,
supported: mdiStarShootingOutline,
featured: mdiStar,
updates: mdiDownload,
// generic category
ai: mdiCreationOutline,
auth: mdiKeyOutline,
customization: mdiCogOutline,
dashboard: mdiViewColumnOutline,
files: mdiFolder,
games: mdiControllerClassicOutline,
integration: mdiOpenInApp,
monitoring: mdiMonitorEye,
multimedia: mdiMultimedia,
office: mdiFileDocumentEdit,
organization: mdiOfficeBuildingOutline,
search: mdiMagnify,
security: mdiSecurity,
social: mdiAccountMultipleOutline,
tools: mdiTools,
workflow: mdiClipboardFlowOutline,
})
/**
* Currently known types of app discover section elements
*/
export const APP_DISCOVER_KNOWN_TYPES = ['post', 'showcase', 'carousel'] as const
+17
View File
@@ -0,0 +1,17 @@
/**
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { createPinia } from 'pinia'
import { createApp } from 'vue'
import AppstoreApp from './AppstoreApp.vue'
import router from './router/index.ts'
import 'vite/modulepreload-polyfill'
const pinia = createPinia()
const app = createApp(AppstoreApp)
app.use(pinia)
app.use(router)
app.mount('#content')
@@ -4,7 +4,7 @@
*/
import { showError } from '@nextcloud/dialogs'
import rebuildNavigation from '../service/rebuild-navigation.js'
import { rebuildNavigation } from '../service/rebuild-navigation.ts'
const productName = window.OC.theme.productName
@@ -47,64 +47,64 @@ export default {
},
updateButtonText() {
if (this.app?.app_api && this.app?.daemon?.accepts_deploy_id === 'manual-install') {
return t('settings', 'Manually installed apps cannot be updated')
return t('appstore', 'Manually installed apps cannot be updated')
}
return t('settings', 'Update to {version}', { version: this.app?.update })
return t('appstore', 'Update to {version}', { version: this.app?.update })
},
enableButtonText() {
if (this.app?.app_api) {
if (this.app && this.app?.status?.action && this.app?.status?.action === 'deploy') {
return t('settings', '{progress}% Deploying …', { progress: this.app?.status?.deploy ?? 0 })
return t('appstore', '{progress}% Deploying …', { progress: this.app?.status?.deploy ?? 0 })
}
if (this.app && this.app?.status?.action && this.app?.status?.action === 'init') {
return t('settings', '{progress}% Initializing …', { progress: this.app?.status?.init ?? 0 })
return t('appstore', '{progress}% Initializing …', { progress: this.app?.status?.init ?? 0 })
}
if (this.app && this.app?.status?.action && this.app?.status?.action === 'healthcheck') {
return t('settings', 'Health checking')
return t('appstore', 'Health checking')
}
if (this.app.needsDownload) {
return t('settings', 'Deploy and Enable')
return t('appstore', 'Deploy and Enable')
}
return t('settings', 'Enable')
return t('appstore', 'Enable')
} else {
if (this.app.needsDownload) {
return t('settings', 'Download and enable')
return t('appstore', 'Download and enable')
}
return t('settings', 'Enable')
return t('appstore', 'Enable')
}
},
disableButtonText() {
if (this.app?.app_api) {
if (this.app && this.app?.status?.action && this.app?.status?.action === 'deploy') {
return t('settings', '{progress}% Deploying …', { progress: this.app?.status?.deploy })
return t('appstore', '{progress}% Deploying …', { progress: this.app?.status?.deploy })
}
if (this.app && this.app?.status?.action && this.app?.status?.action === 'init') {
return t('settings', '{progress}% Initializing …', { progress: this.app?.status?.init })
return t('appstore', '{progress}% Initializing …', { progress: this.app?.status?.init })
}
if (this.app && this.app?.status?.action && this.app?.status?.action === 'healthcheck') {
return t('settings', 'Health checking')
return t('appstore', 'Health checking')
}
}
return t('settings', 'Disable')
return t('appstore', 'Disable')
},
forceEnableButtonText() {
if (this.app.needsDownload) {
return t('settings', 'Allow untested app')
return t('appstore', 'Allow untested app')
}
return t('settings', 'Allow untested app')
return t('appstore', 'Allow untested app')
},
enableButtonTooltip() {
if (!this.app?.app_api && this.app.needsDownload) {
return t('settings', 'The app will be downloaded from the App Store')
return t('appstore', 'The app will be downloaded from the App Store')
}
return null
},
forceEnableButtonTooltip() {
const base = t('settings', 'This app is not marked as compatible with your {productName} version.', { productName })
const base = t('appstore', 'This app is not marked as compatible with your {productName} version.', { productName })
+ ' '
+ t('settings', 'If you continue you will still be able to install the app. Note that the app might not work as expected.')
+ t('appstore', 'If you continue you will still be able to install the app. Note that the app might not work as expected.')
if (this.app.needsDownload) {
return base + ' ' + t('settings', 'The app will be downloaded from the App Store')
return base + ' ' + t('appstore', 'The app will be downloaded from the App Store')
}
return base
},
+16
View File
@@ -0,0 +1,16 @@
/**
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { generateUrl } from '@nextcloud/router'
import { createRouter, createWebHistory } from 'vue-router'
import routes from './routes.ts'
const router = createRouter({
history: createWebHistory(generateUrl('')),
linkActiveClass: 'active',
routes,
})
export default router
+59
View File
@@ -0,0 +1,59 @@
/*!
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { RouteRecordRaw } from 'vue-router'
import { loadState } from '@nextcloud/initial-state'
import { defineAsyncComponent } from 'vue'
const appstoreEnabled = loadState<boolean>('appstore', 'appstoreEnabled', true)
// Dynamic loading
const AppstoreDiscover = defineAsyncComponent(() => import('../views/AppstoreDiscover.vue'))
const AppstoreManage = defineAsyncComponent(() => import('../views/AppstoreManage.vue'))
const AppstoreBundles = defineAsyncComponent(() => import('../views/AppstoreBundles.vue'))
const routes: RouteRecordRaw[] = [
{
path: '/:index(index.php/)?settings/apps',
name: 'apps',
redirect: appstoreEnabled
? {
name: 'apps-discover',
}
: {
name: 'apps-manage',
params: { category: 'installed' },
},
children: [
{
path: 'discover/:id?',
name: 'apps-discover',
component: AppstoreDiscover,
},
{
path: 'bundles/:id?',
name: 'apps-bundles',
component: AppstoreBundles,
},
{
path: ':category(installed|enabled|disabled|updates)/:id?',
name: 'apps-manage',
component: AppstoreManage,
},
{
path: 'apps/:category',
name: 'apps-category',
children: [{
path: ':id',
name: 'apps-details',
component: {},
}],
},
],
},
]
export default routes
@@ -1,9 +1,11 @@
/**
/*
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
export const OFFICE_SUITES = [
import { t } from "@nextcloud/l10n"
export const OFFICE_SUITES = Object.freeze([
{
id: 'nextcloud-office',
appId: 'richdocuments',
@@ -33,24 +35,24 @@ export const OFFICE_SUITES = [
learnMoreUrl: 'https://nextcloud.com/onlyoffice/',
isPrimary: false,
},
]
])
/**
* Get office suite configuration by ID
*
* @param {string} id - The suite ID
* @return {object|undefined} The suite configuration or undefined if not found
* @param id - The suite ID
* @return The suite configuration or undefined if not found
*/
export function getOfficeSuiteById(id) {
export function getOfficeSuiteById(id: string) {
return OFFICE_SUITES.find((suite) => suite.id === id)
}
/**
* Get office suite configuration by app ID
*
* @param {string} appId - The app ID (richdocuments, onlyoffice, etc.)
* @return {object|undefined} The suite configuration or undefined if not found
* @param appId - The app ID (richdocuments, onlyoffice, etc.)
* @return The suite configuration or undefined if not found
*/
export function getOfficeSuiteByAppId(appId) {
export function getOfficeSuiteByAppId(appId: string) {
return OFFICE_SUITES.find((suite) => suite.appId === appId)
}
+105
View File
@@ -0,0 +1,105 @@
/**
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { OCSResponse } from '@nextcloud/typings/ocs'
import type { IAppstoreApp, IAppstoreCategory } from '../apps.d.ts'
import axios from '@nextcloud/axios'
import { confirmPassword } from '@nextcloud/password-confirmation'
import { generateOcsUrl } from '@nextcloud/router'
import PQueue from 'p-queue'
import { APPSTORE_CATEGORY_ICONS } from '../constants.ts'
const BASE_URL = generateOcsUrl('apps/appstore/api/v1')
const Url = Object.freeze({
apps: `${BASE_URL}/apps`,
categories: `${BASE_URL}/apps/categories`,
enable: `${BASE_URL}/apps/enable`,
forceEnable: `${BASE_URL}/apps/force`,
disable: `${BASE_URL}/apps/disable`,
uninstall: `${BASE_URL}/apps/uninstall`,
update: `${BASE_URL}/apps/update`,
})
const queue = new PQueue({ concurrency: 1 })
/**
* Force enable app by ignoring its dependencies
*
* @param appId - The app to force enable
*/
export async function forceEnableApp(appId: string) {
return queue.add(async () => {
await confirmPassword()
await axios.post(Url.forceEnable, { appId })
})
}
/**
* Enable an app by its app id
*
* @param appId - The app to enable
*/
export async function enableApp(appId: string) {
return queue.add(async () => {
await confirmPassword()
await axios.post(Url.enable, { appId })
})
}
/**
* Disable app by its app id
*
* @param appId - The app to disable
*/
export async function disableApp(appId: string) {
return queue.add(async () => {
await confirmPassword()
await axios.post(Url.disable, { appId })
})
}
/**
* Update an app by its app id
*
* @param appId - The app id to update
*/
export async function updateApp(appId: string) {
return queue.add(async () => {
await confirmPassword()
await axios.post(Url.update, { appId })
})
}
/**
* Uninstall an app by its app id
*
* @param appId - The app to uninstall
*/
export async function uninstallApp(appId: string) {
return queue.add(async () => {
await confirmPassword()
await axios.post(Url.uninstall, { appId })
})
}
/**
* Get all apps from the appstore
*/
export async function getApps() {
const { data } = await axios.get<OCSResponse<IAppstoreApp[]>>(Url.apps)
return data.ocs.data
}
/**
* Get app categories
*/
export async function getCategories() {
const { data } = await axios.get<OCSResponse<IAppstoreCategory[]>>(Url.categories)
for (const category of data.ocs.data) {
category.icon = APPSTORE_CATEGORY_ICONS[category.id] ?? ''
}
return data.ocs.data
}
+50
View File
@@ -0,0 +1,50 @@
/*!
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import axios from '@nextcloud/axios'
import { generateUrl } from '@nextcloud/router'
import { filterElements, parseApiResponse } from '../utils/appDiscoverParser.ts'
/**
* Get app discover elements
*/
export async function getDiscoverElements() {
const data = await loadDiscoverElements()
if (data.length === 0) {
throw new Error('No app discover elements available (empty response)')
}
// Parse data to ensure dates are useable and then filter out expired or future elements
const parsedElements = data.map(parseApiResponse)
.filter(filterElements)
// Shuffle elements to make it looks more interesting
const shuffledElements = shuffleArray(parsedElements)
// Sort pinned elements first
shuffledElements.sort((a, b) => (a.order ?? Infinity) < (b.order ?? Infinity) ? -1 : 1)
return shuffledElements
}
/**
* Shuffle using the Fisher-Yates algorithm
*
* @param array The array to shuffle (in place)
*/
function shuffleArray<T>(array: T[]): T[] {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j]!, array[i]!]
}
return array
}
/**
* Load discover elements from the API
*/
async function loadDiscoverElements() {
const url = generateUrl('/apps/appstore/api/v1/discover')
const { data } = await axios.get<Record<string, unknown>[]>(url)
return data
}
+61
View File
@@ -0,0 +1,61 @@
/*!
* SPDX-License-Identifier: AGPL-3.0-or-later
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
*/
import type { IAppstoreExApp, IDeployDaemon, IDeployOptions } from '../apps.d.ts'
import axios from '@nextcloud/axios'
import { confirmPassword } from '@nextcloud/password-confirmation'
import { generateUrl } from '@nextcloud/router'
/**
* Fetch all external (app_api) apps from the server.
*/
export async function fetchApps() {
const { data } = await axios.get(generateUrl('/apps/app_api/apps/list'))
return data.apps as IAppstoreExApp[]
}
/**
* Enable an external app.
*
* @param app - The app to enable
* @param daemon - The daemon to use for deployment
* @param deployOptions - Additional options for deployment
*/
export async function enableExApp(app: IAppstoreExApp, daemon: IDeployDaemon, deployOptions?: IDeployOptions) {
await confirmPassword()
await axios.post(generateUrl(`/apps/app_api/apps/enable/${app.id}/${daemon.name}`), { deployOptions })
}
/**
* Force enable an external app
*
* @param appId - The app to force-enable
*/
export async function forceEnableExApp(appId: string) {
await confirmPassword()
await axios.post(generateUrl('/apps/app_api/apps/force'), { appId })
}
/**
* Disable an external app.
*
* @param appId - The app to disable
*/
export async function disableExApp(appId: string) {
await confirmPassword()
await axios.get(generateUrl(`apps/app_api/apps/disable/${appId}`))
}
/**
* Remove an external app.
*
* @param appId - The app to uninstall
* @param removeData - If all data should be removed
*/
export async function uninstallExApp(appId: string, removeData = false) {
await confirmPassword()
await axios.get(generateUrl(`/apps/app_api/apps/uninstall/${appId}?removeData=${removeData}`))
}
@@ -0,0 +1,23 @@
/*!
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { OCSResponse } from '@nextcloud/typings/ocs'
import axios from '@nextcloud/axios'
import { emit } from '@nextcloud/event-bus'
import { generateOcsUrl } from '@nextcloud/router'
/**
* Rebuilds the app navigation menu
*/
export async function rebuildNavigation() {
const { data } = await axios.get<OCSResponse>(generateOcsUrl('core/navigation/apps?format=json'))
if (data.ocs.meta.statuscode !== 200) {
return
}
emit('nextcloud:app-menu.refresh', { apps: data.ocs.data })
window.dispatchEvent(new Event('resize'))
}
+247
View File
@@ -0,0 +1,247 @@
/*!
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { IAppstoreApp, IAppstoreCategory, IAppstoreExApp } from '../apps.d.ts'
import { showError } from '@nextcloud/dialogs'
import { t } from '@nextcloud/l10n'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import * as api from '../service/api.ts'
import { rebuildNavigation } from '../service/rebuild-navigation.ts'
import { canDisable, canForceEnable, canInstall, canUninstall } from '../utils/appStatus.ts'
import logger from '../utils/logger.ts'
import { useExAppsStore } from './exApps.ts'
export const useAppsStore = defineStore('apps', () => {
const exApps = useExAppsStore()
/**
* All apps available in the appstore
*/
const appstoreApps = ref<IAppstoreApp[]>([])
/**
* All app categories available in the appstore
*/
const categories = ref<IAppstoreCategory[]>([])
/**
* Loading state of the store
*/
const isLoadingApps = ref(false)
const isLoadingCategories = ref(false)
/**
* All apps available
*/
const apps = computed(() => [...appstoreApps.value, ...(exApps.isEnabled ? exApps.apps : [])])
/**
* Get a category by its id
*
* @param categoryId - The id of the category
*/
function getCategoryById(categoryId: string) {
return categories.value.find(({ id }) => id === categoryId) ?? null
}
/**
* Get an app by its id
*
* @param appId - The id of the app
*/
function getAppById(appId: string): IAppstoreApp | IAppstoreExApp | null {
return apps.value.find(({ id }) => id === appId) ?? null
}
/**
* Get all apps of a category
*
* @param categoryId - The id of the category
*/
function getAppsByCategory(categoryId: string): (IAppstoreApp | IAppstoreExApp)[] {
return apps.value.filter((app) => [app.category].flat().includes(categoryId))
}
/**
* Enable an app by its id
*
* @param appId - The app to enable
*/
async function enableApp(appId: string) {
const app = getAppById(appId)
if (!app) {
throw new Error(`App with id ${appId} not found`)
}
if (app.active || (!app.installed && !canInstall(app))) {
throw new Error(`App with id ${appId} cannot be enabled`)
}
app.loading = true
try {
if (app.app_api) {
await exApps.enableApp(appId)
} else {
await api.enableApp(appId)
}
app.active = true
app.installed = true
app.removable = true
await rebuildNavigation()
} finally {
app.loading = false
}
}
/**
* Force enable an app
* @param appId - The app to force-enable
*/
async function forceEnableApp(appId: string) {
const app = getAppById(appId)
if (!app) {
throw new Error(`App with id ${appId} not found`)
}
if (!canForceEnable(app)) {
throw new Error(`App with id ${appId} cannot be force-enabled`)
}
app.loading = true
try {
if (app.app_api) {
await exApps.enableApp(appId)
} else {
await api.forceEnableApp(appId)
}
app.active = true
app.installed = true
await rebuildNavigation()
} finally {
app.loading = false
}
}
/**
* Disable an app by its id
*
* @param appId - The app to disable
*/
async function disableApp(appId: string) {
const app = getAppById(appId)
if (!app) {
throw new Error(`App with id ${appId} not found`)
}
if (!canDisable(app)) {
throw new Error(`App with id ${appId} cannot be disabled`)
}
app.loading = true
try {
if (app.app_api) {
await exApps.disableApp(appId)
} else {
await api.disableApp(appId)
}
app.active = false
await rebuildNavigation()
} finally {
app.loading = false
}
}
/**
* Uninstall an app by its id
*
* @param appId - The app to uninstall
*/
async function uninstallApp(appId: string) {
const app = getAppById(appId)
if (!app) {
throw new Error(`App with id ${appId} not found`)
}
if (!canUninstall(app)) {
throw new Error(`App with id ${appId} cannot be uninstalled`)
}
app.loading = true
try {
if (app.app_api) {
await exApps.uninstallApp(appId)
} else {
await api.uninstallApp(appId)
}
app.installed = false
} finally {
app.loading = false
}
}
/**
* Update the groups of an app
*
* @param appId - The app to update
* @param groups - The new groups
*/
function updateAppGroups(appId: string, groups: string[]) {
const app = apps.value.find(({ id }) => id === appId)
if (app) {
app.groups = [...groups]
}
}
/**
* Load the app categories from the backend
*/
async function loadCategories() {
try {
isLoadingCategories.value = true
categories.value = await api.getCategories()
} catch (error) {
logger.error('Failed to load app categories', { error })
showError(t('appstore', 'Could not load app categories. Please try again later.'))
} finally {
isLoadingCategories.value = false
}
}
/**
* Load the apps from the backend
*/
async function loadApps() {
try {
isLoadingApps.value = true
appstoreApps.value = await api.getApps()
} catch (error) {
logger.error('Failed to load apps list', { error })
showError(t('appstore', 'Could not load apps list. Please try again later.'))
} finally {
isLoadingApps.value = false
}
}
// initialize store
loadApps()
loadCategories()
return {
apps,
categories,
isLoadingApps,
isLoadingCategories,
disableApp,
enableApp,
forceEnableApp,
uninstallApp,
getAppById,
getAppsByCategory,
getCategoryById,
updateAppGroups,
}
})
+301
View File
@@ -0,0 +1,301 @@
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { IAppstoreExApp, IDeployDaemon, IExAppStatus } from '../apps.d.ts'
import axios from '@nextcloud/axios'
import { showError } from '@nextcloud/dialogs'
import { loadState } from '@nextcloud/initial-state'
import { t } from '@nextcloud/l10n'
import { generateUrl } from '@nextcloud/router'
import { spawnDialog } from '@nextcloud/vue'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import DaemonSelectionDialog from '../components/DaemonSelectionDialog/DaemonSelectionDialog.vue'
import * as exAppApi from '../service/exAppApi.ts'
import logger from '../utils/logger.ts'
export const useExAppsStore = defineStore('external-apps', () => {
/**
* Is the App API enabled
*/
const isEnabled = loadState('appstore', 'appApiEnabled', false)
/**
* All external apps available
*/
const apps = ref<IAppstoreExApp[]>([])
/**
* Number of external apps with available updates, used to show the update badge in the UI
*/
const updateCount = ref(loadState('appstore', 'appstoreExAppUpdateCount', 0))
const statusUpdater = ref<number | null | undefined>(null)
const daemonAccessible = ref(loadState('appstore', 'defaultDaemonConfigAccessible', false))
const defaultDaemon = ref(loadState<IDeployDaemon | null>('appstore', 'defaultDaemonConfig', null))
const dockerDaemons = ref<IDeployDaemon[]>([])
const initializingOrDeployingApps = computed(() => apps.value.filter((app) => app?.status?.action
&& (app?.status?.action === 'deploy' || app.status.action === 'init' || app.status.action === 'healthcheck')
&& app.status.type !== ''))
/**
* Get an external app by its ID
*
* @param appId - The app ID
*/
function getById(appId: string): IAppstoreExApp | null {
return apps.value.find(({ id }) => id === appId) ?? null
}
/**
* Enable an exApp.
*
* @param appId - The app ID
* @param daemon - The daemon to use for deployment
* @param deployOptions - Additional options for deployment
*/
async function enableApp(appId: string) {
const app = getById(appId)
if (!app) {
throw new Error(`App with id ${appId} not found`)
}
app.loading = true
try {
if (dockerDaemons.value.length === 1 && app.needsDownload) {
exAppApi.enableExApp(app, dockerDaemons[0])
app.daemon = dockerDaemons[0]
} else if (app.needsDownload) {
const daemon = await spawnDialog(DaemonSelectionDialog, { app })
if (!daemon) {
throw new Error('No daemon selected')
}
await exAppApi.enableExApp(app, daemon)
app.daemon = daemon
} else {
await exAppApi.enableExApp(app, app.daemon!)
}
if (!app.installed) {
app.needsDownload = false
app.status = {
type: 'install',
action: 'deploy',
init: 0,
deploy: 0,
} as IExAppStatus
}
app.removable = true
delete app.error
} finally {
app.loading = false
}
}
/**
* Force enable an exApp by ignoring its dependencies.
*
* @param appId - The app to force-enable
*/
async function forceEnableApp(appId: string) {
const app = getById(appId)
if (!app) {
throw new Error(`App with id ${appId} not found`)
}
app.loading = true
try {
await exAppApi.forceEnableExApp(appId)
await initialize(true)
app.active = false
} finally {
app.loading = false
}
}
/**
* @param appId - The app to disable
*/
async function disableApp(appId: string) {
const app = getById(appId)
if (!app) {
throw new Error(`App with id ${appId} not found`)
}
app.loading = true
try {
await exAppApi.disableExApp(appId)
app.active = false
} finally {
app.loading = false
}
}
/**
* Uninstall an app by its id
*
* @param appId - The app to uninstall
*/
async function uninstallApp(appId: string) {
const app = getById(appId)
if (!app) {
throw new Error(`App with id ${appId} not found`)
}
app.loading = true
try {
await exAppApi.disableExApp(appId)
app.active = false
app.needsDownload = true
app.installed = false
app.daemon = null
app.status = {}
if (app.update !== null) {
updateCount.value--
}
delete app.update
delete app.error
} finally {
app.loading = false
}
}
/**
* Update an external app
*
* @param appId - The app ID
*/
async function updateApp(appId: string) {
const app = getById(appId)
if (!app) {
throw new Error(`App with id ${appId} not found`)
}
app.loading = true
try {
await axios.get(generateUrl(`/apps/app_api/apps/update/${appId}`))
app.version = app.update || app.version
app.status = {
type: 'update',
action: 'deploy',
init: 0,
deploy: 0,
} satisfies IExAppStatus
delete app.update
delete app.error
updateCount.value--
// Trigger status updates
// updateAppsStatus()
} catch (error) {
logger.error('Failed to update ex app', { appId, error })
showError(t('appstore', 'Could not update the app. Please try again later.'))
} finally {
app.loading = false
}
}
/**
* Initialize the store.
* This only needs to be called when an app management operation is performed.
*
* @param force - If the initialization should be forced (to run again)
*/
async function initialize(force = false) {
if (force || (!defaultDaemon.value || !dockerDaemons.value.length)) {
await fetchDockerDaemons()
}
if (force || apps.value.length === 0) {
await fetchAllApps()
}
}
return {
isEnabled,
apps,
updateCount,
defaultDaemon,
dockerDaemons,
getById,
disableApp,
enableApp,
forceEnableApp,
updateApp,
uninstallApp,
initialize,
}
/**
* Fetch the configured docker daemons from the backend.
*/
async function fetchDockerDaemons() {
try {
const { data } = await axios.get(generateUrl('/apps/app_api/daemons'))
defaultDaemon.value = data.daemons.find((daemon: IDeployDaemon) => daemon.name === data.default_daemon_config)
dockerDaemons.value = data.daemons.filter((daemon: IDeployDaemon) => daemon.accepts_deploy_id === 'docker-install')
} catch (error) {
logger.error('[app-api-store] Failed to fetch Docker daemons', { error })
return false
}
return true
}
async function fetchAllApps() {
try {
apps.value = await exAppApi.fetchApps()
} catch (error) {
logger.error('An error occurred while fetching apps', { error })
showError(t('appstore', 'An error occurred during the request. Unable to proceed.'))
}
}
/*
async fetchAppStatus(appId: string) {
return api.get(generateUrl(`/apps/app_api/apps/status/${appId}`))
.then((response) => {
const app = this.apps.find((app) => app.id === appId)
if (app) {
app.status = response.data
}
const initializingOrDeployingApps = this.getInitializingOrDeployingApps
logger.debug('initializingOrDeployingApps after setAppStatus', { initializingOrDeployingApps })
if (initializingOrDeployingApps.length === 0) {
logger.debug('clearing interval')
clearInterval(this.statusUpdater as number)
this.statusUpdater = null
}
if (Object.hasOwn(response.data, 'error')
&& response.data.error !== ''
&& initializingOrDeployingApps.length === 1) {
clearInterval(this.statusUpdater as number)
this.statusUpdater = null
}
})
.catch((error) => {
this.appsApiFailure({ appId, error })
this.apps = this.apps.filter((app) => app.id !== appId)
this.updateAppsStatus()
})
},
updateAppsStatus() {
clearInterval(this.statusUpdater as number)
const initializingOrDeployingApps = this.getInitializingOrDeployingApps
if (initializingOrDeployingApps.length === 0) {
return
}
this.statusUpdater = setInterval(() => {
const initializingOrDeployingApps = this.getInitializingOrDeployingApps
logger.debug('initializingOrDeployingApps', { initializingOrDeployingApps })
initializingOrDeployingApps.forEach((app) => {
this.fetchAppStatus(app.id)
})
}, 2000) as unknown as number
},
}, */
})
+63
View File
@@ -0,0 +1,63 @@
/*!
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { showError } from '@nextcloud/dialogs'
import { loadState } from '@nextcloud/initial-state'
import { t } from '@nextcloud/l10n'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import * as api from '../service/api.ts'
import { rebuildNavigation } from '../service/rebuild-navigation.ts'
import logger from '../utils/logger.ts'
import { useAppsStore } from './apps.ts'
import { useExAppsStore } from './exApps.ts'
export const useUpdatesStore = defineStore('updates', () => {
const exApps = useExAppsStore()
/**
* Number of apps with available updates
*/
const internalUpdateCount = ref(loadState<number>('appstore', 'appstoreUpdateCount', 0))
/**
* Total number of apps with available updates
*/
const updateCount = computed(() => internalUpdateCount.value + exApps.updateCount)
/**
* Update the given app
*
* @param appId - The app id to update
* @throws {Error} if the app is not found
*/
async function updateApp(appId: string) {
const store = useAppsStore()
const app = store.getAppById(appId)
if (!app) {
throw new Error(`App with id ${appId} not found`)
}
try {
if ('app_api' in app && app.app_api) {
await exApps.updateApp(appId)
} else {
await api.updateApp(appId)
internalUpdateCount.value = Math.max(internalUpdateCount.value - 1, 0)
}
rebuildNavigation()
} catch (error) {
logger.error('Failed to update app', { appId, error })
showError(t('appstore', 'Could not update the app. Please try again later.'))
}
}
return {
updateCount,
updateApp,
}
})
+82
View File
@@ -0,0 +1,82 @@
/*!
* SPDX-License-Identifier: AGPL-3.0-or-later
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
*/
import type { IAppstoreApp, IAppstoreExApp } from '../apps.d.ts'
/**
* Check if an app can be installed.
*
* @param app - The app to check if installable
*/
export function canInstall(app: IAppstoreApp | IAppstoreExApp) {
if (app.installed || app.internal) {
return false
}
if (app.missingDependencies === undefined || app.missingDependencies.length === 0) {
return true
}
if (!app.isCompatible && app.missingDependencies.length === 1) {
// incompatible so can be installed but has to be force-enabled
return true
}
return false
}
/**
* Check if an app can be uninstalled.
*
* @param app - The app to check if uninstallable
*/
export function canUninstall(app: IAppstoreApp | IAppstoreExApp) {
return app.installed && app.removable
}
/**
* Check if app can be enabled.
*
* @param app - The app to check
*/
export function canEnable(app: IAppstoreApp | IAppstoreExApp) {
return canForceEnable(app) && app.isCompatible
}
/**
* Check if an app can be force-enabled
*
* @param app - The app to check
*/
export function canForceEnable(app: IAppstoreApp | IAppstoreExApp) {
return !app.active && (app.installed || canInstall(app))
}
/**
* Check if an app needs to be force-enabled
*
* @param app - The app to check
*/
export function needForceEnable(app: IAppstoreApp | IAppstoreExApp) {
return !app.active && !app.isCompatible
}
/**
* Check if an app can be disabled.
*
* @param app - The app to check
*/
export function canDisable(app: IAppstoreApp | IAppstoreExApp) {
return app.active && !app.internal
}
/**
* Check if an app can be updated.
*
* @param app - The app to check if update-able
*/
export function canUpdate(app: IAppstoreApp | IAppstoreExApp) {
return app.update !== undefined
}
+33
View File
@@ -0,0 +1,33 @@
/**
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { AxiosError } from '@nextcloud/axios'
import { showError } from '@nextcloud/dialogs'
import { translate as t } from '@nextcloud/l10n'
import logger from '../utils/logger.ts'
/**
* @param error the error
* @param message the message to display
*/
export function handleError(error: AxiosError, message: string) {
let fullMessage = ''
if (message) {
fullMessage += message
}
if (error.response?.status === 429) {
if (fullMessage) {
fullMessage += '\n'
}
fullMessage += t('appstore', 'There were too many requests from your network. Retry later or contact your administrator if this is an error.')
}
fullMessage = fullMessage || t('appstore', 'Error')
showError(fullMessage)
logger.error(fullMessage, { error })
}
+11
View File
@@ -0,0 +1,11 @@
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { getLoggerBuilder } from '@nextcloud/logger'
export default getLoggerBuilder()
.setApp('appstore')
.detectUser()
.build()
@@ -15,7 +15,7 @@
<NcEmptyContent
v-else-if="isLoading"
class="empty-content__loading"
:name="t('settings', 'Loading app list')">
:name="t('appstore', 'Loading app list')">
<template #icon>
<NcLoadingIcon :size="64" />
</template>
@@ -33,7 +33,7 @@ import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import AppList from '../components/AppList.vue'
import AppStoreDiscoverSection from '../components/AppStoreDiscover/AppStoreDiscoverSection.vue'
import { APPS_SECTION_ENUM } from '../constants/AppsConstants.js'
import { APPSTORE_CATEGORY_NAMES } from '../constants.ts'
import { useAppApiStore } from '../store/app-api-store.ts'
import { useAppsStore } from '../store/apps-store.ts'
@@ -46,9 +46,9 @@ const appApiStore = useAppApiStore()
*/
const currentCategory = computed(() => route.params?.category ?? 'discover')
const viewLabel = computed<string>(() => APPS_SECTION_ENUM[currentCategory.value] ?? store.getCategoryById(currentCategory.value)?.displayName)
const viewLabel = computed<string>(() => APPSTORE_CATEGORY_NAMES[currentCategory.value] ?? store.getCategoryById(currentCategory.value)?.displayName)
const pageHeading = t('settings', 'App Store')
const pageHeading = t('appstore', 'App Store')
const pageTitle = computed(() => `${viewLabel.value} - ${pageHeading}`) // NcAppContent automatically appends the instance name
// TODO this part should be migrated to pinia
@@ -78,10 +78,6 @@ onBeforeUnmount(() => {
}
.app-settings-content__label {
margin-block-start: var(--app-navigation-padding);
margin-inline-start: calc(var(--default-clickable-area) + var(--app-navigation-padding) * 2);
min-height: var(--default-clickable-area);
line-height: var(--default-clickable-area);
vertical-align: center;
}
</style>
@@ -0,0 +1,99 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import type { IAppstoreApp } from '../apps.d.ts'
import { mdiDownloadMultiple } from '@mdi/js'
import { loadState } from '@nextcloud/initial-state'
import { t } from '@nextcloud/l10n'
import { computed } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import AppTable from '../components/AppTable/AppTable.vue'
import { useAppsStore } from '../store/apps.ts'
import { canEnable } from '../utils/appStatus.ts'
type AppBundle = {
id: string
name: string
appIdentifiers: string[]
}
const store = useAppsStore()
const bundles = loadState<AppBundle[]>('appstore', 'appstoreBundles')
const appBundles = computed(() => bundles.map((bundle) => ({
id: bundle.id,
name: bundle.name,
apps: bundle.appIdentifiers
.map((id) => store.apps.find((app) => app.id === id))
.filter(Boolean) as IAppstoreApp[],
isEnabling: false,
})))
/**
* Enable all apps in a bundle
*
* @param bundle - The bundle to enable all apps
*/
async function enableAll(bundle: typeof appBundles.value[number]) {
bundle.isEnabling = true
for (const app of bundle.apps) {
if (!canEnable(app)) {
// already active or needs force-enabling
continue
}
await store.enableApp(app.id)
}
bundle.isEnabling = false
}
</script>
<template>
<!-- Apps list -->
<NcEmptyContent
v-if="store.isLoadingApps"
:name="t('appstore', 'Loading app list')">
<template #icon>
<NcLoadingIcon :size="64" />
</template>
</NcEmptyContent>
<template v-else>
<section v-for="bundle of appBundles" :key="bundle.id">
<div :class="$style.appstoreBundles__header">
<h3>{{ bundle.name }}</h3>
<NcButton variant="primary" @click="enableAll(bundle)">
<template #icon>
<NcIconSvgWrapper :path="mdiDownloadMultiple" />
</template>
{{ t('appstore', 'Download and enable all') }}
</NcButton>
</div>
<AppTable
:class="$style.appstoreBundles__appTable"
:apps="bundle.apps" />
</section>
</template>
</template>
<style module>
.appstoreBundles__header {
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: var(--default-clickable-area);
padding-inline: var(--default-grid-baseline);
}
.appstoreBundles__appTable:last-of-type {
margin-bottom: var(--body-container-margin);
}
</style>
@@ -0,0 +1,98 @@
<!--
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import type { IAppDiscoverElements } from '../apps-discover.d.ts'
import { mdiEyeOffOutline } from '@mdi/js'
import { showError } from '@nextcloud/dialogs'
import { t } from '@nextcloud/l10n'
import { defineAsyncComponent, defineComponent, onBeforeMount, ref } from 'vue'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import { getDiscoverElements } from '../service/app-discover.ts'
import logger from '../utils/logger.ts'
const PostType = defineAsyncComponent(() => import('../components/DiscoverType/DiscoverTypePost.vue'))
const CarouselType = defineAsyncComponent(() => import('../components/DiscoverType/DiscoverTypeCarousel.vue'))
const ShowcaseType = defineAsyncComponent(() => import('../components/DiscoverType/DiscoverTypeShowcase.vue'))
const hasError = ref(false)
const elements = ref<IAppDiscoverElements[]>([])
/**
* Load the app discover section information
*/
onBeforeMount(async () => {
try {
// Set the elements to the UI
elements.value = await getDiscoverElements()
} catch (error) {
hasError.value = true
logger.error(error as Error)
showError(t('appstore', 'Could not load app discover section'))
}
})
/**
* Get the component for the given type
*
* @param type - The type of the component
*/
function getComponent(type: IAppDiscoverElements['type']) {
if (type === 'post') {
return PostType
} else if (type === 'carousel') {
return CarouselType
} else if (type === 'showcase') {
return ShowcaseType
}
return defineComponent({
mounted: () => logger.error('Unknown component requested ', type),
render: (h) => h('div', t('appstore', 'Could not render element')),
})
}
</script>
<template>
<NcEmptyContent
v-if="hasError"
:name="t('appstore', 'Nothing to show')"
:description="t('appstore', 'Could not load section content from app store.')">
<template #icon>
<NcIconSvgWrapper :path="mdiEyeOffOutline" :size="64" />
</template>
</NcEmptyContent>
<NcEmptyContent
v-else-if="elements.length === 0"
:name="t('appstore', 'Loading')"
:description="t('appstore', 'Fetching the latest news…')">
<template #icon>
<NcLoadingIcon :size="64" />
</template>
</NcEmptyContent>
<div v-else class="app-discover">
<component
:is="getComponent(entry.type)"
v-for="entry, index in elements"
:key="entry.id ?? index"
v-bind="entry" />
</div>
</template>
<style scoped lang="scss">
.app-discover {
max-width: 1008px; /* 900px + 2x 54px padding for the carousel controls */
margin-inline: auto;
padding-inline: 54px;
/* Padding required to make last element not bound to the bottom */
padding-block-end: var(--default-clickable-area, 44px);
display: flex;
flex-direction: column;
gap: var(--default-clickable-area, 44px);
}
</style>
@@ -0,0 +1,53 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import { t } from '@nextcloud/l10n'
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import AppTable from '../components/AppTable/AppTable.vue'
import { useAppsStore } from '../store/apps.ts'
const route = useRoute()
const store = useAppsStore()
const currentCategory = computed(() => route.params!.category as 'enabled' | 'installed' | 'disabled' | 'updates')
const apps = computed(() => {
if (currentCategory.value === 'installed') {
return store.apps.filter((app) => app.installed)
} else if (currentCategory.value === 'enabled') {
return store.apps.filter((app) => app.active)
} else if (currentCategory.value === 'disabled') {
return store.apps.filter((app) => app.installed && !app.active)
} else if (currentCategory.value === 'updates') {
return store.apps.filter((app) => app.update)
}
return []
})
</script>
<template>
<!-- Apps list -->
<NcEmptyContent
v-if="store.isLoadingApps"
:name="t('appstore', 'Loading app list')">
<template #icon>
<NcLoadingIcon :size="64" />
</template>
</NcEmptyContent>
<AppTable
v-else
:class="$style.appstoreManage__appTable"
:apps />
</template>
<style module>
.appstoreManage__appTable {
margin-bottom: var(--body-container-margin);
}
</style>
@@ -2,59 +2,111 @@
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import { loadState } from '@nextcloud/initial-state'
import { t } from '@nextcloud/l10n'
import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import NcAppNavigation from '@nextcloud/vue/components/NcAppNavigation'
import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem'
import NcAppNavigationSearch from '@nextcloud/vue/components/NcAppNavigationSearch'
import NcAppNavigationSpacer from '@nextcloud/vue/components/NcAppNavigationSpacer'
import NcCounterBubble from '@nextcloud/vue/components/NcCounterBubble'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import { APPSTORE_CATEGORY_ICONS, APPSTORE_CATEGORY_NAMES } from '../constants.ts'
import { useAppsStore } from '../store/apps.ts'
import { useUpdatesStore } from '../store/updates.ts'
const appstoreEnabled = loadState<boolean>('settings', 'appstoreEnabled', true)
const store = useAppsStore()
const updateStore = useUpdatesStore()
const categories = computed(() => store.categories)
const categoriesLoading = computed(() => store.isLoadingCategories)
const router = useRouter()
const search = ref('')
watch(search, (newValue, oldValue) => {
if (newValue.trim() === oldValue.trim()) {
return
}
if (router.currentRoute.value.name === 'apps-search') {
router.replace({
name: 'apps-search',
query: { q: newValue },
})
return
}
router.push({
name: 'apps-search',
query: { q: newValue },
})
})
/**
* Check if the current instance has a support subscription from the Nextcloud GmbH
*
* For customers of the Nextcloud GmbH the app level will be set to `300` for apps that are supported in their subscription
*/
const isSubscribed = computed(() => store.apps.find(({ level }) => level === 300) !== undefined)
</script>
<template>
<!-- Categories & filters -->
<NcAppNavigation :aria-label="t('settings', 'Apps')">
<NcAppNavigation :aria-label="t('appstore', 'Apps')">
<template #search>
<NcAppNavigationSearch
v-model="search"
:label="t('appstore', 'Search apps…')" />
</template>
<template #list>
<NcAppNavigationItem
v-if="appstoreEnabled"
id="app-category-discover"
:to="{ name: 'apps-category', params: { category: 'discover' } }"
:name="APPS_SECTION_ENUM.discover">
:to="{ name: 'apps-discover' }"
:name="APPSTORE_CATEGORY_NAMES.discover">
<template #icon>
<NcIconSvgWrapper :path="APPSTORE_CATEGORY_ICONS.discover" />
</template>
</NcAppNavigationItem>
<NcAppNavigationItem
id="app-category-installed"
:to="{ name: 'apps-category', params: { category: 'installed' } }"
:name="APPS_SECTION_ENUM.installed">
:to="{ name: 'apps-manage', params: { category: 'installed' } }"
:name="APPSTORE_CATEGORY_NAMES.installed">
<template #icon>
<NcIconSvgWrapper :path="APPSTORE_CATEGORY_ICONS.installed" />
</template>
</NcAppNavigationItem>
<NcAppNavigationItem
id="app-category-enabled"
:to="{ name: 'apps-category', params: { category: 'enabled' } }"
:name="APPS_SECTION_ENUM.enabled">
:to="{ name: 'apps-manage', params: { category: 'enabled' } }"
:name="APPSTORE_CATEGORY_NAMES.enabled">
<template #icon>
<NcIconSvgWrapper :path="APPSTORE_CATEGORY_ICONS.enabled" />
</template>
</NcAppNavigationItem>
<NcAppNavigationItem
id="app-category-disabled"
:to="{ name: 'apps-category', params: { category: 'disabled' } }"
:name="APPS_SECTION_ENUM.disabled">
:to="{ name: 'apps-manage', params: { category: 'disabled' } }"
:name="APPSTORE_CATEGORY_NAMES.disabled">
<template #icon>
<NcIconSvgWrapper :path="APPSTORE_CATEGORY_ICONS.disabled" />
</template>
</NcAppNavigationItem>
<NcAppNavigationItem
v-if="store.updateCount > 0"
id="app-category-updates"
:to="{ name: 'apps-category', params: { category: 'updates' } }"
:name="APPS_SECTION_ENUM.updates">
v-if="updateStore.updateCount > 0"
:to="{ name: 'apps-manage', params: { category: 'updates' } }"
:name="APPSTORE_CATEGORY_NAMES.updates">
<template #counter>
<NcCounterBubble>{{ store.updateCount }}</NcCounterBubble>
<NcCounterBubble :count="updateStore.updateCount" />
</template>
<template #icon>
<NcIconSvgWrapper :path="APPSTORE_CATEGORY_ICONS.updates" />
</template>
</NcAppNavigationItem>
<NcAppNavigationItem
id="app-category-your-bundles"
:to="{ name: 'apps-category', params: { category: 'app-bundles' } }"
:name="APPS_SECTION_ENUM['app-bundles']">
:to="{ name: 'apps-bundles' }"
:name="APPSTORE_CATEGORY_NAMES.bundles">
<template #icon>
<NcIconSvgWrapper :path="APPSTORE_CATEGORY_ICONS.bundles" />
</template>
@@ -63,15 +115,16 @@
<NcAppNavigationSpacer />
<!-- App store categories -->
<li v-if="appstoreEnabled && categoriesLoading" class="categories--loading">
<NcLoadingIcon :size="20" :aria-label="t('settings', 'Loading categories')" />
<li v-if="appstoreEnabled && categoriesLoading" :class="$style.appstoreNavigation__categories_loading">
<NcLoadingIcon :size="20" :name="t('appstore', 'Loading categories')" />
</li>
<template v-else-if="appstoreEnabled && !categoriesLoading">
<NcAppNavigationItem
v-if="isSubscribed"
id="app-category-supported"
:to="{ name: 'apps-category', params: { category: 'supported' } }"
:name="APPS_SECTION_ENUM.supported">
:name="APPSTORE_CATEGORY_NAMES.supported">
<template #icon>
<NcIconSvgWrapper :path="APPSTORE_CATEGORY_ICONS.supported" />
</template>
@@ -79,7 +132,7 @@
<NcAppNavigationItem
id="app-category-featured"
:to="{ name: 'apps-category', params: { category: 'featured' } }"
:name="APPS_SECTION_ENUM.featured">
:name="APPSTORE_CATEGORY_NAMES.featured">
<template #icon>
<NcIconSvgWrapper :path="APPSTORE_CATEGORY_ICONS.featured" />
</template>
@@ -103,43 +156,8 @@
</NcAppNavigation>
</template>
<script setup lang="ts">
import { loadState } from '@nextcloud/initial-state'
import { translate as t } from '@nextcloud/l10n'
import { computed, onBeforeMount } from 'vue'
import NcAppNavigation from '@nextcloud/vue/components/NcAppNavigation'
import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem'
import NcAppNavigationSpacer from '@nextcloud/vue/components/NcAppNavigationSpacer'
import NcCounterBubble from '@nextcloud/vue/components/NcCounterBubble'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import { APPS_SECTION_ENUM } from '../constants/AppsConstants.js'
import APPSTORE_CATEGORY_ICONS from '../constants/AppstoreCategoryIcons.ts'
import { useAppsStore } from '../store/apps-store.ts'
const appstoreEnabled = loadState<boolean>('settings', 'appstoreEnabled', true)
const store = useAppsStore()
const categories = computed(() => store.categories)
const categoriesLoading = computed(() => store.loading.categories)
/**
* Check if the current instance has a support subscription from the Nextcloud GmbH
*
* For customers of the Nextcloud GmbH the app level will be set to `300` for apps that are supported in their subscription
*/
const isSubscribed = computed(() => store.apps.find(({ level }) => level === 300) !== undefined)
// load categories when component is mounted
onBeforeMount(() => {
store.loadCategories()
store.loadApps()
})
</script>
<style scoped>
/* The categories-loading indicator */
.categories--loading {
<style module>
.appstoreNavigation__categories_loading {
flex: 1;
display: flex;
align-items: center;
@@ -2,85 +2,35 @@
- SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<!-- Selected app details -->
<NcAppSidebar
v-if="showSidebar"
class="app-sidebar"
:class="{ 'app-sidebar--with-screenshot': hasScreenshot }"
:active.sync="activeTab"
:background="hasScreenshot ? app.screenshot : undefined"
:compact="!hasScreenshot"
:name="app.name"
:title="app.name"
:subname="licenseText"
:subtitle="licenseText"
@close="hideAppDetails">
<!-- Fallback icon incase no app icon is available -->
<template v-if="!hasScreenshot" #header>
<NcIconSvgWrapper
class="app-sidebar__fallback-icon"
:svg="appIcon ?? ''"
:size="64" />
</template>
<template #description>
<!-- Featured/Supported badges -->
<div class="app-sidebar__badges">
<AppLevelBadge :level="app.level" />
<AppDaemonBadge v-if="app.app_api && app.daemon" :daemon="app.daemon" />
<AppScore v-if="hasRating" :score="rating" />
</div>
</template>
<!-- Tab content -->
<AppDescriptionTab :app="app" />
<AppDetailsTab :key="app.id" :app="app" />
<AppReleasesTab :app="app" />
<AppDeployDaemonTab :app="app" />
</NcAppSidebar>
</template>
<script setup lang="ts">
import { t } from '@nextcloud/l10n'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router/composables'
import { useRoute, useRouter } from 'vue-router'
import NcAppSidebar from '@nextcloud/vue/components/NcAppSidebar'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import AppDaemonBadge from '../components/AppList/AppDaemonBadge.vue'
import AppLevelBadge from '../components/AppList/AppLevelBadge.vue'
import AppScore from '../components/AppList/AppScore.vue'
import AppDeployDaemonTab from '../components/AppStoreSidebar/AppDeployDaemonTab.vue'
import AppDescriptionTab from '../components/AppStoreSidebar/AppDescriptionTab.vue'
import AppDetailsTab from '../components/AppStoreSidebar/AppDetailsTab.vue'
import AppReleasesTab from '../components/AppStoreSidebar/AppReleasesTab.vue'
import AppDaemonBadge from '../components/AppDaemonBadge.vue'
import AppLevelBadge from '../components/AppLevelBadge.vue'
import AppScore from '../components/AppScore.vue'
// import AppDeployDaemonTab from '../components/AppStoreSidebar/AppDeployDaemonTab.vue'
// import AppDescriptionTab from '../components/AppStoreSidebar/AppDescriptionTab.vue'
// import AppDetailsTab from '../components/AppStoreSidebar/AppDetailsTab.vue'
// import AppReleasesTab from '../components/AppStoreSidebar/AppReleasesTab.vue'
import { useAppIcon } from '../composables/useAppIcon.ts'
import { useAppApiStore } from '../store/app-api-store.ts'
import { useAppsStore } from '../store/apps-store.ts'
import { useStore } from '../store/index.js'
import { useAppsStore } from '../store/apps.ts'
const route = useRoute()
const router = useRouter()
const store = useAppsStore()
const appApiStore = useAppApiStore()
const legacyStore = useStore()
const appId = computed(() => route.params.id ?? '')
const app = computed(() => {
if (legacyStore.getters.isAppApiEnabled) {
const exApp = appApiStore.getAllApps
.find((app) => app.id === appId.value) ?? null
if (exApp) {
return exApp
}
}
return store.getAppById(appId.value)!
})
const hasRating = computed(() => app.value.appstoreData?.ratingNumOverall > 5)
const rating = computed(() => app.value.appstoreData?.ratingNumRecent > 5
? app.value.appstoreData.ratingRecent
: (app.value.appstoreData?.ratingOverall ?? 0.5))
const showSidebar = computed(() => app.value !== null)
const appId = computed<string>(() => [route.params.id].flat()[0]!)
const app = computed(() => store.getAppById(appId.value) ?? null)
const hasRating = computed(() => app.value && app.value.appstoreData?.ratingNumOverall && app.value.appstoreData?.ratingNumOverall > 5)
const rating = computed(() => hasRating.value
? app.value!.appstoreData!.ratingRecent
: (app.value!.appstoreData?.ratingOverall ?? 0.5))
const { appIcon } = useAppIcon(app)
@@ -91,10 +41,10 @@ const licenseText = computed(() => {
if (!app.value) {
return ''
}
if (app.value.license !== '') {
return t('settings', 'Version {version}, {license}-licensed', { version: app.value.version, license: app.value.licence.toString().toUpperCase() })
if (app.value.licence) {
return t('appstore', 'Version {version}, {license}-licensed', { version: app.value.version, license: app.value.licence.toString().toUpperCase() })
}
return t('settings', 'Version {version}', { version: app.value.version })
return t('appstore', 'Version {version}', { version: app.value.version })
})
const activeTab = ref('details')
@@ -106,9 +56,12 @@ watch([app], () => {
* Hide the details sidebar by pushing a new route
*/
function hideAppDetails() {
router.push({
name: 'apps-category',
params: { category: route.params.category },
router.replace({
name: route.name!,
params: {
...route.params,
id: undefined,
},
})
}
@@ -134,6 +87,47 @@ watch([app], loadScreenshot)
onMounted(loadScreenshot)
</script>
<template>
<!-- Selected app details -->
<NcAppSidebar
v-model:active="activeTab"
class="app-sidebar"
:class="{ 'app-sidebar--with-screenshot': hasScreenshot }"
:background="hasScreenshot ? app!.screenshot : undefined"
:compact="!hasScreenshot"
:name="app?.name ?? appId"
:title="app?.name ?? appId"
:subname="licenseText"
:subtitle="licenseText"
@close="hideAppDetails">
<!-- Fallback icon in case no app icon is available -->
<template v-if="!hasScreenshot" #header>
<NcIconSvgWrapper
class="app-sidebar__fallback-icon"
:svg="appIcon ?? ''"
:size="64" />
</template>
<template v-if="app" #description>
<!-- Featured/Supported badges -->
<div class="app-sidebar__badges">
<AppLevelBadge :level="app.level" />
<AppDaemonBadge v-if="app.app_api && app.daemon" :daemon="app.daemon" />
<AppScore v-if="hasRating" :score="rating" />
</div>
</template>
<!-- Tab content -->
<NcEmptyContent v-if="!app" name="No such app" />
<template v-else>
<!-- <AppDescriptionTab :app="app" />
<AppDetailsTab :app="app" />
<AppReleasesTab :app="app" />
<AppDeployDaemonTab :app="app" /> -->
</template>
</NcAppSidebar>
</template>
<style scoped lang="scss">
.app-sidebar {
// If a screenshot is available it should cover the whole figure
+6
View File
@@ -0,0 +1,6 @@
<?php
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
/*!
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Appstore\Tests\Controller;
use OC\App\AppManager;
use OC\App\AppStore\Bundles\BundleFetcher;
use OC\App\AppStore\Fetcher\AppFetcher;
use OC\App\AppStore\Fetcher\CategoryFetcher;
use OC\App\DependencyAnalyzer;
use OC\Installer;
use OCA\Appstore\Controller\ApiController;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Http\JSONResponse;
use OCP\IAppConfig;
use OCP\IConfig;
use OCP\IL10N;
use OCP\IRequest;
use OCP\L10N\IFactory;
use OCP\Support\Subscription\IRegistry;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Log\LoggerInterface;
use Test\TestCase;
#[\PHPUnit\Framework\Attributes\Group('DB')]
class ApiControllerTest extends TestCase {
private IRequest&MockObject $request;
private IConfig&MockObject $config;
private IAppConfig&MockObject $appConfig;
private AppManager&MockObject $appManager;
private DependencyAnalyzer&MockObject $dependencyAnalyzer;
private CategoryFetcher&MockObject $categoryFetcher;
private AppFetcher&MockObject $appFetcher;
private IFactory&MockObject $l10nFactory;
private BundleFetcher&MockObject $bundleFetcher;
private Installer&MockObject $installer;
private IRegistry&MockObject $subscriptionRegistry;
private LoggerInterface&MockObject $logger;
private ApiController $apiController;
protected function setUp(): void {
parent::setUp();
$this->request = $this->createMock(IRequest::class);
$this->config = $this->createMock(IConfig::class);
$this->appConfig = $this->createMock(IAppConfig::class);
$this->appManager = $this->createMock(AppManager::class);
$this->dependencyAnalyzer = $this->createMock(DependencyAnalyzer::class);
$this->categoryFetcher = $this->createMock(CategoryFetcher::class);
$this->appFetcher = $this->createMock(AppFetcher::class);
$this->l10nFactory = $this->createMock(IFactory::class);
$this->bundleFetcher = $this->createMock(BundleFetcher::class);
$this->installer = $this->createMock(Installer::class);
$this->subscriptionRegistry = $this->createMock(IRegistry::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->apiController = new ApiController(
$this->request,
$this->config,
$this->appConfig,
$this->appManager,
$this->dependencyAnalyzer,
$this->categoryFetcher,
$this->appFetcher,
$this->l10nFactory,
$this->bundleFetcher,
$this->installer,
$this->subscriptionRegistry,
$this->logger,
);
}
public function testListCategories(): void {
$json = file_get_contents(__DIR__ . '/../fixtures/categories.json');
$this->categoryFetcher
->expects($this->once())
->method('get')
->willReturn(json_decode($json, true)['data']);
$response = $this->apiController->listCategories();
$this->assertInstanceOf(DataResponse::class, $response);
$this->assertSame(200, $response->getStatus());
$jsonResponse = json_encode($response->getData());
$this->assertJsonStringEqualsJsonFile(__DIR__ . '/../fixtures/categories-api-response.json', $jsonResponse);
}
}
@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
/*!
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Appstore\Tests\Controller;
use OC\App\AppManager;
use OC\App\AppStore\Bundles\BundleFetcher;
use OC\Installer;
use OCA\Appstore\Controller\PageController;
use OCP\AppFramework\Http\ContentSecurityPolicy;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Services\IInitialState;
use OCP\IConfig;
use OCP\IL10N;
use OCP\INavigationManager;
use OCP\IRequest;
use OCP\IURLGenerator;
use PHPUnit\Framework\MockObject\MockObject;
use Test\TestCase;
#[\PHPUnit\Framework\Attributes\Group('DB')]
class PageControllerTest extends TestCase {
private IRequest&MockObject $request;
private IL10N&MockObject $l10n;
private IConfig&MockObject $config;
private INavigationManager&MockObject $navigationManager;
private AppManager&MockObject $appManager;
private BundleFetcher&MockObject $bundleFetcher;
private Installer&MockObject $installer;
private IURLGenerator&MockObject $urlGenerator;
private IInitialState&MockObject $initialState;
private PageController $pageController;
protected function setUp(): void {
parent::setUp();
$this->request = $this->createMock(IRequest::class);
$this->l10n = $this->createMock(IL10N::class);
$this->l10n->expects($this->any())
->method('t')
->willReturnArgument(0);
$this->config = $this->createMock(IConfig::class);
$this->navigationManager = $this->createMock(INavigationManager::class);
$this->appManager = $this->createMock(AppManager::class);
$this->bundleFetcher = $this->createMock(BundleFetcher::class);
$this->installer = $this->createMock(Installer::class);
$this->urlGenerator = $this->createMock(IURLGenerator::class);
$this->initialState = $this->createMock(IInitialState::class);
$this->pageController = new PageController(
$this->request,
$this->l10n,
$this->config,
$this->installer,
$this->appManager,
$this->urlGenerator,
$this->initialState,
$this->bundleFetcher,
$this->navigationManager,
);
}
public function testViewApps(): void {
$this->bundleFetcher->expects($this->once())->method('getBundles')->willReturn([]);
$this->installer->expects($this->any())
->method('isUpdateAvailable')
->willReturn(false);
$this->config
->expects($this->once())
->method('getSystemValueBool')
->with('appstoreenabled', true)
->willReturn(true);
$this->navigationManager
->expects($this->once())
->method('setActiveEntry')
->with('core_apps');
$this->initialState
->expects($this->exactly(4))
->method('provideInitialState');
$policy = new ContentSecurityPolicy();
$policy->addAllowedImageDomain('https://usercontent.apps.nextcloud.com');
$expected = new TemplateResponse('appstore',
'empty',
[
'pageTitle' => 'App store'
],
'user');
$expected->setContentSecurityPolicy($policy);
$this->assertEquals($expected, $this->pageController->viewApps());
}
public function testViewAppsAppstoreNotEnabled(): void {
$this->installer->expects($this->any())
->method('isUpdateAvailable')
->willReturn(false);
$this->bundleFetcher->expects($this->once())->method('getBundles')->willReturn([]);
$this->config
->expects($this->once())
->method('getSystemValueBool')
->with('appstoreenabled', true)
->willReturn(false);
$this->navigationManager
->expects($this->once())
->method('setActiveEntry')
->with('core_apps');
$this->initialState
->expects($this->exactly(4))
->method('provideInitialState');
$policy = new ContentSecurityPolicy();
$policy->addAllowedImageDomain('https://usercontent.apps.nextcloud.com');
$expected = new TemplateResponse('appstore',
'empty',
[
'pageTitle' => 'App store'
],
'user');
$expected->setContentSecurityPolicy($policy);
$this->assertEquals($expected, $this->pageController->viewApps());
}
}
@@ -0,0 +1,62 @@
[
{
"displayName": "AI",
"id": "ai"
},
{
"displayName": "Customization",
"id": "customization"
},
{
"displayName": "Dashboard",
"id": "dashboard"
},
{
"displayName": "Files",
"id": "files"
},
{
"displayName": "Games",
"id": "games"
},
{
"displayName": "Integration",
"id": "integration"
},
{
"displayName": "Monitoring",
"id": "monitoring"
},
{
"displayName": "Multimedia",
"id": "multimedia"
},
{
"displayName": "Office & text",
"id": "office"
},
{
"displayName": "Organization",
"id": "organization"
},
{
"displayName": "Search",
"id": "search"
},
{
"displayName": "Security",
"id": "security"
},
{
"displayName": "Social & communication",
"id": "social"
},
{
"displayName": "Tools",
"id": "tools"
},
{
"displayName": "Flow",
"id": "workflow"
}
]
+202
View File
@@ -0,0 +1,202 @@
{
"data": [
{
"id": "ai",
"translations": {
"en": {
"name": "AI",
"description": "Artificial Intelligence apps"
},
"de": {
"name": "AI",
"description": "Apps für künstliche Intelligenz"
}
}
},
{
"id": "customization",
"translations": {
"en": {
"name": "Customization",
"description": "Themes, layout and UX change apps"
},
"de": {
"name": "Anpassung",
"description": "Apps zur Änderung von Design, Layout und Benutzererfahrung"
}
}
},
{
"id": "dashboard",
"translations": {
"en": {
"name": "Dashboard",
"description": "Apps including Nextcloud Dashboard widgets"
},
"de": {
"name": "Dashboard",
"description": "Apps einschließlich Nextcloud Dashboard-Widgets"
}
}
},
{
"id": "files",
"translations": {
"en": {
"name": "Files",
"description": "File management and Files app extension apps"
},
"de": {
"name": "Dateien",
"description": "Dateimanagement sowie Erweiterungs-Apps für die Dateien-App"
}
}
},
{
"id": "games",
"translations": {
"en": {
"name": "Games",
"description": "Games run in your Nextcloud"
},
"de": {
"name": "Spiele",
"description": "Spiele für deine Nextcloud"
}
}
},
{
"id": "integration",
"translations": {
"en": {
"name": "Integration",
"description": "Apps that connect Nextcloud with other services and platforms"
},
"de": {
"name": "Einbindung",
"description": "Apps die Nextcloud mit anderen Diensten und Plattformen verbinden"
}
}
},
{
"id": "monitoring",
"translations": {
"en": {
"name": "Monitoring",
"description": "Data statistics, system diagnostics and activity apps"
},
"de": {
"name": "Überwachung",
"description": "Datenstatistiken-, Systemdiagnose- und Aktivitäten-Apps"
}
}
},
{
"id": "multimedia",
"translations": {
"en": {
"name": "Multimedia",
"description": "Audio, video and picture apps"
},
"de": {
"name": "Multimedia",
"description": "Audio-, Video- und Bilder-Apps"
}
}
},
{
"id": "office",
"translations": {
"en": {
"name": "Office & text",
"description": "Office and text processing apps"
},
"de": {
"name": "Büro & Text",
"description": "Büro- und Textverarbeitungs-Apps"
}
}
},
{
"id": "organization",
"translations": {
"en": {
"name": "Organization",
"description": "Time management, Todo list and calendar apps"
},
"de": {
"name": "Organisation",
"description": "Time management, Todo list and calendar apps"
}
}
},
{
"id": "search",
"translations": {
"en": {
"name": "Search",
"description": "Search related apps"
},
"de": {
"name": "Suche",
"description": "Verwandte Apps durchsuchen"
}
}
},
{
"id": "security",
"translations": {
"en": {
"name": "Security",
"description": "Apps that provide additional security mechanisms like authentication, authorization, encryption, etc."
},
"de": {
"name": "Sicherheit",
"description": "Apps die zusätzliche Sicherheitsmechanismen bereitstellen, wie z. B. Authentifizierung, Autorisierung, Verschlüsselung usw."
}
}
},
{
"id": "social",
"translations": {
"en": {
"name": "Social & communication",
"description": "Messaging, contact management and social media apps"
},
"de": {
"name": "Kommunikation",
"description": "Nachrichten-, Kontaktverwaltungs- und Social-Media-Apps"
}
}
},
{
"id": "tools",
"translations": {
"en": {
"name": "Tools",
"description": "Everything else"
},
"de": {
"name": "Werkzeuge",
"description": "Alles Andere"
}
}
},
{
"id": "workflow",
"translations": {
"en": {
"name": "Flow",
"description": "Apps for Nextcloud Flow"
},
"de": {
"name": "Flow",
"description": "Apps für Nextcloud Flow"
}
}
}
],
"timestamp": 1770475258,
"ncversion": "34.0.0.0",
"ETag": "\"6986f131-6575\""
}
-16
View File
@@ -20,22 +20,6 @@ return [
['name' => 'MailSettings#storeCredentials', 'url' => '/settings/admin/mailsettings/credentials', 'verb' => 'POST' , 'root' => ''],
['name' => 'MailSettings#sendTestMail', 'url' => '/settings/admin/mailtest', 'verb' => 'POST' , 'root' => ''],
['name' => 'AppSettings#getAppDiscoverJSON', 'url' => '/settings/api/apps/discover', 'verb' => 'GET', 'root' => ''],
['name' => 'AppSettings#getAppDiscoverMedia', 'url' => '/settings/api/apps/media', 'verb' => 'GET', 'root' => ''],
['name' => 'AppSettings#listCategories', 'url' => '/settings/apps/categories', 'verb' => 'GET' , 'root' => ''],
['name' => 'AppSettings#viewApps', 'url' => '/settings/apps', 'verb' => 'GET' , 'root' => ''],
['name' => 'AppSettings#listApps', 'url' => '/settings/apps/list', 'verb' => 'GET' , 'root' => ''],
['name' => 'AppSettings#enableApp', 'url' => '/settings/apps/enable/{appId}', 'verb' => 'GET' , 'root' => ''],
['name' => 'AppSettings#enableApp', 'url' => '/settings/apps/enable/{appId}', 'verb' => 'POST' , 'root' => ''],
['name' => 'AppSettings#enableApps', 'url' => '/settings/apps/enable', 'verb' => 'POST' , 'root' => ''],
['name' => 'AppSettings#disableApp', 'url' => '/settings/apps/disable/{appId}', 'verb' => 'GET' , 'root' => ''],
['name' => 'AppSettings#disableApps', 'url' => '/settings/apps/disable', 'verb' => 'POST' , 'root' => ''],
['name' => 'AppSettings#updateApp', 'url' => '/settings/apps/update/{appId}', 'verb' => 'GET' , 'root' => ''],
['name' => 'AppSettings#uninstallApp', 'url' => '/settings/apps/uninstall/{appId}', 'verb' => 'GET' , 'root' => ''],
['name' => 'AppSettings#viewApps', 'url' => '/settings/apps/{category}', 'verb' => 'GET', 'defaults' => ['category' => ''] , 'root' => ''],
['name' => 'AppSettings#viewApps', 'url' => '/settings/apps/{category}/{id}', 'verb' => 'GET', 'defaults' => ['category' => '', 'id' => ''] , 'root' => ''],
['name' => 'AppSettings#force', 'url' => '/settings/apps/force', 'verb' => 'POST' , 'root' => ''],
['name' => 'Users#setDisplayName', 'url' => '/settings/users/{username}/displayName', 'verb' => 'POST' , 'root' => ''],
['name' => 'Users#setEMailAddress', 'url' => '/settings/users/{id}/mailAddress', 'verb' => 'PUT' , 'root' => ''],
['name' => 'Users#setUserSettings', 'url' => '/settings/users/{username}/settings', 'verb' => 'PUT' , 'root' => ''],
@@ -22,7 +22,6 @@ return array(
'OCA\\Settings\\ConfigLexicon' => $baseDir . '/../lib/ConfigLexicon.php',
'OCA\\Settings\\Controller\\AISettingsController' => $baseDir . '/../lib/Controller/AISettingsController.php',
'OCA\\Settings\\Controller\\AdminSettingsController' => $baseDir . '/../lib/Controller/AdminSettingsController.php',
'OCA\\Settings\\Controller\\AppSettingsController' => $baseDir . '/../lib/Controller/AppSettingsController.php',
'OCA\\Settings\\Controller\\AuthSettingsController' => $baseDir . '/../lib/Controller/AuthSettingsController.php',
'OCA\\Settings\\Controller\\AuthorizedGroupController' => $baseDir . '/../lib/Controller/AuthorizedGroupController.php',
'OCA\\Settings\\Controller\\ChangePasswordController' => $baseDir . '/../lib/Controller/ChangePasswordController.php',
@@ -48,7 +47,6 @@ return array(
'OCA\\Settings\\Mailer\\NewUserMailHelper' => $baseDir . '/../lib/Mailer/NewUserMailHelper.php',
'OCA\\Settings\\Middleware\\SubadminMiddleware' => $baseDir . '/../lib/Middleware/SubadminMiddleware.php',
'OCA\\Settings\\ResponseDefinitions' => $baseDir . '/../lib/ResponseDefinitions.php',
'OCA\\Settings\\Search\\AppSearch' => $baseDir . '/../lib/Search/AppSearch.php',
'OCA\\Settings\\Search\\SectionSearch' => $baseDir . '/../lib/Search/SectionSearch.php',
'OCA\\Settings\\Search\\UserSearch' => $baseDir . '/../lib/Search/UserSearch.php',
'OCA\\Settings\\Sections\\Admin\\Additional' => $baseDir . '/../lib/Sections/Admin/Additional.php',
@@ -37,7 +37,6 @@ class ComposerStaticInitSettings
'OCA\\Settings\\ConfigLexicon' => __DIR__ . '/..' . '/../lib/ConfigLexicon.php',
'OCA\\Settings\\Controller\\AISettingsController' => __DIR__ . '/..' . '/../lib/Controller/AISettingsController.php',
'OCA\\Settings\\Controller\\AdminSettingsController' => __DIR__ . '/..' . '/../lib/Controller/AdminSettingsController.php',
'OCA\\Settings\\Controller\\AppSettingsController' => __DIR__ . '/..' . '/../lib/Controller/AppSettingsController.php',
'OCA\\Settings\\Controller\\AuthSettingsController' => __DIR__ . '/..' . '/../lib/Controller/AuthSettingsController.php',
'OCA\\Settings\\Controller\\AuthorizedGroupController' => __DIR__ . '/..' . '/../lib/Controller/AuthorizedGroupController.php',
'OCA\\Settings\\Controller\\ChangePasswordController' => __DIR__ . '/..' . '/../lib/Controller/ChangePasswordController.php',
@@ -63,7 +62,6 @@ class ComposerStaticInitSettings
'OCA\\Settings\\Mailer\\NewUserMailHelper' => __DIR__ . '/..' . '/../lib/Mailer/NewUserMailHelper.php',
'OCA\\Settings\\Middleware\\SubadminMiddleware' => __DIR__ . '/..' . '/../lib/Middleware/SubadminMiddleware.php',
'OCA\\Settings\\ResponseDefinitions' => __DIR__ . '/..' . '/../lib/ResponseDefinitions.php',
'OCA\\Settings\\Search\\AppSearch' => __DIR__ . '/..' . '/../lib/Search/AppSearch.php',
'OCA\\Settings\\Search\\SectionSearch' => __DIR__ . '/..' . '/../lib/Search/SectionSearch.php',
'OCA\\Settings\\Search\\UserSearch' => __DIR__ . '/..' . '/../lib/Search/UserSearch.php',
'OCA\\Settings\\Sections\\Admin\\Additional' => __DIR__ . '/..' . '/../lib/Sections/Admin/Additional.php',
@@ -1,690 +0,0 @@
<?php
/**
* SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
* SPDX-FileCopyrightText: 2016 ownCloud, Inc.
* SPDX-License-Identifier: AGPL-3.0-only
*/
namespace OCA\Settings\Controller;
use OC\App\AppManager;
use OC\App\AppStore\Bundles\BundleFetcher;
use OC\App\AppStore\Fetcher\AppDiscoverFetcher;
use OC\App\AppStore\Fetcher\AppFetcher;
use OC\App\AppStore\Fetcher\CategoryFetcher;
use OC\App\AppStore\Version\VersionParser;
use OC\App\DependencyAnalyzer;
use OC\Installer;
use OCA\AppAPI\Service\ExAppsPageService;
use OCP\App\AppPathNotFoundException;
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\PasswordConfirmationRequired;
use OCP\AppFramework\Http\ContentSecurityPolicy;
use OCP\AppFramework\Http\FileDisplayResponse;
use OCP\AppFramework\Http\JSONResponse;
use OCP\AppFramework\Http\NotFoundResponse;
use OCP\AppFramework\Http\Response;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Services\IInitialState;
use OCP\Files\AppData\IAppDataFactory;
use OCP\Files\IAppData;
use OCP\Files\NotFoundException;
use OCP\Files\NotPermittedException;
use OCP\Files\SimpleFS\ISimpleFile;
use OCP\Files\SimpleFS\ISimpleFolder;
use OCP\Http\Client\IClientService;
use OCP\IConfig;
use OCP\IGroup;
use OCP\IGroupManager;
use OCP\IL10N;
use OCP\INavigationManager;
use OCP\IRequest;
use OCP\IURLGenerator;
use OCP\IUserSession;
use OCP\L10N\IFactory;
use OCP\Security\RateLimiting\ILimiter;
use OCP\Server;
use OCP\Util;
use Psr\Log\LoggerInterface;
#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)]
class AppSettingsController extends Controller {
/** @var array */
private $allApps = [];
private IAppData $appData;
public function __construct(
string $appName,
IRequest $request,
IAppDataFactory $appDataFactory,
private IL10N $l10n,
private IConfig $config,
private INavigationManager $navigationManager,
private AppManager $appManager,
private CategoryFetcher $categoryFetcher,
private AppFetcher $appFetcher,
private IFactory $l10nFactory,
private IGroupManager $groupManager,
private BundleFetcher $bundleFetcher,
private Installer $installer,
private IURLGenerator $urlGenerator,
private LoggerInterface $logger,
private IInitialState $initialState,
private AppDiscoverFetcher $discoverFetcher,
private IClientService $clientService,
) {
parent::__construct($appName, $request);
$this->appData = $appDataFactory->get('appstore');
}
/**
* @psalm-suppress UndefinedClass AppAPI is shipped since 30.0.1
*
* @return TemplateResponse
*/
#[NoCSRFRequired]
public function viewApps(): TemplateResponse {
$this->navigationManager->setActiveEntry('core_apps');
$this->initialState->provideInitialState('appstoreEnabled', $this->config->getSystemValueBool('appstoreenabled', true));
$this->initialState->provideInitialState('appstoreBundles', $this->getBundles());
$this->initialState->provideInitialState('appstoreUpdateCount', count($this->getAppsWithUpdates()));
$this->initialState->provideInitialState('isAllInOne', filter_var(getenv('THIS_IS_AIO'), FILTER_VALIDATE_BOOL));
$groups = array_map(static fn (IGroup $group): array => [
'id' => $group->getGID(),
'name' => $group->getDisplayName(),
], $this->groupManager->search('', 5));
$this->initialState->provideInitialState('usersSettings', [ 'systemGroups' => $groups]);
if ($this->appManager->isEnabledForAnyone('app_api')) {
try {
Server::get(ExAppsPageService::class)->provideAppApiState($this->initialState);
} catch (\Psr\Container\NotFoundExceptionInterface|\Psr\Container\ContainerExceptionInterface $e) {
}
}
$policy = new ContentSecurityPolicy();
$policy->addAllowedImageDomain('https://usercontent.apps.nextcloud.com');
$templateResponse = new TemplateResponse('settings', 'settings/empty', ['pageTitle' => $this->l10n->t('Settings')]);
$templateResponse->setContentSecurityPolicy($policy);
Util::addStyle('settings', 'settings');
Util::addScript('settings', 'vue-settings-apps-users-management');
return $templateResponse;
}
/**
* Get all active entries for the app discover section
*/
#[NoCSRFRequired]
public function getAppDiscoverJSON(): JSONResponse {
$data = $this->discoverFetcher->get(true);
return new JSONResponse(array_values($data));
}
/**
* Get a image for the app discover section - this is proxied for privacy and CSP reasons
*
* @param string $image
* @throws \Exception
*/
#[NoCSRFRequired]
public function getAppDiscoverMedia(string $fileName, ILimiter $limiter, IUserSession $session): Response {
$getEtag = $this->discoverFetcher->getETag() ?? date('Y-m');
$etag = trim($getEtag, '"');
$folder = null;
try {
$folder = $this->appData->getFolder('app-discover-cache');
$this->cleanUpImageCache($folder, $etag);
} catch (\Throwable $e) {
$folder = $this->appData->newFolder('app-discover-cache');
}
// Get the current cache folder
try {
$folder = $folder->getFolder($etag);
} catch (NotFoundException $e) {
$folder = $folder->newFolder($etag);
}
$info = pathinfo($fileName);
$hashName = md5($fileName);
$allFiles = $folder->getDirectoryListing();
// Try to find the file
$file = array_filter($allFiles, function (ISimpleFile $file) use ($hashName) {
return str_starts_with($file->getName(), $hashName);
});
// Get the first entry
$file = reset($file);
// If not found request from Web
if ($file === false) {
$user = $session->getUser();
// this route is not public thus we can assume a user is logged-in
assert($user !== null);
// Register a user request to throttle fetching external data
// this will prevent using the server for DoS of other systems.
$limiter->registerUserRequest(
'settings-discover-media',
// allow up to 24 media requests per hour
// this should be a sane default when a completely new section is loaded
// keep in mind browsers request all files from a source-set
24,
60 * 60,
$user,
);
if (!$this->checkCanDownloadMedia($fileName)) {
$this->logger->warning('Tried to load media files for app discover section from untrusted source');
return new NotFoundResponse(Http::STATUS_BAD_REQUEST);
}
try {
$client = $this->clientService->newClient();
$fileResponse = $client->get($fileName);
$contentType = $fileResponse->getHeader('Content-Type');
$extension = $info['extension'] ?? '';
$file = $folder->newFile($hashName . '.' . base64_encode($contentType) . '.' . $extension, $fileResponse->getBody());
} catch (\Throwable $e) {
$this->logger->warning('Could not load media file for app discover section', ['media_src' => $fileName, 'exception' => $e]);
return new NotFoundResponse();
}
} else {
// File was found so we can get the content type from the file name
$contentType = base64_decode(explode('.', $file->getName())[1] ?? '');
}
$response = new FileDisplayResponse($file, Http::STATUS_OK, ['Content-Type' => $contentType]);
// cache for 7 days
$response->cacheFor(604800, false, true);
return $response;
}
private function checkCanDownloadMedia(string $filename): bool {
$urlInfo = parse_url($filename);
if (!isset($urlInfo['host']) || !isset($urlInfo['path'])) {
return false;
}
// Always allowed hosts
if ($urlInfo['host'] === 'nextcloud.com') {
return true;
}
// Hosts that need further verification
// Github is only allowed if from our organization
$ALLOWED_HOSTS = ['github.com', 'raw.githubusercontent.com'];
if (!in_array($urlInfo['host'], $ALLOWED_HOSTS)) {
return false;
}
if (str_starts_with($urlInfo['path'], '/nextcloud/') || str_starts_with($urlInfo['path'], '/nextcloud-gmbh/')) {
return true;
}
return false;
}
/**
* Remove orphaned folders from the image cache that do not match the current etag
* @param ISimpleFolder $folder The folder to clear
* @param string $etag The etag (directory name) to keep
*/
private function cleanUpImageCache(ISimpleFolder $folder, string $etag): void {
// Cleanup old cache folders
$allFiles = $folder->getDirectoryListing();
foreach ($allFiles as $dir) {
try {
if ($dir->getName() !== $etag) {
$dir->delete();
}
} catch (NotPermittedException $e) {
// ignore folder for now
}
}
}
private function getAppsWithUpdates() {
$appClass = new \OC_App();
$apps = $appClass->listAllApps();
foreach ($apps as $key => $app) {
$newVersion = $this->installer->isUpdateAvailable($app['id']);
if ($newVersion === false) {
unset($apps[$key]);
}
}
return $apps;
}
private function getBundles() {
$result = [];
$bundles = $this->bundleFetcher->getBundles();
foreach ($bundles as $bundle) {
$result[] = [
'name' => $bundle->getName(),
'id' => $bundle->getIdentifier(),
'appIdentifiers' => $bundle->getAppIdentifiers()
];
}
return $result;
}
/**
* Get all available categories
*
* @return JSONResponse
*/
public function listCategories(): JSONResponse {
return new JSONResponse($this->getAllCategories());
}
private function getAllCategories() {
$currentLanguage = substr($this->l10nFactory->findLanguage(), 0, 2);
$categories = $this->categoryFetcher->get();
return array_map(fn ($category) => [
'id' => $category['id'],
'displayName' => $category['translations'][$currentLanguage]['name'] ?? $category['translations']['en']['name'],
], $categories);
}
/**
* Convert URL to proxied URL so CSP is no problem
*/
private function createProxyPreviewUrl(string $url): string {
if ($url === '') {
return '';
}
return 'https://usercontent.apps.nextcloud.com/' . base64_encode($url);
}
private function fetchApps() {
$appClass = new \OC_App();
$apps = $appClass->listAllApps();
foreach ($apps as $app) {
$app['installed'] = true;
if (isset($app['screenshot'][0])) {
$appScreenshot = $app['screenshot'][0] ?? null;
if (is_array($appScreenshot)) {
// Screenshot with thumbnail
$appScreenshot = $appScreenshot['@value'];
}
$app['screenshot'] = $this->createProxyPreviewUrl($appScreenshot);
}
$this->allApps[$app['id']] = $app;
}
$apps = $this->getAppsForCategory('');
$supportedApps = $appClass->getSupportedApps();
foreach ($apps as $app) {
$app['appstore'] = true;
if (!array_key_exists($app['id'], $this->allApps)) {
$this->allApps[$app['id']] = $app;
} else {
$this->allApps[$app['id']] = array_merge($app, $this->allApps[$app['id']]);
}
if (in_array($app['id'], $supportedApps)) {
$this->allApps[$app['id']]['level'] = \OC_App::supportedApp;
}
}
// add bundle information
$bundles = $this->bundleFetcher->getBundles();
foreach ($bundles as $bundle) {
foreach ($bundle->getAppIdentifiers() as $identifier) {
foreach ($this->allApps as &$app) {
if ($app['id'] === $identifier) {
$app['bundleIds'][] = $bundle->getIdentifier();
continue;
}
}
}
}
}
private function getAllApps() {
return $this->allApps;
}
/**
* Get all available apps in a category
*
* @return JSONResponse
* @throws \Exception
*/
public function listApps(): JSONResponse {
$this->fetchApps();
$apps = $this->getAllApps();
$dependencyAnalyzer = Server::get(DependencyAnalyzer::class);
$ignoreMaxApps = $this->config->getSystemValue('app_install_overwrite', []);
if (!is_array($ignoreMaxApps)) {
$this->logger->warning('The value given for app_install_overwrite is not an array. Ignoring...');
$ignoreMaxApps = [];
}
// Extend existing app details
$apps = array_map(function (array $appData) use ($dependencyAnalyzer, $ignoreMaxApps) {
if (isset($appData['appstoreData'])) {
$appstoreData = $appData['appstoreData'];
$appData['screenshot'] = $this->createProxyPreviewUrl($appstoreData['screenshots'][0]['url'] ?? '');
$appData['category'] = $appstoreData['categories'];
$appData['releases'] = $appstoreData['releases'];
}
$newVersion = $this->installer->isUpdateAvailable($appData['id']);
if ($newVersion) {
$appData['update'] = $newVersion;
}
// fix groups to be an array
$groups = [];
if (is_string($appData['groups'])) {
$groups = json_decode($appData['groups']);
// ensure 'groups' is an array
if (!is_array($groups)) {
$groups = [$groups];
}
}
$appData['groups'] = $groups;
$appData['canUnInstall'] = !$appData['active'] && $appData['removable'];
// fix licence vs license
if (isset($appData['license']) && !isset($appData['licence'])) {
$appData['licence'] = $appData['license'];
}
$ignoreMax = in_array($appData['id'], $ignoreMaxApps);
// analyse dependencies
$missing = $dependencyAnalyzer->analyze($appData, $ignoreMax);
$appData['canInstall'] = empty($missing);
$appData['missingDependencies'] = $missing;
$appData['missingMinOwnCloudVersion'] = !isset($appData['dependencies']['nextcloud']['@attributes']['min-version']);
$appData['missingMaxOwnCloudVersion'] = !isset($appData['dependencies']['nextcloud']['@attributes']['max-version']);
$appData['isCompatible'] = $dependencyAnalyzer->isMarkedCompatible($appData);
return $appData;
}, $apps);
usort($apps, [$this, 'sortApps']);
return new JSONResponse(['apps' => $apps, 'status' => 'success']);
}
/**
* Get all apps for a category from the app store
*
* @param string $requestedCategory
* @return array
* @throws \Exception
*/
private function getAppsForCategory($requestedCategory = ''): array {
$versionParser = new VersionParser();
$formattedApps = [];
$apps = $this->appFetcher->get();
foreach ($apps as $app) {
// Skip all apps not in the requested category
if ($requestedCategory !== '') {
$isInCategory = false;
foreach ($app['categories'] as $category) {
if ($category === $requestedCategory) {
$isInCategory = true;
}
}
if (!$isInCategory) {
continue;
}
}
if (!isset($app['releases'][0]['rawPlatformVersionSpec'])) {
continue;
}
$nextCloudVersion = $versionParser->getVersion($app['releases'][0]['rawPlatformVersionSpec']);
$nextCloudVersionDependencies = [];
if ($nextCloudVersion->getMinimumVersion() !== '') {
$nextCloudVersionDependencies['nextcloud']['@attributes']['min-version'] = $nextCloudVersion->getMinimumVersion();
}
if ($nextCloudVersion->getMaximumVersion() !== '') {
$nextCloudVersionDependencies['nextcloud']['@attributes']['max-version'] = $nextCloudVersion->getMaximumVersion();
}
$phpVersion = $versionParser->getVersion($app['releases'][0]['rawPhpVersionSpec']);
try {
$this->appManager->getAppPath($app['id']);
$existsLocally = true;
} catch (AppPathNotFoundException) {
$existsLocally = false;
}
$phpDependencies = [];
if ($phpVersion->getMinimumVersion() !== '') {
$phpDependencies['php']['@attributes']['min-version'] = $phpVersion->getMinimumVersion();
}
if ($phpVersion->getMaximumVersion() !== '') {
$phpDependencies['php']['@attributes']['max-version'] = $phpVersion->getMaximumVersion();
}
if (isset($app['releases'][0]['minIntSize'])) {
$phpDependencies['php']['@attributes']['min-int-size'] = $app['releases'][0]['minIntSize'];
}
$authors = '';
foreach ($app['authors'] as $key => $author) {
$authors .= $author['name'];
if ($key !== count($app['authors']) - 1) {
$authors .= ', ';
}
}
$currentLanguage = substr($this->l10nFactory->findLanguage(), 0, 2);
$enabledValue = $this->config->getAppValue($app['id'], 'enabled', 'no');
$groups = null;
if ($enabledValue !== 'no' && $enabledValue !== 'yes') {
$groups = $enabledValue;
}
$currentVersion = '';
if ($this->appManager->isEnabledForAnyone($app['id'])) {
$currentVersion = $this->appManager->getAppVersion($app['id']);
} else {
$currentVersion = $app['releases'][0]['version'];
}
$formattedApps[] = [
'id' => $app['id'],
'app_api' => false,
'name' => $app['translations'][$currentLanguage]['name'] ?? $app['translations']['en']['name'],
'description' => $app['translations'][$currentLanguage]['description'] ?? $app['translations']['en']['description'],
'summary' => $app['translations'][$currentLanguage]['summary'] ?? $app['translations']['en']['summary'],
'license' => $app['releases'][0]['licenses'],
'author' => $authors,
'shipped' => $this->appManager->isShipped($app['id']),
'version' => $currentVersion,
'default_enable' => '',
'types' => [],
'documentation' => [
'admin' => $app['adminDocs'],
'user' => $app['userDocs'],
'developer' => $app['developerDocs']
],
'website' => $app['website'],
'bugs' => $app['issueTracker'],
'detailpage' => $app['website'],
'dependencies' => array_merge(
$nextCloudVersionDependencies,
$phpDependencies
),
'level' => ($app['isFeatured'] === true) ? 200 : 100,
'missingMaxOwnCloudVersion' => false,
'missingMinOwnCloudVersion' => false,
'canInstall' => true,
'screenshot' => isset($app['screenshots'][0]['url']) ? 'https://usercontent.apps.nextcloud.com/' . base64_encode($app['screenshots'][0]['url']) : '',
'score' => $app['ratingOverall'],
'ratingNumOverall' => $app['ratingNumOverall'],
'ratingNumThresholdReached' => $app['ratingNumOverall'] > 5,
'removable' => $existsLocally,
'active' => $this->appManager->isEnabledForUser($app['id']),
'needsDownload' => !$existsLocally,
'groups' => $groups,
'fromAppStore' => true,
'appstoreData' => $app,
];
}
return $formattedApps;
}
/**
* @param string $appId
* @param array $groups
* @return JSONResponse
*/
#[PasswordConfirmationRequired]
public function enableApp(string $appId, array $groups = []): JSONResponse {
return $this->enableApps([$appId], $groups);
}
/**
* Enable one or more apps
*
* apps will be enabled for specific groups only if $groups is defined
*
* @param array $appIds
* @param array $groups
* @return JSONResponse
*/
#[PasswordConfirmationRequired]
public function enableApps(array $appIds, array $groups = []): JSONResponse {
try {
$updateRequired = false;
foreach ($appIds as $appId) {
$appId = $this->appManager->cleanAppId($appId);
// Check if app is already downloaded
if (!$this->installer->isDownloaded($appId)) {
$this->installer->downloadApp($appId);
}
$this->installer->installApp($appId);
if (count($groups) > 0) {
$this->appManager->enableAppForGroups($appId, $this->getGroupList($groups));
} else {
$this->appManager->enableApp($appId);
}
$updateRequired = $updateRequired || $this->appManager->isUpgradeRequired($appId);
}
return new JSONResponse(['data' => ['update_required' => $updateRequired]]);
} catch (\Throwable $e) {
$this->logger->error('could not enable apps', ['exception' => $e]);
return new JSONResponse(['data' => ['message' => $e->getMessage()]], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
private function getGroupList(array $groups) {
$groupManager = Server::get(IGroupManager::class);
$groupsList = [];
foreach ($groups as $group) {
$groupItem = $groupManager->get($group);
if ($groupItem instanceof IGroup) {
$groupsList[] = $groupManager->get($group);
}
}
return $groupsList;
}
/**
* @param string $appId
* @return JSONResponse
*/
#[PasswordConfirmationRequired]
public function disableApp(string $appId): JSONResponse {
return $this->disableApps([$appId]);
}
/**
* @param array $appIds
* @return JSONResponse
*/
#[PasswordConfirmationRequired]
public function disableApps(array $appIds): JSONResponse {
try {
foreach ($appIds as $appId) {
$appId = $this->appManager->cleanAppId($appId);
$this->appManager->disableApp($appId);
}
return new JSONResponse([]);
} catch (\Exception $e) {
$this->logger->error('could not disable app', ['exception' => $e]);
return new JSONResponse(['data' => ['message' => $e->getMessage()]], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* @param string $appId
* @return JSONResponse
*/
#[PasswordConfirmationRequired]
public function uninstallApp(string $appId): JSONResponse {
$appId = $this->appManager->cleanAppId($appId);
$result = $this->installer->removeApp($appId);
if ($result !== false) {
// If this app was force enabled, remove the force-enabled-state
$this->appManager->removeOverwriteNextcloudRequirement($appId);
$this->appManager->clearAppsCache();
return new JSONResponse(['data' => ['appid' => $appId]]);
}
return new JSONResponse(['data' => ['message' => $this->l10n->t('Could not remove app.')]], Http::STATUS_INTERNAL_SERVER_ERROR);
}
/**
* @param string $appId
* @return JSONResponse
*/
public function updateApp(string $appId): JSONResponse {
$appId = $this->appManager->cleanAppId($appId);
$this->config->setSystemValue('maintenance', true);
try {
$result = $this->installer->updateAppstoreApp($appId);
$this->config->setSystemValue('maintenance', false);
} catch (\Exception $ex) {
$this->config->setSystemValue('maintenance', false);
return new JSONResponse(['data' => ['message' => $ex->getMessage()]], Http::STATUS_INTERNAL_SERVER_ERROR);
}
if ($result !== false) {
return new JSONResponse(['data' => ['appid' => $appId]]);
}
return new JSONResponse(['data' => ['message' => $this->l10n->t('Could not update app.')]], Http::STATUS_INTERNAL_SERVER_ERROR);
}
private function sortApps($a, $b) {
$a = (string)$a['name'];
$b = (string)$b['name'];
if ($a === $b) {
return 0;
}
return ($a < $b) ? -1 : 1;
}
public function force(string $appId): JSONResponse {
$appId = $this->appManager->cleanAppId($appId);
$this->appManager->overwriteNextcloudRequirement($appId);
return new JSONResponse();
}
}
@@ -255,7 +255,7 @@ class UsersController extends Controller {
$this->initialState->provideInitialState('usersSettings', $serverData);
Util::addStyle('settings', 'settings');
Util::addScript('settings', 'vue-settings-apps-users-management');
Util::addScript('settings', 'vue-settings-users-management');
return new TemplateResponse('settings', 'settings/empty', ['pageTitle' => $this->l10n->t('Settings')]);
}

Some files were not shown because too many files have changed in this diff Show More