---
title: "One Web App, Five Screens, and a Laptop That Switched Itself Off"
date: "2026-10-14"
publish_date: "2026-10-24"
excerpt: "NakTV started life as a webOS app for one television. Putting it on an iPhone, an iPad and a Mac took Claude Code an evening, two private WebKit preferences, an API most people have never heard of — and one forced restart."
tags: ["ios", "macos", "wkwebview", "mse", "webos", "xcode", "claude-code", "3d-printing"]
author: "Martin Harris"
canonical: "https://blog.nakomis.com/2026-10-14-naktv-on-iphone-ipad-and-mac"
repos:
  - name: "naktv"
    url: "https://github.com/nakomis/naktv"
approved: true
---

# One Web App, Five Screens, and a Laptop That Switched Itself Off

![A living room at night where a large television, a laptop, a tablet and a phone all glow with the same live camera view of a resin 3D printer, the build plate rising out of a vat of amber resin.](images/naktv-on-iphone-ipad-and-mac-1.png)

NakTV is a launcher app I wrote for my 77-inch LG: a strip of tabs along the top, each one a live view of something in the house. Mostly 3D printers. It's a React app bundled as a single classic `<script>` file, because webOS loads sideloaded apps from `file://` and refuses anything cleverer. It already ran on a second television, a Sony Bravia, inside a thin Android WebView shell.

The trouble with putting your printer cameras on the television is that the television is also where Rick and Morty lives. I can't keep an eye on a twelve-hour resin print and watch Rick turn himself into a pickle on the same screen. The printers needed to come with me.

So I asked Claude Code the obvious question: how long would it take to get NakTV onto my iPhone, iPad and Mac? I had the certificates. I had the toolchain.

The estimate came back as half a day for the Mac and one to two days for iOS, most of it spent on two problems that had nothing to do with packaging — CORS and file-origin restrictions, and MediaSource on iPhone. I said "go for it", asked it to do as much as it could whilst I was away from the keyboard, and left it to it.

It took an evening. The longest single step was Xcode downloading a 10.6 GB iOS platform it had decided it needed. The most dramatic step was my laptop switching itself off.

## A web app in a box

The shell is the same idea as the Android one: a native window containing a web view, with the built web app bundled inside it as a folder called `www/`. On Apple platforms that means `WKWebView`, one Swift controller shared by an iOS target and a macOS target, and an Xcode project generated from a YAML file by [XcodeGen](https://github.com/yonaskolb/XcodeGen) so that nobody ever has to merge a `.pbxproj` file.

The initial macOS target used SwiftUI scenes — that worked until a code review later in the evening prompted a rewrite, which I'll come to.

The web app doesn't know where it's running. It still thinks it's on a television. That's the point — and it's also where the trouble starts, because the television lets it do things Apple doesn't.

## The CORS headers nobody sends

NakTV talks to half a dozen things on my LAN: go2rtc for the video, the resin printer's status service, OctoPrint, a little now-playing server. None of them send CORS headers, because none of them were ever meant to be called from a web page. The page loads from `file://`, so its origin is `null`, so every one of those fetches is cross-origin.

On webOS that's fine. On Android, WebView has a setting for exactly this — `webView.settings.allowUniversalAccessFromFileURLs = true` — and the Kotlin shell turns it on in one line.

Apple has no public equivalent. `WKWebView` simply won't let a `file://` page fetch from `http://172.29.0.36`.

WebKit itself still has the preference, though. It's just not exposed. You can set it by key:

```swift
let configuration = WKWebViewConfiguration()
configuration.setValue(true, forKey: "allowUniversalAccessFromFileURLs")
configuration.preferences.setValue(true, forKey: "allowFileAccessFromFileURLs")
configuration.mediaTypesRequiringUserActionForPlayback = [] as WKAudiovisualMediaTypes
```

Two lines of key-value coding into private API, and the fetches work. The explicit `WKAudiovisualMediaTypes()` assignment is also necessary here: it enables autoplay, which matters because the player appends segments programmatically rather than waiting for a user gesture. Be aware that private KVC keys like these can stop working across OS updates without notice.

These two private preferences also mean this app can never go anywhere near App Review, which would reject it on sight. If you need something App-Store-safe, the cleaner alternatives are to serve the app via a `WKURLSchemeHandler` so it has a non-`file://` origin, or to proxy LAN requests through the native layer. For something I install on my own devices straight from Xcode, that's a trade I'll take. It is now written in large letters in the repo's `CLAUDE.md` so that no future session gets ambitious about the App Store.

One other thing worth noting if you're fetching from `http://` LAN addresses on iOS: on recent iOS versions you'll need an `NSLocalNetworkUsageDescription` entry in your `Info.plist`, otherwise the system will block the requests before your CORS workaround gets a look-in. Note that Apple's ATS does not apply to raw IP addresses like `172.29.0.36` or to `.local` hostnames, so no ATS exceptions are needed for typical LAN setups.

## On iPhone, WebKit doesn't expose MediaSource

The camera feed reaches the TV over Media Source Extensions: go2rtc pushes fragmented MP4 down a WebSocket, and the page appends each segment to a `SourceBuffer`. That's the only transport the LG would play both the video and the muxed audio track from — a story in itself, which I told in [One Media Element](https://blog.nakomis.com/2026-09-20-one-media-element).

The Mac has `MediaSource`. The iPad has `MediaSource`. On iPhone, WebKit doesn't expose it, and never has. Apple kept it off the phone for years, and in iOS 17.1 shipped something else instead: `ManagedMediaSource`. It takes the same `SourceBuffer` calls, but the system gets to decide when you should be buffering and when it would rather you stopped — primarily for battery and memory management. This means iPhone live playback requires iOS 17.1 or later; earlier versions won't expose `ManagedMediaSource` and there's no fallback for live streaming. If the app encounters a device without either API, the affected tab shows an inline message rather than a black rectangle — it fails loudly rather than silently.

The fix in the player is small:

```typescript
function mediaSourceConstructor():
  { Constructor: typeof MediaSource; managed: boolean } | undefined {
  const scope = globalThis as {
    MediaSource?: typeof MediaSource;
    // ManagedMediaSource has the same surface as MediaSource for our purposes;
    // casting to typeof MediaSource is a small white lie to the type system.
    ManagedMediaSource?: typeof MediaSource;
  };
  if (scope.MediaSource) return { Constructor: scope.MediaSource, managed: false };
  if (scope.ManagedMediaSource) return { Constructor: scope.ManagedMediaSource, managed: true };
  return undefined;
}
```

It prefers the classic one wherever that exists, so the televisions take exactly the path they always did. The cast to `typeof MediaSource` works because the two APIs have the same surface for our specific use — though `ManagedMediaSource` additionally fires `startstreaming` and `endstreaming` events that the system uses to throttle buffering; if you build on this, you'll want to listen to those rather than let the system buffer into a wall.

There is one catch with `ManagedMediaSource` on iPhone: if the element is eligible for AirPlay, the system will prefer remote playback routes unless an AirPlay-safe alternative source is provided. A live WebSocket feed has no such thing — so unless you disable remote playback, `ManagedMediaSource` won't open at all. The player opts out:

```typescript
if (source.managed) {
  (video as { disableRemotePlayback?: boolean }).disableRemotePlayback = true;
}
```

Leave that line out and nothing happens. No error, no event — the source just never opens. If you're chasing this symptom, log `video.disableRemotePlayback` after the injection to confirm the page sees it.

![A smartphone lying on a workbench showing a live video of a resin printer's build plate lifting out of the vat, a USB cable trailing off to a laptop.](images/naktv-on-iphone-ipad-and-mac-2.png)

## Please kill the audio

Halfway through, I opened the Mac build, watched it for a bit, and asked for the sound to be turned off.

I'd spent a whole blog post getting Spotify muxed into that camera stream so the television would play both from one element. On a phone, or a laptop on a desk, it's just noise. The TVs should keep it; Apple devices shouldn't. Rather than touch the React app — which would mean the televisions losing their soundtrack — the shell handles it instead. It injects a script at document-start via `WKUserScript` that pins the `muted` setter before any page code runs:

```javascript
var muted = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'muted');
Object.defineProperty(HTMLMediaElement.prototype, 'muted', {
  configurable: true,
  get: muted.get,
  set: function () { muted.set.call(this, true); }
});
```

The page can set `muted = false` as often as it likes. It doesn't take. Because this override sits on the prototype, it applies to every `HTMLMediaElement` instance — including any `<video>` the page creates later. The web app is unchanged, the televisions still have their soundtrack, and as a bonus a muted video on the iPhone leaves whatever music I'm already playing alone.

## Checking it without looking at it

With me away from the keyboard, nobody was there to squint at a screen, so Claude built itself a way to see. The shell forwards the page's console to Apple's unified log via a `WKUserContentController` message handler, and a `-NakTVProbe` launch argument logs the player's state every five seconds. A second argument, `-NakTVTab Elegoo`, opens straight onto the resin printer's tab, which had live video because a print was running. Claude ran these checks by issuing terminal commands to read `idevicesyslog` over the cable whilst I was away — that's how it could verify the real device without anyone watching a screen.

This is what the real iPhone said:

```
probe: {"tab":"Elegoo","MediaSource":"undefined","ManagedMediaSource":"function",
  "video":{"readyState":4,"paused":false,"muted":true,"size":"1280x720"},
  "text":"... Tripod Hook Mold.goo STATUS Exposing LAYER 1009 / 3810 ..."}
```

No `MediaSource`, a managed one doing the work, 720p, muted, and a tripod-hook mould 26% of the way through printing in the server room — where the resin printer lives because it likes to be kept nice and warm, and the servers are happy to oblige. The iPad said the same thing with `"MediaSource":"function"`. The Mac too.

## The laptop that switched itself off

Before the real devices, Claude checked the iPhone build in the iOS Simulator. Then an iPad build alongside it. Both were playing the H.264 feed.

My laptop — mu, an Intel MacBook I generously describe as a clunker — has an Intel UHD 630 for graphics. Over six minutes it logged twenty GPU resets, every one blamed on `SimMetalHost`, the Simulator's graphics process. WindowServer stopped responding, the watchdog noticed, and macOS gave up and restarted the machine with the message "An error occurred".

I came back to a login screen and asked what had happened. Claude read the crash reports, worked out it had done it, and said so. Nothing was lost: the work was committed and pushed, and the pull request was already open. The lesson is now recorded where future sessions will find it: on that machine, one simulator at most, and preferably not one playing video. Heavy things go on the M-series MacBook Pro.

![An old laptop on a desk at night, its screen gone dark, two tiny phone outlines glowing faintly in the darkness beside it.](images/naktv-on-iphone-ipad-and-mac-3.png)

## The window that wasn't there

A code review afterwards, from a separate Claude subagent running a fresh context with no memory of the earlier session, found the things you'd hope a review finds. The Mac build was signed without a secure timestamp and with the debugger entitlement, so notarisation would have refused it. The install script got confused once more than one device was paired — fixed by selecting devices by UDID.

It also pointed out that the Mac app would happily open a second window on Cmd-N, each one another consumer of the camera stream. Fixing that turned up something worse: the SwiftUI version sometimes relaunched with no window at all — a running process, a menu bar, and nothing to look at. Rather than argue with scene restoration, Claude replaced the SwiftUI shell with a hundred-odd lines of plain AppKit that creates one window at launch, every time. Five quit-and-relaunch cycles, five windows.

## Five screens

NakTV now runs on a 77-inch LG, a 49-inch Bravia, a Mac, an iPad and an iPhone, from one web app that still believes it's on a television. Each platform needed something different from its shell:

- webOS needed the bundle to be a classic script.
- Android needed Back forwarding into the page and `window.close()` redirecting.
- Apple needed two private preferences, a different flavour of MediaSource, and a mute button the page isn't allowed to press.

The printers don't care which screen I'm watching them on. They just keep printing. A tripod hook mould is on its way to layer 3810 in a warm room full of servers, and I can check on it from my pocket whilst the big screen gets on with Rick and Morty.

---

*Martin Harris connects cloud infrastructure to the real world. He estimated two days, Claude finished in an evening, and between them they turned the laptop off and on again without meaning to.*


{{donate}}