<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Vache Hovhannisyan</title>
    <description>The latest articles on DEV Community by Vache Hovhannisyan (@vaache).</description>
    <link>https://dev.to/vaache</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4236727%2F1b063eba-84e0-4c04-85a1-161b3b09fea7.png</url>
      <title>DEV Community: Vache Hovhannisyan</title>
      <link>https://dev.to/vaache</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/vaache"/>
    <language>en</language>
    <item>
      <title>One app, three native UIs: what I learned building the same app in SwiftUI, WinUI 3 and GTK 4</title>
      <dc:creator>Vache Hovhannisyan</dc:creator>
      <pubDate>Tue, 10 Nov 2026 10:05:26 +0000</pubDate>
      <link>https://dev.to/vaache/one-app-three-native-uis-what-i-learned-building-the-same-app-in-swiftui-winui-3-and-gtk-4-la7</link>
      <guid>https://dev.to/vaache/one-app-three-native-uis-what-i-learned-building-the-same-app-in-swiftui-winui-3-and-gtk-4-la7</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers
&lt;/h2&gt;

&lt;p&gt;From the repository today (lines of source, tests excluded where they live in separate files):&lt;/p&gt;

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

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not one cross-platform codebase?
&lt;/h2&gt;

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

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The core screen is a giant scrolling diff.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Each platform has its own idea of where things live.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I wanted it small.&lt;/strong&gt; For v2.13.6 the macOS DMG is about 10 MB and the Linux &lt;code&gt;.deb&lt;/code&gt; 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.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The cost is obvious: three times the code to write and three times the code to keep correct.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Native" still means a web view in places
&lt;/h2&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;macOS:&lt;/strong&gt; the diff is an AppKit &lt;code&gt;NSTableView&lt;/code&gt;. Conversation and rendered Markdown use &lt;code&gt;WKWebView&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Windows:&lt;/strong&gt; the diff and the agent chat are HTML in &lt;strong&gt;WebView2&lt;/strong&gt;, with a WinUI &lt;code&gt;ListView&lt;/code&gt; for the file list.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Linux:&lt;/strong&gt; the diff is a &lt;code&gt;gtk::ListView&lt;/code&gt; with custom-drawn rows. Conversation HTML uses WebKitGTK.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Same feature, three diff viewers
&lt;/h2&gt;

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

&lt;p&gt;&lt;strong&gt;macOS: &lt;code&gt;NSTableView&lt;/code&gt;.&lt;/strong&gt; The rest of the Mac app is SwiftUI, but the diff is an AppKit table wrapped in an &lt;code&gt;NSViewRepresentable&lt;/code&gt;. I went down to AppKit for its text stack: &lt;code&gt;NSTextView&lt;/code&gt;, &lt;code&gt;NSTextStorage&lt;/code&gt;, &lt;code&gt;NSLayoutManager&lt;/code&gt; and the newer TextKit 2 &lt;code&gt;NSTextLayoutManager&lt;/code&gt;, plus &lt;code&gt;NSScrollView&lt;/code&gt; for native scrolling. The diff itself uses only part of it: the table sits in an &lt;code&gt;NSScrollView&lt;/code&gt;, 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 (&lt;code&gt;makeView(withIdentifier:)&lt;/code&gt;) as you scroll. File headers are group rows with &lt;code&gt;floatsGroupRows = true&lt;/code&gt;, which is how they stay pinned at the top. Line cells draw their text directly in &lt;code&gt;draw(_:)&lt;/code&gt; instead of nesting text fields, and row heights are cached because long lines wrap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Windows: HTML with &lt;code&gt;content-visibility&lt;/code&gt;.&lt;/strong&gt; The diff is generated as HTML and shown in WebView2. Two CSS properties do most of the work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.fh&lt;/span&gt;   &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;sticky&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;top&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.file&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;content-visibility&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;auto&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;contain-intrinsic-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;auto&lt;/span&gt; &lt;span class="mi"&gt;400px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;position: sticky&lt;/code&gt; pins file headers. &lt;code&gt;content-visibility: auto&lt;/code&gt; lets the browser skip layout and painting for files that are off-screen, with &lt;code&gt;contain-intrinsic-size&lt;/code&gt; 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.&lt;/p&gt;

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

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Same feature, three secret stores
&lt;/h2&gt;

&lt;p&gt;Tokens (GitHub, GitLab, Bitbucket, Gitea) never go into a settings file if the platform has a secret store:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;macOS:&lt;/strong&gt; Keychain via the Security framework (&lt;code&gt;SecItemAdd&lt;/code&gt; / &lt;code&gt;SecItemCopyMatching&lt;/code&gt;, generic password items).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Windows:&lt;/strong&gt; Credential Manager through P/Invoke to &lt;code&gt;CredWriteW&lt;/code&gt; / &lt;code&gt;CredReadW&lt;/code&gt; in &lt;code&gt;advapi32&lt;/code&gt;, generic credentials. No file fallback: if the write fails, sign-in fails.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Linux:&lt;/strong&gt; the Secret Service API through the &lt;code&gt;oo7&lt;/code&gt; crate. Here there &lt;em&gt;is&lt;/em&gt; 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 &lt;code&gt;~/.config/pullyard/&lt;/code&gt; with mode &lt;code&gt;0600&lt;/code&gt; rather than refusing to work.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Same feature, three ways to start a process
&lt;/h2&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;macOS:&lt;/strong&gt; Foundation &lt;code&gt;Process&lt;/code&gt; with &lt;code&gt;Pipe&lt;/code&gt;s. Simple. Watch out for apps launched from Finder getting a minimal &lt;code&gt;PATH&lt;/code&gt;, so look for binaries in known locations instead of trusting &lt;code&gt;PATH&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Windows:&lt;/strong&gt; &lt;code&gt;System.Diagnostics.Process&lt;/code&gt;. Kill with &lt;code&gt;Kill(entireProcessTree: true)&lt;/code&gt;, or child processes outlive yours. npm-installed CLIs are &lt;code&gt;.cmd&lt;/code&gt; shims, which need &lt;code&gt;cmd.exe /c&lt;/code&gt; or a direct &lt;code&gt;node&lt;/code&gt; call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Linux:&lt;/strong&gt; &lt;code&gt;std::process::Command&lt;/code&gt;, 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 &lt;code&gt;flatpak-spawn --host&lt;/code&gt;. Secrets go through a temp file that the shell sources and deletes, because &lt;code&gt;--env=KEY=value&lt;/code&gt; would be readable in the process list.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Keeping three apps in sync
&lt;/h2&gt;

&lt;p&gt;There's no clever tooling here. It's discipline:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Feature triplets.&lt;/strong&gt; A feature lands as three commits with the same message and a platform prefix: &lt;code&gt;macOS: …&lt;/code&gt;, &lt;code&gt;Windows: …&lt;/code&gt;, &lt;code&gt;Linux: …&lt;/code&gt;. The git log reads like a checklist, and a missing third commit is easy to spot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Port from the first implementation, not from the spec.&lt;/strong&gt; The macOS version usually comes first. The Windows and Linux versions are written by reading it, including its edge cases.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mirror the tests.&lt;/strong&gt; Unit tests have the same names on each platform. If a test exists on one platform and not another, something wasn't ported.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Accept small drift.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is it worth it?
&lt;/h2&gt;

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

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;p&gt;PullYard is free and MIT-licensed. All three apps are in one repo if you want to compare the implementations: &lt;a href="https://github.com/pullyard/pullyard" rel="noopener noreferrer"&gt;github.com/pullyard/pullyard&lt;/a&gt; (&lt;code&gt;Sources/&lt;/code&gt; for macOS, &lt;code&gt;windows/&lt;/code&gt;, &lt;code&gt;linux/&lt;/code&gt;).&lt;/p&gt;

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

</description>
      <category>showdev</category>
      <category>swift</category>
      <category>rust</category>
      <category>csharp</category>
    </item>
  </channel>
</rss>
