Back in April I ported the official Ollama desktop app to Linux. Upstream ships it for Windows and macOS only, but the C webview library underneath already had full GTK/WebKit2GTK support. The Go build tags were the only thing in the way.
Thanks to everyone who starred it and tried it. Seven months later, upstream has gone from v0.20.6 to v0.40.16, and there is still no official Linux desktop app.
So I updated the port. Along the way I found two bugs in my own v1, and discovered that upstream had deleted a feature I depended on. So I rebuilt it.
That's the v0.40 chat UI running natively on Ubuntu 26.04, generating an image locally on an RTX 5070 in about 22 seconds.
Twenty versions of drift
The port is a patch against upstream plus a handful of _linux.go files. Twenty releases later, most of the patch still applied. The interesting part was everything that compiled but was quietly wrong.
The signatures moved
Upstream changed a few function shapes:
// v0.20 func handleExistingInstance(startHidden bool) func osRun(shutdown func(), hasCompletedFirstRun, startHidden bool) // v0.40 func handleExistingInstance(startHidden bool) bool func osRun(shutdown func(), hasCompletedFirstRun, startHidden, showOnboarding bool, urlSchemeRequest string) There were also new macOS/Windows-only integrations (Claude Desktop and Codex model settings) that Linux needed stubs for. Easy fixes, since the compiler tells you exactly what's wrong.
Linux is "Windows" now (as far as the UI cares)
The React frontend makes layout decisions with isWindowsPlatform(). macOS gets a frameless window with traffic-light padding and the macOS-only app integrations. Windows gets a native title bar.
A GTK window has a native title bar too. So the Linux webview now injects window.OLLAMA_PLATFORM = "linux", and the helper treats Linux like Windows:
return ( window.OLLAMA_PLATFORM === "windows" || window.OLLAMA_PLATFORM === "linux" ); That one change fixes the padding, the title offsets, and hides the Mac-only settings, all at once.
Bug #1: your chat history goes nowhere
This one compiled fine, launched fine, and would have silently thrown away everyone's chats.
v0.40 moved the database path into a shared helper:
func AppDatabasePath() string { switch { case runtime.GOOS == "darwin" && home != "": return filepath.Join(home, "Library", "Application Support", "Ollama", "db.sqlite") case runtime.GOOS == "windows" && localAppData != "": return filepath.Join(localAppData, "Ollama", "db.sqlite") default: return "" } } On Linux that returns "". The SQLite driver happily opens an empty path as a throwaway database. The app works perfectly until you restart it and all your chats are gone.
The tell was a file that appeared in my source tree during tests, literally named:
?_foreign_keys=on&_journal_mode=WAL&_busy_timeout=5000&_txlock=immediate That's the DSN query string with no filename in front of it. The fix is one case pointing Linux back at ~/.ollama/db.sqlite, where v1 kept it. I tested the migration on a copy of a real v1 database: schema 16 → 19, chats intact.
Bug #2: Quit didn't quit (and that one was mine)
While smoke-testing, I sent the app SIGTERM. It logged shutting down... and the window stayed open. Forever.
A goroutine dump showed Terminate() blocked on a mutex. Here's what my v1 Linux webview did:
func (w *Webview) Run(path string) unsafe.Pointer { w.mutex.Lock() defer w.mutex.Unlock() // ... create window ... wv.Run() // blocks in gtk_main() until the app quits } It held the lock for the entire lifetime of the window. So anything that needed the lock while the app was open blocked forever: quit(), Terminate(), IsRunning(). That included the tray's Quit button. In v1, clicking Quit froze the app.
The fix: release the lock before entering the GTK loop, let Terminate() only request the quit (webview queues gtk_main_quit onto the GTK thread, which is thread-safe), and destroy the window back on the main thread once the loop returns.
locked = false w.mutex.Unlock() wv.Run() // blocks until Terminate() queues gtk_main_quit wv.Destroy() // on the GTK thread, after the loop has exited Lesson relearned: never hold a lock across an event loop.
Upstream deleted image generation
v1 had experimental image generation: Ollama's server could run diffusion models through its x/ image runner, and I patched the desktop UI to show the results.
In v0.40 that's gone. The image runner is gone, and so are the Width, Height, Steps and Image fields on the generate API. My patch applied cleanly and then failed to compile against fields that no longer exist.
So the app now skips the Ollama server for images and talks to a small local runner directly:
┌──────────────┐ chat models ┌──────────────────┐ Ollama ──▶│ app UI server│────────────────▶│ ollama :11434 │ desktop │ (Go) │ └──────────────────┘ window │ │ image models ┌──────────────────┐ │ │────────────────▶│ imagegen :11435 │ └──────────────┘ │ PyTorch+diffusers│ └──────────────────┘ The trick is making the existing UI believe image models are real models. The app's UI server proxies /api/tags (model list) and /api/show (model details) to Ollama. I wrapped both:
-
/api/tagsappends the runner's models, sox/z-image-turboshows up in the model picker next to your Ollama models. -
/api/showanswers for image models with"capabilities": ["image"]. The frontend already knows what to do with that capability: it shows the image settings and renders the result.
Then the chat handler sends image models to the runner before Ollama's "do you have this model? no? let's download it" round trip:
if imgRunner.IsImageModel(ctx, req.Model) { cancelLoading() return s.imageChat(ctx, w, flusher, chat, req) } If the runner isn't running, there are no image models, and the app is exactly the plain chat app. It's completely optional.
Fitting a 33 GB model on a 12 GB GPU
I tested with Z-Image-Turbo. First run:
torch.OutOfMemoryError: CUDA out of memory. diffusers' enable_model_cpu_offload() moves each component (text encoder, transformer, VAE) to the GPU whole. Z-Image's transformer is 6B parameters, about 12 GB in bf16, on a 12 GB card. The alternative, enable_sequential_cpu_offload(), streams weights layer by layer: slower, but it fits anywhere.
I didn't want users to have to know that. So the runner tries the fast mode and falls back automatically:
try: result = run() except torch.OutOfMemoryError: unload_all() _sequential_fallback.add(base) result = run() # sequential offload this time Second run: out of memory again. Same numbers, to the megabyte.
The fallback had triggered. The log said so. But look where the retry runs: inside the except block. While Python is handling an exception, the traceback holds references to every frame in the failed call, including the first pipeline and its 9.7 GB of CUDA tensors. unload_all() ran gc.collect() and empty_cache(), and couldn't free anything that was still referenced.
The fix is to leave the except block first:
retry = False try: result = run() except torch.OutOfMemoryError: retry = True if retry: # the traceback is gone now, so the VRAM can actually be freed unload_all() _sequential_fallback.add(base) result = run() Third run: Generated 1024x1024 in 8 steps, 25.5s. After that it's about 22 seconds per image.
Two more small ones
The image wouldn't display. The PNG was saved, but the chat showed a broken-image icon. Upstream's ImageThumbnail component creates a blob URL in useMemo and revokes it in a useEffect cleanup. In dev mode, React StrictMode mounts, unmounts and remounts components to catch exactly this. The cleanup revokes the URL, and the remount reuses the memoized dead one. The fix is to create the URL inside the same effect that revokes it.
The runner held onto 22 GB of RAM. With CPU offload, the whole pipeline lives in system RAM, and nothing ever unloaded it. I added an Ollama-style keep-alive that unloads after 10 idle minutes. RSS still only dropped to 15.7 GB, because glibc keeps freed pages instead of returning them to the OS. One line fixed it:
ctypes.CDLL("libc.so.6").malloc_trim(0) Down to 2 GB.
Try it
App (prebuilt binary):
sudo apt install libwebkit2gtk-4.1-0 libgtk-3-0 libayatana-appindicator3-1 zenity curl -fLO https://github.com/johnohhh1/ollama-webchat-ubuntu/releases/latest/download/ollama-app chmod +x ollama-app && sudo mv ollama-app /usr/local/bin/ ollama-app Image generation (optional; needs an NVIDIA GPU and uv):
git clone https://github.com/johnohhh1/ollama-webchat-ubuntu.git bash ollama-webchat-ubuntu/src/imagegen/install.sh That installs the runner as a systemd user service. Models are configured in ~/.config/ollama-imagegen/models.json. The first image downloads the model weights, which are big (25–35 GB).
If you're upgrading from v1, your chat history comes with you.
What I'd love help with
- Other GPUs. I've only tested a 12 GB RTX 5070. 16 GB and 24 GB cards should take the faster offload path. I'd love numbers.
- Other distros. Fedora 40+ and Arch should work (they ship
webkit2gtk-4.1), but I haven't confirmed it. - More models. FLUX.2-klein is in the default config but untested by me.
Repo: github.com/johnohhh1/ollama-webchat-ubuntu
Issues and PRs welcome. If it saves you from running Ollama in a browser tab, a ⭐ helps other Linux folks find it.
I built this update with help from Claude Code (Anthropic's coding agent). The debugging stories above are what actually happened.



