Moving TorBox Stream from React Native to Swift
·4 min read

Moving TorBox Stream from React Native to Swift

After I rewrote TorBox Stream in Swift, the player sometimes refused to close. I could open a video and watch it, then get stuck trying to leave. It wasn't consistent enough to make the cause obvious.

Several review passes pointed at the same part of the presentation state:

Swift
Binding(get: { player.filename != nil }, set: ... )

The state, SwiftUI and view-wrapper reviews approached it differently, but together they gave me a reason to investigate that binding. The fix moved presentation to fullScreenCover(item:) with an identity-stable session object.

Why I moved to Swift

TorBox Stream already had a working React Native version. Its more demanding features were implemented in Swift-backed Expo modules: VLC playback, chunked downloads, content blocking and thumbnail extraction. React Native coordinated them and kept another representation of some native state.

Downloads made that duplication particularly noticeable. There was a Zustand store for the UI and a native download manager responsible for work that could outlive a screen or an app session. I wanted ownership of that state to be easier to follow.

This is an iOS-only, sideloaded personal app. I didn't have an Android or web version to support, so moving the interface to SwiftUI was a reasonable trade for this project. It wouldn't be the same decision for an app that needed a shared interface across platforms.

Moving it in stages

I split the port into foundation, services, observable models, screens, polish and cutover. The native modules and services moved before the screens depended on them. That gave each stage something concrete to check against the React Native version.

I kept the old app installable alongside the new one. I didn't build an importer for resume positions, browser history or download lists, so those don't transfer at cutover. For this personal app, I accepted that limit and kept the old build available.

Downloads need to survive the screen

The download manager splits files into ranged chunks, saves its state, stages the completed chunks and assembles the result. When the app is going away, it has a synchronous save path:

Swift
private func saveStateNow() {
saveQueue.sync {
self.savePending = false
self.lastSaveAt = Date()
self.writeStateToDisk()
}
}

The intention is to preserve enough state to recover work across app lifecycle changes. Active transfers use a foreground session and hand off to a background session as needed. At the time of the write-up, handoff timing, force-quit recovery and resume still needed testing on hardware. I couldn't count the design alone as proof that those cases worked.

The browser took more work than I expected

Handing a video from a WKWebView to the player caused several awkward bugs: stacked players, fullscreen transitions, stale local files and repeated handoffs of the same URL. Some streams also needed the WebView's user agent and cookies carried through.

I made a few deliberate changes during the port. A small initial blocklist covers the gap while the larger list loads. An LRU pool keeps up to six WebViews alive across tab switches, preserving browser history. Reader mode renders extracted article HTML in a separate WebView instead of modifying and then trying to restore the live page.

Those were useful improvements, but I needed to record them as intentional differences so a later comparison wouldn't mistake them for incomplete parity.

What the review caught

I used seven architecture reviewers, a component-by-component comparison with the old app, and an adversarial pass over the more serious findings. The final report counted 68 findings: four high, ten medium, 27 low and 27 informational, with none classified critical.

One of the clearest fixes involved download speed. speedBps changed four times a second and was included in persisted jobs, so the app kept writing a multi-kilobyte blob to UserDefaults. Removing transient speed from the saved state made more sense than repeatedly storing a value that would be stale on the next launch.

The adversarial pass also rejected two plausible findings. Tracing the event ordering ruled out one claimed recovery race; checking the live WebView controller ruled out another configuration claim. That part of the review mattered as much as adding bugs to the list.

Where I left it

At the time of this review, the final device QA and sideload cutover were still pending. There was also unused discovery and metadata code to revisit after Jellyfin took over more of the browsing role.

The rewrite made state ownership easier for me to understand. It didn't remove the need to test the awkward transitions: closing a player, leaving the app during a download, switching tabs and recovering after a restart. Those are the checks I'd plan earlier if I did another port.