Hi everyone!
I would like to add a tab to the sidebar of Files and I am developing it as an ExApp with C#.
Looking at the docs and every example of ExApps and āregularā apps that add a tab to Files, that I could find, I got it working to the point where it shows as ExApp installed with manual_install in Nextcloud, and no error messages in Nextcloud, the ExApp backend or in the browser.
But the tab itself is also not showing up in the sidebar. In the networks tab in the browser dev tools, it also doesnāt seem that the script for my tab is being loaded.
Since I can only post one hyperlink (new user), one post I took inspiration from:
https://help.nextcloud.com/t/create-a-sidebar-of-my-app-from-files/141826
Unfortunately I couldnāt find an example that is an ExApp and adds a tab to Files at the same time.
If you could give the following relevant files a look and see if I missed something or if you have some tips how I could further debug this problem, I would be very thankful.
info.xml
<?xml version="1.0"?>
<info xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://apps.nextcloud.com/schema/apps/info.xsd">
<id>dms-extras</id>
<name>DMS-Zusatzfunktionen</name>
<summary>Zusatzfunktionen für das DMS</summary>
<description>Zusatzfunktionen für das DMS</description>
<version>1.0.0</version>
<licence>agpl</licence>
<namespace>Dms</namespace>
<category>files</category>
<bugs>https://example.com</bugs>
<dependencies>
<nextcloud min-version="29"/>
</dependencies>
<external-app>
<routes>
<route>
<url>.*</url>
<verb>GET,POST,PUT,DELETE</verb>
<access_level>USER</access_level>
<headers_to_exclude>[]</headers_to_exclude>
</route>
</routes>
<scopes>
<value>FILES</value>
</scopes>
</external-app>
</info>
Program.cs - ExApp backend
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.FileProviders;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
builder.Services.AddHttpClient();
var host = Environment.GetEnvironmentVariable("APP_HOST") ?? "0.0.0.0";
var port = Environment.GetEnvironmentVariable("APP_PORT") ?? "9031";
builder.WebHost.UseUrls($"http://{host}:{port}");
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseHttpsRedirection();
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = new PhysicalFileProvider(Path.Combine(app.Environment.ContentRootPath, "js")),
RequestPath = "/js"
});
app.MapGet("/", () => "Hello World");
app.MapGet("/heartbeat", () => new { status = "ok" }).WithName("Heartbeat");
app.MapPut("/enabled", (HttpRequest request, HttpClient httpClient) =>
{
var appId = request.Headers["EX-APP-ID"];
if (appId != Environment.GetEnvironmentVariable("APP_ID"))
{
Console.Error.WriteLine($"Invalid app ID: {appId}");
return Results.Unauthorized();
}
var appVersion = request.Headers["EX-APP-VERSION"];
if (appVersion != Environment.GetEnvironmentVariable("APP_VERSION"))
{
Console.Error.WriteLine($"Invalid app version: {appVersion}");
return Results.Unauthorized();
}
var decoded = Convert.FromBase64String(request.Headers["AUTHORIZATION-APP-API"]!);
var decodedString = Encoding.UTF8.GetString(decoded);
var parts = decodedString.Split(':');
//var userName = parts[0];
var appSecret = parts[1];
if (appSecret != Environment.GetEnvironmentVariable("APP_SECRET"))
{
Console.Error.WriteLine($"Invalid app secret: {appSecret}");
return Results.Unauthorized();
}
bool isEnabled;
try
{
isEnabled = request.Query["enabled"] == "1";
}
catch (Exception e)
{
Console.Error.WriteLine(e);
return Results.BadRequest();
}
var frontendScriptPayload = new
{
type = "resources",
name = "dms-main",
path = "js/dms-main.js"
};
if (isEnabled)
{
var response = OcsCall(httpClient, HttpMethod.Post, "/ocs/v1.php/apps/app_api/api/v1/ui/script", "", JsonSerializer.Serialize(frontendScriptPayload));
if (!response.IsSuccessStatusCode) Console.Error.WriteLine("Registering dms-main.js failed");
Console.WriteLine("App was enabled");
}
else
{
var response = OcsCall(httpClient, HttpMethod.Delete, "/ocs/v1.php/apps/app_api/api/v1/ui/script", "", JsonSerializer.Serialize(frontendScriptPayload));
if (!response.IsSuccessStatusCode) Console.Error.WriteLine("Unregistering dms-main.js failed");
Console.WriteLine("App was disabled");
}
return Results.Ok();
}).WithName("Enabled");
app.Run();
return;
static HttpResponseMessage OcsCall(HttpClient httpClient, HttpMethod method, string url, string userName, string jsonPayload)
{
// build URL
var fullUrl = Environment.GetEnvironmentVariable("NEXTCLOUD_URL")!;
fullUrl = fullUrl.TrimEnd('/');
if (fullUrl.EndsWith("/index.php")) fullUrl = fullUrl[..^"/index.php".Length];
fullUrl += url;
// build request
var request = new HttpRequestMessage(method, fullUrl);
request.Content = new StringContent(jsonPayload, Encoding.UTF8, "application/json");
request.Headers.Add("EX-APP-ID", Environment.GetEnvironmentVariable("APP_ID"));
request.Headers.Add("EX-APP-VERSION", Environment.GetEnvironmentVariable("APP_VERSION"));
request.Headers.Add("OCS-APIRequest", "true");
var secret = Environment.GetEnvironmentVariable("APP_SECRET")!;
var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{userName}:{secret}"));
request.Headers.Add("AUTHORIZATION-APP-API", credentials);
// send request
var response = httpClient.Send(request);
return response;
}
main.js - ExApp Frontend
import Vue from 'vue'
import DmsTab from './DmsTab.vue'
const View = Vue.extend(DmsTab)
let tabInstance = null
window.addEventListener('DOMContentLoaded', () => {
if (OCA.Files?.Sidebar === undefined) {
console.error('Error with OCA.Files and/or OCA.Files.Sidebar')
return
}
OCA.Files.Sidebar.registerTab(new OCA.Files.Sidebar.Tab({
id: 'dms-extras',
name: 'DMS',
icon: 'icon-share',
async mount(el, fileInfo, context) {
if (tabInstance) {
tabInstance.$destroy()
}
tabInstance = new View({
parent: context,
})
await tabInstance.update(fileInfo)
tabInstance.$mount(el)
},
update(fileInfo) {
tabInstance.update(fileInfo)
},
setIsActive(isActive) {
if (!tabInstance) return;
tabInstance.setIsActive(isActive)
},
destroy() {
tabInstance.$destroy()
tabInstance = null
},
enabled(fileInfo) {
return true
},
}));
})
DmsTab.vue
<template>
<p>
Hello world!
</p>
</template>
<script>
export default {
name: 'DmsTab',
components: {
},
data() {
return {
loading: false,
fileInfo: {},
}
},
computed: {
activeTab() {
return this.$parent.activeTab
},
},
beforeDestroy() {
try {
this.tab.$destroy()
} catch (error) {
console.error('Unable to unmount DMS tab', error)
}
},
watch: {
fileInfo(newFile, oldFile) {
if (newFile !== oldFile) {
this.resetState()
}
}
},
methods: {
update(fileInfo) {
this.fileInfo = fileInfo
},
resetState() {
this.loading = false
}
}
}
</script>
<style scoped>
</style>
webpack.js
const webpackConfig = require('@nextcloud/webpack-vue-config')
const path = require('path')
webpackConfig.entry = {
main: { import: path.join(__dirname, 'src', 'main.js'), filename: 'dms-main.js' },
}
module.exports = webpackConfig