Java sidecar for a Tauri + Angular app

Jose I Santa Cruz G
ITNEXT
Published in
13 min readMay 23, 2023

--

Simpler than expected with a few drawbacks

This kind of sidecar seems suitable — Photo by Austrian National Library on Unsplash

I've been helping a good friend of mine to build the software UI for a Midi hardware unit he's working on. After a few cycles, I suggested to make the app using Tauri + Angular (+ Tailwind) and the backend that sends all messages to the HW in Java, as the main communication module already is developed in Java.

The challenge:

— Making the Tauri app interact with this Java "backend"

Tauri

Some context before:
When thinking in a desktop app, we most likely think in some kind of executable file with an installer, and this definition applies for MS Windows, Apple MacOS, or any Linux flavor.

What if we could make a desktop app using some web application framework? And here comes Tauri, and before that: Electron, and before that: NW.js (and there are many other tools that share the same idea).
AFAIK Electron is the one that has gained the most momentum.

Some people call Tauri "the Electron killer". And this is simply because most Electron apps are huge, not meaning the app itself is huge, but the final distributable bundle is huge. And this is because it drags Chromium and NodeJS.

On the other side, Tauri only drags what it requires, this is the already built web application, and some Rust dependencies. Instead of dragging Chromium as the "browser component", it uses the Webkit component that should be already available on the user's operating system, worst case an installation will be required, but that's not a big deal, just remember when as a Windows user you had to install .Net libraries, or DirectX.

IMHO, installing a library that can be used by another app, is better than having a huge installer that steals half of your hard drive available space.

Tauri instructions are really clear and easy to follow. Believe me it worth giving it a try: https://tauri.app/

Sidecars

There're two ways of making a Tauri app "make things":

  1. Using Rust directly. Yes, you can do it. It will require some, or more than some, knowledge on Rust programming. It isn't the case, neither my friend nor I have enough Rust programming experience to solve our requirement.
    I should also add that the communication module towards the Midi HW unit is already programmed using Java, and there's no intention of rewriting it.
  2. Use a "sidecar" app. A sidecar is an extra application that's bundled with your app, and takes care of the heavy lifting. In the Tauri app you just make a call for the sidecar command and wait for the answer.

There's a 3rd way, but it's so similar to calling the sidecar command that, even though is the one that we finally ended using, I'll just end explain it with the example code.

The Java backend sidecar approach

Duke is cloud surfing

As I've already mentioned, the communication module is already programmed using Java. So, let's think, how can a webapp (Angular app) "talk" to "another app":

  1. Using Tauri, or as a desktop app: Calling a command or the sidecar command in order it "make things".
  2. As a webapp: Consuming some kind of API using HTTP. Let's say (for simplicity) a REST API.

In our case, establishing the communication with the HW unit is an expensive task, so it's seems that having some kind of server that reuses the established connection may be a good choice. And almost as a natural result of this reasoning we decided to build a simple REST API. And this was done using https://github.com/curtcox/JLHTTP/blob/main/src/main/java/net/freeutils/httpserver/HTTPServer.java as a starting code base.

So all endpoints in our API call a method that "does something" on the HW unit, such as reading settings, updating settings, retrieving settings, a CRUD without the D.

Calling the API

No rocket science in this part:

// from the environment file
export const environment = {
production: false,
apiUrl: 'http://localhost',
apiPort: 9080,
serverUrl() {
return `${this.apiUrl}:${this.apiPort}`;
}
};
// from the ConnectionService
http : HttpClient = inject(HttpClient);
API_URL : string = environment.serverUrl();

checkConnection(): Observable<availablePortsResponse> {
return this.http.get<availablePortsResponse>(this.API_URL);
}

and of course, wherever you need to use this function:

// from an internal component
backendService = inject(BackendService);
ports$: Observable<availablePortsResponse> = this.backendService.checkConnection();

just like developing a full-stack app. The server (backend) is running in a certain port, and we call the API from a service, easy-peasy.

The rocket science part: Angular TauriFetchInterceptor

Nah, not really.
What happens with Tauri is that HTTP requests have to be done through Rust, by Tauri internals means. So a plain request, as the one on the checkConnection function won't work out of the box.

For this, we require an interceptor where, if the app is running on Tauri, solves the request using Tauri's API, otherwise it uses Angular's HttpClient:

@Injectable()
export class TauriFetchInterceptor implements HttpInterceptor {
IS_TAURI = inject(IS_TAURI);
REQUEST_TIMEOUT = 30;

constructor() {}

intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
const method = request.method;
const url = request.url;
const body = request.body;
const params = request.params;
const headers = request.headers;
// console.log('TauriFetchInterceptor', method, url, body, params, headers, this.IS_TAURI);

return (this.IS_TAURI) ?
defer(() => from( fetch(url, {
method: `${method}` as HttpVerb,
timeout: this.REQUEST_TIMEOUT
}).then(
response => {
// console.log('TauriFetchInterceptor response', response);
return new HttpResponse({
body: response.data,
status: response.status,
headers: new HttpHeaders(response.headers),
});
}
) )) :
next.handle(request);
}
}

and the IS_TAURI InjectionToken:


export const IS_TAURI = new InjectionToken<boolean>('IS_TAURI', {
providedIn: 'root',
factory: (): boolean => {
let isTauri = false;
try {
isTauri = !!window.__TAURI_METADATA__;
} catch(e) {
console.error('ERROR isTauri', e);
}

return isTauri;
}
});

and yes, the window object should be used ala Angular through another InjectionToken .

What about Tauri?

For working the same as our full-stack app, Tauri has an additional requirement: The Java server has to start when the app starts.

Had to run npm run tauri dev many times, debug, rinse and repeat, watch the log messages, check the console. The server has to start somehow.

Here's where the sidecar comes in place. The sidecar is a "standalone executable" by itself. An extra application that is meant to be included in the distributable bundle, and that can run by itself.

Folder tree structure for the java sidecar app

Java apps are usually compiled in one or many jar files, dependencies are usually included in a lib folder, and jar files are not executable by themselves. They require an installed JVM in order to work.

So our first attempt was to make a shell script. It can be executed by itself and we can make it call another command, something like:

#!/bin/sh

# exec_server shell script
java -jar $1/ApiServer.jar

where $1 is the folder there the ApiServer.jar jar file is found.

Every sidecar binary requires an specific version for every OS your app is aiming for. So besides the original binaries/exec_server script you will require extra sidecars. For eg:

  • binaries/exec_server is the original script, this value has to be placed on the tauri.conf.json file.
  • binaries/exec_server-aarch6-apple-darwin is the specific binary for a Mac M1, this value is not required in the tauri.conf.json file.

You can get the extra suffix for your sidecar by executing: rust -vV , and taking the information labeled as host:

"host" value is required for your sidecar's file names

On the tauri.conf.json file we have to include this script as a sidecar, AND we also have to include the required java libraries as a "resource". Tauri calls resources every other extra things that may be required by the app.

// From the tauri.conf.json file
...
"tauri": {
"bundle": {
"active": true,
"category": "DeveloperTool",
"copyright": "",
"deb": {
"depends": []
},
"externalBin": [
"binaries/exec_server"
],
...
"resources": [
"java/**/*.jar"
],
...
}
...
}

So, all files in the java folder (and recursively inside every other folder inside the java folder) will be added as a resource. The javafolder is located at the first level inside the src-tauri folder.

And on the Angular side we have to call the sidecar script:


import { Command } from '@tauri-apps/api/shell';
import { resolveResource } from '@tauri-apps/api/path';
// ...

const resourcePath: string = await resolveResource('java/');
const command: Command = Command.sidecar('binaries/exec_server', [ resourcePath ]);
command.execute().then(cmdOutput => {
// Do something with the commandOutput after the Promise executions is resolved
// ...
});

Note that the Command.sidecar receives a second parameter, an array with the resourcePath. This is the $1 parameter in the script (so the Java command runs the right jar in the right path) and it has to match a resource path inside Tauri's resources, eg "java/**/*.jar" in tauri.conf.json file.

Once the sidecar's command promise resolves it has to do something, for example mark Java as installed.

Houston we have a problem

When the Tauri app execution was ended (by closing the app's window) and reopened I noticed this error:

Error starting server
Port already open (?)

The server wasn't closed when the app was closed. So we required "something" that closed/stopped/killed the server when the Tauri app execution was ended.

Morty, the kill script looks fine…

Another sidecar script appeared:

#!/bin/sh

# kill_server shell script
ps aux | grep ApiServer.jar | grep -v grep | awk '{ print $2 }' | xargs -I{} kill -9 {}

The resulting script is a little toxic for most users, but I will explain it line by line:

  1. ps aux: get a list for all running processes.
  2. grep ApiServer.jar: find ApiServer.jar in the previous command output, some process running this specific jar file.
  3. grep -v grep: do not show the 1rst grep command in the process list.
  4. awk '{ print $2 }': use awkto print the process id (number that identifies the matching process)
  5. xargs -I{} kill -9 {}: takes the previous command output as an input, and replace {} as the arguments for the kill -9 command. It will kill all processes that match ApiServer.jar c
    A word of warning (for copy & pasters): kill -9 <process id> can be harmful if not used with care. In naive hands, kill IS a dangerous command.

And in the tauri.conf.json file :

      "externalBin": [
"binaries/exec_server",
"binaries/kill_server",
],

The same way as for the exec_server script, we need the specific OS version for each externalBin (sidecars).

The best of all is that running this script works, and we have to make sure it is executed when the Tauri app is closed.
Tauri already provides some event listeners for the app window, so we can use them directly from our frontend app (in this project the Angular side):


import { Command } from '@tauri-apps/api/shell';
import { appWindow } from "@tauri-apps/api/window";
import { TauriEvent } from '@tauri-apps/api/event';

// ...

appWindow.listen(TauriEvent.WINDOW_CLOSE_REQUESTED, () => {
console.log('Close requested');
const closeCommand: Command = Command.sidecar('binaries/kill_server');
closeCommand.execute().then(_ => {
console.log('Server killed');
appWindow.close();
},
_err => console.error('This should not happen , but...', _err));
});

By the way, I prefer calling Promises without async-await , but that's me and my likes of catching errors properly. You can wrap your code inside try-catch blocks and regret later 😬 (just placing some good old bait for those who like arguing in the internet 🙂).
Joking aside, truth is that async-await is a "too happy path programming" style for me (and I've seen it on some commercial products, as if database connections always worked as expected…). I still use it anyway (an example is below in this same article 😅).

So far so good, Tauri app starts, the Java backend starts. Tauri app is closed, the Java server is stopped.
See any problem here? a 2 word hint: MS Windows.

The script strategy will work for Mac and Linux, but MS Windows will fail, unless some kind of execute_binary wrapper is built.
There has to be another way… like a 3rd way of running an external program.

Executing non-sidecar commands with Tauri

The question is: do we really need sidecars? and as always, it depends. In this particular case, as the backend is not a standalone executable but a jar file meant to be executed using Java, what we really need is Java… and if it isn't installed we have to install it.

Checking if Java is installed

In the context of this particular app, we can "think" with a certain probability of success that Java isn't installed if the first API call (which is not supposed to fail) fails.

Are there any better ways? Indeed, but we're simplifying things a bit. If Java is most probably not installed, lets show a message for the user and open the browser so the Java JRE installer can be installed.

Lets change the code a bit:

The tauri.conf.json file (snippet):

// ...
"tauri": {
"bundle": {
"active": true,
"category": "DeveloperTool",
"copyright": "",
"deb": {
"depends": []
},
"externalBin": [],
// ...
"resources": [
"java/**/*.jar"
],
// ...
},
"allowlist": {
"path": {
"all": true
},
"process": {
"relaunch": true
},
"http": {
"all": true,
"request": true,
"scope": [ "http://localhost:9080/*", "http://localhost:4200/*" ]
},
"window": {
"close": true,
"show": true,
"create": true
},
"shell": {
"open": true,
"execute": true,
"sidecar": true,
"scope": [
{
"name": "ApiServer",
"cmd": "java",
"args": [ "-jar", "ApiServer.jar" ]
}
]
}
}

No external binaries (empty array), but a shell scope defined (called ApiServer ).

The BackendService file (on the Angular side):


import { open } from '@tauri-apps/api/shell';
// other imports and else

// async-await 😬
export async function openJavaUrl(isTauri: boolean = true) {
const url = 'https://www.java.com';
if(isTauri) {
// opens a browser from Tauri
await open(url);
} else {
// yes! window should be used ala Angular using an Injection Token
window.open(url, '_blank');
}
}

@Injectable({
providedIn: 'root'
})
export class BackendService {
IS_TAURI = inject(IS_TAURI);
// skipping some injects and variable declaration

checkConnection(): Observable<availablePortsResponse> {
return this.http.get<availablePortsResponse>(this.API_URL).pipe(
catchError(err => {
console.error('checkConnection', err);
this.globalSignalsService.setSignal<boolean>('isJavaInstalled', false);
openJavaUrl(this.IS_TAURI);
this.router.navigate(['/noJava']);

return throwError(() => new Error('Java not installed'));;
})
);
}

//...

}

The important part of our Angular component for the /noJava Route:

import { relaunch } from '@tauri-apps/api/process';
// other imports here

@Component({
selector: 'no-java',
standalone: true,
imports: [
CommonModule,
DialogComponent
],
templateUrl: './no-java.component.html',
styleUrls: ['./no-java.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class NoJavaComponent {

IS_TAURI = inject(IS_TAURI);

// async-await 😬
async restartApp() {
if(this.IS_TAURI) {
await relaunch();
} else {
console.log("Not Tauri");
window.location.replace('/');
}
}

}

The restart button; from the "/noJava dialog page" image; actually restarts the app (template code is too boring to be included, just a dialog with a button).
And finally the block of code that "checks" if Java is installed:

      const resourcePath: string = await resolveResource('java/');
const command: Command = new Command('ApiServer', [], { cwd: resourcePath });
command.execute().then(cmdOutput => {
console.log('output', cmdOutput, resourcePath);

backendService.checkConnection().subscribe(data => {
console.log('checkConnectiondata', data);
globalSignalsService.setSignal<boolean>('isJavaInstalled', true);
router.navigate(['/connect']);
});
});

Oh yes! I'm playing with Angular 16 and signals here (and recycling the GlobalSignalService from my article here) .

The command is referring to ApiServer shell scope, defined on tauri.conf.json . As you can see the Command constructor receives 3 parameters, the 2nd is an empty array telling it to use no extra parameters (besides the ones already defined on tauri.conf.json), and the 3rd parameter tells the command to execute from the resourcePath folder (cwd attribute)

/noJava dialog page

The checkConnection function will throw an error if Java isn't installed (or if the API call fails). This is done using the catchError RXJS operator.
If Java isn't detected the app will navigate to the /noJava route, show the dialog message and open the browser in the https://java.com site using the openJavaUrl() function.

As an advice, remember not every user can install Java (or anything else) using the command line, or follow a "not click and play" set of instructions. In most projects we will not be programming for ourselves, but programming for end users, so every UI should hopefully be easy to use and understand. Better so if it doesn't require a manual or an instructional video.

So if Java IS installed the java -jar ApiServer.jar command will be executed without problems, and the API will give a status 200 response. Otherwise the /noJava page will display the "No Java" dialog and open the URL so the user can download the JVM.

Again, there may be better ways, and this one (right or wrong) works for us.

And all that for... ehm… what?

Oh! yes, almost lost the purpose. Remember our friend MS Windows? Well, using Java from an already finished installation (ie download the installer, double click it, install, yes, yes, yes, OK, finish), is kind of easier than building an executable wrapper application (instead of using our ̶n̶o̶t̶ ̶s̶o̶ ̶p̶r̶e̶t̶t̶y̶ pretty shell scripts).

It's even better than bundling the whole Java installer inside the app (even more if we consider that we shouldn't distribute Java by ourselves). Providing a link so users can donwload Java should be more than enough.

What about "killing" the server process?

Yeah! What about that?

We just learned that the kill -9 command is not going to work on MS Windows. So we have to find a better way of stoping our REST API backend server.
"Wait… you said REST API server?" And yes, the answer is right there, on the REST API server.

What if we make an extra endpoint that "shutdowns" the server?
And that's what we did:

class StopServer implements ContextHandler {
HTTPServer server;

public StopServer(HTTPServer server) {
this.server = server;
}

@Override
public int serve(Request req, Response resp) throws IOException {
System.out.print("Server down! Bye!");
this.server.stop();
System.exit(0);
return 0;
}
}

This class is mapped to a special endpoint called /shutdownApiServer that when called stops the server and exits the Java process with status 0 (status 1 is error).

And on the Angular side (code from the kill_server script modified to call this new API endpoint):

import { Command } from '@tauri-apps/api/shell';
import { appWindow } from "@tauri-apps/api/window";
import { TauriEvent } from '@tauri-apps/api/event';

// ...

appWindow.listen(TauriEvent.WINDOW_CLOSE_REQUESTED, async () => {
console.log('Close requested');
backendService.stopServer().pipe(
finalize(() => {
appWindow.close();
})
).subscribe(_ => {
console.log('Server stopped!');
});
});

and on the BackendService :

  stopServer() {
return this.http.get(`${this.API_URL}/shutdownApiServer`);
}

It was a quite long history to get to this paragraph.

Trying to summarize:

  1. Tauri sidecars are standalone executable files.
  2. A Java "sidecar" for a Tauri app doesn't have to be a sidecar. It can be a Java command execution, declared in the shell scope.
  3. Both sidecars and shell scopes have to be configured in tauri.conf.json file.
  4. A Java "sidecar", sidecar or not, requires something to detect if Java is installed on the client.
  5. A Javasidecar”, sidecar or not, requires something that triggers something to stop the sidecar's process. I guess this is not exclusively for a Java sidecar but for any other kind of Tauri sidecars.

So that's it. Hope you enjoyed this article.
If you want please share any thoughts, observations or even your own experience dealing with Tauri and sidecars.

Stay safe 👍

--

--

Polyglot senior software engineer, amateur guitar & bass player, geek, husband and dog father. Not precisely in that order.