initial implementation of community packages

This commit is contained in:
Girish Ramakrishnan
2026-02-05 13:20:00 +01:00
parent a931d2a91f
commit a7de7fb286
11 changed files with 238 additions and 70 deletions
+8 -30
View File
@@ -7,7 +7,6 @@ import { prettyDate, prettyBinarySize } from '@cloudron/pankow/utils';
import AccessControl from './AccessControl.vue';
import PortBindings from './PortBindings.vue';
import AppsModel from '../models/AppsModel.js';
import AppstoreModel from '../models/AppstoreModel.js';
import DomainsModel from '../models/DomainsModel.js';
import UsersModel from '../models/UsersModel.js';
import GroupsModel from '../models/GroupsModel.js';
@@ -19,7 +18,6 @@ const STEP = Object.freeze({
INSTALL: Symbol('install'),
});
const appstoreModel = AppstoreModel.create();
const appsModel = AppsModel.create();
const domainsModel = DomainsModel.create();
const usersModel = UsersModel.create();
@@ -210,35 +208,15 @@ function onScreenshotNext() {
elem.scrollIntoView({ behavior: 'smooth', inline: 'start', block: 'nearest' });
}
async function getApp(id, version = '') {
const [error, result] = await appstoreModel.get(id, version);
if (error) {
console.error(error);
return null;
}
return result;
}
defineExpose({
open: async function(appId, version, appCountExceeded, domainList) {
open: function(appData, appCountExceeded, domainList) {
busy.value = false;
step.value = STEP.LOADING;
step.value = STEP.DETAILS;
formError.value = {};
// give it some time to fetch before showing loading
const openTimer = setTimeout(dialog.value.open, 200);
const a = await getApp(appId, version);
if (!a) {
clearTimeout(openTimer);
dialog.value.close();
throw new Error('app not found');
}
app.value = a;
app.value = appData;
appMaxCountExceeded.value = appCountExceeded;
manifest.value = a.manifest;
manifest.value = appData.manifest;
location.value = '';
accessRestrictionOption.value = ACL_OPTIONS.ANY;
accessRestrictionAcl.value = { users: [], groups: [] };
@@ -255,8 +233,8 @@ defineExpose({
// preselect with dashboard domain
domain.value = domains.value.find(d => d.domain === dashboardDomain.value).domain;
tcpPorts.value = a.manifest.tcpPorts;
udpPorts.value = a.manifest.udpPorts;
tcpPorts.value = appData.manifest.tcpPorts;
udpPorts.value = appData.manifest.udpPorts;
// ensure we have value property
for (const p in tcpPorts.value) {
@@ -268,7 +246,7 @@ defineExpose({
udpPorts.value[p].enabled = udpPorts.value[p].enabledByDefault ?? true;
}
secondaryDomains.value = a.manifest.httpPorts;
secondaryDomains.value = appData.manifest.httpPorts;
for (const p in secondaryDomains.value) {
const port = secondaryDomains.value[p];
port.value = port.defaultValue;
@@ -276,7 +254,7 @@ defineExpose({
}
currentScreenshotPos = 0;
step.value = STEP.DETAILS;
dialog.value.open();
},
close() {
dialog.value.close();
@@ -0,0 +1,75 @@
<script setup>
import { ref, useTemplateRef } from 'vue';
import { Dialog, TextInput, FormGroup } from '@cloudron/pankow';
import CommunityModel from '../models/CommunityModel.js';
const communityModel = CommunityModel.create();
const emit = defineEmits([ 'success' ]);
const dialog = useTemplateRef('dialog');
const busy = ref(false);
const formError = ref({});
const url = ref('');
const version = ref('latest');
async function onSubmit() {
busy.value = true;
formError.value = {};
const [error, result] = await communityModel.getApp(url.value, version.value);
if (error) {
formError.value.generic = error.body?.message || 'Failed to fetch community app';
busy.value = false;
return console.error(error);
}
emit('success', result);
dialog.value.close();
busy.value = false;
}
defineExpose({
open() {
url.value = '';
version.value = 'latest';
formError.value = {};
busy.value = false;
dialog.value.open();
}
});
</script>
<template>
<Dialog ref="dialog"
title="Install Community App"
confirm-label="Continue"
:confirm-busy="busy"
:confirm-active="!busy && url !== ''"
reject-style="secondary"
reject-label="Cancel"
:reject-active="!busy"
@confirm="onSubmit()"
>
<form @submit.prevent="onSubmit()" autocomplete="off">
<fieldset :disabled="busy">
<input type="submit" style="display: none;" />
<div class="error-label" v-if="formError.generic">{{ formError.generic }}</div>
<FormGroup>
<label for="urlInput">CloudronVersions.json URL</label>
<TextInput id="urlInput" v-model="url" required placeholder="https://example.com/CloudronVersions.json"/>
</FormGroup>
<FormGroup>
<label for="versionInput">Version</label>
<TextInput id="versionInput" v-model="version" required/>
</FormGroup>
</fieldset>
</form>
</Dialog>
</template>
+25
View File
@@ -0,0 +1,25 @@
import { fetcher } from '@cloudron/pankow';
import { API_ORIGIN } from '../constants.js';
function create() {
const accessToken = localStorage.token;
return {
async getApp(url, version) {
let result;
try {
result = await fetcher.get(`${API_ORIGIN}/api/v1/community/app`, { access_token: accessToken, url, version });
} catch (e) {
return [e];
}
if (result.status !== 200) return [result];
return [null, result.body];
}
};
}
export default {
create,
};
+18 -5
View File
@@ -13,6 +13,7 @@ import DomainsModel from '../models/DomainsModel.js';
import ApplinkDialog from '../components/ApplinkDialog.vue';
import AppInstallDialog from '../components/AppInstallDialog.vue';
import AppStoreItem from '../components/AppStoreItem.vue';
import CommunityAppDialog from '../components/CommunityAppDialog.vue';
const appsModel = AppsModel.create();
const appstoreModel = AppstoreModel.create();
@@ -143,16 +144,17 @@ async function onHashChange() {
const params = new URLSearchParams(window.location.hash.slice(window.location.hash.indexOf('?')));
const version = params.get('version') || 'latest';
try {
await appInstallDialog.value.open(appId, version, installedApps.value.length >= features.value.appMaxCount, domains.value);
// eslint-disable-next-line no-unused-vars
} catch (e) {
inputDialog.value.info({
const [error, appData] = await appstoreModel.get(appId, version);
if (error) {
console.error(error);
return inputDialog.value.info({
title: t('appstore.appNotFoundDialog.title'),
message: t('appstore.appNotFoundDialog.description', { appId, version }),
confirmLabel: t('main.dialog.close'),
});
}
appInstallDialog.value.open(appData, installedApps.value.length >= features.value.appMaxCount, domains.value);
}
}
@@ -178,6 +180,7 @@ async function getDomains() {
domains.value = result;
}
const applinkDialog = useTemplateRef('applinkDialog');
const communityAppDialog = useTemplateRef('communityAppDialog');
function onAddAppLink() {
applinkDialog.value.open();
@@ -187,6 +190,14 @@ function onApplinkAdded() {
window.location.href = '#/apps';
}
function onInstallCommunityApp() {
communityAppDialog.value.open();
}
function onCommunityAppSuccess(appData) {
appInstallDialog.value.open(appData, installedApps.value.length >= features.value.appMaxCount, domains.value);
}
onActivated(async () => {
setItemWidth();
@@ -222,6 +233,7 @@ onDeactivated(() => {
<div ref="view" class="content-large" style="width: 100%; height: 100%;">
<InputDialog ref="inputDialog"/>
<ApplinkDialog ref="applinkDialog" @success="onApplinkAdded"/>
<CommunityAppDialog ref="communityAppDialog" @success="onCommunityAppSuccess"/>
<AppInstallDialog ref="appInstallDialog" @close="onAppInstallDialogClose"/>
<div class="filter-bar">
@@ -229,6 +241,7 @@ onDeactivated(() => {
<TextInput ref="searchInput" @keydown.esc="search = ''" v-model="search" :disabled="!ready" :placeholder="$t('appstore.searchPlaceholder')" style="flex-grow: 1;" autocomplete="off"/>
<Button tool outline href="/#/appstore/io.cloudron.builtin.appproxy">Add app proxy</Button>
<Button tool outline @click="onAddAppLink()">Add external link</Button>
<Button tool outline @click="onInstallCommunityApp()">Install Community App</Button>
</div>
<div v-if="!ready" style="margin-top: 15px">