Executor.app — Porting Plan

Code review findings · 27 July 2026

A GNUstep front end for Executor

Give ARDI's Macintosh Toolbox reimplementation a native AppKit front end, so classic Mac applications run as first-class windows on Gershwin. This is a new component of roughly 1,000–2,000 lines, not a port of the 8,453-line 1993 NeXTSTEP front end. That old code is worth reading for its hard-won Mac semantics and worth almost nothing as source.

4pure virtuals to implement
~700lines for the driver
+300–600lines of UI, built in code
8 dto milestone one

Read this before scoping anything

One question in this plan is genuinely unanswered, and it is not a detail: no emulator of any kind has ever used GNUstep AppKit for framebuffer display. Previous, Basilisk II and SheepShaver all use SDL, Cocoa, Qt or GTK. The blit path is unproven, GNUstep's backend has no shared-memory X extension anywhere in it, and the only published drawing benchmark dates from 2015. Spike that before writing anything else — see step 0.

Fork: github.com/pkgdemon/executor (Cliff Matthews' 2008 release, MIT, containing the NeXTSTEP front end). Build target is autc04's modern tree — C++17, CMake, and seventeen years of divergence. The two are read together: one for design, one for the contract.

1 — What the review found

Five agents read both trees. Four findings changed the shape of the work; two of them reversed assumptions this project was carrying.

Modern front ends are tiny

The host-integration contract is far smaller than the 1993 code suggests, because that code carried an entire application around with it — registration nags, serial numbers, a splash screen, ARDI's phone number in five separate string constants.

Front endLinesNote
headless31Proves the true minimum surface.
sdl2472
qt599The template. The only high-level-toolkit front end, and the closest structural analogue to AppKit.
wayland690
x1,495Source of the reusable keycode table.
old nextstep8,453Design reference only.

Reversal: per-Mac-window NSWindows are not possible

The appealing idea — make each Mac window a real AppKit window and let Gershwin's window manager handle them natively — does not survive contact with the code. The guest renders every Mac window into one flat framebuffer, and Executor's Window Manager knows nothing about host windows. A grep for rootless across the entire 2008 tree returns zero hits, and MacViewClass.m:39-45 apologises in its own comments for being a hard singleton.

What is available is better than nothing and worse than the fantasy — see §5.

Reversal, the other way: rootless already exists upstream

The modern tree has first-class rootless support that the 1993 code never had: Framebuffer::rootless (vdriver.h:55), setRootlessRegion(RgnHandle) (:131), isRootless() (:149), plus src/wind/windRootless.cpp. Qt turns it on at qt.cpp:213 and shapes its window from the region at :244-249. Seamless windowing is not research. It is a feature you switch on.

The 1997 OpenStep branch is not a head start

Every file in the old front end is dual-compiled #ifdef OPENSTEP. The NEXTSTEP branch is finished, shipped code. The OpenStep branch is an abandoned port: tracking rects dead, keyboard translation gutted, dead keys dropped, and at MacViewClass.m:2606 a declaration taking NSString by value, which cannot compile. Read the NEXTSTEP branch for design; read the OpenStep branch as a list of what breaks.

2 — The contract

Subclass one C++ class. There are no required free functions and no global symbols to define beyond a single typedef.

Must implement

MemberCalled onConstraint
runEventLoop()main threadBlocks for the whole session. In AppKit this is [NSApp run] — roughly three lines.
endEventLoop()emulator threadMust be async and thread-safe. [NSApp stop:] plus a posted dummy event, or it won't take effect until the next event arrives.
setMode(w,h,bpp,gray)emulator threadAllocates the framebuffer. May block on the main thread; must not require the run loop to be spinning already.
requestUpdate()emulator threadCalled with the driver mutex held. Must never block. See trap 01.
ctor(IEventListener*, int& argc, char**)main threadRuns before the emulator thread exists.
default_vdriver.hThree lines: using DefaultVDriver = GNUstepVideoDriver; Included by main.cpp, which is compiled as plain C++ — so this header and its includes must be Objective-C-free.

Everything else has a working default. Cursor, title, scrap, beep, palette and rootless region are all optional — and Qt, the reference implementation, overrides only the two cursor methods.

What you call into

// IEventListener — available as the protected member callbacks_
mouseButtonEvent(bool down, int h, int v);
mouseMoved(int h, int v);
keyboardEvent(bool down, unsigned char mkvkey);   // Mac virtual key code
suspendEvent();                                   // focus lost
resumeEvent(bool updateClipboard);                // focus gained
requestQuit();

All are safe to call from the GUI thread. They marshal internally: the event sink queues a std::function and fires a synthetic 68k interrupt, which the emulator thread services. The front end never touches Toolbox state directly.

3 — Threading and the pixel path

The toolkit owns the main thread; the emulator runs on a worker. That is exactly the AppKit model, and the impedance match is the single best thing about this port.

Main thread — AppKit owns it [NSApp run] -drawRect: All NSView / NSWindow / NSCursor calls no exceptions Emulator thread 68k guest execution Toolbox + QuickDraw setMode setColors setCursor updateScreen called from here callbacks_ marshal back Worker pool — owned by the base class updateBuffer() — depth, endian and palette conversion, fanned across hardware_concurrency()-2 threads Must be called with the mutex RELEASED. It runs long. The deadlock requestUpdate() is always invoked with mutex_ already locked, and your draw path takes that same mutex. Marshal it with waitUntilDone:NO. A blocking marshal here hangs instantly, every time.
Fig. 1 — Thread ownership

Pixels

Two buffers, one image rep aliasing a buffer you own, no copy. The 1993 code invented this and the modern base class does the hard part for you.

Guest framebuffer updateBuffer() Staging buffer NSBitmapImageRep 1/2/4/8 indexed, MSB-first 16/32 bpp big-endian Mac depth + endian + palette on the worker pool uint32_t 0xAARRGGBB host order, you own it wraps the buffer, no copy -drawInRect:fromRect: The framebuffer is big-endian Mac format. Never hand it to AppKit directly. Dirty rects are capped at 5 and auto-merged into unions when they overlap. Palette changes dirty the whole screen — correct, since a CLUT change alters every pixel's meaning. Qt performs zero conversion of its own: QImage::Format_RGB32 is bit-identical to what updateBuffer emits. The AppKit equivalent needs one specific format flag — see trap 02.
Fig. 2 — Pixel pipeline
4 — Files to write

A new directory, src/config/front-ends/gnustep/, mirroring how qt/ is organised one for one.

FileLOCPurpose
gnustep.mm380–450The driver. ExecutorView : NSView, a borderless ExecutorWindow : NSWindow, the app delegate, and every VideoDriver override.
gnustepkeycodes.mm120–160Fallback unichar→MKV map, plus modifier decoding for -flagsChanged:. AppKit does not deliver modifiers as key events; Qt sidestepped this and you cannot.
gnustep_ui.mm300–600The menu and windows, built in code. There is no nib — see below. Qt needs none of this, so it is pure delta against the reference implementation.
gnustep_mainthread.mm50–70runOnMainThread(fn, wait) over -performSelectorOnMainThread:. The one file with no Qt counterpart — Qt got this free from its framework.
available_geometry.mm40–55Screen rects from [NSScreen screens], bottom-left to top-left. Simpler than Qt's, which carries X11 multi-monitor workarounds.
gnustep.h40–55Pure C++, no Objective-C. Objective-C types hidden behind #ifdef __OBJC__ @class … #else typedef struct objc_object …
CMakeLists.txt35–45Locate GNUstep, apply -x objective-c++ to the .mm files, link.
default_vdriver.h3The typedef.
../x/x_keycodes.cpp0Reused verbatim. Listed as a source, exactly as Qt's CMakeLists already does.

There is no nib, and there cannot be one

Every interface file in the 1993 tree is NeXT typedstream version 4 — the pre-keyed-archiving format. Gorm cannot open them. GNUstep's model loader dispatches by signature to Gorm, keyed .nib, .xib and .gmodel handlers; there is no typedstream path anywhere, so loading them at runtime fails too. The conversion tools are dead ends: nib2gmodel was last touched in 2008 and requires Apple or NeXT libraries; nib2xib is maintained but runs only on OPENSTEP 4.2.

It costs almost nothing, because there is almost nothing in them. The live UI is one menu, one window with the framebuffer view, one info panel, and four implemented actions. Of seventeen actions the nibs declare, thirteen are dead. MacViewClass declares no instance variables at all, so all seven outlets its metadata references are stale. One declared class has no source file anywhere in the tree. The rest is ARDI-era registration and serial-number UI whose backing code is gone — you would be converting dead weight in order to delete it.

Build the UI programmatically. That is the 300–600 line file above, and it also lets you delete the entire outlet layer — roughly 25 globals the old code copied out of the nib so plain C could reach them.

Build wiring

Three edits to existing files: add gnustep to the FRONT_ENDS cache list, add one add_subdirectory, and ship the new CMakeLists.txt. Everything downstream — the per-front-end executable, the target naming, the include path — is picked up automatically.

Three toolchain rules that are not optional

Each of these produces a failure that looks like something else — heap corruption, a mystery segfault, a link error about a symbol you never wrote. Getting them right up front costs nothing; getting them wrong costs days.

Clang for the whole project

GNUstep's runtime is libobjc2, and GCC has no -fobjc-runtime= flag at all. gnustep-make's own configure forces CC=clang. Since the .mm files ride CMAKE_CXX_COMPILER, that means the entire project must be clang — not just the shim.

Never enable_language(OBJCXX)

CMake's OBJCXX detection prefers clang++, while its CXX detection resolves to g++. On a box with both, you get clang++ for one .mm and g++ for four hundred .cpp files — a split C++ ABI across exactly the boundary the front end straddles, since that translation unit consumes vdriver.h with shared_ptr, function and string in it. Let .mm ride the CXX compiler and set the language per source file.

-fuse-ld=lld

The v2 Objective-C ABI depends on section-boundary symbols that GNU ld mishandles; the classic symptom is cannot locate symbol __start___objc_selectors. gnustep-make warns about this itself and admits it has no accurate test. gold is deprecated upstream, so lld is the answer.

The footgun that will actually get you

Clang's default Objective-C runtime on Linux is not GNUstep 2.x — a bare -fobjc-runtime=gnustep means 1.6. Compile a .mm without pinning the version and clang emits the wrong personality function, after which catching a std::exception segfaults. It was filed against LLVM and closed as invalid, the answer being "specify the runtime." CMake will cheerfully compile Objective-C++ with no Objective-C flags whatsoever and hand you crashes that read as heap corruption.

Pin -fobjc-runtime=gnustep-2.2 per source file. Not 2.0 — they are ABI-identical, but 2.2 unlocks compiler fast paths, and it is what gnustep-make defaults to. Requires clang 18 or newer.

Two related corrections to widely-repeated advice: there is no -lobjcxx any more — it was folded into libobjc.so in 2017, though libobjc2's own INSTALL file still describes it. And Objective-C++ exception interop is genuinely regression-tested in both directions across libstdc++ and libc++, so it works — provided the runtime flag is right.

The mechanics below follow the existing pattern in the tree: set the language explicitly per source file rather than enabling it project-wide.

find_program(GNUSTEP_CONFIG gnustep-config)
if(GNUSTEP_CONFIG)
  execute_process(COMMAND ${GNUSTEP_CONFIG} --objc-flags OUTPUT_VARIABLE GS_OBJC_FLAGS
                  OUTPUT_STRIP_TRAILING_WHITESPACE)
  execute_process(COMMAND ${GNUSTEP_CONFIG} --gui-libs   OUTPUT_VARIABLE GS_GUI_LIBS
                  OUTPUT_STRIP_TRAILING_WHITESPACE)
  separate_arguments(GS_OBJC_FLAGS_LIST UNIX_COMMAND "${GS_OBJC_FLAGS}")
  separate_arguments(GS_GUI_LIBS_LIST   UNIX_COMMAND "${GS_GUI_LIBS}")

  add_library(front-end-gnustep
      default_vdriver.h gnustep.h gnustep.mm gnustepkeycodes.mm
      gnustep_mainthread.mm available_geometry.mm ../x/x_keycodes.cpp)

  set_source_files_properties(gnustep.mm gnustepkeycodes.mm gnustep_mainthread.mm
      available_geometry.mm PROPERTIES COMPILE_OPTIONS "-x;objective-c++")

  target_compile_options(front-end-gnustep PRIVATE ${GS_OBJC_FLAGS_LIST})
  # The version pin is the whole ballgame. See above.
  set_property(SOURCE gnustep.mm gnustepkeycodes.mm gnustep_ui.mm
    APPEND PROPERTY COMPILE_OPTIONS
      ${GS_OBJC_FLAGS_LIST}
      -fobjc-runtime=gnustep-2.2
      -fblocks -fexceptions -fobjc-exceptions -D_NATIVE_OBJC_EXCEPTIONS)

  target_include_directories(front-end-gnustep PUBLIC .)
  target_link_libraries(front-end-gnustep syn68k romlib ${GS_GUI_LIBS_LIST})
endif()

# Configure the whole project with:
#   cmake .. -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ \
#            -DCMAKE_EXE_LINKER_FLAGS="-fuse-ld=lld"

--objc-flags carries -MMD -MP, which fight CMake's own depfile handling — filter those two out and keep the rest. --gui-libs is a superset of the base and objc link flags, so one call covers the link side. Do not enable ARC; this codebase is manual retain/release throughout, though ARC is per-file so mixing would be legal if you ever wanted it.

Worth adding a hard failure in CMake if the discovered flags mention -fobjc-runtime= while the C++ compiler is not clang. That single check converts the most likely misconfiguration from a runtime segfault into a configure-time error message.

5 — Rootless, honestly

You get Mac windows floating over your desktop with no grey box around them. You do not get NSWindows the window manager can move independently.

The fantasy — not possible NSWindowNSWindowNSWindow Each Mac window an independent host window. Executor's Window The reality — and it looks the same shaped regionshaped regionshaped region one transparent NSWindow, masked Manager has no concept of host windows and never will. Everything the guest draws lands in one flat framebuffer. That is a structural fact of Executor, not an oversight. Executor computes the region covering all Mac windows and hands it to you. You mask one full-screen transparent window with it. Visually identical; architecturally one window.
Fig. 3 — What rootless actually means

Executor's own documentation is wrong about this

The README advertises "emulated windows are part of your desktop," and the subsystem docs state that in rootless mode "windows are not drawn onto the emulator framebuffer" but are instead "delegated to the host compositor." The code flatly contradicts both. The rootless path unions every visible window's structure region, plus the menu bar, into one region and makes one call. Windows are still drawn into the framebuffer; the region only selects which spans get copied and which are left transparent.

This matters beyond pedantry. Anyone scoping this project from the documentation would believe per-window host integration is already half-built. Settle the expectation in writing before any code is committed — and consider correcting those two doc files as a first, trivially reviewable pull request.

Why per-window is a core rewrite, not a front end

The single-framebuffer assumption is load-bearing at four independent levels. Two of them cannot be removed without breaking guest applications.

LevelWhat it means
Every port shares one bitmapEach window's GrafPort points at the same screen bitmap. Windows draw at absolute screen coordinates, clipped by a visible region. They have no independent backing surface — which is faithful, because the real Macintosh worked this way too.
The framebuffer lives in the guest's address spaceIt is mapped into the 68k memory map and published as low-memory globals. Guest code writes to screen memory directly, and there is an entire subsystem — a periodic per-strip checksummer — that exists solely to notice when applications bypass QuickDraw and do exactly this. Those writes address screen coordinates. There is no general way to route them into per-window surfaces.
Window chrome can be guest codeFrames are drawn by the Window Definition Procedure, which is a resource — and it can come from the application's own resource fork and run as 68k code. An app with a custom WDEF draws arbitrary chrome that no native titlebar can reproduce.
The guest manages its own windowsHit-testing walks Executor's window list in its own Z-order; dragging is implemented by XOR-ing a grey outline into the shared framebuffer and polling to mouse-up. The host window manager has zero involvement, and occlusion is computed by Executor. Hand stacking to the host and its order can disagree with the visible region QuickDraw clips against.

The historical evidence agrees. ARDI wrote the NeXTSTEP front end with full AppKit available and complete control of their own source, and used one window. Their wishlist file got as far as "give some thought to rootless windows during the code restructuring." What shipped thirty years later is the shape mask.

And GNUstep could not do it today regardless

Five of the window features per-window integration would need are missing or inert: -setStyleMask: has no implementation in libs-gui at all, so a window's style cannot change after creation; -setOpaque: is a stub with a FIXME and no backend call behind it; window creation always uses the shared screen visual, so there is no per-pixel ARGB transparency; shaped windows exist only as private API used internally by drag views; and -addChildWindow: records children but never makes them follow the parent. Window levels do map to real EWMH hints, but the always-above hint is never set — "floating" relies on the window manager inferring it, which KWin and Mutter generally do not.

Ship v1 without rootless at all

Leave the rootless flag at its default and Executor draws a normal Mac desktop in a normal window — every rootless path short-circuits cleanly. That drops the region mask and window transparency, which are the shakiest parts of GNUstep's backends and exactly where you least want to be on day three. Turn it on behind a flag once the basics hold.

6 — Subsystems
SubsystemGNUstepDaysApproach
KeyboardGood on X114 GNUstep's X11 backend puts the raw X11 hardware keycode in -[NSEvent keyCode], so x_keycode_to_mac_virt[] is reused verbatim. Ignore -characters entirely — the core runs the guest's own KCHR and would double-translate.
MouseGood1.5 Six overrides. Set acceptsMouseMovedEvents, override -isFlipped to YES to delete all the Y-flip arithmetic, and fold the right button into the left.
ClipboardPartial4 NSPasteboard maps onto the 1995 code almost line for line. Interop is the risk: it needs the external gpbs daemon and cross-application exchange is documented as effectively plain-text-only. Keep a raw X11 fallback.
SoundNot via AppKit6 See below. Fully deferrable.
PrintingDo nothing0.5 Already works and is entirely independent of the front end — the core generates PostScript and pipes it to lpr. Do not port NEXTprint.m. Do not touch MacPrintClass.h, which has no implementation file anywhere and zero references.

Correction: GNUstep does still have the PostScript operators

The obvious assumption — Display PostScript is dead, so all of it must be rewritten — is wrong. GNUstep ships live PS* and DPS* operators as a real C function-pointer dispatch table on the graphics context, the same path AppKit uses internally. Colour, gstate, matrix, path, text and even the NeXT compositing extensions are all present, and DPSPrintf is a genuine variadic implementation.

The total Display PostScript surface in the old front end is about ten call sites, and most compile unchanged — only two need substituting, plus two header renames. The NSView printing callback protocol is fully implemented too, none of it stubbed. And the pswrap input file that looked like a problem contains exactly one line: % no longer needed.

The recommendation is still to leave printing alone, because the Linux path already works without any of this. But the cost of touching it later is much lower than it appears.

Sound

GNUstep's NSSound plays complete, pre-existing files through loadable sink bundles. There is no PCM callback and no streaming-buffer API — nothing resembling SDL_AudioCallback. Executor synthesises audio on the fly from the guest's snd  resources at guest-chosen sample rates with hard latency requirements, and that cannot be expressed through the current interface.

It matters less than it sounds. Executor's sound goes through its own SoundDriver abstraction — a hunger model where the driver hands the core a buffer and a time window, the core fills it, the driver plays it. The backend is a swappable class of roughly 170 lines. Worth knowing: modern Executor is silent on every current front end anyway, since the only real driver in the tree belongs to the legacy SDL1 build, and the 1993 NeXTSTEP one was never implemented either — both its functions are empty bodies.

SDL2 audio

The pragmatic default. A working 171-line SDL1 implementation already exists in the tree; moving it to SDL_OpenAudioDevice is largely mechanical. No new dependency the project doesn't already have.

SoundKit available today

NEXTSPACE's Frameworks/SoundKit is a PulseAudio-backed GNUstep framework with a NeXT-style API. SNDPlayStream exposes -playBuffer:size:tag: with write and empty callbacks — precisely the hunger model, already built. GPL v2+, so it makes the resulting binary GPL.

Streaming NSSound

The preferred endpoint if the in-progress work lands. Would need a new GSSoundSink exposing an application-supplied buffer source. Nothing to this effect appears in libs-gui's public branches yet.

Because all three sit behind one interface, this is not a decision that has to be made now — and nothing else blocks on it, since the fake driver gives correct timing and silence.

7 — Traps

Each of these cost someone a day in 1993, or will cost you one in 2026. They are ordered by when you will hit them.

  1. The update deadlock. requestUpdate() is always called with the driver mutex held, and your draw path takes the same mutex. Marshal with waitUntilDone:NO. Blocking there hangs on the first frame, every time. Conversely setMode and the cursor methods are called without the mutex and may block.
  2. The bitmap format flag. The exact equivalent of what updateBuffer() emits is NSAlphaFirstBitmapFormat | NSBitmapFormatThirtyTwoBitLittleEndian. That second constant arrived in gnustep-gui 0.25. Verify it exists in your tree before writing anything else — if it doesn't, the fallback is a swizzle loop costing a full-frame pass.
  3. A wrong pixel format silently costs you 307,200 iterations per frame. GNUstep's context checks whether a bitmap rep is "compatible" and, for anything that isn't canonical 8-bits-per-channel interleaved, falls into a nested per-pixel loop calling accessor methods once per pixel. At 640×480 that is three hundred thousand iterations of near-Objective-C work every single frame, and nothing warns you. The 1993 code's 16-bit 4:4:4 format would land squarely in it. Use 8 bits per channel, 32 bits per pixel, device RGB. Executor's colour spec is fully parameterised, so this is a constants change.
  4. The 2-bit grayscale path draws nothing at all. Cairo's backend rejects any bitmap whose colour space isn't device or calibrated RGB — it logs a line and returns. A silent no-op. That path exists only for 1990s NeXT mono hardware; delete it rather than porting it.
  5. Call [view allocateGState]. The old code does this with the comment "since we will be repeatedly focused on." Without it, per-frame lockFocus is ruinous and you will misdiagnose it as the conversion being slow.
  6. Target the X11/cairo backend, not Wayland. GNUstep's Wayland backend emits no NSFlagsChanged events at all, so command, shift and option are simply dead, and it zeroes the keycode for Enter and Delete.
  7. Modifiers are keys, not flags. The modern core derives modifier state by testing the key map, so there is no flags path at all. You must send explicit down and up keyboardEvent calls for the modifier keys themselves.
  8. Grab the keyboard aggressively. Return YES unconditionally from -performKeyEquivalent: while a guest application is running, or the host menu swallows ⌘Q, ⌘W and friends before the guest sees them.
  9. Call disableCursorRects once, wholesale. AppKit's cursor rectangles will otherwise fight the emulator for the pointer continuously.
  10. The XOR cursor. Mac cursors have an invert mode that alpha-mask cursors cannot express. The 1993 solution was dithering inverted regions to a 50% checkerboard with a per-row alternating pattern. Read that code before writing yours.
  11. Fix resumeEvent while you're here. Qt hardcodes true, SDL2 hardcodes false, and both are wrong. The 1995 code computed it correctly from the pasteboard change count, which is what decides whether the guest needlessly reconverts the clipboard on every focus change.
  12. Don't inherit the singleton-via-file-statics pattern. The old code apologises for it in its own source. Hold your view and window as members of the driver object.
8 — What to read, what to delete

Worth reading closely

  • The core bridge — about 250 lines spanning init, screen update and drawRect:. Port the shape, retype every line.
  • The -step pump — run guest code until the host has an event pending, then yield. Good heuristic; its asm-coroutine implementation is not.
  • The clipboard — five flavours with generation counters, CR/LF conversion, and a synthesised RTF font table built from the guest's own FOND resources. Genuinely hard-won, and better than anything the modern tree has.
  • Focus to suspend/resume — including the change-count gate.
  • The flush-policy comment — documents a real failure mode where skipping the flush lets messages queue until the machine pages itself to death.

Archaeology — do not port

  • OldMacViewClass.m, all 2,907 lines. Superseded, not in the build, and its one unique idea is dead code behind if (0 && …).
  • blockinterrupts.m — the author #error'd it himself: "succumbed to bitrot." Contains a disabled attempt to run Mac callbacks by suspending a thread and rewriting its program counter.
  • The context switcher — m68k and i386 inline-asm coroutines over hand-built stacks. Modern Executor uses a real thread.
  • The kernel module loader — loads a setuid-root Mach server and probes for it by deliberately triggering SIGILL.
  • Printing — raw PostScript into a DPS context, with a 10,000-iteration timeout that exists for one Excel bug and a deliberately false %%BeginDocument: comment to work around Word 5.
  • ~80% of the app class — registration keys, serial numbers, licence enforcement.

And the one that looked like treasure: HFS_XFer

A 9,194-line utility for moving files on and off HFS volumes, which sounds exactly like what the desktop project needs. Ignore it. It is a Mac Toolbox application, not a NeXTSTEP one — its only Objective-C is a 14-line stub. Its 5,470-line HFS engine is wrapped first line to last in #if defined(OUTDATEDCODE), and it is a superseded fork besides: the maintained copies in mainline are 30–130% larger and, critically, endian-corrected. The HFS_XFer copies contain zero byte-swap macros against 168 in the mainline B-tree alone. It only ever ran on big-endian 68k, backed by a 2.88 MB static RAM array. It only ever worked on floppies. Three live defects were found in passing, including an inverted bounds check guarding a memcpy.

Two things in it are worth ten minutes each: the copy engine is a compact, correct specification of what "copy a Mac file faithfully" means — create, data fork, resource fork, then restore Finder info and dates last — and the auto-mount hook sketches a design worth stealing at about forty lines.

9 — Sequence
#StepDaysDone when
0Spike the blit2An NSView blitting a 640×480 32-bit NSBitmapImageRep at 60 fps against back-cairo on X11, with the format verified correct. This is the only genuinely unknown question in the plan and nobody has answered it before. If it fails and the raw-Xlib escape hatch doesn't pan out, stop here — everything after it would be wasted.
1Build skeleton1front-end-gnustep compiles and links, executor-gnustep runs headless-equivalent and exits cleanly.
2Window and framebuffer2Event loop, setMode, requestUpdate and the draw path work against a solid grey framebuffer. No input yet. Non-rootless.
3Mouse1.5The guest tracks the pointer and clicks land. Cursor shape and visibility follow.
4Keyboard4Typing works in a real application, modifiers included, and ⌘-equivalents reach the guest rather than the host menu.
5Clipboard, TEXT only1Copy and paste between a classic application and a GNUstep one. Parity with the current X11 front end.
5bApp shell2Menu bar, about panel and window construction, all in code. Qt ships none of this; a native-feeling GNUstep app needs it.
Milestone one~8A usable native front end. Sound faked, printing via the existing path, single window.
6Rootless3Mac windows float over the Gershwin desktop with no surrounding grey.
7Clipboard flavours3PICT, TIFF and RTF, with the font-table synthesis. Exceeds every current front end.
8Sound6A SoundDriver subclass against whichever backend won.

Start with step 0, not step 1

Everything after it is ordinary work with a known shape. Step 0 is the only item that can invalidate the whole design, and it costs two days to settle. If the cairo path can't sustain the blit, the mechanical escape hatch is real — -[NSWindow windowRef] is implemented and on X11 hands back a structure exposing the Display * and drawable, from which XShmPutImage is reachable. But no one has ever done that either, and it may require an internal header that distributions don't ship. Find out on day one.

Open: four libraries not yet reviewed

This review read Executor's two trees plus libs-gui and libs-back. Four more libraries were not read, and each could materially change a conclusion here — two of them the most expensive conclusions. Review these before committing to the architecture.

LibraryWhat it isWhy it could change this plan
libs-opal Core Graphics for GNUstep Highest potential impact. The top risk in this plan is blit throughput through NSBitmapImageRep, which round-trips X with no shared memory and no surface caching. If Opal offers a usable CGBitmapContext or CGImage path to the screen, that could be a better route than AppKit's — and it would matter most in exactly the place the plan currently calls unproven. Caveats already known: its indexed colour space file was last touched in 2010, and Opal's backend was non-functional for years until a libs-back change landed in April 2026.
libs-quartzcore CoreAnimation for GNUstep Bears on rootless. This plan concludes that per-Mac-window host windows are impossible and that the shaped-mask approach is the ceiling. Layers are a third option neither the Qt nor Wayland front ends consider. If a working layer implementation exists, per-window compositing might be reachable without the core rewrite section 5 describes — still bounded by the same guest-writes-to-screen-memory problem, but worth knowing before the question is closed.
NSSound in libs-gui Sound playback The verdict here — that it cannot serve Executor's hunger model because it has no PCM callback — was reached by reading the current implementation. Work is reportedly in progress. If a streaming sink lands, the AppKit-native path becomes viable and the SDL2 and SoundKit options become fallbacks rather than the plan. Worth confirming directly with whoever is doing it whether it is a new GSSoundSink, a fork, or staged outside the public branches.
libs-corebase CoreFoundation for GNUstep Lowest impact but cheapest to check. Relevant to how much of the front end must be Objective-C at all — a C-level interop layer could let more of the bridge stay in plain C++17 rather than Objective-C++, which shrinks the surface exposed to the runtime and exception-interop traps in section 4.

The Gershwin desktop sources are the other unread input, and they answer a question none of the above do: what the desktop already provides, so the front end integrates with its window manager and menu bar rather than duplicating them.

What nobody could determine

Stated plainly, because a plan that hides its unknowns is worse than one that names them. None of the following was verified, and no code in this review was compiled or executed:

  • The literal output of gnustep-config --objc-flags on a current install. The flags above are derived from gnustep-make's own makefiles, not observed.
  • Whether distribution -dev packages install the internal header that gates the raw-Xlib escape hatch.
  • Any GNUstep drawing benchmark newer than 2015.
  • Whether recent binutils fixed the GNU ld problem, or whether lld remains mandatory.
  • First-hand reports for window levels under KWin, Mutter or i3 — the always-above gap is inferred from source only.
10 — Provenance and licensing
ComponentLicenceConsequence
Executor coreMITCliff Matthews open-sourced it in 2008. No ROM, no Apple system software, no redistribution problem.
cxmon (debugger)GPL v2+Optional and removable. A stock build is effectively GPL-bound; for a GPL distribution this is moot.
New GNUstep front endyour choiceMIT keeps it contributable upstream as a clean component.
SoundKit, if usedGPL v2+Makes the binary GPL. Fine for the distro; blocks an MIT-clean upstream contribution if the front end hard-depends on it.
GNUstepLGPL / GPLLibrary linkage as normal.

The licensing story is unusually clean for retrocomputing: none of this requires an Apple ROM or a copy of Mac OS, which is the constraint that blocks Basilisk II, Mini vMac and the QEMU m68k path from ever shipping in an image.