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 0000000..2a11dc9 Binary files /dev/null and b/ios/Resources/Assets.xcassets/AppIcon.appiconset/icon-1024.png differ 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