Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
24 changes: 15 additions & 9 deletions apps/appstore/lib/Controller/ApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -82,12 +82,13 @@ public function listCategories(): DataResponse {
/**
* Get all available apps
*
* @param bool $details - Whether to include detailed appstore information about the app
* @return DataResponse<Http::STATUS_OK, list<array{id: string, name: string, groups: list<string>, internal: bool, isCompatible: bool, missingDependencies?: list<string>, missingMaxNextcloudVersion: bool, missingMinNextcloudVersion: bool, ...<array-key, mixed>}>, array{}>
*
* 200: The apps were found successfully
*/
#[ApiRoute(verb: 'GET', url: '/api/v1/apps')]
public function listApps(): DataResponse {
public function listApps(bool $details = false): DataResponse {
$apps = $this->getAllApps();

/** @var array<string>|mixed $ignoreMaxApps */
Expand All @@ -98,12 +99,16 @@ public function listApps(): DataResponse {
}

// Extend existing app details
$apps = array_map(function (array $appData) use ($ignoreMaxApps): array {
$apps = array_map(function (array $appData) use ($ignoreMaxApps, $details): array {
if (isset($appData['appstoreData'])) {
$appstoreData = $appData['appstoreData'];
$appData['screenshot'] = $this->createProxyPreviewUrl($appstoreData['screenshots'][0]['url'] ?? '');
$appData['category'] = $appstoreData['categories'];
$appData['releases'] = $appstoreData['releases'];

if (!$details) {
unset($appData['appstoreData']);
}
}

$newVersion = $this->installer->isUpdateAvailable($appData['id']);
Expand All @@ -123,17 +128,15 @@ public function listApps(): DataResponse {
}

$appData['groups'] = $groups;
$appData['canUninstall'] = !$appData['active'] && $appData['removable'];

// analyze dependencies
$ignoreMax = in_array($appData['id'], $ignoreMaxApps);
$missing = $this->dependencyAnalyzer->analyze($appData, $ignoreMax);
$appData['canInstall'] = empty($missing);
$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);
Expand Down Expand Up @@ -204,6 +207,7 @@ public function enableApp(string $appId, array $groups = [], bool $force = false
public function disableApp(string $appId): DataResponse {
try {
$appId = $this->appManager->cleanAppId($appId);
$this->appManager->removeOverwriteNextcloudRequirement($appId);
$this->appManager->disableApp($appId);
return new DataResponse([]);
} catch (\Exception $exception) {
Expand All @@ -214,7 +218,6 @@ public function disableApp(string $appId): DataResponse {

/**
* Uninstall an app.
* This will disable the app - if needed - and then remove the app from the system
*
* @param string $appId - The app to uninstall
* @return DataResponse<Http::STATUS_OK, array{}, array{}>
Expand All @@ -226,6 +229,10 @@ public function disableApp(string $appId): DataResponse {
#[ApiRoute(verb: 'POST', url: '/api/v1/apps/uninstall')]
public function uninstallApp(string $appId): DataResponse {
$appId = $this->appManager->cleanAppId($appId);
if ($this->appManager->isEnabledForAnyone($appId)) {
$this->disableApp($appId);
}
Comment thread
susnux marked this conversation as resolved.

$result = $this->installer->removeApp($appId);
if ($result !== false) {
// If this app was force enabled, remove the force-enabled-state
Expand Down Expand Up @@ -452,6 +459,7 @@ private function getAppsForCategory(string $requestedCategory = ''): array {
'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' => [
Expand All @@ -468,11 +476,9 @@ private function getAppsForCategory(string $requestedCategory = ''): array {
'level' => ($app['isFeatured'] === true) ? 200 : 100,
'missingMaxNextcloudVersion' => false,
'missingMinNextcloudVersion' => false,
'canInstall' => true,
'screenshot' => isset($app['screenshots'][0]['url']) ? 'https://usercontent.apps.nextcloud.com/' . base64_encode($app['screenshots'][0]['url']) : '',
'score' => $app['ratingOverall'],
'ratingOverall' => $app['ratingOverall'],
'ratingNumOverall' => $app['ratingNumOverall'],
'ratingNumThresholdReached' => $app['ratingNumOverall'] > 5,
'removable' => $existsLocally,
'active' => $this->appManager->isEnabledForUser($app['id']),
'needsDownload' => !$existsLocally,
Expand Down
11 changes: 10 additions & 1 deletion apps/appstore/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,15 @@
}
],
"parameters": [
{
"name": "details",
"in": "query",
"description": "- Whether to include detailed appstore information about the app",
"schema": {
"type": "boolean",
"default": false
}
},
{
"name": "OCS-APIRequest",
"in": "header",
Expand Down Expand Up @@ -640,7 +649,7 @@
"/ocs/v2.php/apps/appstore/api/v1/apps/uninstall": {
"post": {
"operationId": "api-uninstall-app",
"summary": "Uninstall an app. This will disable the app - if needed - and then remove the app from the system",
"summary": "Uninstall an app.",
"description": "This endpoint requires admin access\nThis endpoint requires password confirmation",
"tags": [
"api"
Expand Down
72 changes: 72 additions & 0 deletions apps/appstore/src/AppstoreApp.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<!--
- 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'
import { useAppsStore } from './store/apps.ts'

const route = useRoute()
const store = useAppsStore()

const currentCategory = computed(() => {
if (route.params.category) {
return [route.params.category].flat()[0]!
}
if (route.name === 'apps-bundles') {
return 'bundles'
} else if (route.name === 'apps-search') {
return 'search'
}
return 'discover'
})

const heading = computed(() => {
if (currentCategory.value in APPSTORE_CATEGORY_NAMES) {
return APPSTORE_CATEGORY_NAMES[currentCategory.value]
}
return store.getCategoryById(currentCategory.value)?.displayName ?? 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);
position: relative;
}

.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>
24 changes: 24 additions & 0 deletions apps/appstore/src/actions/actionDisable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/*!
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import type { IAppstoreApp, IAppstoreExApp } from '../apps.d.ts'
import type { AppAction } from './index.ts'

import { mdiClose } from '@mdi/js'
import { t } from '@nextcloud/l10n'
import { useAppsStore } from '../store/apps.ts'
import { canDisable } from '../utils/appStatus.ts'

export const actionDisable: AppAction = {
id: 'disable',
icon: mdiClose,
order: 10,
enabled: canDisable,
label: () => t('appstore', 'Disable'),
async callback(app: IAppstoreApp | IAppstoreExApp) {
const store = useAppsStore()
await store.disableApp(app.id)
},
}
27 changes: 27 additions & 0 deletions apps/appstore/src/actions/actionEnable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/*!
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import type { IAppstoreApp, IAppstoreExApp } from '../apps.d.ts'
import type { AppAction } from './index.ts'

import { mdiCheck } from '@mdi/js'
import { t } from '@nextcloud/l10n'
import { useAppsStore } from '../store/apps.ts'
import { canEnable, canInstall } from '../utils/appStatus.ts'

export const actionEnable: AppAction = {
id: 'enable',
icon: mdiCheck,
order: 1,
variant: 'primary',
enabled(app: IAppstoreApp | IAppstoreExApp) {
return !canInstall(app) && canEnable(app)
},
label: () => t('appstore', 'Enable'),
async callback(app: IAppstoreApp | IAppstoreExApp) {
const store = useAppsStore()
await store.enableApp(app.id)
},
}
28 changes: 28 additions & 0 deletions apps/appstore/src/actions/actionForceEnable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/*!
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import type { IAppstoreApp, IAppstoreExApp } from '../apps.d.ts'
import type { AppAction } from './index.ts'

import { mdiAlertCircleCheckOutline } from '@mdi/js'
import { t } from '@nextcloud/l10n'
import { useAppsStore } from '../store/apps.ts'
import { canForceEnable, canInstall, needForceEnable } from '../utils/appStatus.ts'

export const actionForceEnable: AppAction = {
id: 'force-enable',
icon: mdiAlertCircleCheckOutline,
order: 3,
inline: false,
variant: 'warning',
label: () => t('appstore', 'Force enable'),
enabled(app: IAppstoreApp | IAppstoreExApp) {
return !canInstall(app) && canForceEnable(app) && needForceEnable(app)
},
async callback(app: IAppstoreApp | IAppstoreExApp) {
const store = useAppsStore()
await store.forceEnableApp(app.id)
},
}
34 changes: 34 additions & 0 deletions apps/appstore/src/actions/actionInstall.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/*!
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import type { IAppstoreApp, IAppstoreExApp } from '../apps.d.ts'
import type { AppAction } from './index.ts'

import { mdiDownload } from '@mdi/js'
import { t } from '@nextcloud/l10n'
import { useAppsStore } from '../store/apps.ts'
import { canInstall, needForceEnable } from '../utils/appStatus.ts'

export const actionInstall: AppAction = {
id: 'install',
icon: mdiDownload,
order: 5,
enabled(app) {
return canInstall(app) && !needForceEnable(app)
},
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)
},
}
35 changes: 35 additions & 0 deletions apps/appstore/src/actions/actionInstallForced.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/*!
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import type { IAppstoreApp, IAppstoreExApp } from '../apps.d.ts'
import type { AppAction } from './index.ts'

import { mdiDownload } from '@mdi/js'
import { t } from '@nextcloud/l10n'
import { useAppsStore } from '../store/apps.ts'
import { canInstall, needForceEnable } from '../utils/appStatus.ts'

export const actionInstallForced: AppAction = {
id: 'install-forced',
icon: mdiDownload,
order: 5,
inline: false,
enabled(app) {
return canInstall(app) && needForceEnable(app)
},
label: (app: IAppstoreApp | IAppstoreExApp) => {
if (app.app_api) {
return t('appstore', 'Deploy and force enable')
}
if (app.needsDownload) {
return t('appstore', 'Download and force enable')
}
return t('appstore', 'Install and force enable')
},
async callback(app: IAppstoreApp | IAppstoreExApp) {
const store = useAppsStore()
await store.enableApp(app.id, true)
},
}
Loading
Loading