Using GraalVM, JavaFX and a Clanker to Build a Cross Platform Desktop App

So, this particular rabbit hole started when I, to my dismay, was running out of diskspace on one of my disks, filling it up with local AI models. I wanted to use a pretty and nice tool to start reclaiming space, something using a sunburst visualization. After some quick research, I couldn’t find anything that I really liked. Nothing was pretty, except DaisyDisk on Mac. But that was only available on Mac. And there was nothing out there that allowed you to interact with it whilst data was being collected. Also the stuff that I found was committed to just one of many useful visualizations. And I wanted to dynamically be able to switch theme. And coloring. And <insert impossibly long list of wants>…

Since there was a bunch of other more technical things I’d wanted to try for a while anyways, I decided to go ahead and use this project as an excuse to try them:

  1. Is it possible to reasonably use GraalVM native image to make binary desktop apps with Java?
  2. Is it possible to use GraalVM with JavaFX?
  3. If I need to do native bindings, how does that work in GraalVM?

I had already used GraalVM native image for various other projects where it makes sense, such as in my email MCP server, but it had always mostly been for smaller Java projects where I needed quick startup and low memory utilization. To make this one work well, I was pretty sure I’d need to do platform specific magic to make it as fast as can be.

DiskSpace showing the default sunburst visualization.

My Friend the AI Model

This project was a proper collaboration between me and my AI models, in an area where they usually are very strong — a new greenfield project. Well defined outcomes. Very strong design cues and up-front technology choices. I worked on the design document first, together with the AI. I laid out incremental goals, like first trying to get the thing to compile with a simple scene, trying to get a simple native binding to work, etc. Before implementing anything, that design/architectural document was discussed, debated, edited and finalized. Next, I was providing guidance and help every step of the way, sometimes coding actively when I felt things were hitting a dead end and I could just do the right thing quicker myself.

Design language is pretty hard to communicate, and abstract ideas like how much space something needs to take to “look just right”, or the particular shade of grey that is required to make something “look good” are so subjective and very hard to get to a good place without a lot of back and forth.

At the same time, very technical work was sometimes (not always) executed flawlessly, saving tons of time. Did this project go quicker than if I had done it just on my own? Undoubtedly so. All the hunting down weird linkage problems, setting up the project to build properly on all platforms. All that tedious, no fun work, went massively faster. I spent most of the time doing back and forth with the AI model and getting it to do the right thing, and providing it with all the ideas and context to be able to solve most of its problems with as little additional context as possible. Which brings me to…

DiskSpace, now in DarkMode, showing the squarified tree map visualization.

Performance

You’d think that performance in the rendering would not ever become a problem in a project like this, but lo and behold, it did. Every once in a while when scanning my biggest and most diversely populated disks (C: on my Windows box), it would freeze. I first suspected GC. Since I was going to do the analysis with JFR anyways, I also introduced custom JFR events for context, to make it easier to attribute the JFR data to actual real world events.

In the end, I added four of them:

  1. Scan
    One event per scan run (carrying strategy, root path, file count, total bytes, using the @DataAmount annotation to ensure everything is rendered properly in JMC).
  2. Render
    A render event per UI repaint (mode, trigger, node count, canvas dimensions).
  3. VisualizationActive
    An event spanning the time a specific visualization was active, with a repaint counter.
  4. UserAction
    An event per keypress or click.

The events were all stitched together via a per-scan correlation ID, so that it’s easy to contrast different scans to each other and pivot between scans and all the renders, visualizations and user actions changing them. For the render event, I disabled stack trace capturing. It wouldn’t be that exciting given the other events I have, not to mention the additional cost of walking the stacks over and over again.

@Name("se.hirt.diskspace.Render")
@Label("Visualization Render") 
@Category({"DiskSpace", "UI"})
@Description("One repaint of the visualization on the JavaFX UI thread.")
@StackTrace(false) // 60+ Hz: per-event stack capture unnecessary work here
public static class RenderEvent extends Event {
  @Label("Mode")
  @Description("SUNBURST or HEATMAP.")
  String mode;
  @Label("Trigger")
  @Description("scan-update / mode-change / resize / ...") 
  String trigger; 
  @Label("Node Count")
  @Description("Live file count at render time.")
  int nodeCount;
  @Label("Width")
  int widthPx;
  @Label("Height")
  int heightPx;
  @Label("Scan ID")
  @Description("Correlation ID matching the Scan event.")
  long scanId;
}

JFR isn’t properly supported in the GraalVM JDK version (21) used by GluonFX, so I ran the project in plain Java when measuring. I then ran the recordings through my JMC AI view (coming in JMC 10, I’ll write more about it later), which was able to quickly root cause the algorithmic complexity in the squarified tree map renderer as the culprit, and suggest improvements.

Properly chosen contextual events is as usual King, and the self describing nature of JFR makes adding them, with the proper metadata, a breeze for the right AI tools to use. No parser updates, or any additional changes required to have them use it other than just adding it.

A Few More Fun Bits

This project has more under-the-hood stuff than fits one post, but a few bits are worth calling out:

The Windows MFT Scanner

Walking a directory tree the obvious way (FindFirstFile / FindNextFile) issues a
syscall per file just to read its size. On a C: drive with millions of files, that can add up to minutes. NTFS has a much better idea: the Master File Table is already a giant table of every file’s metadata, and Windows exposes the whole thing via FSCTL_ENUM_USN_DATA. Read it once, get every file on the volume in a few seconds.

The catch is that it needs SeBackupPrivilege, which means UAC elevation. That’s the reason DiskSpace on Windows has an auto-elevation preference (Ask once / Always / Never). Once elevated, DiskSpace will drop all privileges except for the backup one, and scans drop from minutes to seconds on the biggest drives I can find to throw at it.

The macOS Bulk Scanner

The macOS equivalent of the MFT trick is getattrlistbulk(2). Instead of one stat syscall per file, it returns metadata for roughly 500–800 entries per call. Combined with a per-directory ForkJoinPool and parallelism tuned to the storage profile, APFS scans finish very quickly. 

The storage-profile detection (the thing that decides “parallel-16 for NVMe, sequential for spinning disks”) walks the IORegistry via DiskArbitration + IOKit to find the underlying media type. Same plumbing diskutil info uses internally, just routed through GraalVM’s @CFunction bindings rather than the command line.

Interact While It’s Still Scanning

The thing that bugged me about every other disk-usage tool I tried: you click “scan”, wait until it finishes,
then you can explore. With DiskSpace the sunburst materialises as data streams in. You can drill into a folder before its size is even known, switch tabs, switch visualization modes (sunburst ↔ treemap), or kick off a second scan on a different drive — all without waiting for anything to complete.

The volume picker has the same property. If you have a dodgy SD card that mounts but doesn’t respond, the old picker would block startup for thirty seconds or more. The new one, thanks to one virtual thread per volume root, shows up instantly and fills in each row as it resolves, with the slow ones spinning quietly until they decide to answer.

So Animate, Much LERPs, Wow

The drill-in / drill-out animation on the sunburst (the one where you click a sector and the whole visualization smoothly reflows so that sector becomes the new centre) is built out of nothing but linear interpolation and a single ease-out curve. The layout system computes the before and after sector positions (depth, start angle, sweep angle) and the animation just lerps each value from one to the other over a few hundred milliseconds.

The fun bits aren’t in the lerp itself, they’re in what you give it at the endpoints. New sectors that
weren’t visible before the drill, deeper nodes that only show up at the new zoom level, start their animation
with sweep = 0 at the midpoint of their final position. So instead of popping into existence, they blossom outward from a single angular point until they reach full width. Departing sectors do the inverse: they collapse to their own midpoint and fade to transparent. The outgoing inner ring stays in place and fades out while the new one fades in over it.

Native Image + JavaFX + Native Bindings

GraalVM native-image + Gluon Substrate + JavaFX gives you a single-binary desktop app with native application startup times. The platform-specific APIs (the MFT scanner, getattrlistbulk,
DiskArbitration, IOKit, the Win32 storage queries) are all bound through GraalVM’s @CFunction annotations — a handful of lines of Java per native function, no JNI shim, no separate C side, no -Djava.library.path dance at runtime. The native-image build resolves the symbols at link time against the system libraries, and at runtime the call overhead is comparable to a regular C function call. Clean and fast.

Sub-pixel Arc Culling

A small rendering trick on the sunburst side: when an arc would project to less than one screen pixel, it’s literally invisible — so don’t issue the draw call at all. On deep trees with millions of leaves that drops the per-frame node walk to a fraction of what it would otherwise be. Same principle a frustum-culling game engine uses, just in polar coordinates.

TL;DR

  • The AI was immensely helpful getting to the bottom of various linkage problems and ensuring that the project would build on the various platforms.
  • AI ended up doing much of the typing for this greenfield project.
  • Even the latest models need a ton of supervision and course corrections to yield good results.
  • With today’s AI models, building well still takes the same skill as building it yourself.
  • Yes, GraalVM native image works well for building native desktop apps with Java. GluonFX bridges it well to JavaFX, and @CFunction makes native bindings painless.

I just released 0.3.1 of DiskSpace here:
https://github.com/thegreystone/diskspace/releases

This release marks the first community contribution! Many thanks to Scott Gerring (@scottgerring,
https://github.com/scottgerring) for adding APFS clone deduplication to the macOS bulk scanner, so that scan totals on Mac now match Disk Utility and df properly.

Please give it a spin, and if you like it, consider giving it a star on GitHub. This will help me getting free signing of the DiskSpace artifacts from the SignPath foundation in the future.

Claude and My Email

I declared bankruptcy on my personal inbox a long time ago. There were tens of thousands of emails sitting in there, and my various folders and rules were only partly helping. Since I’ve been having fun with AI for quite some time now, I thought I might try to do something about it. Since I have multiple different providers (Google, Apple, my own mail server, and so on) I decided to have IMAP and SMTP be the common denominator.

I am very hesitant to run OpenClaw on any of the computers on my network, and I’m mostly spending time in Claude Code and the Claude App anyways, so I decided to build an MCP server with tools needed to facilitate the workflow, and then create a skill that can be scheduled in the Claude App. I quite like having the results rendered in proper tables and structured summaries.

The MCP Server

The first version was slow and token-heavy. I quickly realized that mail headers are enormous these days, so I changed the triage flow to be a bit more lightweight while still keeping access to the full headers when needed. There is still a lot of useful information in them.

I also discovered that the Claude app won’t accept binary data from MCP servers other than images, which meant I couldn’t directly access information in PDF attachments. Fortunately, there are great Java libraries for extracting text from PDFs, so adding that support was straightforward.

To make email reading more efficient, HTML emails are converted to Markdown. That cuts the size dramatically, whilst still preserving important structural cues for the AI.

Since this is an MCP server, I also didn’t want to require people to need to have a full Java runtime installed. And since the server doesn’t do especially heavy processing, peak runtime performance wasn’t the main concern. I decided to build it as a GraalVM native image and optimize for memory footprint and startup time instead.

Here is the MCP server, with instructions on how to install it in both Claude Code and the Claude App:
https://github.com/thegreystone/mcp-email

In early experiments, after interacting with it a bit, I asked it whether there were any email conversations with my friends where I dropped the ball. It identified two. Correctly. Ugh. I also asked it things like:

Please find any emails from my tax lawyers around the taxation year 2025, identify all the necessary documentation asked for, and provide an action plan for getting that documentation.

It did very well. Being able to read PDF attachments turned out to be especially useful.

I also often ask it to prepare drafts for me, especially when I need to communicate in German, for example with authorities or businesses. The draft ends up in the Drafts folder, properly set up with the right thread, recipients, and so on, leaving me only to decide whether to send it as-is or make a few edits first.

Initially, this was my favourite prompt:

Please help triage the emails in my inboxes. As per usual, flag anything that is actionable. Don’t hesitate to read emails that you are uncertain about. For anything actionable, provide an action plan. For anything non-actionable, file according to the folder layout.

Adding a Skill

Once the MCP server is installed, adding a skill to Claude is as easy as asking it to create one for itself. Claude has a skill-creator tool that it can use for this.

I asked it to do something along these lines (it was actually edited and improved a few times iteratively, so this is a rough approximation):

Please create a skill using the skill-creator that triages the email across all my accounts in parallel. Flag anything that is actionable. Don’t hesitate to read emails that you are uncertain about. For anything actionable, provide an action plan. For anything non-actionable, file according to the folder layout. At the end of the triage, provide a summary of everything that was done by folder.

The skill should:

  1. Load a persistent ACTIONABLE.md file at the start of each session (path configurable), carry forward unresolved items, and update it at the end.
  2. Run triageCompact on all accounts in parallel (unread first, then a pass over read-but-unfiled emails).
  3. Read full emails when context is needed (e.g. financial, GitHub notifications, ambiguous senders, shipping, calendar invites).
  4. Identify and move spam (will be used to train SpamAssassin later), leave pre-classified Junk alone.
  5. Flag actionable emails before filing.
  6. File everything according to the existing folder hierarchy (if an additional folder would be useful, suggest it).
  7. Cross-reference flagged emails against open ACTIONABLE.md items to detect resolutions.
  8. Produce a structured summary with action plan.

Key resolution logic: Only mark items resolved with positive evidence (delivery confirmed, payment received, PR closed, reply sent). When in doubt, keep open and ask.

Initially, the skill had no memory between sessions. Each scheduled run would rediscover the same actionable items, and it couldn’t tell whether something had been resolved since last time. This became obvious when I asked about an ongoing conversation with my tax lawyers; Claude couldn’t find it because the earlier session was no longer accessible. The fix was simple: a persistent ACTIONABLE.md file that the skill reads at the start of each triage and updates at the end. Open items carry forward, resolved ones get cleared (but only with positive evidence — a delivery confirmed, a payment received, a reply sent), and new items get added. Getting the file location right took a couple of iterations. Claude initially computed it via a convoluted relative path before I pointed out that with the workspace folder configured, ./ACTIONABLE.md was all it needed. It’s just a Markdown file on disk, but it gives the scheduled runs the continuity they were missing.

A quick note on SpamAssassin: the reason I move detected spam to a separate folder rather than deleting it is that I use it to train SpamAssassin. Over time, the obvious spam gets caught before it ever reaches Claude, saving tokens and triage effort.

With the skill installed in both Claude Code and the Claude App, there is no longer any need to write long prompts every time. You just run the skill.

Claude Cowork

With a skill, it becomes very easy to run the triage on a schedule. Simply go to the Cowork tab in Claude App, click on Scheduled on the left, and hit New task on the right.

image

Add the prompt you want to run, for example:

Triage my emails using the email skill.

Fill out the details, like the frequency (e.g. daily), the time (e.g. 18:30), the model to use for the work, and a folder where you want Claude to have read and write access.

Considerations

Let’s be clear about what this setup does: it autonomously moves, files, and flags your email. If it gets something wrong, an important email could end up in the wrong folder or, worse, in Spam. You should go into this with your eyes open.

I would not have tried this without a solid backup strategy for my emails. I do regular IMAP backups, so if something goes sideways, I can recover. That said, I’ve been running this daily for over two months now and haven’t lost anything. The skill is also deliberately conservative — it prefers moving emails to deleting them, and requires explicit approval for deletions.

You may also want to think about where you run the MCP server. I run it on a Mac mini that I mostly use for testing and that doesn’t contain anything sensitive. Running it in a virtual machine, or at least under a user account with tightly scoped permissions, is worth considering. The MCP server holds the IMAP and SMTP credentials and connects directly to your mail servers; Claude never sees those credentials. It only interacts with your email through the tools the MCP server exposes. This isn’t fundamentally different from how any email client works — Mail.app, Thunderbird, and Outlook all hold your credentials and maintain persistent connections to your mail servers. The difference is that this one takes actions autonomously, so please treat it accordingly.

There’s also the question of trust. The skill does a genuinely impressive job. It correctly identified dropped conversations with friends, it handles German-language correspondence with authorities, and it went from 30,000+ emails to fewer than ten in my inbox at any given time. But “impressive” isn’t “infallible.” I review the triage summaries after each run, and I’d recommend you do the same, at least until you’ve built up confidence in how it handles your particular email patterns. I may have to eat these words one day, but today I trust it to do a better job than me at keeping track of my ongoing email conversations.

TL;DR

I declared email bankruptcy at 30,000+ unread emails and built an MCP server exposing tools over IMAP/SMTP so Claude can triage, file, and draft replies across all my accounts — no third-party service required. A scheduled skill in Claude Cowork now runs the whole workflow daily, keeping my inbox down to a handful of actively tracked items.