iPodderX for iPhone, iPad and Mac

A shell around the ipodderx-rs web UI whose audio is played by the host
rather than the page, so it carries on with the screen locked and a car can
control it. The page's half of the bridge is web/src/native.ts in
ipodderx-rs (#45): it replaces the playback surface of the page's media
element with one that posts here, so player.ts is unchanged and the player
bar, the row buttons and the keyboard shortcuts work as they always did.

Playback, Library, Bridge and CookieBridge never touch a view, which is
what makes a CarPlay scene a later addition rather than a rewrite, and what
lets the same target build for Mac Catalyst with no conditional code:
AVAudioSession, MPNowPlayingInfoCenter and the remote commands all exist
there. The Mac gets media keys and Now Playing; it has no CarPlay and no
lock screen, so it is a convenience rather than the reason for any of this.

Authentication is the web view's. ipx decides who is asking by cookie --
ipx_session, and CF_Authorization from Cloudflare Access in front of the
tunnel -- and its auth layer was written so a plain <audio src> would work,
which is why the player needs no API of its own. CookieBridge keeps
HTTPCookieStorage in step with the web view's store and hands them to each
asset.

Named ipodderx-app, not -ios: native.ts already carries the branch for an
Android host, and the iOS project is one directory rather than the repo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-19 19:21:06 -04:00
commit 74a4ca6016
15 changed files with 1260 additions and 0 deletions

View File

@@ -0,0 +1,55 @@
import Foundation
/// Where ipx is. Asked for on first run and kept, because there is more than one way in: the
/// Cloudflare tunnel from anywhere, and a LAN address at home. Nothing here is a secret --
/// signing in happens in the web view, and the cookies it leaves behind authenticate the rest.
enum ServerSettings {
private static let key = "ipx.server"
/// What the setup screen starts with. A suggestion, not a default: nothing is stored until
/// it is saved, so the first run always asks.
static let suggestion = "https://ipodderx.sdf1.net"
static var base: URL? {
get { UserDefaults.standard.string(forKey: key).flatMap(parse) }
set { UserDefaults.standard.set(newValue?.absoluteString, forKey: key) }
}
static var isConfigured: Bool { base != nil }
/// What someone typed, as an address, or nil if it cannot be one.
///
/// A bare host gets https, because typing `ipodderx.sdf1.net` is the common case and
/// `URL(string:)` would otherwise hand back something with no scheme that fails much later,
/// in a network error nobody can act on.
static func parse(_ raw: String) -> URL? {
var text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return nil }
if !text.contains("://") { text = "https://" + text }
guard let url = URL(string: text),
let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https",
let host = url.host, !host.isEmpty else { return nil }
return url
}
/// Whether plain HTTP will actually be allowed to load. The app permits it on a local
/// network and nowhere else, so this is the difference between a clear warning at setup and
/// a blocked request much later.
static func isLocal(_ url: URL) -> Bool {
guard let host = url.host?.lowercased() else { return false }
if host == "localhost" || host.hasSuffix(".local") || host == "127.0.0.1" || host == "::1" { return true }
if host.hasPrefix("192.168.") || host.hasPrefix("10.") || host.hasPrefix("169.254.") { return true }
// 172.16.0.0/12 is the one that needs arithmetic rather than a prefix.
let parts = host.split(separator: ".")
if parts.count == 4, parts[0] == "172", let second = Int(parts[1]), (16...31).contains(second) { return true }
return false
}
/// A path the page gave us, against the server. The page speaks in absolute paths
/// (`/media/42`), which are relative to whichever host it was served from.
static func url(_ path: String) -> URL? {
if let u = URL(string: path), u.scheme != nil { return u }
guard let base else { return nil }
return URL(string: path, relativeTo: base)?.absoluteURL
}
}