To move focus out of the editor, press Escape and then Tab.
Blog
· 6 min read

The bug that only happened in production

The macOS app couldn't reach LM Studio. Chrome, on the same machine, talking to the same server, could. Both were running on someone's own computer, and one of them was refusing to connect to it.

Here's what turned out to be wrong, which is more interesting than it sounds, and what we did about it.

Four combinations, twenty minutes

The tempting thing with a bug like this is to start guessing. Local network permissions. Apple's rules about insecure connections. CORS, the mechanism that lets a server say which websites are allowed to call it. All three are plausible, and each one would have taken an afternoon to chase down.

So we tried every combination instead. Two browser engines, two addresses the page could be loaded from:

WebKit (Mac app) Chromium (Chrome)
https origin (production) fails works
http origin (local dev build) works works

One failing cell out of four, and the other three ruled out most of the suspects on their own. The same WebKit view happily makes the same insecure request to the same port when the page is served over plain http, so it isn't a network permission and it isn't Apple's transport rules - those don't care which address the page came from. Chrome reaches the server from the production address, so the local server is perfectly happy to be called by our page, which rules out CORS.

Which leaves one explanation: WebKit, on an https page, refusing to make a request to http://localhost.

Why WebKit says no

Browsers block an https page from loading things over plain http. That rule is correct and nobody's asking for it back.

The specification carves out an exception for addresses that point at your own machine, on the grounds that a request which never touches a network can't be intercepted on the way. Chromium implements the exception. Firefox implements it. WebKit never has, and the bug has been open since 2017.

Our production app loads https://snownotes.co and renders it in a WebKit view, so every request to http://localhost:1234 was being stopped before it left the page.

The part that cost us the most time is worth saying plainly, because it's exactly the wrong thing to assume when you're debugging: our Content Security Policy already allowed the request. That policy is the list of places a page is permitted to fetch from, and ours named http://localhost:* explicitly. It made no difference, because permitting something in that list and the mixed-content check are two separate gates, and the second one still gets a vote. Adding a permission there doesn't grant anything; it only removes a restriction we'd otherwise have imposed ourselves.

There was a smaller problem underneath. The code that checks whether a provider is reachable caught the exception and reported the provider as unreachable, so a policy decision made by the browser arrived looking like a server that wasn't running, with nothing written down anywhere that anyone could read. We'd probably have found the real cause in an hour if the error had said what it was.

The fix: leave the webview

If the browser engine won't make the request, something else has to. Our native apps are built with Tauri, which means there's a Rust process sitting underneath the web page, and that process has no opinion about mixed content. So the request goes there instead. The page hands it over, Rust makes the connection, and the response streams back in.

The decision worth drawing attention to isn't the transport, though. It's that every native app now uses it, including the ones that were never broken.

The broken ones
macOS, Linux, iOS, and iPadOS all render in WebKit, so every one of them failed the same way for the same reason.
The working ones
Windows and Android render in Chromium engines and were fine. They go through the native path anyway.
One path, not five
Fixing only the broken platforms leaves five behaviours to keep in your head. With one path, a bug found on Linux is a bug found everywhere.
No quiet drift
Engines change their policies underneath us, and not hypothetically - see the Chrome note below.

Leaving the two working platforms on their own quiet path would have saved a day that week and cost us a little every week after.

Letting a web page make arbitrary network requests through native code is a bad idea if you're careless about it. A compromised page shouldn't get a readable window into somebody's home network.

So the native path isn't a general-purpose HTTP client. Requests to your own machine go through without ceremony, since that's the ordinary case and nothing leaves the computer. Anything else, like a model running on another box in your flat, has to be approved by you once, and we remember the answer. Redirects aren't followed, so a host you approved can't bounce the request somewhere you didn't.

Where this works today

The honest version, since this is the part such a post usually skips.

In the native apps
This is where the fix lives, and there's a native build for every platform we target - macOS, Windows, Linux, iOS, iPadOS, and Android on phones and tablets. How you get hold of one varies while the store listings are in progress.
In Chrome and Firefox
Local models work from an ordinary browser tab, and always did. Chrome 142 now shows its own permission prompt the first time a page reaches your local network, which is the same consent model arriving natively.
In Safari, not at all
Safari on the web hits the same WebKit block with no native process to hand the request to. There's no fix we can ship, so this is the one case where the answer is the app rather than a preference - that, or putting your local server behind https yourself.
With a cloud model, everywhere
If you're not running a model yourself, none of this applies. Cloud providers are reached over https and work in every browser we support.

What we took from it

Two things, one specific and one less so.

The specific one: when a platform behaves differently from a specification you've read, check whether it implements the part you're leaning on. We had a comment in our config asserting that requests to your own machine are exempt from mixed-content blocking. It was true of the engines whoever wrote it had tried, and false of the one that broke.

The general one: run the whole matrix before you form a theory. Those four combinations took about twenty minutes and eliminated three explanations that would each have cost an afternoon. Starting from a single failing case is how you end up carefully fixing something that was never broken.

We're in closed beta while we work through more of this. If you'd like an invite, join the waitlist.