refactor(appstore): migrate sidebar to Vue 3 and Typescript

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
This commit is contained in:
Ferdinand Thiessen 2026-04-23 02:58:15 +02:00
parent 3b1ed578c5
commit 5e7f45ace6
No known key found for this signature in database
GPG key ID: 7E849AE05218500F
36 changed files with 1401 additions and 1257 deletions

View file

@ -477,9 +477,8 @@ class ApiController extends OCSController {
'missingMaxNextcloudVersion' => false,
'missingMinNextcloudVersion' => false,
'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,

View file

@ -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)
},
}

View file

@ -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)
},
}

View file

@ -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)
},
}

View file

@ -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)
},
}

View file

@ -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)
},
}

View file

@ -0,0 +1,65 @@
/*!
* 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 { mdiBugOutline, mdiForumOutline, mdiStarOutline, mdiWeb } from '@mdi/js'
import { t } from '@nextcloud/l10n'
export const actionsInteract: AppAction[] = [
{
id: 'rate',
icon: mdiStarOutline,
order: 30,
inline: false,
label: () => t('appstore', 'Rate the app'),
enabled(app: IAppstoreApp | IAppstoreExApp) {
return !!app.fromAppStore
},
href(app: IAppstoreApp | IAppstoreExApp) {
return `https://apps.nextcloud.com/apps/${encodeURIComponent(app.id)}#comments`
},
},
{
id: 'report-bug',
icon: mdiBugOutline,
order: 32,
inline: false,
label: () => t('appstore', 'Report a bug'),
enabled(app: IAppstoreApp | IAppstoreExApp) {
return !!app.bugs
},
href(app: IAppstoreApp | IAppstoreExApp) {
return app.bugs!
},
},
{
id: 'discussion',
icon: mdiForumOutline,
order: 35,
inline: false,
label: () => t('appstore', 'Ask questions or discuss the app'),
enabled(app: IAppstoreApp | IAppstoreExApp) {
return !!app.discussion
},
href(app: IAppstoreApp | IAppstoreExApp) {
return app.discussion!
},
},
{
id: 'website',
icon: mdiWeb,
order: 38,
inline: false,
label: () => t('appstore', 'Visit the website'),
enabled(app: IAppstoreApp | IAppstoreExApp) {
return !!app.website
},
href(app: IAppstoreApp | IAppstoreExApp) {
return app.website!
},
},
]

View file

@ -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 { mdiAccountGroup } from '@mdi/js'
import { t } from '@nextcloud/l10n'
import { spawnDialog } from '@nextcloud/vue'
import { defineAsyncComponent } from 'vue'
import { canLimitToGroups } from '../utils/appStatus.ts'
const LimitToGroupDialog = defineAsyncComponent(() => import('../components/LimitToGroupDialog.vue'))
export const actionLimitToGroup: AppAction = {
id: 'limit-to-group',
icon: mdiAccountGroup,
order: 16,
inline: false,
label: () => t('appstore', 'Limit to groups'),
enabled: canLimitToGroups,
async callback(app: IAppstoreApp | IAppstoreExApp) {
await spawnDialog(LimitToGroupDialog, { app })
},
}

View file

@ -0,0 +1,26 @@
/*!
* 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 { mdiTrashCanOutline } from '@mdi/js'
import { t } from '@nextcloud/l10n'
import { useAppsStore } from '../store/apps.ts'
import { canUninstall } from '../utils/appStatus.ts'
export const actionRemove: AppAction = {
id: 'remove',
order: 20,
icon: mdiTrashCanOutline,
variant: 'error',
inline: false,
enabled: canUninstall,
label: () => t('appstore', 'Remove'),
async callback(app: IAppstoreApp | IAppstoreExApp) {
const store = useAppsStore()
await store.uninstallApp(app.id)
},
}

View file

@ -0,0 +1,38 @@
/*!
* 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 { mdiUpdate } from '@mdi/js'
import { t } from '@nextcloud/l10n'
import { useExAppsStore } from '../store/exApps.ts'
import { useUpdatesStore } from '../store/updates.ts'
import { canUpdate } from '../utils/appStatus.ts'
export const actionUpdate: AppAction = {
id: 'update',
icon: mdiUpdate,
variant: 'primary',
order: 0,
enabled(app) {
if (!canUpdate(app)) {
return false
}
if (app.app_api) {
if (app.daemon && app.daemon?.accepts_deploy_id === 'manual-install') {
return true
}
const exAppsStore = useExAppsStore()
return exAppsStore.daemonAccessible
}
return true
},
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)
},
}

View file

@ -0,0 +1,54 @@
/*
* SPDX-License-Identifier: AGPL-3.0-or-later
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
*/
import type { RouteLocationRaw } from 'vue-router'
import type { IAppstoreApp, IAppstoreExApp } from '../apps.d.ts'
import { actionDisable } from './actionDisable.ts'
import { actionEnable } from './actionEnable.ts'
import { actionForceEnable } from './actionForceEnable.ts'
import { actionInstall } from './actionInstall.ts'
import { actionInstallForced } from './actionInstallForced.ts'
import { actionsInteract } from './actionInteract.ts'
import { actionLimitToGroup } from './actionLimitToGroup.ts'
import { actionRemove } from './actionRemove.ts'
import { actionUpdate } from './actionUpdate.ts'
interface AppActionBase {
enabled: (app: IAppstoreApp | IAppstoreExApp) => boolean
id: string
icon: string
order: number
label: (app: IAppstoreApp | IAppstoreExApp) => string
variant?: 'primary' | 'error' | 'warning'
inline?: boolean
}
interface AppActionWithCallback extends AppActionBase {
callback: (app: IAppstoreApp | IAppstoreExApp) => Promise<void>
}
interface AppActionWithHref extends AppActionBase {
href: (app: IAppstoreApp | IAppstoreExApp) => string
}
interface AppActionWithRoute extends AppActionBase {
to: (app: IAppstoreApp | IAppstoreExApp) => RouteLocationRaw
}
export type AppAction = AppActionWithCallback | AppActionWithHref | AppActionWithRoute
export const actions = [
actionUpdate,
actionEnable,
actionDisable,
actionForceEnable,
actionInstall,
actionInstallForced,
actionRemove,
actionLimitToGroup,
...actionsInteract,
].sort((a, b) => a.order - b.order)

View file

@ -20,6 +20,7 @@ export interface IAppstoreCategory {
export interface IAppstoreAppRelease {
version: string
lastModified?: string
translations: {
[key: string]: {
changelog: string
@ -27,50 +28,89 @@ export interface IAppstoreAppRelease {
}
}
export interface IAppstoreAppData extends Record<string, unknown> {
ratingOverall: number
ratingNumOverall: number
ratingRecent: number
ratingNumRecent: number
type IAppInfoTypes = 'prelogin' | 'filesystem' | 'authentication' | 'extended_authentication' | 'logging' | 'dav' | 'prevent_group_restriction' | 'session'
releases: IAppstoreAppRelease[]
}
export interface IAppstoreAppResponse {
/**
* The metadata that is available in the info.xml of an app.
* This is sourced by the appstore but also available for already installed apps (e.g. shipped apps).
*/
interface IAppInfoData {
id: string
name: string
summary: string
description: string
/** The license of the app */
license: string
/** The author(s) of the app (either list of names or object for XML nodes) */
author: string[] | Record<string, string>
/** The support level of this app (e.g. maintained by Nextcloud GmbH) */
level: number
/** The version of the app */
version: string
/** The category(s) this app belongs to */
category: string | string[]
icon?: string
/** The URL of the app's screenshot */
screenshot?: string
/** The types this app supports */
types?: IAppInfoTypes[]
/**
* Groups this app is limited to.
* (only available if app is already installed)
*/
groups?: string[]
documentation?: {
admin: string
user: string
developer: string
}
website?: string
discussion?: string
bugs?: string
}
score: number
ratingNumThresholdReached: boolean
/**
* Metadata added when this app is sourced from the appstore.
* It is not available for non-appstore apps.
*/
interface IAppstoreMetadata {
fromAppStore: true
/** List of appstore release information (e.g. changelog) */
releases: IAppstoreAppRelease[]
/** The overall rating of the app */
ratingOverall: number
/** The number of ratings for the app */
ratingNumOverall: number
}
export interface IAppstoreAppResponse extends IAppInfoData, Partial<IAppstoreMetadata> {
/** The app icon to use */
icon?: string
// App dependency information
dependencies: unknown
missingMaxNextcloudVersion: boolean
missingMinNextcloudVersion: boolean
// App state information
/** Whether the app is an ExApp (docker based app) */
app_api: false
active: boolean
/** Whether the app is internal = always enabled an cannot be disabled */
internal: boolean
/** Whether the app is shipped / bundled with Nextcloud (not from appstore) */
shipped: boolean
/** Whether the app is currently active (enabled) */
active: boolean
/** Whether the app can be removed */
removable: boolean
/** Whether the app is installed */
installed: boolean
/** If all dependencies are met */
isCompatible: boolean
/** Whether the app needs to be downloaded (not locally available) */
needsDownload: boolean
/** List of missing dependencies */
missingDependencies?: string[]
/** Available update version */
update?: string
appstoreData?: IAppstoreAppData
releases?: IAppstoreAppRelease[]
/** User groups this app is limited to */
groups?: string[]
}
export interface IAppstoreApp extends IAppstoreAppResponse {

View file

@ -0,0 +1,103 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import type { AppAction } from '../actions/index.ts'
import type { IAppstoreApp, IAppstoreExApp } from '../apps.d.ts'
import { computed } from 'vue'
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
import NcActionLink from '@nextcloud/vue/components/NcActionLink'
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'
const { actions, maxInlineActions = 1 } = defineProps<{
app: IAppstoreApp | IAppstoreExApp
actions: AppAction[]
maxInlineActions?: number
iconOnly?: boolean
}>()
const inlineActions = computed(() => {
if (actions.length <= maxInlineActions) {
return actions
}
return actions
.filter((action) => action.inline !== false)
.slice(0, maxInlineActions)
})
const menuActions = computed(() => actions
.filter((action) => !inlineActions.value.includes(action)))
</script>
<template>
<div :class="$style.appActions">
<NcButton
v-for="action in inlineActions"
:key="action.id"
:ariaLabel="iconOnly ? action.label(app) : undefined"
:title="iconOnly ? action.label(app) : undefined"
:variant="action.variant"
:href="'href' in action ? action.href(app) : undefined"
:to="'to' in action ? action.to(app) : undefined"
:target="'href' in action ? '_blank' : undefined"
@click="'callback' in action && action.callback(app)">
<template #icon>
<NcIconSvgWrapper :path="action.icon" />
</template>
<template v-if="!iconOnly" #default>
{{ action.label(app) }}
</template>
</NcButton>
<NcActions forceMenu>
<template v-for="action in menuActions">
<NcActionButton
v-if="'callback' in action"
:key="'callback-' + action.id"
closeAfterClick
:variant="action.variant"
@click="action.callback(app)">
<template #icon>
<NcIconSvgWrapper :path="action.icon" />
</template>
{{ action.label(app) }}
</NcActionButton>
<NcActionLink
v-else-if="'href' in action"
:key="'link-' + action.id"
closeAfterClick
:variant="action.variant"
:href="action.href(app)">
<template #icon>
<NcIconSvgWrapper :path="action.icon" />
</template>
{{ action.label(app) }}
</NcActionLink>
<NcActionRouter
v-else
:key="'route-' + action.id"
closeAfterClick
:variant="action.variant"
:to="action.to(app)">
<template #icon>
<NcIconSvgWrapper :path="action.icon" />
</template>
{{ action.label(app) }}
</NcActionRouter>
</template>
</NcActions>
</div>
</template>
<style module>
.appActions {
display: flex;
flex-direction: row;
gap: calc(2 * var(--default-grid-baseline));
}
</style>

View file

@ -1,519 +0,0 @@
<!--
- SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<NcAppSidebarTab
id="details"
:name="t('appstore', 'Details')"
:order="1">
<template #icon>
<NcIconSvgWrapper :path="mdiTextBoxOutline" />
</template>
<div class="app-details">
<div class="app-details__actions">
<div v-if="app.active && canLimitToGroups(app)" class="app-details__actions-groups">
<input
:id="`groups_enable_${app.id}`"
v-model="groupCheckedAppsData"
type="checkbox"
:value="app.id"
class="groups-enable__checkbox checkbox"
@change="setGroupLimit">
<label :for="`groups_enable_${app.id}`">{{ t('appstore', 'Limit to groups') }}</label>
<input
type="hidden"
class="group_select"
:title="t('appstore', 'All')"
value="">
<br>
<label for="limitToGroups">
<span>{{ t('appstore', 'Limit app usage to groups') }}</span>
</label>
<NcSelect
v-if="isLimitedToGroups(app)"
input-id="limitToGroups"
:options="groups"
:model-value="appGroups"
:limit="5"
label="name"
:multiple="true"
keep-open
@option:selected="addGroupLimitation"
@option:deselected="removeGroupLimitation"
@search="asyncFindGroup">
<span slot="noResult">{{ t('appstore', 'No results') }}</span>
</NcSelect>
</div>
<div class="app-details__actions-manage">
<input
v-if="app.update"
class="update primary"
type="button"
: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('appstore', 'Remove')"
:disabled="installing || isLoading"
@click="remove(app.id, removeData)">
<input
v-if="app.active"
class="enable"
type="button"
:value="disableButtonText"
:disabled="installing || isLoading || isInitializing || isDeploying"
@click="disable(app.id)">
<input
v-if="!app.active && (app.canInstall || app.isCompatible)"
:title="enableButtonTooltip"
:aria-label="enableButtonTooltip"
class="enable primary"
type="button"
:value="enableButtonText"
:disabled="!app.canInstall || installing || isLoading || !defaultDeployDaemonAccessible || isInitializing || isDeploying"
@click="enableButtonAction">
<input
v-else-if="!app.active && !app.canInstall"
:title="forceEnableButtonTooltip"
:aria-label="forceEnableButtonTooltip"
class="enable force"
type="button"
:value="forceEnableButtonText"
:disabled="installing || isLoading"
@click="forceEnable(app.id)">
<NcButton
v-if="app?.app_api && (app.canInstall || app.isCompatible)"
:aria-label="t('appstore', 'Advanced deploy options')"
variant="secondary"
@click="() => showDeployOptionsModal = true">
<template #icon>
<NcIconSvgWrapper :path="mdiToyBrickPlusOutline" />
</template>
{{ t('appstore', 'Deploy options') }}
</NcButton>
</div>
<p v-if="!defaultDeployDaemonAccessible" class="warning">
{{ 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('appstore', 'Delete data on remove') }}
</NcCheckboxRadioSwitch>
</div>
<ul class="app-details__dependencies">
<li v-if="app.missingMinOwnCloudVersion">
{{ 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('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('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 }}
</li>
</ul>
</li>
</ul>
<div v-if="lastModified && !app.shipped" class="app-details__section">
<h4>
{{ t('appstore', 'Latest updated') }}
</h4>
<NcDateTime :timestamp="lastModified" />
</div>
<div class="app-details__section">
<h4>
{{ t('appstore', 'Author') }}
</h4>
<p class="app-details__authors">
{{ appAuthors }}
</p>
</div>
<div class="app-details__section">
<h4>
{{ t('appstore', 'Categories') }}
</h4>
<p>
{{ appCategories }}
</p>
</div>
<div v-if="externalResources.length > 0" class="app-details__section">
<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"
:href="resource.href"
target="_blank"
rel="noreferrer noopener">
{{ resource.label }}
</a>
</li>
</ul>
</div>
<div class="app-details__section">
<h4>{{ t('appstore', 'Interact') }}</h4>
<div class="app-details__interact">
<NcButton
:disabled="!app.bugs"
:href="app.bugs ?? '#'"
:aria-label="t('appstore', 'Report a bug')"
:title="t('appstore', 'Report a bug')">
<template #icon>
<NcIconSvgWrapper :path="mdiBugOutline" />
</template>
</NcButton>
<NcButton
:disabled="!app.bugs"
:href="app.bugs ?? '#'"
:aria-label="t('appstore', 'Request feature')"
:title="t('appstore', 'Request feature')">
<template #icon>
<NcIconSvgWrapper :path="mdiFeatureSearchOutline" />
</template>
</NcButton>
<NcButton
v-if="app.appstoreData?.discussion"
:href="app.appstoreData.discussion"
:aria-label="t('appstore', 'Ask questions or discuss')"
:title="t('appstore', 'Ask questions or discuss')">
<template #icon>
<NcIconSvgWrapper :path="mdiTooltipQuestionOutline" />
</template>
</NcButton>
<NcButton
v-if="!app.internal"
:href="rateAppUrl"
:aria-label="t('appstore', 'Rate the app')"
:title="t('appstore', 'Rate')">
<template #icon>
<NcIconSvgWrapper :path="mdiStar" />
</template>
</NcButton>
</div>
</div>
<AppDeployOptionsModal
v-if="app?.app_api"
:show.sync="showDeployOptionsModal"
:app="app" />
<DaemonSelectionDialog
v-if="app?.app_api"
:show.sync="showSelectDaemonModal"
:app="app"
:deploy-options="deployOptions" />
</div>
</NcAppSidebarTab>
</template>
<script>
import { mdiBugOutline, mdiFeatureSearchOutline, mdiStar, mdiTextBoxOutline, mdiTooltipQuestionOutline, mdiToyBrickPlusOutline } from '@mdi/js'
import { subscribe, unsubscribe } from '@nextcloud/event-bus'
import NcAppSidebarTab from '@nextcloud/vue/components/NcAppSidebarTab'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import NcDateTime from '@nextcloud/vue/components/NcDateTime'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import NcSelect from '@nextcloud/vue/components/NcSelect'
import DaemonSelectionDialog from '../AppAPI/DaemonSelectionDialog.vue'
import AppDeployOptionsModal from './AppDeployOptionsModal.vue'
import AppManagement from '../../mixins/AppManagement.js'
import { useAppApiStore } from '../../store/app-api-store.js'
import { useAppsStore } from '../../store/apps-store.js'
export default {
name: 'AppDetailsTab',
components: {
NcAppSidebarTab,
NcButton,
NcDateTime,
NcIconSvgWrapper,
NcSelect,
NcCheckboxRadioSwitch,
AppDeployOptionsModal,
DaemonSelectionDialog,
},
mixins: [AppManagement],
props: {
app: {
type: Object,
required: true,
},
},
setup() {
const store = useAppsStore()
const appApiStore = useAppApiStore()
return {
store,
appApiStore,
productName: window.OC.theme.productName,
mdiBugOutline,
mdiFeatureSearchOutline,
mdiStar,
mdiTextBoxOutline,
mdiTooltipQuestionOutline,
mdiToyBrickPlusOutline,
}
},
data() {
return {
groupCheckedAppsData: false,
removeData: false,
showDeployOptionsModal: false,
showSelectDaemonModal: false,
deployOptions: null,
}
},
computed: {
lastModified() {
return (this.app.appstoreData?.releases ?? [])
.map(({ lastModified }) => Date.parse(lastModified))
.sort()
.at(0) ?? null
},
/**
* App authors as comma separated string
*/
appAuthors() {
if (!this.app) {
return ''
}
const authorName = (xmlNode) => {
if (xmlNode['@value']) {
// Complex node (with email or homepage attribute)
return xmlNode['@value']
}
// Simple text node
return xmlNode
}
const authors = Array.isArray(this.app.author)
? this.app.author.map(authorName)
: [authorName(this.app.author)]
return authors
.sort((a, b) => a.split(' ').at(-1).localeCompare(b.split(' ').at(-1)))
.join(', ')
},
appstoreUrl() {
return `https://apps.nextcloud.com/apps/${this.app.id}`
},
/**
* Further external resources (e.g. website)
*/
externalResources() {
const resources = []
if (!this.app.internal) {
resources.push({
id: 'appstore',
href: this.appstoreUrl,
label: t('appstore', 'View in store'),
})
}
if (this.app.website) {
resources.push({
id: 'website',
href: this.app.website,
label: t('appstore', 'Visit website'),
})
}
if (this.app.documentation) {
if (this.app.documentation.user) {
resources.push({
id: 'doc-user',
href: this.app.documentation.user,
label: t('appstore', 'Usage documentation'),
})
}
if (this.app.documentation.admin) {
resources.push({
id: 'doc-admin',
href: this.app.documentation.admin,
label: t('appstore', 'Admin documentation'),
})
}
if (this.app.documentation.developer) {
resources.push({
id: 'doc-developer',
href: this.app.documentation.developer,
label: t('appstore', 'Developer documentation'),
})
}
}
return resources
},
appCategories() {
return [this.app.category].flat()
.map((id) => this.store.getCategoryById(id)?.displayName ?? id)
.join(', ')
},
rateAppUrl() {
return `${this.appstoreUrl}#comments`
},
appGroups() {
return this.app.groups.map((group) => {
return { id: group, name: group }
})
},
groups() {
return this.$store.getters.getGroups
.filter((group) => group.id !== 'disabled')
.sort((a, b) => a.name.localeCompare(b.name))
},
},
beforeUnmount() {
this.deployOptions = null
unsubscribe('showDaemonSelectionModal')
},
mounted() {
if (this.app.groups.length > 0) {
this.groupCheckedAppsData = true
}
subscribe('showDaemonSelectionModal', (deployOptions) => {
this.showSelectionModal(deployOptions)
})
},
methods: {
toggleRemoveData() {
this.removeData = !this.removeData
},
showSelectionModal(deployOptions = null) {
this.deployOptions = deployOptions
this.showSelectDaemonModal = true
},
async enableButtonAction() {
if (!this.app?.app_api) {
this.enable(this.app.id)
return
}
await this.appApiStore.fetchDockerDaemons()
if (this.appApiStore.dockerDaemons.length === 1 && this.app.needsDownload) {
this.enable(this.app.id, this.appApiStore.dockerDaemons[0])
} else if (this.app.needsDownload) {
this.showSelectionModal()
} else {
this.enable(this.app.id, this.app.daemon)
}
},
},
}
</script>
<style scoped lang="scss">
.app-details {
padding: 20px;
&__actions {
// app management
&-manage {
// if too many, shrink them and ellipsis
display: flex;
align-items: center;
input {
flex: 0 1 auto;
min-width: 0;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
}
}
&__authors {
color: var(--color-text-maxcontrast);
}
&__section {
margin-top: 15px;
h4 {
font-size: 16px;
font-weight: bold;
margin-block-end: 5px;
}
}
&__interact {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 12px;
}
&__documentation {
a {
text-decoration: underline;
}
li {
padding-inline-start: 20px;
&::before {
width: 5px;
height: 5px;
border-radius: 100%;
background-color: var(--color-main-text);
content: "";
float: inline-start;
margin-inline-start: -13px;
position: relative;
top: 10px;
}
}
}
}
.force {
color: var(--color-text-error);
border-color: var(--color-border-error);
background: var(--color-main-background);
}
.force:hover,
.force:active {
color: var(--color-main-background);
border-color: var(--color-border-error) !important;
background: var(--color-error);
}
.missing-dependencies {
list-style: initial;
list-style-type: initial;
list-style-position: inside;
}
</style>

View file

@ -1,57 +0,0 @@
<!--
- SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<NcAppSidebarTab
v-if="hasChangelog"
id="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>
<MarkdownPreview
class="app-sidebar-tabs__release-text"
:text="createChangelogFromRelease(release)" />
</div>
</NcAppSidebarTab>
</template>
<script setup lang="ts">
import type { IAppstoreApp, IAppstoreAppRelease } from '../../app-types.ts'
import { mdiClockFast } from '@mdi/js'
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 MarkdownPreview from '../MarkdownPreview.vue'
const props = defineProps<{ app: IAppstoreApp }>()
const hasChangelog = computed(() => Object.values(props.app.releases?.[0]?.translations ?? {}).some(({ changelog }) => !!changelog))
const createChangelogFromRelease = (release: IAppstoreAppRelease) => release.translations?.[getLanguage()]?.changelog ?? release.translations?.en?.changelog ?? ''
</script>
<style scoped lang="scss">
.app-sidebar-tabs__release {
h2 {
border-bottom: 1px solid var(--color-border);
font-size: 24px;
}
&-text {
// Overwrite changelog heading styles
:deep(h3) {
font-size: 20px;
}
:deep(h4) {
font-size: 17px;
}
}
}
</style>

View file

@ -4,17 +4,19 @@
-->
<script setup lang="ts">
import type { AppAction } from '../../actions/index.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 NcButton from '@nextcloud/vue/components/NcButton'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import AppActions from '../AppActions.vue'
import AppDaemonBadge from '../AppDaemonBadge.vue'
import AppIcon from '../AppIcon.vue'
import AppLevelBadge from '../AppLevelBadge.vue'
import AppActions from '../AppActions.vue'
import { useActions } from '../../composables/useActions.ts'
const { app, isNarrow } = defineProps<{
@ -22,15 +24,30 @@ const { app, isNarrow } = defineProps<{
isNarrow?: boolean
}>()
const actions = useActions(() => app)
const route = useRoute()
const detailsRoute = computed(() => ({
name: route.name!,
...route,
params: {
...route.params,
id: app.id,
},
}))
const detailsAction = computed<AppAction>(() => ({
id: 'details',
order: 99,
enabled: () => true,
label: () => t('appstore', 'Show details'),
icon: mdiInformationOutline,
to: () => detailsRoute.value,
inline: false,
}))
const rawActions = useActions(() => app)
const actions = computed(() => [
...rawActions.value,
detailsAction.value,
])
</script>
<template>

View file

@ -3,28 +3,8 @@
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<NcAppSidebarTab
v-if="app?.daemon"
id="daemon"
:name="t('appstore', 'Daemon')"
:order="3">
<template #icon>
<NcIconSvgWrapper :path="mdiFileChart" :size="24" />
</template>
<div class="daemon">
<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>
<script setup lang="ts">
import type { IAppstoreExApp } from '../../app-types.ts'
import type { IAppstoreExApp } from '../../apps.d.ts'
import { mdiFileChart } from '@mdi/js'
import { t } from '@nextcloud/l10n'
@ -39,13 +19,33 @@ const props = defineProps<{
const gpuSupport = ref(props.app?.daemon?.deploy_config?.computeDevice?.id !== 'cpu' || false)
</script>
<style scoped lang="scss">
.daemon {
padding: 20px;
<template>
<NcAppSidebarTab
v-if="app?.daemon"
id="daemon"
:name="t('appstore', 'Daemon')"
:order="5">
<template #icon>
<NcIconSvgWrapper :path="mdiFileChart" :size="24" />
</template>
<div :class="$style.appDeployDaemonTab">
<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>
h4 {
font-weight: bold;
margin: 10px auto;
}
<style module>
.appDeployDaemonTab {
padding: 20px;
h4 {
font-weight: bold;
margin: 10px auto;
}
}
</style>

View file

@ -3,6 +3,20 @@
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import type { IAppstoreApp, IAppstoreExApp } from '../../apps.d.ts'
import { mdiTextShort } from '@mdi/js'
import { t } from '@nextcloud/l10n'
import NcAppSidebarTab from '@nextcloud/vue/components/NcAppSidebarTab'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import MarkdownPreview from '../MarkdownPreview.vue'
defineProps<{
app: IAppstoreApp | IAppstoreExApp
}>()
</script>
<template>
<NcAppSidebarTab
id="desc"
@ -11,28 +25,14 @@
<template #icon>
<NcIconSvgWrapper :path="mdiTextShort" />
</template>
<div class="app-description">
<Markdown :text="app.description" :min-heading="4" />
<div :class="$style.appDescriptionTab">
<MarkdownPreview :text="app.description" :minHeadingLevel="3" />
</div>
</NcAppSidebarTab>
</template>
<script setup lang="ts">
import type { IAppstoreApp } from '../../app-types.ts'
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 MarkdownPreview from '../MarkdownPreview.vue'
defineProps<{
app: IAppstoreApp
}>()
</script>
<style scoped lang="scss">
.app-description {
<style module>
.appDescriptionTab {
padding: 12px;
}
</style>

View file

@ -0,0 +1,267 @@
<!--
- SPDX-FileCopyrightText: 2018 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 { mdiTextBoxOutline } from '@mdi/js'
import { getCapabilities } from '@nextcloud/capabilities'
import { t } from '@nextcloud/l10n'
import { computed, useId } from 'vue'
import NcAppSidebarTab from '@nextcloud/vue/components/NcAppSidebarTab'
import NcDateTime from '@nextcloud/vue/components/NcDateTime'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import AppDaemonBadge from '../AppDaemonBadge.vue'
import AppLevelBadge from '../AppLevelBadge.vue'
import AppScore from '../AppScore.vue'
import { useAppsStore } from '../../store/apps.ts'
const { app } = defineProps<{ app: IAppstoreApp | IAppstoreExApp }>()
const store = useAppsStore()
// @ts-expect-error - missing types
const productName = getCapabilities().theming.productName as string
const idLimitedToGroups = useId()
const lastModified = computed(() => app.releases
?.map((release) => release.lastModified)
.map((date) => Date.parse(date))
.sort()
.at(-1))
/**
* App authors as comma separated string
*/
const appAuthors = computed(() => {
if (!app) {
return ''
}
return [app.author].flat().map(authorName)
.sort((a, b) => a.split(' ').at(-1)!.localeCompare(b.split(' ').at(-1)!))
.join(', ')
})
const groupsAppIsLimitedto = computed(() => {
if (!app.groups) {
return []
}
return app.groups.map((group) => ({ id: group, name: group }))
})
const appstoreUrl = computed(() => `https://apps.nextcloud.com/apps/${app.id}`)
/**
* Further external resources (e.g. website)
*/
const externalResources = computed(() => {
const resources: { id: string, href: string, label: string }[] = []
if (!app.internal) {
resources.push({
id: 'appstore',
href: appstoreUrl.value,
label: t('appstore', 'View in store'),
})
}
if (app.website) {
resources.push({
id: 'website',
href: app.website,
label: t('appstore', 'Visit website'),
})
}
if (app.documentation) {
if (app.documentation.user) {
resources.push({
id: 'doc-user',
href: app.documentation.user,
label: t('appstore', 'Usage documentation'),
})
}
if (app.documentation.admin) {
resources.push({
id: 'doc-admin',
href: app.documentation.admin,
label: t('appstore', 'Admin documentation'),
})
}
if (app.documentation.developer) {
resources.push({
id: 'doc-developer',
href: app.documentation.developer,
label: t('appstore', 'Developer documentation'),
})
}
}
return resources
})
const appCategories = computed(() => {
return [app.category].flat()
.map((id) => store.getCategoryById(id)?.displayName ?? id)
.join(', ')
})
/**
* Get the author name from the XML node
*
* @param xmlNode - The XML node to get the author name from
*/
function authorName(xmlNode): string {
if (xmlNode['@value']) {
// Complex node (with email or homepage attribute)
return xmlNode['@value']
}
// Simple text node
return xmlNode
}
</script>
<template>
<NcAppSidebarTab
id="details"
:name="t('appstore', 'Details')"
:order="1">
<template #icon>
<NcIconSvgWrapper :path="mdiTextBoxOutline" />
</template>
<div class="app-details">
<!-- Featured/Supported badges -->
<div :class="$style.appstoreDetailsTab__badges">
<AppLevelBadge :level="app.level" />
<AppDaemonBadge v-if="app.app_api && app.daemon" :daemon="app.daemon" />
<AppScore v-if="(app.ratingNumOverall ?? 0) > 5" :score="app.ratingOverall!" />
</div>
<NcNoteCard v-if="app.missingMinNextcloudVersion || app.missingMaxNextcloudVersion" type="warning">
<template v-if="app.missingMinNextcloudVersion">
{{ t('appstore', 'This app has no minimum {productName} version assigned. This will be an error in the future.', { productName }) }}
</template>
<template v-if="app.missingMaxNextcloudVersion">
{{ t('appstore', 'This app has no maximum {productName} version assigned. This will be an error in the future.', { productName }) }}
</template>
</NcNoteCard>
<NcNoteCard v-if="!app.isCompatible && app.missingDependencies && app.missingDependencies.length" type="error">
{{ t('appstore', 'This app cannot be installed because the following dependencies are not fulfilled:') }}
<ul :aria-label="t('appstore', 'Missing dependencies')" :class="$style.appstoreDetailsTab__missingDependencies">
<li v-for="(dep, index) in app.missingDependencies" :key="index">
{{ dep }}
</li>
</ul>
</NcNoteCard>
<div v-if="groupsAppIsLimitedto.length" :class="$style.appstoreDetailsTab__section">
<h4 :id="idLimitedToGroups">
{{ t('appstore', 'Limited to groups') }}
</h4>
<ul :aria-labelledby="idLimitedToGroups" :class="$style.appstoreDetailsTab__sectionDetails">
<li
v-for="group of groupsAppIsLimitedto"
:key="group.id"
:title="group.id">
{{ group.name }}
</li>
</ul>
</div>
<div v-if="lastModified && !app.shipped" :class="$style.appstoreDetailsTab__section">
<h4>
{{ t('appstore', 'Latest updated') }}
</h4>
<NcDateTime :class="$style.appstoreDetailsTab__sectionDetails" :timestamp="lastModified" />
</div>
<div :class="$style.appstoreDetailsTab__section">
<h4>
{{ t('appstore', 'Author') }}
</h4>
<p :class="$style.appstoreDetailsTab__sectionDetails">
{{ appAuthors }}
</p>
</div>
<div :class="$style.appstoreDetailsTab__section">
<h4>
{{ t('appstore', 'Categories') }}
</h4>
<p :class="$style.appstoreDetailsTab__sectionDetails">
{{ appCategories }}
</p>
</div>
<div v-if="externalResources.length > 0" :class="$style.appstoreDetailsTab__section">
<h4>{{ t('appstore', 'Resources') }}</h4>
<ul
:class="$style.appstoreDetailsTab__resources"
:aria-label="t('appstore', 'Documentation resources')">
<li
v-for="resource of externalResources"
:key="resource.id"
:class="$style.appstoreDetailsTab__resourcesItem">
<a
:class="$style.appstoreDetailsTab__resourcesLink"
:href="resource.href"
target="_blank"
rel="noreferrer noopener">
{{ resource.label }}
</a>
</li>
</ul>
</div>
</div>
</NcAppSidebarTab>
</template>
<style module>
.appstoreDetailsTab__badges {
display: flex;
flex-direction: row;
gap: 12px;
}
.appstoreDetailsTab__section {
margin-top: 15px;
h4 {
font-size: 16px;
font-weight: bold;
margin-block-end: 5px;
}
}
.appstoreDetailsTab__sectionDetails {
color: var(--color-text-maxcontrast);
}
.appstoreDetailsTab__missingDependencies {
list-style: disc;
padding-block: 0.5lh 0;
padding-inline: 1em 0;
}
.appstoreDetailsTab__resourcesLink {
text-decoration: underline;
}
.appstoreDetailsTab__resourcesItem {
padding-inline-start: 20px;
&::before {
width: 5px;
height: 5px;
border-radius: 100%;
background-color: var(--color-main-text);
content: "";
float: inline-start;
margin-inline-start: -13px;
position: relative;
top: 10px;
}
}
</style>

View file

@ -0,0 +1,72 @@
<!--
- SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import type { IAppstoreApp, IAppstoreAppRelease, IAppstoreExApp } from '../../apps.d.ts'
import { mdiClockFast } from '@mdi/js'
import { getLanguage, t } from '@nextcloud/l10n'
import { computed } from 'vue'
import NcAppSidebarTab from '@nextcloud/vue/components/NcAppSidebarTab'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import MarkdownPreview from '../MarkdownPreview.vue'
const props = defineProps<{ app: IAppstoreApp | IAppstoreExApp }>()
const releases = computed(() => (props.app.releases ?? [])
.filter((release) => {
const values = Object.values(release.translations ?? {})
return values.length > 0 && values.some(({ changelog }) => !!changelog)
}))
/**
* Create a changelog text from a release
*
* @param release - The release to create the changelog from
*/
function createChangelogFromRelease(release: IAppstoreAppRelease) {
const localizedEntry = release.translations[getLanguage()]
return localizedEntry?.changelog ?? release.translations.en?.changelog ?? ''
}
</script>
<template>
<NcAppSidebarTab
v-if="releases.length > 0"
id="changelog"
:name="t('appstore', 'Changelog')"
:order="2">
<template #icon>
<NcIconSvgWrapper :path="mdiClockFast" :size="24" />
</template>
<div v-for="release in releases" :key="release.version" :class="$style.appReleasesTab">
<h3 :class="$style.appReleasesTab__heading">
{{ release.version }}
</h3>
<MarkdownPreview
:class="$style.appReleasesTab__text"
:minHeadingLevel="3"
:text="createChangelogFromRelease(release)" />
</div>
</NcAppSidebarTab>
</template>
<style module>
.appReleasesTab__heading {
border-bottom: 1px solid var(--color-border);
font-size: 20px;
}
.appReleasesTab__text {
/* Overwrite changelog heading styles */
h4 {
font-size: 19px;
}
h5 {
font-size: 17px;
}
}
</style>

View file

@ -0,0 +1,104 @@
<!--
- 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 { NcLoadingIcon } from '@nextcloud/vue'
import { useDebounceFn } from '@vueuse/core'
import { computed, ref, watch } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcDialog from '@nextcloud/vue/components/NcDialog'
import NcSelectUsers, { type NcSelectUsersModel } from '@nextcloud/vue/components/NcSelectUsers'
import { useAppsStore } from '../store/apps.ts'
import { useGroupsStore } from '../store/groups.ts'
const { app } = defineProps<{ app: IAppstoreApp | IAppstoreExApp }>()
const emit = defineEmits<{ close: [] }>()
const store = useAppsStore()
const groupsStore = useGroupsStore()
const loading = ref(false)
const groups = ref<NcSelectUsersModel[]>([])
watch(() => app, () => {
groups.value = (app.groups ?? [])
.map((g) => {
const group = groupsStore.getGroupById(g)
if (!group) {
groupsStore.searchGroups(g)
}
return group ?? { id: g, displayName: g, isNoUser: true }
})
}, { immediate: true })
const availableGroups = computed(() => groupsStore.groups.filter((group) => !groups.value.includes(group)))
const onSearch = useDebounceFn(groupsStore.searchGroups, 400)
/**
* Save the limitation of this app
*/
async function onSave() {
try {
loading.value = true
await store.limitAppToGroups(app.id, groups.value.map((g) => g.id))
emit('close')
} finally {
loading.value = false
}
}
/**
* Handle reset
*/
async function onReset() {
try {
loading.value = true
await store.limitAppToGroups(app.id, [])
emit('close')
} finally {
loading.value = false
}
}
</script>
<template>
<NcDialog
isForm
:name="t('appstore', 'Limit to groups')"
@submit="onSave"
@reset="onReset">
<p>{{ t('appstore', 'Restrict the usage of {app} to members of the following groups.', { app: app.name }) }}</p>
<NcSelectUsers
v-model="groups"
:class="$style.limitToGroupDialog__input"
keepOpen
labelOutside
multiple
:options="availableGroups"
@search="onSearch" />
<template #actions>
<NcButton :disabled="loading" type="reset">
{{ t('appstore', 'Reset limitation') }}
</NcButton>
<NcButton :disabled="loading" type="submit" variant="primary">
<template v-if="loading" #icon>
<NcLoadingIcon />
</template>
{{ t('appstore', 'Save') }}
</NcButton>
</template>
</NcDialog>
</template>
<style module>
.limitToGroupDialog__input {
width: 100%;
padding-block: 1lh calc(2 * var(--default-clickable-area) + var(--default-grid-baseline));
}
</style>

View file

@ -6,120 +6,14 @@
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,
})
import { actions } from '../actions/index.ts'
/**
* 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
})
export function useActions(app: MaybeRefOrGetter<IAppstoreApp | IAppstoreExApp | null>) {
return computed(() => toValue(app) ? actions.filter((action) => action.enabled(toValue(app)!)) : [])
}

View file

@ -86,7 +86,7 @@ function markedLink({ href, title, text }: Tokens.Link) {
if (title) {
out += ' title="' + title + '"'
}
out += '>' + text + '</a>'
out += '>' + text.replaceAll(/(?<!\\)\\([^\\])/g, '$1') + '</a>'
return out
}

View file

@ -12,6 +12,7 @@ import 'vite/modulepreload-polyfill'
const pinia = createPinia()
const app = createApp(AppstoreApp)
app.config.idPrefix = 'appstore'
app.use(pinia)
app.use(router)
app.mount('#content')

View file

@ -1,271 +0,0 @@
/**
* SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { showError } from '@nextcloud/dialogs'
import { rebuildNavigation } from '../service/rebuild-navigation.ts'
const productName = window.OC.theme.productName
export default {
computed: {
appGroups() {
return this.app.groups.map((group) => {
return { id: group, name: group }
})
},
installing() {
if (this.app?.app_api) {
return this.app && this?.appApiStore.getLoading('install') === true
}
return this.$store.getters.loading('install')
},
isLoading() {
if (this.app?.app_api) {
return this.app && this?.appApiStore.getLoading(this.app.id) === true
}
return this.app && this.$store.getters.loading(this.app.id)
},
isInitializing() {
if (this.app?.app_api) {
return this.app && (this.app?.status?.action === 'init' || this.app?.status?.action === 'healthcheck')
}
return false
},
isDeploying() {
if (this.app?.app_api) {
return this.app && this.app?.status?.action === 'deploy'
}
return false
},
isManualInstall() {
if (this.app?.app_api) {
return this.app?.daemon?.accepts_deploy_id === 'manual-install'
}
return false
},
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('settings', '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 })
}
if (this.app && this.app?.status?.action && this.app?.status?.action === 'init') {
return t('settings', '{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')
}
if (this.app.needsDownload) {
return t('settings', 'Deploy and Enable')
}
return t('settings', 'Enable')
} else {
if (this.app.needsDownload) {
return t('settings', 'Download and enable')
}
return t('settings', '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 })
}
if (this.app && this.app?.status?.action && this.app?.status?.action === 'init') {
return t('settings', '{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('settings', 'Disable')
},
forceEnableButtonText() {
if (this.app.needsDownload) {
return t('settings', 'Allow untested app')
}
return t('settings', '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 null
},
forceEnableButtonTooltip() {
const base = t('settings', '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.')
if (this.app.needsDownload) {
return base + ' ' + t('settings', 'The app will be downloaded from the App Store')
}
return base
},
defaultDeployDaemonAccessible() {
if (this.app?.app_api) {
if (this.app?.daemon && this.app?.daemon?.accepts_deploy_id === 'manual-install') {
return true
}
if (this.app?.daemon?.accepts_deploy_id === 'docker-install'
&& this.appApiStore.getDefaultDaemon?.name === this.app?.daemon?.name) {
return this?.appApiStore.getDaemonAccessible === true
}
return this?.appApiStore.getDaemonAccessible
}
return true
},
},
data() {
return {
groupCheckedAppsData: false,
}
},
mounted() {
if (this.app && this.app.groups && this.app.groups.length > 0) {
this.groupCheckedAppsData = true
}
},
methods: {
asyncFindGroup(query) {
return this.$store.dispatch('getGroups', { search: query, limit: 5, offset: 0 })
},
isLimitedToGroups() {
if (this.app?.app_api) {
return false
}
return this.app.groups.length || this.groupCheckedAppsData
},
setGroupLimit() {
if (this.app?.app_api) {
return // not supported for app_api apps
}
if (!this.groupCheckedAppsData) {
this.$store.dispatch('enableApp', { appId: this.app.id, groups: [] })
}
},
canLimitToGroups(app) {
if ((app.types && app.types.includes('filesystem'))
|| app.types.includes('prelogin')
|| app.types.includes('authentication')
|| app.types.includes('logging')
|| app.types.includes('prevent_group_restriction')
|| app?.app_api) {
return false
}
return true
},
addGroupLimitation(groupArray) {
if (this.app?.app_api) {
return
}
const group = groupArray.pop()
const groups = this.app.groups.concat([]).concat([group.id])
if (this.store && this.store.updateAppGroups) {
this.store.updateAppGroups(this.app.id, groups)
}
this.$store.dispatch('enableApp', { appId: this.app.id, groups })
},
removeGroupLimitation(group) {
if (this.app?.app_api) {
return
}
const currentGroups = this.app.groups.concat([])
const index = currentGroups.indexOf(group.id)
if (index > -1) {
currentGroups.splice(index, 1)
}
if (this.store && this.store.updateAppGroups) {
this.store.updateAppGroups(this.app.id, currentGroups)
}
if (currentGroups.length === 0) {
this.groupCheckedAppsData = false
}
this.$store.dispatch('enableApp', { appId: this.app.id, groups: currentGroups })
},
forceEnable(appId) {
if (this.app?.app_api) {
this.appApiStore.forceEnableApp(appId)
.then(() => { rebuildNavigation() })
.catch((error) => { showError(error) })
} else {
this.$store.dispatch('forceEnableApp', { appId, groups: [] })
.then(() => { rebuildNavigation() })
.catch((error) => { showError(error) })
}
},
enable(appId, daemon = null, deployOptions = {}) {
if (this.app?.app_api) {
this.appApiStore.enableApp(appId, daemon, deployOptions)
.then(() => { rebuildNavigation() })
.catch((error) => { showError(error) })
} else {
this.$store.dispatch('enableApp', { appId, groups: [] })
.then(() => { rebuildNavigation() })
.catch((error) => { showError(error) })
}
},
disable(appId) {
if (this.app?.app_api) {
this.appApiStore.disableApp(appId)
.then(() => { rebuildNavigation() })
.catch((error) => { showError(error) })
} else {
this.$store.dispatch('disableApp', { appId })
.then(() => { rebuildNavigation() })
.catch((error) => { showError(error) })
}
},
async remove(appId, removeData = false) {
try {
if (this.app?.app_api) {
await this.appApiStore.uninstallApp(appId, removeData)
} else {
await this.$store.dispatch('uninstallApp', { appId, removeData })
}
await rebuildNavigation()
} catch (error) {
showError(error)
}
},
install(appId) {
if (this.app?.app_api) {
this.appApiStore.enableApp(appId)
.then(() => { rebuildNavigation() })
.catch((error) => { showError(error) })
} else {
this.$store.dispatch('enableApp', { appId })
.then(() => { rebuildNavigation() })
.catch((error) => { showError(error) })
}
},
update(appId) {
if (this.app?.app_api) {
return this.appApiStore.updateApp(appId)
.then(() => { rebuildNavigation() })
.catch((error) => { showError(error) })
} else {
return this.$store.dispatch('updateApp', { appId })
.catch((error) => { showError(error) })
.then(() => {
rebuildNavigation()
this.store.updateCount = Math.max(this.store.updateCount - 1, 0)
})
}
},
},
}

View file

@ -11,9 +11,9 @@ 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 AppstoreDiscover = () => import('../views/AppstoreDiscover.vue')
const AppstoreManage = () => import('../views/AppstoreManage.vue')
const AppstoreBundles = () => import('../views/AppstoreBundles.vue')
const routes: RouteRecordRaw[] = [
{

View file

@ -32,10 +32,11 @@ const queue = new PQueue({ concurrency: 1 })
*
* @param appId - The app to enable
* @param force - Whether to force enable the app
* @param groups - The groups to enable the app for
*/
export async function enableApp(appId: string, force = false) {
export async function enableApp(appId: string, force = false, groups?: string[]) {
return queue.add(async () => {
await axios.post(Url.enable, { appId, force: force || undefined }, { confirmPassword: PwdConfirmationMode.Strict })
await axios.post(Url.enable, { appId, groups, force: force || undefined }, { confirmPassword: PwdConfirmationMode.Strict })
})
}

View file

@ -3,7 +3,7 @@
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
*/
import type { IAppstoreExApp, IDeployDaemon, IDeployOptions } from '../apps.d.ts'
import type { IAppstoreExApp, IDeployDaemon, IDeployOptions, IExAppStatus } from '../apps.d.ts'
import axios from '@nextcloud/axios'
import { confirmPassword } from '@nextcloud/password-confirmation'
@ -17,6 +17,16 @@ export async function fetchApps() {
return data.apps as IAppstoreExApp[]
}
/**
* Get the status of an external app.
*
* @param appId - The app to fetch
*/
export async function fetchAppStatus(appId: string) {
const { data } = await axios.get<IExAppStatus>(generateUrl(`/apps/app_api/apps/status/${appId}`))
return data
}
/**
* Enable an external app.
*

View file

@ -12,7 +12,7 @@ import { defineStore } from 'pinia'
import { computed, readonly, ref } from 'vue'
import * as api from '../service/api.ts'
import { rebuildNavigation } from '../service/rebuild-navigation.ts'
import { canDisable, canInstall, canUninstall, needForceEnable } from '../utils/appStatus.ts'
import { canDisable, canInstall, canLimitToGroups, canUninstall, needForceEnable } from '../utils/appStatus.ts'
import logger from '../utils/logger.ts'
import { useExAppsStore } from './exApps.ts'
@ -132,6 +132,8 @@ export const useAppsStore = defineStore('apps', () => {
await api.disableApp(appId)
}
app.active = false
// revert "force enable"
app.isCompatible = app.missingDependencies === undefined || app.missingDependencies.length === 0
await rebuildNavigation()
} finally {
app.loading = false
@ -169,15 +171,31 @@ export const useAppsStore = defineStore('apps', () => {
}
/**
* Update the groups of an app
* Limit access to an app to specific groups
*
* @param appId - The app to update
* @param groups - The new groups
* @param appId - The app to limit access to
* @param groups - The groups which should have access
*/
function updateAppGroups(appId: string, groups: string[]) {
const app = apps.value.find(({ id }) => id === appId)
if (app) {
app.groups = [...groups]
async function limitAppToGroups(appId: string, groups: string[]) {
const app = getAppById(appId)
if (!app) {
throw new Error(`App with id ${appId} not found`)
}
if (!canLimitToGroups(app)) {
throw new Error(`App with id ${appId} cannot be limited to groups`)
}
if (app.app_api) {
return
}
try {
app.loading = true
await api.enableApp(appId, false, groups)
app.groups = groups
} finally {
app.loading = false
}
}
@ -262,6 +280,6 @@ export const useAppsStore = defineStore('apps', () => {
getAppById,
getAppsByCategory,
getCategoryById,
updateAppGroups,
limitAppToGroups,
}
})

View file

@ -33,14 +33,30 @@ export const useExAppsStore = defineStore('external-apps', () => {
*/
const updateCount = ref(loadState('appstore', 'appstoreExAppUpdateCount', 0))
const statusUpdater = ref<number | null | undefined>(null)
/**
* The interval ID for the status updater
*/
let statusUpdater: number | null = null
/**
* Whether at least one of the configured daemons is accessible.
*/
const daemonAccessible = ref(loadState('appstore', 'defaultDaemonConfigAccessible', false))
/**
* The default daemon, used for apps that don't specify a daemon or have a daemon that is not accessible.
*/
const defaultDaemon = ref(loadState<IDeployDaemon | null>('appstore', 'defaultDaemonConfig', null))
/**
* The list of daemons that support docker-based deployment, used to show the daemon selection when enabling an app.
*/
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 !== ''))
const initializingOrDeployingApps = computed(() => apps.value
.filter((app) => app?.status?.action
&& app.status.type !== ''
&& (app?.status?.action === 'deploy' || app.status.action === 'init' || app.status.action === 'healthcheck')))
/**
* Get an external app by its ID
@ -89,6 +105,8 @@ export const useExAppsStore = defineStore('external-apps', () => {
}
app.removable = true
delete app.error
await fetchAppStatus(appId)
} finally {
app.loading = false
}
@ -186,8 +204,8 @@ export const useExAppsStore = defineStore('external-apps', () => {
delete app.update
delete app.error
updateCount.value--
// Trigger status updates
// updateAppsStatus()
await fetchAppStatus(appId)
} catch (error) {
logger.error('Failed to update ex app', { appId, error })
showError(t('appstore', 'Could not update the app. Please try again later.'))
@ -218,6 +236,7 @@ export const useExAppsStore = defineStore('external-apps', () => {
updateCount,
defaultDaemon,
dockerDaemons,
daemonAccessible,
getById,
disableApp,
@ -255,48 +274,54 @@ export const useExAppsStore = defineStore('external-apps', () => {
}
}
/*
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()
})
},
/**
* Get the status of an external app.
*
* @param appId - The app ID to fetch the status for
*/
async function fetchAppStatus(appId: string) {
const app = getById(appId)
if (!app) {
logger.error('[app-api-store] app not found while fetching status', { appId })
return
}
updateAppsStatus() {
clearInterval(this.statusUpdater as number)
const initializingOrDeployingApps = this.getInitializingOrDeployingApps
if (initializingOrDeployingApps.length === 0) {
return
app.loading = true
try {
const status = await exAppApi.fetchAppStatus(appId)
app.status = status
logger.debug('[app-api-store] initializingOrDeployingApps after setAppStatus', { initializingOrDeployingApps })
if (initializingOrDeployingApps.value.length === 0) {
logger.debug('[app-api-store] Clearing interval')
clearInterval(statusUpdater as number)
statusUpdater = null
}
this.statusUpdater = setInterval(() => {
const initializingOrDeployingApps = this.getInitializingOrDeployingApps
logger.debug('initializingOrDeployingApps', { initializingOrDeployingApps })
initializingOrDeployingApps.forEach((app) => {
this.fetchAppStatus(app.id)
})
}, 2000) as unknown as number
},
}, */
if (app.status.error && initializingOrDeployingApps.value.length === 1) {
clearInterval(statusUpdater as number)
statusUpdater = null
}
} catch (e) {
updateAppsStatus()
throw e
} finally {
app.loading = false
}
}
/**
* Update the status of all apps that are currently initializing or deploying
*/
function updateAppsStatus() {
clearInterval(statusUpdater as number)
if (initializingOrDeployingApps.value.length === 0) {
return
}
statusUpdater = window.setInterval(() => {
logger.debug('[app-api-store] initializingOrDeployingApps', { initializingOrDeployingApps })
for (const app of initializingOrDeployingApps.value) {
fetchAppStatus(app.id)
}
}, 2000)
}
})

View file

@ -0,0 +1,63 @@
/**
* SPDX-License-Identifier: AGPL-3.0-or-later
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
*/
import type { OCSResponse } from '@nextcloud/typings/ocs'
import type { NcSelectUsersModel } from '@nextcloud/vue/components/NcSelectUsers'
import axios from '@nextcloud/axios'
import { generateOcsUrl } from '@nextcloud/router'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import logger from '../utils/logger.ts'
export const useGroupsStore = defineStore('groups', () => {
const groups = ref(new Map<string, NcSelectUsersModel>())
/**
* Search the API for groups matching the query
*
* @param query - Query to search
*/
async function searchGroups(query: string) {
const url = generateOcsUrl('/cloud/groups/details')
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data } = await axios.get<OCSResponse<{ groups: any }>>(url, {
params: {
search: query.trim(),
limit: 10,
},
})
for (const group of data.ocs.data.groups) {
if (groups.value.has(group.id)) {
continue
}
groups.value.set(group.id, {
id: group.id,
displayName: group.displayname,
isNoUser: true,
})
}
} catch (error) {
logger.error('Failed to search groups', { error })
}
}
/**
* Get a group by its id
*
* @param groupId - The id of the group to retrieve
*/
function getGroupById(groupId: string) {
return groups.value.get(groupId)
}
return {
groups: computed(() => Array.from(groups.value.values())),
searchGroups,
getGroupById,
}
})

View file

@ -42,7 +42,7 @@ export function canUninstall(app: IAppstoreApp | IAppstoreExApp) {
* @param app - The app to check
*/
export function canEnable(app: IAppstoreApp | IAppstoreExApp) {
return canForceEnable(app) && app.isCompatible
return !isInitializing(app) && !isDeploying(app) && canForceEnable(app) && app.isCompatible
}
/**
@ -69,7 +69,7 @@ export function needForceEnable(app: IAppstoreApp | IAppstoreExApp) {
* @param app - The app to check
*/
export function canDisable(app: IAppstoreApp | IAppstoreExApp) {
return app.active && !app.internal
return !isInitializing(app) && !isDeploying(app) && app.active && !app.internal
}
/**
@ -80,3 +80,46 @@ export function canDisable(app: IAppstoreApp | IAppstoreExApp) {
export function canUpdate(app: IAppstoreApp | IAppstoreExApp) {
return app.update !== undefined
}
const restrictedTypes = ['filesystem', 'prelogin', 'authentication', 'logging', 'prevent_group_restriction']
/**
* Check if an app can be limited to groups
*
* @param app - The app to check if can be limited to groups
*/
export function canLimitToGroups(app: IAppstoreApp | IAppstoreExApp) {
if (!app.active && !app.installed) {
return false
}
if (!app.active && needForceEnable(app)) {
return false
}
if (!app.types) {
return true
}
return app.types.every((type) => !restrictedTypes.includes(type))
}
/**
* Check if an app is currently being initialized.
*
* @param app - The app to check
*/
function isInitializing(app: IAppstoreApp | IAppstoreExApp) {
return app.app_api
&& (app.status.action === 'init' || app.status.action === 'healthcheck')
}
/**
* Check if an app is currently being deployed.
*
* @param app - The app to check
*/
function isDeploying(app: IAppstoreApp | IAppstoreExApp) {
return app.app_api
&& app.status.action === 'deploy'
}

View file

@ -1,162 +0,0 @@
<!--
- 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 NcAppSidebar from '@nextcloud/vue/components/NcAppSidebar'
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 { 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'
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 { appIcon } = useAppIcon(app)
/**
* The second text line shown on the sidebar
*/
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.license.toString().toUpperCase() })
}
return t('settings', 'Version {version}', { version: app.value.version })
})
const activeTab = ref('details')
watch([app], () => {
activeTab.value = 'details'
})
/**
* Hide the details sidebar by pushing a new route
*/
function hideAppDetails() {
router.push({
name: 'apps-category',
params: { category: route.params.category },
})
}
/**
* Whether the app screenshot is loaded
*/
const screenshotLoaded = ref(false)
const hasScreenshot = computed(() => app.value?.screenshot && screenshotLoaded.value)
/**
* Preload the app screenshot
*/
function loadScreenshot() {
if (app.value?.releases && app.value?.screenshot) {
const image = new Image()
image.onload = () => {
screenshotLoaded.value = true
}
image.src = app.value.screenshot
}
}
// Watch app and set screenshot loaded when
watch([app], loadScreenshot)
onMounted(loadScreenshot)
</script>
<style scoped lang="scss">
.app-sidebar {
// If a screenshot is available it should cover the whole figure
&--with-screenshot {
:deep(.app-sidebar-header__figure) {
background-size: cover;
}
}
&__fallback-icon {
// both 100% to center the icon
width: 100%;
height: 100%;
}
&__badges {
display: flex;
flex-direction: row;
gap: 12px;
}
&__version {
color: var(--color-text-maxcontrast);
}
}
</style>

View file

@ -0,0 +1,137 @@
<!--
- SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import { t } from '@nextcloud/l10n'
import { computed, onMounted, ref, watch } from 'vue'
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 AppActions from '../components/AppActions.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 { useActions } from '../composables/useActions.ts'
import { useAppIcon } from '../composables/useAppIcon.ts'
import { useAppsStore } from '../store/apps.ts'
const route = useRoute()
const router = useRouter()
const store = useAppsStore()
const appId = computed<string>(() => [route.params.id].flat()[0]!)
const app = computed(() => store.getAppById(appId.value) ?? null)
const { appIcon } = useAppIcon(app)
/**
* The second text line shown on the sidebar
*/
const licenseText = computed(() => {
if (!app.value) {
return ''
}
if (app.value.license) {
return t('appstore', 'Version {version}, {license}-licensed', { version: app.value.version, license: String(app.value.license).toUpperCase() })
}
return t('appstore', 'Version {version}', { version: app.value.version })
})
const activeTab = ref('details')
watch([app], () => {
activeTab.value = 'details'
})
/**
* Hide the details sidebar by pushing a new route
*/
function hideAppDetails() {
router.replace({
name: route.name!,
params: {
...route.params,
id: undefined,
},
})
}
/**
* Whether the app screenshot is loaded
*/
const screenshotLoaded = ref(false)
const hasScreenshot = computed(() => app.value?.screenshot && screenshotLoaded.value)
/**
* Preload the app screenshot
*/
function loadScreenshot() {
if (app.value?.releases && app.value?.screenshot) {
const image = new Image()
image.onload = () => {
screenshotLoaded.value = true
}
image.src = app.value.screenshot
}
}
// Watch app and set screenshot loaded when
watch([app], loadScreenshot)
onMounted(loadScreenshot)
const actions = useActions(() => app.value)
</script>
<template>
<!-- Selected app details -->
<NcAppSidebar
v-model:active="activeTab"
:class="[$style.appstoreSidebar, { [$style.appstoreSidebar_withScreenshot]: 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="$style.appstoreSidebar__fallbackIcon"
:svg="appIcon ?? ''"
:size="64" />
</template>
<template v-if="app" #description>
<AppActions
:app
:actions
iconOnly
:maxInlineActions="6" />
</template>
<!-- Tab content -->
<NcEmptyContent v-if="!app" name="No such app" />
<template v-else>
<AppDescriptionTab :app />
<AppReleasesTab :app />
<AppDetailsTab :app />
<AppDeployDaemonTab v-if="app.app_api" :app />
</template>
</NcAppSidebar>
</template>
<style module>
/* If a screenshot is available it should cover the whole figure */
.appstoreSidebar_withScreenshot {
:global(.app-sidebar-header__figure) {
background-size: cover;
}
}
.appstoreSidebar__fallbackIcon {
width: 100%;
height: 100%;
}
</style>

View file

@ -470,6 +470,7 @@ class OC_App {
}
}
$info['license'] ??= $info['licence'];
$info['version'] = $appManager->getAppVersion($app);
$info['license'] ??= $info['licence'];
$appList[] = $info;