Sidebar tab in Files with ExApp

Hi everyone! :slightly_smiling_face:

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. :slightly_smiling_face:

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

Hello,

generally, it looks not too bad what you did so far. The fact that you are using an ExApp should be only minimal. You have to first get the frontend code running in general, before you can actually work on the backend.

What is missing as far as I can tell is the execution of the hoof to actually laod your JS frontend extension. In the provided example code, there was this section

public function register(IRegistrationContext $context): void {
   $context->registerEventListener(LoadSidebar::class,LoadSidebarScripts::class);
}

I honestly have no clue, how this is using ExApps (well this is one of the parts where the backend makes a difference). But maybe you can try to dig there further.

Chris

Hi Chris,

thanks for your answer.

I also assume, that there must be something wrong with registering the script. In my Program.cs, I have this part for registering (or trying to) the script, but I’m not sure if that is enough to make the frontend script loadable as sidebar tab:

    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 ...

Is there any trace of the registration of the script in the HTML source code of NC? Is there any issue on the web dev console returning 404? Is there any log entry/error log that might help us to track it down?

Chris

Unfortunately, no errors show up and in the browser dev tools the script does not seem to be loaded.

I checked again what the response status code for the request to register the frontend script was. I thought it was a simple 200 OK, but it seems to be an OK with status code 100:

<?xml version="1.0"?>
<ocs>
 <meta>
  <status>ok</status>
  <statuscode>100</statuscode>
  <message>OK</message>
  <totalitems></totalitems>
  <itemsperpage></itemsperpage>
 </meta>
 <data/>
</ocs>

Is there anything in the NC logs (best go to debug mode)?

This is the relevant implementation of the API in nc_py_api: nc_py_api/nc_py_api/ex_app/ui/resources.py at main Ā· cloud-py-api/nc_py_api Ā· GitHub

I hope this helps :slight_smile:

Thanks for the hint that you can change the displayed logging level. Didn’t find this option until now. Here I re-registered the ExApp and the code to register the frontend script gets called again. To me the log messages don’t look like they give a hint about what is wrong.

This is probably where I found the endpoint to register the script. But I will compare and check my code again. Thank you. :slightly_smiling_face:

This sounds strange as there is thsi *PREFIX* stuff in the SQL. I know, it is used by the SQL driver that way to attach the prefix (typically oc_) to the table names. I would have expected the log to either show the full table name (oc_ex_apps) or only the abbreviated one (ex_apps). Anyone else finds this suspicious?

Hello,
It looks like the PHP listener is not in place for the sidebar to be attached in the UI. This listener should be added in app_api. See an example here: app_api/lib/Listener/LoadMenuEntriesListener.php at main Ā· nextcloud/app_api Ā· GitHub
From the LoadSidebarScripts example it seems to only require a script and styles file but it’s still not possible in the current state since the API request in app_api expects a type which only supports ā€œtop_menuā€: app_api/lib/Controller/OCSUiController.php at 66acf4ddb9771c8f3d176f8c8a6ec7ebcb9f1368 Ā· nextcloud/app_api Ā· GitHub

And this script is only called from ā€œlib/Controller/TopMenuController.phpā€: app_api/lib/Service/UI/ScriptsService.php at 66acf4ddb9771c8f3d176f8c8a6ec7ebcb9f1368 Ā· nextcloud/app_api Ā· GitHub

The way forward could be a feature request or a PR to support sidebar tabs in app_api.

PS: just for your reference here’s an example UI ex-app: GitHub - nextcloud/ui_example: UI Example

2 Likes

Hi, thank you so much for your answer. This clears it up. :slightly_smiling_face:

In that case, I will maybe try to make a feature request and for the time being I will use some alternative implementation instead of a Files sidebar tab.