Porting NEXTSPACE to FreeBSD: A Seven-Phase Journey
- 17 minutes read - 3436 wordsThis is Part 2 of a three-part series on my 2025 obsession with NeXTSTEP, OpenStep, and NEXTSPACE. In Part 1, I introduced the project and what drove me to port NEXTSPACE to FreeBSD. Here, I’ll walk through the technical journey itself.
The Slow-Boiled Frog
We do these things not because they are easy, but because we thought they would be easy.
Attributed to Maciej Ceglowski
Starting Off with Claude
As of this summer, I was trying various new laptops and putting them through their paces as far as being my new “Just Focus” on FreeBSD machine. While testing various hardware platforms for FreeBSD support, I started leaning on first ChatGPT and then Claude’s chatbot to get information about how to get diagnostics or correct bugs as I was getting oriented on the platform. Eventually, I consolidated to Claude because I found its answers to coding/systems adminsitration problems to be better.
In my small sessions the duration of a toddler nap or a few hours before bed, I was able to find my optional hardware platform – with much of that velocity added by Claude. In addition to the velocity boost, Claude behaved a bit like a gym buddy or a trainer: every time I reconnected, it remembered the project and the last thing that we did. Amid a high-interrupt life with toddler, this was a huge help.
Eventually, it would help me ship a complex C/Objective-C/Shell project.
Once I had my Thinkpad, I then set it up to use the Wayland + Sway window manager I described elswhere. Eventually though, I started to want a lightweight, but also helpful desktop. I wanted to show of the awesomeness of FreeBSD, and I wanted a few frills here and there: fonts that my eyes could read without glasses, maybe a volume slider, etc. The more I experimented, the more I realized I wanted the NeXT experience.
While I was looking for options that might recreate this on FreeBSD, I came across Sergeii’s NEXTSPACE…for Linux. I was struck by the quality and clarity of Sergeii’s codebase and figured that it couldn’t be that hard to port an awesome Linux application to FreeBSD ("because we thought they would be easy").
Phase I: Installer shell scripts
In this initial phase, I was using my knowledge of shell scripting and Claude’s web chat to migrate and debug NEXTSPACE’s installer shell scripts. The changes were pragmatic.
1-ECHO="/usr/bin/echo -e"
2+ECHO () {
3+ printf "%b\n" "$*"
4+}
echo on FreeBSD behaves like it does on Linux. 1+if [ "$(id -u)" = 0 ]; then
2+ PRIV_CMD=""
3+elif command -v doas >/dev/null 2>&1; then
4+ PRIV_CMD="doas"
5+elif command -v sudo >/dev/null 2>&1; then
6+ PRIV_CMD="sudo"
7+else
8+ ECHO "Error: Neither doas nor sudo found" >&2
9+ exit 1
10+fi
doas a first-class privilege-escalation command.1- INSTALL_CMD="sudo -E ${MAKE_CMD} install"
2+ if [ "$(id -u)" = 0 ]; then
3+ INSTALL_CMD="${MAKE_CMD} install"
4+ elif [ "${PRIV_CMD}" = "sudo" ]; then
5+ INSTALL_CMD="${PRIV_CMD} -E ${MAKE_CMD} install"
6+ else
7+ INSTALL_CMD="${PRIV_CMD} ${MAKE_CMD} install"
8+ fi
make and GNU makeAt its core, this was very much the style of development I’ve done for years. I edit something, I have years of experience and approximate code invocations in my head, and I use memory, my vimwiki, documentation, and the odd Google search to fix syntax. It’s not so different than what I was doing when Google debuted.
But with the help of AI, I was able to avoid ad-laden discussion web-sites (not saying I’m eager to lose an open web). And when I was trying to figure out a subtle bug, AI would fix my code as the example, not give me another paradigmatic model to painstaikingly eyeball against. It was very empowering.
Phase II: Embrace the hoosegow: jail-based development
I’ve written several installers for Unix systems. Yet one critical problem gets in the way like none other: given a pristine machine, your first (failed) attempt to run an installer will leave junk around and it’s hard to know if that was created as part of the bitched first attempt or is an as-yet unfixed bug. To get back to a place where you know things are clean, you have to scorch the system and get to a pristine startup.
What. A. Drag.
Fortunately, FreeBSD provides jails. These are basically isolated file trees on
your “host” FreeBSD that look like a complete Unix system. Think of them as
very lightweight virtual machines that boot instantly, cost nothing to destroy,
and can be duplicated with simple commands like cp (watch this). If my
installer script bricked the jail, I merely deleted the jail and started fresh.
No reimaging, no multi-hour OS reinstalls, no “well, it’s almost clean”
compromises.
Phase III: Snapshots
Remember how I said to keep an eye on cp a paragraph ago? Well, here’s where we
streamline jail-based development and swap the crude cp for snapshots.
The most common file-system used in FreeBSD is zfs (“zed-eff-ess”), which has
a superpower: you can snapshot a file system tree and name it.
Think of it as a save-game checkpoint in a video game. For example, a snapshot
called after-gnustep-install takes a fraction of a second to create and
stores the exact state of the jail (i.e. a section of the file tree) and tag it
with a meaningful name. If the next script run fails, crashes, detonates
halfway through, or halts due to an unmet dependency, one can roll back to the
previous checkpoint. You then hunt the bug and try again. I leaned on this
heavily: I had snapshots named things like after-script-3, before-font-fix,
pre-threading-experiment. The names tell the story of progress.
Phase IV: Peanut Butter Meets Jelly
The workflow I landed on was:
- Edit code on my host operating system. This meant my editor, its configuration, and its tools were locked and ready to go on the host OS not “in” the jails.
- The files I edited were then
mount-ed into the jail as a source directory - The mounted files would then be run and build in the jail OS. When the jail OS reached its next target, it would be rolled back to the last checkpoint one last time. If it ran correctly, a snapshot of the jail was made and the code was committed.
- If there was a failure, I reverted the execution environment, the jail, and then manipulated the source files on my local system. Easy.
- On success, create a new snapshot.
This meant I could experiment aggressively. There’s a psychological difference between “if I get this wrong I lose an afternoon” and “if I get this wrong I lose ten seconds.” The jail-plus-ZFS harness collapsed that cost to nearly zero, and it’s a workflow I’d recommend to anyone doing this kind of systems work—AI-assisted or not.
Phase V: Chase the Bugs
Once the development harness was in place, I could dive into the real work of making NEXTSPACE actually build and run on FreeBSD. And here’s where Claude really started to show incredible value.
Picking apart the architecture was hard. NEXTSPACE layers shell scripts over GNUstep — a mix of C libraries and Objective-C applications — with its crown jewel, Workspace, grafting WindowMaker in one thread onto GNUstep’s runtime in another, coordinated by Apple’s open-source CFRunLoop. To touch this code you needed fluency in C, Objective-C, and GNU make. That was true, until GenAI started letting people — newbies, dabblers, hobbyists — start punching above their weight class. Here are some examples:
1+#ifndef __FreeBSD__
2 #include <pty.h>
3+#else
4+#include <sys/types.h>
5+#include <sys/ioctl.h>
6+#include <libutil.h>
7+#endif
#ifdef directives to establish parity in C files1+ifeq ($(findstring freebsd,$(GNUSTEP_TARGET_OS)),freebsd)
2+ ADDITIONAL_CPPFLAGS += -I/usr/local/NextSpace/include -F/usr/local/NextSpace/Frameworks/
3+ ADDITIONAL_LDFLAGS += -L/usr/local/NextSpace/lib
4+ # libinotify shim
5+ ADDITIONAL_LDFLAGS += -linotify
6+endif
Now I’ll grant, prompting this required a seasoned mental model of how Unix works, so I knew I was saying in English what I needed in code, but isn’t that amazing?
As I iterated towards getting the applications up and running, I found issues that bothered me: small fonts were no good for my middle-age eyes; certain screen items weren’t where I thought they should be, etc. Claude helped me fix them! Sure, I couldn’t say “Make this great on FreeBSD,” but with some planning and some research, I could usually solve problems during a lunch break or while doing chores.
A broken image path could break startup. NEXTSPACE hardcoded its install
location as /usr/NextSpace, but FreeBSD convention expects third-party
software under /usr/local. Here’s where I added that flexibility. Claude
helped me find a definitions file that needed updating.
1+ \"/usr/local/NextSpace/Images/\", \
2+ \"/usr/local/NextSpace/Apps/Workspace.app/Resources/\", \
FreeBSD’s font landscape differs enough from Linux that NEXTSPACE’s hardcoded expectations fell flat. Fonts were too small for my middle-aged eyes, the picker ignored my changes, and GNUstep kept reaching for Helvetica as if it were 1991.
1+ // FreeBSD/NextSpace: NSSearchPathForDirectoriesInDomains doesn't return NextSpace paths.
2+ // Explicitly add NextSpace font directories to ensure fonts are found.
3+ // The loop below will append "/Fonts" to each path.
4+ NSMutableArray *fontPaths = [NSMutableArray arrayWithArray:paths];
5+ [fontPaths addObject:@"/usr/local/NextSpace/Library"];
6+ [fontPaths addObject:@"/usr/local/NextSpace/Frameworks/DesktopKit.framework/Resources"];
7+ paths = fontPaths;
1+ // FreeBSD/NextSpace: Create family-level aliases for default faces.
2+ // Fonts are registered by their PostScript names (e.g. "Helvetica-Medium", "Helvetica-Bold")
3+ // but lookups often use just the family name (e.g. "Helvetica").
4+ // Create aliases mapping family names to their default/regular face.
5+ // Prefer non-italic faces with weight closest to 5 (normal/medium weight).
6+ NSLog(@"[FTFontEnumerator] Creating family-level font aliases...");
7+ NSEnumerator *familyEnum = [fcfg_allFontFamilies keyEnumerator];
8+ NSString *familyName;
9+ while ((familyName = [familyEnum nextObject])) {
10+ NSArray *facesArray = [fcfg_allFontFamilies objectForKey:familyName];
11+ if ([facesArray count] == 0)
12+ continue;
13+
14+ // Find the default face (weight=5 is normal/medium, weight=7 is bold)
15+ // Prefer Medium/Regular (weight=5), non-italic, fall back to first face
16+ NSString *defaultFontName = nil;
17+ int bestWeight = 999;
18+ unsigned int bestTraits = 999;
19+
20+ NSLog(@"[FTFontEnumerator] Family '%@' has %lu faces:", familyName, (unsigned long)[facesArray count]);
21+ for (int i = 0; i < [facesArray count]; i++) {
22+ NSArray *faceInfo = [facesArray objectAtIndex:i];
23+ NSString *psName = [faceInfo objectAtIndex:0]; // PostScript name
24+ NSString *faceName = [faceInfo objectAtIndex:1]; // face name
25+ NSNumber *weightNum = [faceInfo objectAtIndex:2]; // weight
26+ NSNumber *traitsNum = [faceInfo objectAtIndex:3]; // traits
27+ int weight = [weightNum intValue];
28+ unsigned int traits = [traitsNum unsignedIntValue];
29+
30+ NSLog(@"[FTFontEnumerator] Face '%@': weight=%d, traits=%u", psName, weight, traits);
31+
32+ // Prefer weight=5 (Medium/Regular) with traits=0 (upright, not italic/oblique)
33+ // Prioritize: 1) non-italic (traits&1==0), 2) weight closest to 5
34+ BOOL isItalic = (traits & 1) != 0;
35+ BOOL currentIsItalic = (bestTraits & 1) != 0;
36+
37+ if (defaultFontName == nil) {
38+ // First face, take it
39+ defaultFontName = psName;
40+ bestWeight = weight;
41+ bestTraits = traits;
42+ } else if (!isItalic && currentIsItalic) {
43+ // Prefer non-italic over italic
44+ defaultFontName = psName;
45+ bestWeight = weight;
46+ bestTraits = traits;
47+ } else if (isItalic == currentIsItalic && abs(weight - 5) < abs(bestWeight - 5)) {
48+ // Same italic status, prefer better weight
49+ defaultFontName = psName;
50+ bestWeight = weight;
51+ bestTraits = traits;
52+ }
53+ }
54+
55+ if (defaultFontName) {
56+ FTFaceInfo *defaultFace = [fcfg_all_fonts objectForKey:defaultFontName];
57+ if (defaultFace) {
58+ // Register family name as alias to default face
59+ [fcfg_all_fonts setObject:defaultFace forKey:familyName];
60+ NSLog(@"[FTFontEnumerator] Alias: '%@' -> '%@'", familyName, defaultFontName);
61+ }
62+ }
But the worst bugs, the bugs that I would have little hope of attacking even at this very moment are those that crept in around the event loop—a foretaste of what Phase VII would bring.
1+#ifdef __FreeBSD__
2+ // FreeBSD: X11/WM offset detection times out (30+ seconds).
3+ // Pre-populate with hardcoded WindowMaker offsets and skip detection.
4+ if (generic.wm & XGWM_WINDOWMAKER) {
5+ NSLog(@"FreeBSD: Using hardcoded WindowMaker offsets (skipping slow detection)");
6+ for (i = 1; i < 16; i++) {
7+ generic.offsets[i].l = generic.offsets[i].r = generic.offsets[i].t = generic.offsets[i].b = 1.0;
8+ if (NSResizableWindowMask & i) {
9+ generic.offsets[i].b = 9.0;
10+ }
11+ if ((i & NSTitledWindowMask) || (i & NSClosableWindowMask) ||
12+ (i & NSMiniaturizableWindowMask)) {
13+ generic.offsets[i].t = 25.0;
14+ }
15+ generic.offsets[i].known = YES;
16+ }
17+ // Skip the detection loop and property storage
18+ goto skip_detection;
19+ }
20+#endif
1+ /*
2+ * Resolves race condition when testing for display twice in too narrow of a
3+ * time window.
4+ */
5+ usleep(100000);
6+
7 if (_isWindowManagerRunning() == YES) {
On a laptop, closing the lid is a fact of life. But NEXTSPACE’s event queues didn’t survive suspend-resume — the screen would wake but the desktop would be deaf to input. Fixing it meant reaching into several places:
1+ // Log what application we're trying to restore
2+ CFStringRef name_value = CFDictionaryGetValue((CFDictionaryRef)value, dName);
3+ const char *app_name = name_value ? CFStringGetCStringPtr(name_value, kCFStringEncodingUTF8) : "Unknown";
4+ WMLogInfo("Restoring dock icon %d: %s", i, app_name);
1+ WMLogInfo("Successfully restored icon %d: %s at position (%d,%d), dock_pos=(%d,%d)",
2+ i, app_name, aicon->xindex, aicon->yindex,
3+ dock->x_pos, dock->y_pos);
1+ } else {
2+ WMLogWarning("Failed to restore icon %d: %s (returned NULL)", i, app_name);
3+ if (dock->icon_count == 0 && type == WM_DOCK) {
4+ dock->icon_count++;
5+ }
1+ // Log icon name and current position
2+ char *icon_name = icon->wm_class ? icon->wm_class : (icon->wm_instance ? icon->wm_instance : "Unknown");
3+ WMLogInfo("wDockReattachIcon: icon=%s, old_idx=(%d,%d), new_idx=(%d,%d)",
4+ icon_name, icon->xindex, icon->yindex, x, y);
5+
6 // FIX: Ensure dock->y_pos is correct for WM_DOCK type
7+ int old_dock_y_pos = dock->y_pos;
8 if (dock->type == WM_DOCK) {
9 dock->y_pos = calculateDockYPos(dock);
10+ if (old_dock_y_pos != dock->y_pos) {
11+ WMLogInfo("wDockReattachIcon: dock->y_pos changed from %d to %d for icon %s",
12+ old_dock_y_pos, dock->y_pos, icon_name);
13+ }
In all these cases, web chat with Claude got me from hopeless through to working(ish). I say “ish” because I have a condition where the CPU spikes and redlines at 100%. We’re still working on it, but Claude instrumented core runtime code to help me get metrics on what’s going wrong:
1+ int v0_loop_count = 0;
2 while (wm_runloop == NULL) {
3+ v0_loop_count++;
4+ if (v0_loop_count % 1000 == 0) {
5+ WMLogError("[V0-SPIN] Looped %d times, wm_runloop still NULL", v0_loop_count);
6+ }
7 WMNextEvent(dpy, &event);
8 WMHandleEvent(&event);
9 }
10- WMLogError("WMRunLoop_V0: run loop V1 is ready.");
11+ WMLogError("WMRunLoop_V0: exiting after %d iterations", v0_loop_count);
git, when it’s time to take down the scaffold, it will be a breeze1+ pthread_setname_np(pthread_self(), "WM_V0");
2 WMRunLoop_V0();
Slowly, by chat, by edit, by commit, by rollback, by roll-forward, I kept falling forward to success.
Interlude: Claude Code
In May 2025, Anthropic released Claude Code: a text-based interface (yay) that could process output and introspect into files directly. It eliminated the non-ergonomic cycle of running code, seeing errors, copy-pasting into a browser, reading results, implementing them, and retrying. Instead of shuttling between terminal and browser, I had a single agent that could read the source files, understand the build system, and make edits—all in one place.
I quickly realized it could be a force multiplier. Running it in my source code root, I could say “Instrument this function so I can see what’s happening” or “Find the build target that’s failing and explain why” and get a targeted response with the changes ready to apply.
Over time I came to appreciate what lots of people say about Claude Code: it’s a Unix, or maybe an extension to Unix. It understands directories, it wants to stay out of your way, its configuration is plain text files in a recursive pattern of ever-deeper behavior modifying prompts. It’s text-driven through and through. Because of all of this it felt like a support instead of an overzealous plugin.
Phase VI: Integration is the Hard Part
While this human “stayed in the loop” the whole time, once all the pieces were installed, there were some real snags. Many of them had to do with library dependence order, installing fonts, and handling the eventing queue. By mid-August 2025, I had a working proof of concept.
First flickers of success
It took another 10 weeks or so, but eventually I got some of the hardest bugs worked out and got to this milestone.
All the applications running on a single thread
I thought I was pretty much home free at this point. But then I realized I’d made an error: I had all the work happening on a single thread. This meant that certain applications weren’t going to work. That is, if I dragged a file into a recycling bin on the main user interface thread, the background thread that actually does the removal wouldn’t run. I found that I’d disabled this in development to keep moving forward. Whoops. So turning it back on shouldn’t be a big deal…but, oh, I was wrong. So wrong.
Phase VII: The Demons of Multithreading
Enabling that background thread unleashed multithreading bugs in earnest. As a class of bugs, these are some of the hardest to battle: the reports of what failed, and when, may not arrive in the correct order — a fitting irony for a concurrency bug. Honestly, I was completely despairing at this point.
Under the old model of development and support, I was going to have to browse a thousand condescending forums and email lists. I was going to have to, I figured, go back and learn those three giant areas of knowledge (Objective-C, C, and GNU Make) so that I could have enough context and kudos to be able to ask for help. It was really dispiriting. I think my mental state comes through in this update:
WindowMaker [the underlying desktop package NEXTSPACE rests on] is from a single-threaded era and, in a number of points it tries to flush changes via X (Xlib, XCB, whatever). It doesn’t know that another process in another thread may have done something that it doesn’t know how to deal with.
I don’t know how this wasn’t a problem on Linux. And for whatever reason, in my prior hack-it-together approach, I somehow didn’t hit thread errors. Maybe I installed something wrong.
Yeah, that’s despair.
That update was a cry for help—but also the moment I leaned even more heavily on Claude Code. I was so close, and not going to have the time to level up in all three required disciplines before the new year.
But helped by Claude Code, having it instrument all the code paths and build
solutions, I started to find the seams. The critical commit was Workspace: Uncork thread displaying desktop/menus — a single targeted change that re-enabled the background thread
I’d been suppressing, paired with enough synchronization fixes to keep
WindowMaker from tearing itself apart. There was also a Spin fix that
addressed a busy-wait loop burning CPU while the desktop was idle. Neither of
these would have been obvious to me without Claude Code helping me read the
call graph and trace what was actually happening across threads.
Roughly a week after that cry-for-help update, I had a developer release ready.
NEXTSPACE running on FreeBSD
Before the month was out, Sergeii chimed in to recognize my effort and even used my codebase to try running his creation on FreeBSD as well. He’s been exceedingly gracious and we’re going to see if we can merge my work with his architecture to have Linux and FreeBSD as supported platforms!
For AI skeptics, you can’t refute this. I have a very weird custom desktop running in my lap right now. Yes, I spent a lot of time prompting, but I know that it’s less than it would have taken for me to learn this stack of erudite matters. As my desktop took flight, I had to grant, there’s real value-creation and/or toil avoidance.
In Part 3, I reflect on the broader implications of AI-assisted development and make the positive case for this new way of working.