From a581e3fc889f3c2bb6114ebddf7716f2cb3603d4 Mon Sep 17 00:00:00 2001 From: rays Date: Sat, 7 Nov 2026 21:50:43 +0000 Subject: [PATCH] An iOS app: the page in a web view, with the phone's own player (#418) web/src/native.ts (#45) has handed playback to a host since September, and there was no host in this repo: the earlier app, rays/ipodderx-app, grew native lists beside the page and is left behind. This one is the page and a player: a WKWebView of ipx-web, AVPlayer behind the bridge with the audio session to play on locked, now playing and the remote commands (15 back, 30 forward, scrubbing), and positions and read written from the player's own clock, with DeadAir's copy named so the server reads the position on its clock. Built with XcodeGen on a Mac; nothing here compiles the Swift. Its icon is web/logo.svg at 1024. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RqkrVDqdiQEMWAkNGQ2tYA --- CLAUDE.md | 11 +- ios/.gitignore | 3 + ios/CHANGELOG.md | 9 + ios/README.md | 79 +++++ .../AppIcon.appiconset/Contents.json | 6 + .../AppIcon.appiconset/icon-1024.png | Bin 0 -> 48391 bytes ios/Resources/Assets.xcassets/Contents.json | 1 + ios/Sources/App.swift | 19 ++ ios/Sources/Bridge.swift | 74 +++++ ios/Sources/CookieBridge.swift | 31 ++ ios/Sources/Playback.swift | 302 ++++++++++++++++++ ios/Sources/Server.swift | 43 +++ ios/Sources/WebViewController.swift | 112 +++++++ ios/Tests/ServerTests.swift | 24 ++ ios/project.yml | 71 ++++ 15 files changed, 784 insertions(+), 1 deletion(-) create mode 100644 ios/.gitignore create mode 100644 ios/CHANGELOG.md create mode 100644 ios/README.md create mode 100644 ios/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 ios/Resources/Assets.xcassets/AppIcon.appiconset/icon-1024.png create mode 100644 ios/Resources/Assets.xcassets/Contents.json create mode 100644 ios/Sources/App.swift create mode 100644 ios/Sources/Bridge.swift create mode 100644 ios/Sources/CookieBridge.swift create mode 100644 ios/Sources/Playback.swift create mode 100644 ios/Sources/Server.swift create mode 100644 ios/Sources/WebViewController.swift create mode 100644 ios/Tests/ServerTests.swift create mode 100644 ios/project.yml diff --git a/CLAUDE.md b/CLAUDE.md index f0c325a..696751f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -124,6 +124,14 @@ a daemon by hand for testing, **stop it by its own PID**: start it with `& echo production's daemon as well (issue #38). Never `pkill -f ipx` either: `-f` matches the shell running the command and kills the session (exit 144), which has happened more than once. +## The iOS app + +`ios/` is the page in a `WKWebView` with the phone's own player behind `web/src/native.ts` (#418); +see [ios/README.md](ios/README.md). It builds only on a Mac with Xcode, from `ios/project.yml` +through XcodeGen: nothing here compiles or checks the Swift, so say so when handing over a change +to it. A change to the bridge's messages is a change to `native.ts`, `tests/native-bridge.ts` and +`ios/Sources/Bridge.swift` together, and the page deploys first. + ## Before you touch the page There are three pages: the app (`web/index.html`), the admin page (`web/admin.html`, sent to @@ -393,7 +401,8 @@ Every change gets one line under `## [Unreleased]` in the changelog of the servi its [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) group: Added, Changed, Deprecated, Removed, Fixed or Security. One changelog a service: [crates/ipx/CHANGELOG.md](crates/ipx/CHANGELOG.md) (the daemon and its API; until 0.12.0 everything), [web/CHANGELOG.md](web/CHANGELOG.md) (the page -and ipx-web) and [crates/directory/CHANGELOG.md](crates/directory/CHANGELOG.md) (ipx-directory). A +and ipx-web) [crates/directory/CHANGELOG.md](crates/directory/CHANGELOG.md) (ipx-directory) and +[ios/CHANGELOG.md](ios/CHANGELOG.md) (the iOS app). A change to more than one gets a line in each, each saying what that service does differently. The monitoring (the Grafana dashboard, the prod-check skill) and the repository's own tooling are not a service anyone uses and get no line. Say it the way someone using ipx would notice it. When there is more to diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 0000000..76a4e67 --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,3 @@ +*.xcodeproj/ +build/ +DerivedData/ diff --git a/ios/CHANGELOG.md b/ios/CHANGELOG.md new file mode 100644 index 0000000..2787637 --- /dev/null +++ b/ios/CHANGELOG.md @@ -0,0 +1,9 @@ +# Changelog + +The iOS app, `ios/`. Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). + +## [Unreleased] + +### Added + +- iPX on the iPhone: the page in an app, playing through the phone's own player, so an episode plays on with the screen locked and the lock screen, Control Center and a car's buttons drive it (#418). diff --git a/ios/README.md b/ios/README.md new file mode 100644 index 0000000..de98870 --- /dev/null +++ b/ios/README.md @@ -0,0 +1,79 @@ +# iPX for iOS + +ipx's page in an app, with the audio played by the phone rather than the page: it plays on with +the screen locked, and the lock screen, Control Center, headphones and a car over Bluetooth or USB +drive it. Everything you see is the page, as on the home screen; the app adds the player behind it. + +## Build it + +On a Mac with Xcode: + +```sh +brew install xcodegen +cd ios && xcodegen generate && open iPX.xcodeproj +``` + +Set your team under Signing & Capabilities, pick your phone, and run. The project is generated +from `project.yml`, so `.xcodeproj` is not in git: edit the yml. Nothing on Tower can build it. + +A free personal team works, but a build on it expires after seven days and is reinstalled from +Xcode. A paid account ($99 a year) makes that a year and adds TestFlight. Playing with the screen +locked is a plist key (`UIBackgroundModes: audio`), not an entitlement, so it works on either. +CarPlay's own dashboard app does not: Apple grants `com.apple.developer.carplay-audio` on request. + +## Which server + +The first launch asks, with `https://ipodderx.sdf1.net` filled in; a bare host gets `https://`. +Shake the phone to change it. Plain `http://` only on your own network +(`http://192.168.1.130:8099`), which is all `NSAllowsLocalNetworking` allows. Sign in on the page +that follows, through Cloudflare Access or ipx's own form; the cookies that leaves are what the +player uses (`CookieBridge`). `-ipx.server ` as a launch argument overrides it for a run. + +## How it hangs together + +| | | +|---|---| +| `Sources/App.swift` | the app and its one scene | +| `Sources/WebViewController.swift` | the `WKWebView`, the server question, a page that will not load | +| `Sources/Bridge.swift` | the messages to and from `web/src/native.ts` | +| `Sources/Playback.swift` | `AVPlayer`, the audio session, now playing, the remote commands, saving positions | +| `Sources/CookieBridge.swift` | the web view's cookies into `HTTPCookieStorage.shared`, for the player | +| `Sources/Server.swift` | which server | + +## The messages + +The page's half is `web/src/native.ts`, and `node tests/native-bridge.ts` holds it to these. A +change to them is a change to both halves in one commit, and the page has to be deployed before +an app that needs it. + +Page to app, on `webkit.messageHandlers.ipx`: + +| | | +|---|---| +| `{t:"ready", version, rate, volume}` | the bridge is in | +| `{t:"load", url, enc, feedId, guid, title, feedTitle, artwork, position, duration, rate, volume}` | the element's `src` was set to an audio file | +| `{t:"play"}` `{t:"pause"}` `{t:"stop"}` | | +| `{t:"seek", to}` `{t:"rate", v}` `{t:"volume", v}` | | +| `{t:"position", url}` | save where we are; a path, not a time, since a page frozen in the background holds an old one | + +App to page, as `window.ipxNative.on(...)`: + +| | | +|---|---| +| `{t:"time", cur, dur}` | `timeupdate` | +| `{t:"meta", dur}` | `loadedmetadata`: the page seeks to where you left off | +| `{t:"state", playing}` | `play` or `pause` | +| `{t:"ended"}` | | +| `{t:"error", message}` | the page says so | + +The app writes the position (every ten seconds and on pause) and marks an item read at 90%, from +its own clock, so both happen while the page is frozen. Video stays in the page. + +## Not yet + +* Episodes kept on the device (#412) are the page's service worker's, which a `WKWebView` does + not run without App-Bound Domains; the app plays them from the server. +* The cookie is the only credential: when Cloudflare Access's expires mid-drive, playback stops + with "Sign in again", and opening the app signs in. A token in the keychain would end that. +* CarPlay's dashboard app needs Apple's entitlement first; then it is a second scene over the same + `Playback`. diff --git a/ios/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..df8b22a --- /dev/null +++ b/ios/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,6 @@ +{ + "images" : [ + { "filename" : "icon-1024.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/ios/Resources/Assets.xcassets/AppIcon.appiconset/icon-1024.png b/ios/Resources/Assets.xcassets/AppIcon.appiconset/icon-1024.png new file mode 100644 index 0000000000000000000000000000000000000000..2a11dc9e943719870dedbe29044731d808d7c6d1 GIT binary patch literal 48391 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 literal 0 HcmV?d00001 diff --git a/ios/Resources/Assets.xcassets/Contents.json b/ios/Resources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..ac502da --- /dev/null +++ b/ios/Resources/Assets.xcassets/Contents.json @@ -0,0 +1 @@ +{ "info": { "author": "xcode", "version": 1 } } diff --git a/ios/Sources/App.swift b/ios/Sources/App.swift new file mode 100644 index 0000000..d60b173 --- /dev/null +++ b/ios/Sources/App.swift @@ -0,0 +1,19 @@ +import UIKit + +@main +final class AppDelegate: UIResponder, UIApplicationDelegate {} + +/// The one window. iOS will not run an app that has not adopted scenes, and a CarPlay app would +/// be a second scene beside this one. +final class SceneDelegate: UIResponder, UIWindowSceneDelegate { + var window: UIWindow? + + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, + options: UIScene.ConnectionOptions) { + guard let scene = scene as? UIWindowScene else { return } + let window = UIWindow(windowScene: scene) + window.rootViewController = WebViewController() + window.makeKeyAndVisible() + self.window = window + } +} diff --git a/ios/Sources/Bridge.swift b/ios/Sources/Bridge.swift new file mode 100644 index 0000000..8b7fdde --- /dev/null +++ b/ios/Sources/Bridge.swift @@ -0,0 +1,74 @@ +import Foundation +import WebKit + +/// Between the page and the phone's player. +/// +/// The page's half is web/src/native.ts. It replaces the playback surface of the page's media +/// element with one that posts here, so player.ts (the player bar, the row buttons, the keys) +/// carries on unchanged while this side plays. tests/native-bridge.ts holds the page's half to +/// the messages; ios/README.md lists them. +@MainActor +final class Bridge: NSObject, WKScriptMessageHandler { + static let name = "ipx" + + private weak var webView: WKWebView? + private let playback: Playback + + init(webView: WKWebView, playback: Playback) { + self.webView = webView + self.playback = playback + super.init() + playback.onTime = { [weak self] cur, dur in + // Built up rather than `dur as Any`: a nil cast to Any is an Any holding nil, which + // JSONSerialization refuses, and every tick before the length was known would drop. + var m: [String: Any] = ["t": "time", "cur": cur] + if let dur { m["dur"] = dur } + self?.send(m) + } + playback.onMeta = { [weak self] dur in self?.send(["t": "meta", "dur": dur]) } + playback.onState = { [weak self] playing in self?.send(["t": "state", "playing": playing]) } + playback.onEnded = { [weak self] in self?.send(["t": "ended"]) } + playback.onError = { [weak self] msg in self?.send(["t": "error", "message": msg]) } + } + + // MARK: - page to host + + func userContentController(_ controller: WKUserContentController, + didReceive message: WKScriptMessage) { + guard let m = message.body as? [String: Any], let t = m["t"] as? String else { return } + switch t { + case "ready": + if let r = m["rate"] as? Double { playback.setRate(Float(r)) } + if let v = m["volume"] as? Double { playback.setVolume(Float(v)) } + case "load": + guard let path = m["url"] as? String, let url = Server.resolve(path), + let enc = m["enc"] as? Int, + let feedId = m["feedId"] as? String, let guid = m["guid"] as? String else { return } + playback.load(.init( + url: url, enc: enc, feedId: feedId, guid: guid, + title: m["title"] as? String ?? "", + feedTitle: m["feedTitle"] as? String ?? "", + artwork: m["artwork"] as? String, + duration: m["duration"] as? Int)) + case "play": playback.play() + case "pause": playback.pause() + case "stop": playback.stop() + case "seek": if let to = m["to"] as? Double { playback.seek(to: to) } + case "rate": if let v = m["v"] as? Double { playback.setRate(Float(v)) } + case "volume": if let v = m["v"] as? Double { playback.setVolume(Float(v)) } + // The page sends the path it would have written to, not a time: ours is the clock that + // is not stale. + case "position": playback.savePosition() + default: break + } + } + + // MARK: - host to page + + private func send(_ payload: [String: Any]) { + guard let webView, let data = try? JSONSerialization.data(withJSONObject: payload), + let json = String(data: data, encoding: .utf8) else { return } + // A frozen or reloading page misses a tick; the next one puts it right. + webView.evaluateJavaScript("window.ipxNative && window.ipxNative.on(\(json))", completionHandler: nil) + } +} diff --git a/ios/Sources/CookieBridge.swift b/ios/Sources/CookieBridge.swift new file mode 100644 index 0000000..044c4b7 --- /dev/null +++ b/ios/Sources/CookieBridge.swift @@ -0,0 +1,31 @@ +import Foundation +import WebKit + +/// The web view signs in; the player rides on what that left behind. +/// +/// ipx knows who is asking by cookie: `ipx_session` from its own form, `CF_Authorization` from +/// Cloudflare Access in front of the tunnel. That is why the player needs no API of its own. But +/// AVPlayer and URLSession read `HTTPCookieStorage.shared`, and WKWebView keeps a store of its +/// own, so this copies one into the other whenever the web view's changes. +final class CookieBridge: NSObject, WKHTTPCookieStoreObserver { + private let store: WKHTTPCookieStore + + init(store: WKHTTPCookieStore) { + self.store = store + super.init() + store.add(self) + sync() + } + + func cookiesDidChange(in cookieStore: WKHTTPCookieStore) { sync() } + + /// All of them, replaced: a cookie the web view dropped (signing out) has to go here too, or + /// the player would go on playing as someone who signed out. + private func sync() { + store.getAllCookies { all in + let shared = HTTPCookieStorage.shared + shared.cookies?.forEach(shared.deleteCookie) + all.forEach(shared.setCookie) + } + } +} diff --git a/ios/Sources/Playback.swift b/ios/Sources/Playback.swift new file mode 100644 index 0000000..91a70d9 --- /dev/null +++ b/ios/Sources/Playback.swift @@ -0,0 +1,302 @@ +import AVFoundation +import MediaPlayer +import UIKit + +/// The phone's player: the one that plays on with the screen locked, and the one the lock screen +/// and a car's buttons reach. Knows nothing of the web view; the bridge drives it. +@MainActor +final class Playback: NSObject { + /// What is loaded: enough for the lock screen, and to save a position without asking the page. + struct Item { + var url: URL + var enc: Int + var feedId: String + var guid: String + var title: String + var feedTitle: String + var artwork: String? + var duration: Int? + } + + var onTime: ((Double, Double?) -> Void)? + var onMeta: ((Double) -> Void)? + var onState: ((Bool) -> Void)? + var onEnded: (() -> Void)? + var onError: ((String) -> Void)? + + private let session: URLSession = { + let cfg = URLSessionConfiguration.default + cfg.httpCookieStorage = .shared + cfg.timeoutIntervalForRequest = 20 + return URLSession(configuration: cfg) + }() + private var player: AVPlayer? + private var item: Item? + private var timeObserver: Any? + private var statusObservation: NSKeyValueObservation? + private var endObserver: NSObjectProtocol? + private var lastSaved: Double = 0 + private var markedRead = false + private var rate: Float = 1 + private var volume: Float = 1 + + var isPlaying: Bool { player?.timeControlStatus == .playing } + var currentTime: Double { player?.currentTime().seconds ?? 0 } + var duration: Double? { + guard let d = player?.currentItem?.duration.seconds, d.isFinite, d > 0 else { return nil } + return d + } + + override init() { + super.init() + // spokenAudio is what tells the system this is speech, so navigation's voice ducks it + // properly, and longFormAudio is what routes it as a podcast does. + do { + try AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio, + policy: .longFormAudio) + } catch { + NSLog("ipx: audio session: %@", error.localizedDescription) + } + wireRemoteCommands() + } + + // MARK: - what the page asks for + + func load(_ item: Item) { + stop(notify: false) + self.item = item + markedRead = false + lastSaved = 0 + + // An AVURLAsset does not read the shared cookie store by itself, and the cookie is how + // ipx knows whose file this is. + let asset = AVURLAsset(url: item.url, options: [ + AVURLAssetHTTPCookiesKey: HTTPCookieStorage.shared.cookies(for: item.url) ?? [], + ]) + let playerItem = AVPlayerItem(asset: asset) + let player = AVPlayer(playerItem: playerItem) + player.volume = volume + player.defaultRate = rate + self.player = player + + // Where to start is the page's to decide, on loadedmetadata, so one piece of code owns it. + // Here only the length is reported. + statusObservation = playerItem.observe(\.status, options: [.new]) { [weak self] pi, _ in + Task { @MainActor in + guard let self else { return } + switch pi.status { + case .readyToPlay: + if let d = self.duration { self.onMeta?(d) } + case .failed: + // A file that will not play, or a session that went while it played: only the + // second has something to do about it. + let msg = pi.error?.localizedDescription ?? "the file could not be played" + let status = await self.request("GET", "/api/me") + self.onError?(status == 401 ? "Sign in again" : msg) + default: break + } + } + } + timeObserver = player.addPeriodicTimeObserver( + forInterval: CMTime(seconds: 0.5, preferredTimescale: 600), queue: .main + ) { [weak self] t in + MainActor.assumeIsolated { self?.tick(t.seconds) } + } + endObserver = NotificationCenter.default.addObserver( + forName: .AVPlayerItemDidPlayToEndTime, object: playerItem, queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { self?.didEnd() } + } + updateNowPlaying() + } + + func play() { + guard let player else { return } + try? AVAudioSession.sharedInstance().setActive(true) + player.playImmediately(atRate: rate) + onState?(true) + updateNowPlaying() + } + + func pause() { + player?.pause() + onState?(false) + savePosition() + updateNowPlaying() + } + + func seek(to secs: Double) { + // Exact, not the default tolerance, which snaps to a keyframe minutes from where the + // scrubber was let go on a long episode. + player?.seek(to: CMTime(seconds: max(0, secs), preferredTimescale: 600), + toleranceBefore: .zero, toleranceAfter: .zero) + updateNowPlaying() + } + + func setRate(_ r: Float) { + rate = r + player?.defaultRate = r + // Setting rate on a paused player starts it, which changing the speed should not do. + if isPlaying { player?.rate = r } + updateNowPlaying() + } + + func setVolume(_ v: Float) { + volume = v + player?.volume = v + } + + func stop(notify: Bool = true) { + savePosition() + if let timeObserver { player?.removeTimeObserver(timeObserver) } + if let endObserver { NotificationCenter.default.removeObserver(endObserver) } + timeObserver = nil + endObserver = nil + statusObservation = nil + player?.pause() + player = nil + item = nil + MPNowPlayingInfoCenter.default().nowPlayingInfo = nil + try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + if notify { onState?(false) } + } + + /// Where we are, saved from this clock. The page asks for it too, and sends no time of its + /// own: a web view frozen in the background holds a time from minutes ago, and writing that + /// over where you got to is the stale write player.ts guards against. + func savePosition() { + guard let item, let player, player.currentItem?.status == .readyToPlay else { return } + let secs = Int(player.currentTime().seconds.rounded()) + guard secs > 0 else { return } + lastSaved = Double(secs) + var body: [String: Any] = ["secs": secs] + if let d = duration { body["duration"] = Int(d.rounded()) } + // DeadAir's copy runs on a shorter clock; the server has to know which one this is. + if item.url.path.contains("/deadair") { body["deadair"] = item.enc } + Task { await request("POST", entryPath(item, "position"), body) } + } + + // MARK: - as it plays + + private func tick(_ secs: Double) { + guard secs.isFinite else { return } + onTime?(secs, duration) + updateElapsed(secs) + if abs(secs - lastSaved) > 10 { savePosition() } + // Heard, not just started: the same 90% the page uses, so an item does not leave Unread + // the moment it is pressed. The page does the same while it runs; this is for when it + // does not. + if let d = duration, secs / d >= 0.9 { markRead() } + } + + private func didEnd() { + savePosition() + markRead() + onState?(false) + onEnded?() + updateNowPlaying() + } + + private func markRead() { + guard !markedRead, let item else { return } + markedRead = true + Task { await request("POST", entryPath(item, "flags"), ["read": true]) } + } + + // MARK: - the lock screen and the car + + private func wireRemoteCommands() { + let c = MPRemoteCommandCenter.shared() + c.playCommand.addTarget { [weak self] _ in self?.play(); return .success } + c.pauseCommand.addTarget { [weak self] _ in self?.pause(); return .success } + c.togglePlayPauseCommand.addTarget { [weak self] _ in + guard let self else { return .commandFailed } + self.isPlaying ? self.pause() : self.play() + return .success + } + // The page's 15 back and 30 forward, so a steering wheel and the keyboard agree. + c.skipBackwardCommand.preferredIntervals = [15] + c.skipForwardCommand.preferredIntervals = [30] + c.skipBackwardCommand.addTarget { [weak self] _ in + guard let self else { return .commandFailed } + self.seek(to: self.currentTime - 15) + return .success + } + c.skipForwardCommand.addTarget { [weak self] _ in + guard let self else { return .commandFailed } + self.seek(to: self.currentTime + 30) + return .success + } + c.changePlaybackPositionCommand.addTarget { [weak self] ev in + guard let self, let e = ev as? MPChangePlaybackPositionCommandEvent else { + return .commandFailed + } + self.seek(to: e.positionTime) + return .success + } + } + + private func updateNowPlaying() { + guard let item else { return } + var info: [String: Any] = [ + MPMediaItemPropertyTitle: item.title, + MPMediaItemPropertyArtist: item.feedTitle, + MPMediaItemPropertyAlbumTitle: item.feedTitle, + MPNowPlayingInfoPropertyMediaType: MPNowPlayingInfoMediaType.audio.rawValue, + MPNowPlayingInfoPropertyPlaybackRate: isPlaying ? rate : 0, + MPNowPlayingInfoPropertyElapsedPlaybackTime: currentTime, + ] + if let d = duration ?? item.duration.map(Double.init) { + info[MPMediaItemPropertyPlaybackDuration] = d + } + let centre = MPNowPlayingInfoCenter.default() + centre.nowPlayingInfo = info + + guard let art = item.artwork, let url = Server.resolve(art) else { return } + Task { + guard let (data, _) = try? await session.data(from: url), + let image = UIImage(data: data), self.item?.guid == item.guid else { return } + // Read back, not `info`: by the time the picture arrives that elapsed time is stale, + // and writing it would jerk the progress bar back. + var latest = centre.nowPlayingInfo ?? [:] + latest[MPMediaItemPropertyArtwork] = Self.artwork(image) + centre.nowPlayingInfo = latest + } + } + + /// Made outside the main actor: the system asks for the picture on a thread of its own. + nonisolated private static func artwork(_ image: UIImage) -> MPMediaItemArtwork { + MPMediaItemArtwork(boundsSize: image.size) { _ in image } + } + + /// The clock alone, each tick: rewriting the whole dictionary that often makes a car's + /// progress bar stutter. + private func updateElapsed(_ secs: Double) { + guard var info = MPNowPlayingInfoCenter.default().nowPlayingInfo else { return } + info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = secs + info[MPNowPlayingInfoPropertyPlaybackRate] = isPlaying ? rate : 0 + MPNowPlayingInfoCenter.default().nowPlayingInfo = info + } + + // MARK: - ipx + + private func entryPath(_ item: Item, _ what: String) -> String { + let esc = { (s: String) in s.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? s } + return "/api/entries/\(esc(item.feedId))/\(esc(item.guid))/\(what)" + } + + /// One call to ipx, signed in by the web view's cookies; its status, or nil if it never + /// arrived. A position that does not save is saved again ten seconds on. + @discardableResult + private func request(_ method: String, _ path: String, _ json: [String: Any]? = nil) async -> Int? { + guard let url = Server.resolve(path) else { return nil } + var req = URLRequest(url: url) + req.httpMethod = method + if let json { + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.httpBody = try? JSONSerialization.data(withJSONObject: json) + } + guard let (_, resp) = try? await session.data(for: req) else { return nil } + return (resp as? HTTPURLResponse)?.statusCode + } +} diff --git a/ios/Sources/Server.swift b/ios/Sources/Server.swift new file mode 100644 index 0000000..996c302 --- /dev/null +++ b/ios/Sources/Server.swift @@ -0,0 +1,43 @@ +import Foundation + +/// Which ipx-web the app shows. Asked for on first launch; shaking the phone asks again. +/// `-ipx.server ` on the launch arguments overrides it for that run (UserDefaults' argument +/// domain), which is how a test points the app somewhere else. +enum Server { + private static let key = "ipx.server" + static let suggested = "https://ipodderx.sdf1.net" + + static var url: URL? { + get { UserDefaults.standard.string(forKey: key).flatMap(normalise) } + set { UserDefaults.standard.set(newValue?.absoluteString, forKey: key) } + } + + /// What someone typed, as an address: a bare host gets https. Plain http is allowed only on + /// your own network, which is all NSAllowsLocalNetworking lets through, so it is refused here + /// rather than failing later and looking like the server is down. + static func normalise(_ text: String) -> URL? { + var s = text.trimmingCharacters(in: .whitespacesAndNewlines) + if s.isEmpty { return nil } + if !s.contains("://") { s = "https://" + s } + guard let u = URL(string: s), let host = u.host, !host.isEmpty else { return nil } + switch u.scheme { + case "https": return u + case "http": return isLocal(host) ? u : nil + default: return nil + } + } + + static func isLocal(_ host: String) -> Bool { + if host == "localhost" || host.hasSuffix(".local") { return true } + let p = host.split(separator: ".").compactMap { Int($0) } + guard p.count == 4 else { return false } + return p[0] == 10 || p[0] == 127 || (p[0] == 192 && p[1] == 168) + || (p[0] == 172 && (16...31).contains(p[1])) + } + + /// A path the page sent (`/media/42`), against the server. + static func resolve(_ path: String) -> URL? { + guard let base = url else { return nil } + return URL(string: path, relativeTo: base)?.absoluteURL + } +} diff --git a/ios/Sources/WebViewController.swift b/ios/Sources/WebViewController.swift new file mode 100644 index 0000000..eb5c900 --- /dev/null +++ b/ios/Sources/WebViewController.swift @@ -0,0 +1,112 @@ +import UIKit +import WebKit + +/// The app, very nearly: ipx's own page, full screen, with the phone's player behind it. +final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDelegate { + private var webView: WKWebView! + private var cookies: CookieBridge! + // Lazy, so it is made in viewDidLoad, on the main actor it belongs to. + private lazy var playback = Playback() + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .black + + let cfg = WKWebViewConfiguration() + // The persistent store, so a sign-in through Cloudflare Access survives a relaunch, and + // there are cookies for the player to use at all. + cfg.websiteDataStore = .default() + cfg.allowsInlineMediaPlayback = true + // Video still plays in the page, and a tap on play is already the user's action. + cfg.mediaTypesRequiringUserActionForPlayback = [] + + webView = WKWebView(frame: view.bounds, configuration: cfg) + webView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + webView.navigationDelegate = self + webView.uiDelegate = self + // The page is viewport-fit=cover and pads itself by the safe area, as it does on the + // home screen; letting the scroll view inset it too would pad it twice. + webView.scrollView.contentInsetAdjustmentBehavior = .never + webView.isOpaque = false + webView.backgroundColor = .black + view.addSubview(webView) + + cookies = CookieBridge(store: cfg.websiteDataStore.httpCookieStore) + cfg.userContentController.add(Bridge(webView: webView, playback: playback), name: Bridge.name) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + if webView.url == nil { + if let url = Server.url { webView.load(URLRequest(url: url)) } else { askForServer() } + } + } + + // MARK: - which server + + /// Shake to change it. A setting touched about once, so it takes no room on the screen. + override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) { + if motion == .motionShake { askForServer() } + } + + private func askForServer(_ problem: String? = nil) { + guard presentedViewController == nil else { return } + let alert = UIAlertController( + title: "Your iPX server", + message: problem ?? "The address you open iPX at. Shake the phone to change it later.", + preferredStyle: .alert) + alert.addTextField { f in + f.text = Server.url?.absoluteString ?? Server.suggested + f.keyboardType = .URL + f.autocapitalizationType = .none + f.autocorrectionType = .no + f.clearButtonMode = .whileEditing + } + if Server.url != nil { alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) } + alert.addAction(UIAlertAction(title: "Open", style: .default) { [weak self, weak alert] _ in + guard let self else { return } + let text = alert?.textFields?.first?.text ?? "" + guard let url = Server.normalise(text) else { + self.askForServer("That is not an address the app can open. Plain http only works " + + "on your own network, such as 192.168.1.130:8099.") + return + } + Server.url = url + self.playback.stop() + self.webView.load(URLRequest(url: url)) + }) + present(alert, animated: true) + } + + // MARK: - the page + + /// Sign-in leaves the server's address (Cloudflare Access, its identity provider) and must + /// stay in here to leave its cookies behind; only what the page opens in a new tab, an + /// item's own link, goes to Safari. + func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, + for navigationAction: WKNavigationAction, + windowFeatures: WKWindowFeatures) -> WKWebView? { + if let url = navigationAction.request.url { UIApplication.shared.open(url) } + return nil + } + + func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, + withError error: Error) { + let host = Server.url?.host ?? "the server" + let alert = UIAlertController(title: "Cannot reach \(host)", + message: error.localizedDescription, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "Try Again", style: .default) { [weak self] _ in + if let url = Server.url { self?.webView.load(URLRequest(url: url)) } + }) + alert.addAction(UIAlertAction(title: "Change Server", style: .default) { [weak self] _ in + self?.askForServer() + }) + if presentedViewController == nil { present(alert, animated: true) } + } + + /// WebKit's content process can be killed in the background to free memory, leaving a blank + /// page. The player is in this process and plays on; the page comes back where it was. + func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { + webView.reload() + } +} diff --git a/ios/Tests/ServerTests.swift b/ios/Tests/ServerTests.swift new file mode 100644 index 0000000..e12f5f6 --- /dev/null +++ b/ios/Tests/ServerTests.swift @@ -0,0 +1,24 @@ +import XCTest +@testable import iPX + +final class ServerTests: XCTestCase { + func testABareHostGetsHTTPS() { + XCTAssertEqual(Server.normalise(" ipodderx.sdf1.net ")?.absoluteString, "https://ipodderx.sdf1.net") + } + + func testPlainHTTPOnlyOnYourOwnNetwork() { + XCTAssertNotNil(Server.normalise("http://192.168.1.130:8099")) + XCTAssertNotNil(Server.normalise("http://tower.local:8099")) + XCTAssertNotNil(Server.normalise("http://172.20.0.5")) + XCTAssertNil(Server.normalise("http://ipodderx.sdf1.net"), "ATS would refuse it later") + XCTAssertNil(Server.normalise("http://172.32.0.5")) + XCTAssertNil(Server.normalise("ftp://192.168.1.130")) + XCTAssertNil(Server.normalise("")) + } + + func testThePagesPathsResolveAgainstTheServer() { + UserDefaults.standard.set("https://ipodderx.sdf1.net", forKey: "ipx.server") + XCTAssertEqual(Server.resolve("/media/42/deadair.m3u8?traceparent=x")?.absoluteString, + "https://ipodderx.sdf1.net/media/42/deadair.m3u8?traceparent=x") + } +} diff --git a/ios/project.yml b/ios/project.yml new file mode 100644 index 0000000..390466b --- /dev/null +++ b/ios/project.yml @@ -0,0 +1,71 @@ +# XcodeGen makes the .xcodeproj from this, so the project is something a diff can show rather +# than a pbxproj nobody reviews. On a Mac: +# +# brew install xcodegen && cd ios && xcodegen generate && open iPX.xcodeproj +name: iPX +options: + bundleIdPrefix: net.sdf1 + deploymentTarget: + iOS: "17.0" + createIntermediateGroups: true +settings: + base: + SWIFT_VERSION: "5.9" + TARGETED_DEVICE_FAMILY: "1,2" + CODE_SIGN_STYLE: Automatic +targets: + iPX: + type: application + platform: iOS + sources: + - Sources + - Resources/Assets.xcassets + info: + path: Resources/Info.plist + properties: + CFBundleDisplayName: iPX + CFBundleShortVersionString: $(MARKETING_VERSION) + CFBundleVersion: "1" + # Playing on with the screen locked. A plist key, not a signed entitlement, so it works on + # a free personal team; CarPlay's own dashboard app is an entitlement Apple has to grant. + UIBackgroundModes: [audio] + UILaunchScreen: {} + UIApplicationSceneManifest: + UIApplicationSupportsMultipleScenes: false + UISceneConfigurations: + UIWindowSceneSessionRoleApplication: + - UISceneConfigurationName: Default Configuration + UISceneDelegateClassName: $(PRODUCT_MODULE_NAME).SceneDelegate + UISupportedInterfaceOrientations: + - UIInterfaceOrientationPortrait + - UIInterfaceOrientationLandscapeLeft + - UIInterfaceOrientationLandscapeRight + # Plain HTTP only for a server on your own network (192.168.1.130:8099); the tunnel is HTTPS. + NSAppTransportSecurity: + NSAllowsLocalNetworking: true + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: net.sdf1.ipx + MARKETING_VERSION: "0.1.0-dev" + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + + iPXTests: + type: bundle.unit-test + platform: iOS + sources: + - Tests + dependencies: + - target: iPX + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: net.sdf1.ipx.tests + +schemes: + iPX: + build: + targets: + iPX: all + iPXTests: [test] + test: + targets: + - iPXTests