Many desktop apps, like Slack, Notion, Microsoft Teams, and Linear, use web-based UIs. This is a common practice in modern software development that allows developers to create applications with familiar web technologies, simplifying the development process.
In this article, we’ll show you how to create a cross-platform Java desktop app with a modern web-based UI created on top of shadcn/ui, React, Tailwind CSS, and TypeScript.
Web vs native UI
Native Java UI toolkits like Swing, JavaFX, and SWT make UI maintenance and modifications difficult, let alone building modern, polished interfaces. They lack or have only limited support for key modern UI features like rich styling, dynamic graphics, animations, and transition effects. Their default controls look clunky and outdated, forcing developers to create custom ones.
That’s hardly a surprise because these toolkits were designed many years ago and simply don’t meet today’s UI requirements.
Web UIs, on the other hand, make development much easier thanks to a huge ecosystem of ready-to-use libraries and components, along with a large and active community. Modern web browsers support high-DPI displays, including touch screens. Web UI can be easily adapted for different screen sizes, and it looks the same on different operating systems.
Using web UI in a desktop application can be a game-changer as it brings all the benefits of web development to the desktop. Plus, it saves you the trouble of dealing with outdated UI toolkits and finding the rare developers who know how to use them.
Web UI in Java desktop app
In this article, we’ll build a Java desktop app with a web-based UI. We’ll use shadcn/ui — a React library with ready-to-use, responsive components, and TypeScript as a programming language.
The app shows a dialog for preferences and saves them to the file system, so they are preserved after restarting. Here’s what it looks like on macOS:
Screenshot of the desktop app with web UI.
The full source code is available on GitHub.
When building a desktop app with a web-based UI, we face a few hurdles:
- We need a reliable web view component because Java’s built-in components fall behind the modern web standards.
- We need a way to load web resources without a remote or local server to avoid unnecessary complexity.
- We need a way to allow JavaScript to talk to the Java code without a web server. The Java part is responsible for reading and saving preferences to the file system.
App window and web view
We’ll create a window using Swing’s JFrame
. It is built into Java and is easy to
use. We’ll add a Chromium-based web view component from JxBrowser to display web
content inside this window.
Here’s how we add a web view into a Java window:
var engine = Engine.newInstance(HARDWARE_ACCELERATED);
var browser = engine.newBrowser();
SwingUtilities.invokeLater(() -> {
var view = BrowserView.newInstance(browser);
var frame = new JFrame("Application");
frame.addWindowListener(new WindowAdapter() {
@Override
public void windowClosing(WindowEvent e) {
engine.close();
}
});
frame.setDefaultCloseOperation(WindowConstants.DISPOSE_ON_CLOSE);
frame.add(view, BorderLayout.CENTER);
frame.setSize(1280, 900);
frame.setLocationRelativeTo(null);
frame.setVisible(true);
});
Loading web UI in desktop app
The web part of the application is a regular React app. We load it inside the browser view component that we added when setting up the desktop window.
The way we load the web UI depends on whether it’s a development or production environment. In the development environment, we have a typical web development workflow — using a local dev server with hot reloads, and other familiar features. While in the production environment, we need a compressed, secure application that runs without relying on a local or remote server.
Development environment
Loading the web app in the development environment is pretty straightforward. We start a local dev server and navigate to localhost:
./gradlew startDevServer
if (!AppDetails.isProduction()) {
browser.navigation().loadUrl("http://localhost:[port]");
} else {
...
}
Production environment
We can’t use the same approach in the production environment, because there is no dev server there. Using a remote server isn’t an option either. We want the web app to be capable of working offline.
Plus, serving the UI from a local or remote server adds extra security risks; users might load the web app URL in their browser and inspect the source code of the app, exposing sensitive logic. We certainly don’t want this, we want our UI to be accessible only within our desktop app, and its source code to be hidden inside.
To achieve this, we add the web app files to the resources and serve them from
the classpath using JxBrowser’s UrlRequest
Interceptor API.
To allow the request interceptor to handle web resource requests, we assign it to a custom protocol:
var options = EngineOptions.newBuilder(HARDWARE_ACCELERATED)
.addScheme(Scheme.of("jxb"), new UrlRequestInterceptor());
var engine = Engine.newInstance(options.build());
Every time a browser loads jxb://
URL, the interceptor handles the request and
returns index.html
from the app’s resources. After that, the web page requests
assets like CSS or JavaScript files, the interceptor also handles those requests
and returns the related files.
This way, all web resource requests are handled inside the app, so no one can intercept them and access the resources.
At the same time, using a custom protocol ensures that regular HTTPS/XHR requests, such as authentication, API calls, can come through. To switch between the development and production URLs, we use this approach:
if (!AppDetails.isProduction()) {
browser.navigation().loadUrl("http://localhost:[port]");
} else {
browser.navigation.loadUrl("jxb://my-app.com");
}
Communication between web app and Java
Just as any web app communicates with a backend, our web app needs to talk to the Java code which is responsible for reading and saving preferences to the file system.
JxBrowser JavaScript-Java Bridge API
The obvious first choice is to call Java code directly from JavaScript. This is possible in most of the web view components. Some exchange JSON objects, while others — like JxBrowser — allow you to call methods and directly access Java objects.
Here’s how you can set up communication between JavaScript and Java in JxBrowser:
@JsAccessible
class PrefsService {
void setFontSize(int size) {
}
}
declare class PrefsService {
setFontSize(size: number): void;
}
declare const prefService: PrefsService;
...
prefsService.setFontSize(12);
This approach works for small projects. When you only have a few methods, it’s easy to keep track of them and keep everything in sync.
But as the project grows, this approach becomes harder to manage and more error-prone. Without build-time checks or auto-completion, it’s easy to make mistakes and hard to catch them. For larger projects, we need a proper communication protocol and code generation to keep things reliable — otherwise, we’ll end up buried in bugs.
In this section, we present a better solution.
Protobuf + gRPC
We decided to check out Protobuf. This technology allows us to
define an API in a simple format, and it automatically generates type-safe
client and server code. We define our messages and services in a .proto
file,
and Protobuf generates Java and TypeScript code for us.
service PrefsService {
rpc SetFontSize(FontSize) returns (google.protobuf.Empty);
}
enum FontSize {
SMALL = 0;
DEFAULT = 1;
LARGE = 2;
}
This creates a stable, type-safe contract enforced at build time, along with helpful development features that speed things up, like code autocompletion and type-checking.
As a transport layer that can send/receive Protobuf messages, we use gRPC. Naturally, the Java side runs a gRPC server while the web side acts as a client.
class PrefsService extends PrefsServiceImplBase {
@Override
public void setTheme(Theme request, StreamObserver<Empty> responseObserver) {
}
}
...
var serverBuilder = Server.builder()
.http(50051)
.service(GrpcService.builder()
// Register actual implementation of the server
.addService(new PrefsService())
.build());
try (var server = serverBuilder.build()) {
server.start();
server.blockUntilShutdown();
}
When the server is running, the next step is to connect the gRPC client.
import {createGrpcWebTransport} from "@connectrpc/connect-web";
import {createClient} from "@connectrpc/connect";
import {PreferencesService} from "@/gen/prefs_pb.ts";
const transport = createGrpcWebTransport({
baseUrl: `http://localhost:50051`,
});
const prefsClient = createClient(PrefsService, transport);
Then, the prefsClient
can be used to receive and update the preferences:
prefsClient.setFontSize(FontSize.SMALL);
Communication diagram.
Conclusion
Web UIs are a solid choice for modern desktop apps. This approach simplifies the development process, offers plenty of ready-to-use libraries, and comes with a large developer community, making it easier to build modern interfaces and find experienced developers.
In this article, we built a simple Java desktop app with a UI created using shadcn/ui, React, Tailwind CSS, and TypeScript.
Along the way, we solved key hurdles developers face when building a desktop app with a web-based UI, such as embedding a web view into a Java window for displaying the web UI, loading web resources without a local or remote server, and enabling communication between JavaScript and Java.
Here’s how the desktop Java app with web UI can look like:
Sending…
Sorry, the sending was interrupted
Please try again. If the issue persists, contact us at info@teamdev.com.
Your personal JxBrowser trial key and quick start guide will arrive in your Email Inbox in a few minutes.