I started this app as "GitHub Lite". All I wanted was a quick way to check my pull requests for new comments, because opening GitHub in the browser made my PC lag so badly that I couldn't work properly. Once that worked, I thought it would be better to automate the next step too, so I built "Fix with agent": it looks at the review comments (it doesn't fix anything itself), writes a prompt from them and starts an agent session to fix them. Then I realized I could do the whole loop inside the app: open the PR, review it, fix, re-request review, answer or leave comments, and so on.

Today that app is called PullYard, and it exists three times: a Swift/SwiftUI app for macOS, a C#/WinUI 3 app for Windows, and a Rust/GTK 4 app for Linux. They share no code. Getting the Windows and Linux versions to launch took one day, but that was only launching and minimal functionality. After that I had to work seriously on security and memory leaks, and fix a lot of things.

People who hear this tend to ask "why not Electron / Tauri / Flutter?" before anything else. This post is my honest answer, plus the parts I'd warn you about if you're thinking of doing the same.

The numbers

From the repository today (lines of source, tests excluded where they live in separate files):

macOS Windows Linux
Language Swift 5.10 C# on .NET 8 Rust 2021
UI SwiftUI + AppKit WinUI 3 (Windows App SDK) GTK 4 + libadwaita
Build SwiftPM MSBuild / csproj Cargo
Source files 123 .swift 142 .cs + XAML 124 .rs
Lines ~35k ~38k (+ ~1k XAML, ~2.3k web assets) ~54k (includes inline tests)
Packaging DMG, Homebrew cask installer + portable zip, x64 and ARM64 .deb, Flatpak, tarball
v2.13.6 download ~10 MB DMG ~63 MB x64 installer ~5 MB .deb

Roughly the same app, roughly the same size in each language. Part of the Rust number is unit tests, which live inline in the same files.

Why not one cross-platform codebase?

I don't think cross-platform frameworks are wrong. I use apps built with them every day. My reasons were specific to this app:

  1. The core screen is a giant scrolling diff. Thousands of lines, syntax colors, comment threads in between, file headers that stay at the top. How smooth that feels depends a lot on the platform's own list and text rendering, and I wanted to control that per platform.
  2. Each platform has its own idea of where things live. Menu bar badge on macOS, tray icon on Windows, libadwaita header bars on GNOME. Keychain, Credential Manager, Secret Service. Faking these from a shared layer is possible, but you end up writing platform code anyway.
  3. I wanted it small. For v2.13.6 the macOS DMG is about 10 MB and the Linux .deb about 5 MB. The Windows installer is about 63 MB, because it is self-contained and ships the .NET 8 runtime and the Windows App SDK. Small is a per-platform result, not a guarantee.

The cost is obvious: three times the code to write and three times the code to keep correct.

"Native" still means a web view in places

I want to be upfront here, because "native" gets used loosely. PullYard is native in its shell, navigation, lists and system integration on all three platforms. But some content is HTML, and for that it uses the system web view:

  • macOS: the diff is an AppKit NSTableView. Conversation and rendered Markdown use WKWebView.
  • Windows: the diff and the agent chat are HTML in WebView2, with a WinUI ListView for the file list.
  • Linux: the diff is a gtk::ListView with custom-drawn rows. Conversation HTML uses WebKitGTK.

So the Windows diff isn't a native control at all. I chose WebView2 there because I needed syntax highlighting for many languages, comparing two versions of a file, showing changes inside one document and, most important I think, a way to keep large files fast.

What I take from this: the toolkit you build the shell in matters less than how you build the one or two screens where the app spends its time.

Same feature, three diff viewers

The diff viewer shows the differences well, because each version had to solve "render 10,000 lines without dying" in its own way.

macOS: NSTableView. The rest of the Mac app is SwiftUI, but the diff is an AppKit table wrapped in an NSViewRepresentable. I went down to AppKit for its text stack: NSTextView, NSTextStorage, NSLayoutManager and the newer TextKit 2 NSTextLayoutManager, plus NSScrollView for native scrolling. The diff itself uses only part of it: the table sits in an NSScrollView, and each line draws its own attributed string. Each line is a row. The table only creates views for rows on screen and recycles them (makeView(withIdentifier:)) as you scroll. File headers are group rows with floatsGroupRows = true, which is how they stay pinned at the top. Line cells draw their text directly in draw(_:) instead of nesting text fields, and row heights are cached because long lines wrap.

Windows: HTML with content-visibility. The diff is generated as HTML and shown in WebView2. Two CSS properties do most of the work:

.fh { position: sticky; top: 0; } .file { content-visibility: auto; contain-intrinsic-size: auto 400px; } 

position: sticky pins file headers. content-visibility: auto lets the browser skip layout and painting for files that are off-screen, with contain-intrinsic-size as a placeholder height so the scrollbar doesn't jump. File bodies are sent into the page lazily, so the first paint doesn't wait for every file.

Linux: gtk::ListView. GTK 4's list widgets are built for this: a gio::ListStore model, and a SignalListItemFactory with setup/bind/unbind callbacks that reuse row widgets as you scroll, like the macOS table. Each line widget draws itself in a custom snapshot() with a Pango layout instead of using a label per span.

Three different APIs, one idea: only build what's on screen, reuse it, and draw text yourself when the toolkit's text widgets are too heavy per row.

Same feature, three secret stores

Tokens (GitHub, GitLab, Bitbucket, Gitea) never go into a settings file if the platform has a secret store:

  • macOS: Keychain via the Security framework (SecItemAdd / SecItemCopyMatching, generic password items).
  • Windows: Credential Manager through P/Invoke to CredWriteW / CredReadW in advapi32, generic credentials. No file fallback: if the write fails, sign-in fails.
  • Linux: the Secret Service API through the oo7 crate. Here there is a fallback, and it's a real trade-off: some minimal Linux setups have no keyring daemon running. In that case PullYard writes the token to a file in ~/.config/pullyard/ with mode 0600 rather than refusing to work.

If you're building for Linux, decide this early. "Requires a running Secret Service" will be someone's bug report on day one. A silent plain-file fallback is a security choice your users should know about.

Same feature, three ways to start a process

The app runs the git, gh and claude command-line tools. Spawning a child process sounds like the most portable thing there is. It isn't:

  • macOS: Foundation Process with Pipes. Simple. Watch out for apps launched from Finder getting a minimal PATH, so look for binaries in known locations instead of trusting PATH.
  • Windows: System.Diagnostics.Process. Kill with Kill(entireProcessTree: true), or child processes outlive yours. npm-installed CLIs are .cmd shims, which need cmd.exe /c or a direct node call.
  • Linux: std::process::Command, started in its own process group so the whole tree can be signalled. Inside Flatpak, nothing on the host is visible to you, so every host command goes through flatpak-spawn --host. Secrets go through a temp file that the shell sources and deletes, because --env=KEY=value would be readable in the process list.

Keeping three apps in sync

There's no clever tooling here. It's discipline:

  • Feature triplets. A feature lands as three commits with the same message and a platform prefix: macOS: …, Windows: …, Linux: …. The git log reads like a checklist, and a missing third commit is easy to spot.
  • Port from the first implementation, not from the spec. The macOS version usually comes first. The Windows and Linux versions are written by reading it, including its edge cases.
  • Mirror the tests. Unit tests have the same names on each platform. If a test exists on one platform and not another, something wasn't ported.
  • Accept small drift. Some constants differ, for example how many CI annotations go into a prompt is 30 on macOS and 20 on Windows. As long as behavior matches, I don't chase every number.

And yes, I used AI for almost all of the porting between languages. I watched it closely to keep the holes to a minimum. I don't think a good app can be perfect without any bugs, so the goal was fewer holes, not zero.

Is it worth it?

For me, yes. Fix with agent helps me so much that I stopped using the Claude Code desktop app and switched to my own.

The worst bug so far is scrolling in the agent chat. I'm still fixing it. Parts of the fix went out in 2.13.2 and 2.13.4, a few spots are still left, and the changes for them are already in for the next release.

If you're weighing this for your own project, my short version: it's worth it when one or two screens decide whether the app feels good, and those screens behave differently per platform. If your app is mostly forms and settings, a cross-platform framework will save you a lot of time.

PullYard is free and MIT-licensed. All three apps are in one repo if you want to compare the implementations: github.com/pullyard/pullyard (Sources/ for macOS, windows/, linux/).

Have you shipped the same app natively on more than one platform? I'm curious how others keep the versions from drifting apart.