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:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Generated by xcodegen from project.yml.
|
||||||
|
*.xcodeproj/
|
||||||
|
*.xcworkspace/
|
||||||
|
DerivedData/
|
||||||
|
build/
|
||||||
|
.DS_Store
|
||||||
|
xcuserdata/
|
||||||
150
README.md
Normal file
150
README.md
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
# ipodderx-app
|
||||||
|
|
||||||
|
The [ipodderx-rs](https://git.sdf1.net/rays/ipodderx-rs) web UI in an app, with the audio played by
|
||||||
|
the phone rather than the page, so it keeps going when the screen locks and a car can control it.
|
||||||
|
|
||||||
|
## Why it is not just a web view
|
||||||
|
|
||||||
|
CarPlay and Android Auto cannot render a web view. Both are template surfaces — `CPListTemplate`
|
||||||
|
and `CPNowPlayingTemplate`, or Android's media browse tree — and the only audio they will control
|
||||||
|
is the host's own `AVPlayer` or `ExoPlayer`. So an app that is "the web UI plus CarPlay" is really
|
||||||
|
"the web UI whose audio engine is native", and that is what this is.
|
||||||
|
|
||||||
|
The page keeps its face. Everything in ipx's `web/src/player.ts` speaks to its media element
|
||||||
|
through a small surface, so `web/src/native.ts` replaces that surface on the element with one that
|
||||||
|
posts to this app. The player bar, the row buttons, the EQ bars and the keyboard shortcuts all work
|
||||||
|
as they do in a browser, with nothing in `player.ts` changed. Video still plays in the page:
|
||||||
|
CarPlay is audio-only, and a native video layer under a web view buys nothing.
|
||||||
|
|
||||||
|
## What works, and what needs an Apple account
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Plays with the screen locked | Yes, on a free personal team. `UIBackgroundModes: [audio]` is a plist key, not a signed entitlement |
|
||||||
|
| Lock screen and Control Center | Yes — `MPNowPlayingInfoCenter`, `MPRemoteCommandCenter` |
|
||||||
|
| A car over Bluetooth or USB audio | Yes — the head unit shows the episode and its buttons drive the app |
|
||||||
|
| CarPlay's own app on the dashboard | **No.** `com.apple.developer.carplay-audio` is granted by Apple on request and does not exist on a free team |
|
||||||
|
|
||||||
|
A free personal team also expires a build after seven days, so it is reinstalled from Xcode
|
||||||
|
weekly, and allows three apps at a time. $99/yr makes that a year and adds TestFlight; CarPlay
|
||||||
|
additionally needs a granted entitlement request at <https://developer.apple.com/carplay/>.
|
||||||
|
|
||||||
|
`Playback` and `Library` deliberately know nothing about the web view. A CarPlay scene is a
|
||||||
|
`CPTemplateApplicationSceneDelegate` over the same two objects, plus a browse tree built from
|
||||||
|
`GET /api/feeds` and `GET /api/entries` — no change to this app's shape or to the server.
|
||||||
|
|
||||||
|
## Build it
|
||||||
|
|
||||||
|
```sh
|
||||||
|
brew install xcodegen
|
||||||
|
cd ios && xcodegen generate
|
||||||
|
open iPodderX.xcodeproj
|
||||||
|
```
|
||||||
|
|
||||||
|
Pick a destination and run. **My Mac (Mac Catalyst)** is one of them: the same UIKit app in a
|
||||||
|
window, built from the same target. Nothing in the Swift is conditional -- `AVAudioSession`,
|
||||||
|
`MPNowPlayingInfoCenter` and the remote commands all exist under Catalyst -- so the Mac gets
|
||||||
|
media keys and Now Playing in Control Center for free. It has no CarPlay and no lock screen,
|
||||||
|
which is most of the point on a phone, so the Mac build is a convenience rather than the reason
|
||||||
|
any of this exists.
|
||||||
|
|
||||||
|
`SUPPORTS_MACCATALYST` is set as a build setting rather than through xcodegen's
|
||||||
|
`supportsMacCatalyst:`, which this version accepts and then writes nothing for -- the generated
|
||||||
|
project had no such setting and the Mac destination simply did not exist.
|
||||||
|
|
||||||
|
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, not the project.
|
||||||
|
|
||||||
|
## Which server
|
||||||
|
|
||||||
|
The first launch asks, with `https://ipodderx.sdf1.net` filled in. A bare host gets `https://`, so
|
||||||
|
typing `ipodderx.sdf1.net` is enough. Then sign in on the page that follows — through Cloudflare
|
||||||
|
Access, or ipx's own form — and the cookies that leaves are what the player uses.
|
||||||
|
|
||||||
|
**Shake the phone to change it**, or tap **Server** in the red banner when something is wrong.
|
||||||
|
There is no button in the chrome because there is no chrome: the page fills the screen, and this
|
||||||
|
is a setting touched about once.
|
||||||
|
|
||||||
|
Plain `http://` is allowed only on your own network — `localhost`, `*.local`, and the private
|
||||||
|
ranges — which is what `NSAllowsLocalNetworking` covers. The setup screen says so as you type
|
||||||
|
rather than letting the load fail later looking like the server is down.
|
||||||
|
|
||||||
|
For automation, `-ipx.server <url>` as a launch argument overrides the stored value for that run.
|
||||||
|
|
||||||
|
## How it hangs together
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| `ios/Sources/App.swift` | the app delegate, and which scene is which |
|
||||||
|
| `ios/Sources/SceneDelegate.swift` | the window. iOS 27 will not run an app without a scene, and CarPlay is a second one |
|
||||||
|
| `ios/Sources/ServerSetupViewController.swift` | the first-run question, and the way back to it |
|
||||||
|
| `ios/Sources/WebViewController.swift` | the `WKWebView`, and the banner when something is wrong |
|
||||||
|
| `ios/Sources/Bridge.swift` | the messages, both directions |
|
||||||
|
| `ios/Sources/Playback.swift` | `AVPlayer`, the audio session, now-playing, the remote commands |
|
||||||
|
| `ios/Sources/Library.swift` | the API calls the host makes: position, read, artwork |
|
||||||
|
| `ios/Sources/CookieBridge.swift` | `WKHTTPCookieStore` into `HTTPCookieStorage.shared` |
|
||||||
|
| `ios/Sources/ServerSettings.swift` | which server |
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
ipx authenticates by cookie: `ipx_session` from its own sign-in, and `CF_Authorization` from
|
||||||
|
Cloudflare Access in front of the tunnel. Its auth layer was written that way so a plain
|
||||||
|
`<audio src>` would work, which is why the player needs no API of its own — but `AVPlayer` and
|
||||||
|
`URLSession` read `HTTPCookieStorage.shared` while `WKWebView` keeps its own store, so
|
||||||
|
`CookieBridge` keeps the two in step and passes them to each asset as `AVURLAssetHTTPCookiesKey`.
|
||||||
|
|
||||||
|
*ponytail: when the Access cookie expires mid-drive, playback 401s and the fix is to open the app
|
||||||
|
and sign in again. The upgrade is per-user device tokens in ipx's auth layer, at which point this
|
||||||
|
app holds a token in the keychain and stops depending on the web view's session.*
|
||||||
|
|
||||||
|
### The messages
|
||||||
|
|
||||||
|
Page to host, on `webkit.messageHandlers.ipx`:
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| `{t:"ready", version, rate, volume}` | the bridge installed; the app waits for this before trusting the page |
|
||||||
|
| `{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 — the page sends the path, not a time, because a backgrounded web view's time may be minutes old |
|
||||||
|
|
||||||
|
Host to page, as `window.ipxNative.on(…)`:
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| `{t:"time", cur, dur}` | drives `timeupdate` |
|
||||||
|
| `{t:"meta", dur}` | the length is known; the page seeks to where you left off |
|
||||||
|
| `{t:"state", playing}` | drives `play` / `pause` |
|
||||||
|
| `{t:"ended"}` | |
|
||||||
|
| `{t:"error", message}` | the page toasts it |
|
||||||
|
|
||||||
|
`position` and `duration` in `load` are for the car's display. Where playback *starts* is the
|
||||||
|
page's decision, on `loadedmetadata`, so one piece of code owns it.
|
||||||
|
|
||||||
|
Position and read are written by the host, not the page: ipx's `player.ts` has been bitten before
|
||||||
|
by a stale write — "one left paused in another tab saved its older place as that tab reloaded, over
|
||||||
|
where you had got to since" — and a backgrounded web view is exactly that tab.
|
||||||
|
|
||||||
|
### Changing the bridge
|
||||||
|
|
||||||
|
The page's half lives in ipodderx-rs at `web/src/native.ts`, and `tests/native-bridge.js` there is
|
||||||
|
what holds the two ends together. A change to the protocol is a change in both repos and a deploy
|
||||||
|
of the server before the app will work.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
ios/ the Xcode project: iPhone, iPad and Mac Catalyst from one target
|
||||||
|
android/ not started
|
||||||
|
```
|
||||||
|
|
||||||
|
Named for what it is rather than one platform, because the half of the bridge that lives in the
|
||||||
|
page already has a branch for Android (`window.ipxAndroid`) and there is no sense renaming later.
|
||||||
|
|
||||||
|
## Android
|
||||||
|
|
||||||
|
Not started. The shape is the same — `WebView` for the page, Media3 `MediaLibraryService` and
|
||||||
|
`ExoPlayer` for the audio, `automotive_app_desc.xml` for Android Auto — and `native.ts` already has
|
||||||
|
the detection branch for it (`window.ipxAndroid.postMessage`). Android Auto needs no approval:
|
||||||
|
sideload, and turn on unknown sources in Android Auto's developer settings.
|
||||||
0
ios/Resources/.gitkeep
Normal file
0
ios/Resources/.gitkeep
Normal file
62
ios/Resources/Info.plist
Normal file
62
ios/Resources/Info.plist
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>iPodderX</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>0.1.0</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>NSAppTransportSecurity</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSAllowsLocalNetworking</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
<key>UIApplicationSceneManifest</key>
|
||||||
|
<dict>
|
||||||
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
|
<false/>
|
||||||
|
<key>UISceneConfigurations</key>
|
||||||
|
<dict>
|
||||||
|
<key>UIWindowSceneSessionRoleApplication</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>UISceneConfigurationName</key>
|
||||||
|
<string>Default Configuration</string>
|
||||||
|
<key>UISceneDelegateClassName</key>
|
||||||
|
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>audio</string>
|
||||||
|
</array>
|
||||||
|
<key>UILaunchScreen</key>
|
||||||
|
<dict/>
|
||||||
|
<key>UIRequiresFullScreen</key>
|
||||||
|
<false/>
|
||||||
|
<key>UIStatusBarStyle</key>
|
||||||
|
<string>UIStatusBarStyleLightContent</string>
|
||||||
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
18
ios/Sources/App.swift
Normal file
18
ios/Sources/App.swift
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
@main
|
||||||
|
final class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||||
|
func application(_ application: UIApplication,
|
||||||
|
didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scenes, not a window on the delegate: iOS 27 refuses to run an app that has not adopted the
|
||||||
|
/// scene lifecycle at all. It is also what CarPlay needs -- a CarPlay app is a second scene
|
||||||
|
/// alongside this one, matched here by its role -- so there is nothing to undo later.
|
||||||
|
func application(_ application: UIApplication,
|
||||||
|
configurationForConnecting session: UISceneSession,
|
||||||
|
options: UIScene.ConnectionOptions) -> UISceneConfiguration {
|
||||||
|
UISceneConfiguration(name: "Default Configuration", sessionRole: session.role)
|
||||||
|
}
|
||||||
|
}
|
||||||
93
ios/Sources/Bridge.swift
Normal file
93
ios/Sources/Bridge.swift
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import Foundation
|
||||||
|
import WebKit
|
||||||
|
|
||||||
|
/// Between the page and the host's player.
|
||||||
|
///
|
||||||
|
/// The page's half is `web/src/native.ts` in ipodderx-rs. It replaces the playback surface of the
|
||||||
|
/// page's media element with one that posts here, so everything in `player.ts` -- the player bar,
|
||||||
|
/// the row buttons, the keyboard shortcuts -- carries on unchanged while the host does the
|
||||||
|
/// playing. Messages are documented in this repo's README.
|
||||||
|
final class Bridge: NSObject, WKScriptMessageHandler {
|
||||||
|
static let handlerName = "ipx"
|
||||||
|
|
||||||
|
private weak var webView: WKWebView?
|
||||||
|
private let playback: Playback
|
||||||
|
|
||||||
|
/// Nil until the page says hello. The app uses it to tell a server too old to carry the
|
||||||
|
/// bridge from one that is fine, instead of sitting silent.
|
||||||
|
private(set) var pageVersion: Int?
|
||||||
|
var onReady: ((Int) -> Void)?
|
||||||
|
|
||||||
|
init(webView: WKWebView, playback: Playback) {
|
||||||
|
self.webView = webView
|
||||||
|
self.playback = playback
|
||||||
|
super.init()
|
||||||
|
wire()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func wire() {
|
||||||
|
playback.onTime = { [weak self] cur, dur in
|
||||||
|
// Built up rather than `dur as Any`: a nil Optional cast to Any is not nil, it is an
|
||||||
|
// Any wrapping nil, which JSONSerialization refuses -- so every tick before the length
|
||||||
|
// was known would have been dropped without a word.
|
||||||
|
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":
|
||||||
|
let v = m["version"] as? Int ?? 0
|
||||||
|
pageVersion = v
|
||||||
|
if let r = m["rate"] as? Double { playback.setRate(Float(r)) }
|
||||||
|
if let v = m["volume"] as? Double { playback.setVolume(Float(v)) }
|
||||||
|
onReady?(v)
|
||||||
|
case "load":
|
||||||
|
guard let path = m["url"] as? String, let url = ServerSettings.url(path),
|
||||||
|
let feedId = m["feedId"] as? String, let guid = m["guid"] as? String else { return }
|
||||||
|
playback.load(.init(
|
||||||
|
url: url, feedId: feedId, guid: guid,
|
||||||
|
title: m["title"] as? String ?? "",
|
||||||
|
feedTitle: m["feedTitle"] as? String ?? "",
|
||||||
|
artwork: m["artwork"] as? String,
|
||||||
|
position: m["position"] as? Int ?? 0,
|
||||||
|
duration: m["duration"] as? Int))
|
||||||
|
case "play": playback.play()
|
||||||
|
case "pause": playback.pause()
|
||||||
|
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)) }
|
||||||
|
case "stop": playback.stop()
|
||||||
|
case "position": playback.savePosition(path: m["url"] as? String)
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - host to page
|
||||||
|
|
||||||
|
private func send(_ payload: [String: Any]) {
|
||||||
|
guard let webView, JSONSerialization.isValidJSONObject(payload),
|
||||||
|
let data = try? JSONSerialization.data(withJSONObject: payload),
|
||||||
|
let json = String(data: data, encoding: .utf8) else {
|
||||||
|
NSLog("ipx: dropping an unencodable message: %@", String(describing: payload))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let js = "window.ipxNative && window.ipxNative.on(\(json))"
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
webView.evaluateJavaScript(js) { _, err in
|
||||||
|
// A frozen or reloading web view is not a problem worth a log line every half
|
||||||
|
// second: the host is the one playing, and it carries on either way.
|
||||||
|
if let err = err as NSError?, err.code != WKError.javaScriptExceptionOccurred.rawValue { return }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
46
ios/Sources/CookieBridge.swift
Normal file
46
ios/Sources/CookieBridge.swift
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import Foundation
|
||||||
|
import WebKit
|
||||||
|
|
||||||
|
/// The web view signs in; everything else rides on what that left behind.
|
||||||
|
///
|
||||||
|
/// ipx authenticates a request by cookie -- `ipx_session` from its own sign-in form, and
|
||||||
|
/// `CF_Authorization` from Cloudflare Access in front of the tunnel. The auth layer was built that
|
||||||
|
/// way so a plain `<audio src>` would work, and it is why the native player needs no API of its
|
||||||
|
/// own. But `AVPlayer` and `URLSession` read `HTTPCookieStorage.shared`, and `WKWebView` keeps its
|
||||||
|
/// own store, so the two have to be kept in step.
|
||||||
|
final class CookieBridge: NSObject, WKHTTPCookieStoreObserver {
|
||||||
|
private let store: WKHTTPCookieStore
|
||||||
|
private(set) var cookies: [HTTPCookie] = []
|
||||||
|
|
||||||
|
init(store: WKHTTPCookieStore) {
|
||||||
|
self.store = store
|
||||||
|
super.init()
|
||||||
|
store.add(self)
|
||||||
|
sync()
|
||||||
|
}
|
||||||
|
|
||||||
|
func cookiesDidChange(in cookieStore: WKHTTPCookieStore) { sync() }
|
||||||
|
|
||||||
|
/// Copy everything across. Cheap, and it runs only when the web view's store changes --
|
||||||
|
/// a sign-in, or Access handing out a fresh token.
|
||||||
|
func sync(then done: (() -> Void)? = nil) {
|
||||||
|
store.getAllCookies { [weak self] all in
|
||||||
|
guard let self else { return }
|
||||||
|
self.cookies = all
|
||||||
|
for c in all { HTTPCookieStorage.shared.setCookie(c) }
|
||||||
|
done?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The cookies for one request, as `AVURLAsset` wants them.
|
||||||
|
func cookies(for url: URL) -> [HTTPCookie] {
|
||||||
|
cookies.filter { c in
|
||||||
|
guard let host = url.host else { return false }
|
||||||
|
let domain = c.domain.hasPrefix(".") ? String(c.domain.dropFirst()) : c.domain
|
||||||
|
guard host == domain || host.hasSuffix("." + domain) else { return false }
|
||||||
|
// A Secure cookie over plain HTTP is not sent, which is the whole point of the flag.
|
||||||
|
if c.isSecure && url.scheme != "https" { return false }
|
||||||
|
return url.path.hasPrefix(c.path) || c.path == "/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
79
ios/Sources/Library.swift
Normal file
79
ios/Sources/Library.swift
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// The bits of ipx's API the native side writes back to, and the artwork it shows.
|
||||||
|
///
|
||||||
|
/// Small on purpose. The page does the browsing; this is only what the host has to do when the
|
||||||
|
/// page cannot -- which is any time the screen is locked, and all the time in CarPlay.
|
||||||
|
final class Library {
|
||||||
|
private let cookies: CookieBridge
|
||||||
|
private let session: URLSession
|
||||||
|
|
||||||
|
init(cookies: CookieBridge) {
|
||||||
|
self.cookies = cookies
|
||||||
|
let cfg = URLSessionConfiguration.default
|
||||||
|
cfg.httpCookieStorage = HTTPCookieStorage.shared
|
||||||
|
cfg.httpShouldSetCookies = true
|
||||||
|
cfg.waitsForConnectivity = true
|
||||||
|
self.session = URLSession(configuration: cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Unauthorized: Error {}
|
||||||
|
|
||||||
|
/// Where you are in an episode. The page sends this too, but only while it is running:
|
||||||
|
/// a backgrounded web view is frozen, and this is the drive it would miss.
|
||||||
|
func savePosition(feedId: String, guid: String, secs: Int, duration: Int?) {
|
||||||
|
post("/api/entries/\(esc(feedId))/\(esc(guid))/position", position(secs, duration))
|
||||||
|
}
|
||||||
|
|
||||||
|
func markRead(feedId: String, guid: String) {
|
||||||
|
post("/api/entries/\(esc(feedId))/\(esc(guid))/flags", ["read": true])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A path the page handed over, used as-is. It already names the right item, and rebuilding it
|
||||||
|
/// here would be a second place for the escaping to be wrong.
|
||||||
|
func savePosition(path: String, secs: Int, duration: Int?) {
|
||||||
|
post(path, position(secs, duration))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `duration` is left out when there is none rather than sent as null: it is Option<i64> on
|
||||||
|
/// the server either way, and `duration as Any` on a nil Optional is an Any wrapping nil,
|
||||||
|
/// which JSONSerialization refuses -- leaving the request with no body at all.
|
||||||
|
private func position(_ secs: Int, _ duration: Int?) -> [String: Any] {
|
||||||
|
var body: [String: Any] = ["secs": secs]
|
||||||
|
if let duration { body["duration"] = duration }
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
func artwork(_ path: String, done: @escaping (UIImage?) -> Void) {
|
||||||
|
guard let url = ServerSettings.url(path) else { return done(nil) }
|
||||||
|
session.dataTask(with: url) { data, _, _ in
|
||||||
|
done(data.flatMap(UIImage.init(data:)))
|
||||||
|
}.resume()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the session is still good, so a failure can say "sign in again" rather than stall.
|
||||||
|
func check(done: @escaping (Bool) -> Void) {
|
||||||
|
guard let url = ServerSettings.url("/api/me") else { return done(false) }
|
||||||
|
session.dataTask(with: url) { _, resp, _ in
|
||||||
|
done((resp as? HTTPURLResponse)?.statusCode == 200)
|
||||||
|
}.resume()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func post(_ path: String, _ body: [String: Any]) {
|
||||||
|
guard let url = ServerSettings.url(path) else { return }
|
||||||
|
var req = URLRequest(url: url)
|
||||||
|
req.httpMethod = "POST"
|
||||||
|
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
req.httpBody = try? JSONSerialization.data(withJSONObject: body)
|
||||||
|
session.dataTask(with: req) { _, resp, err in
|
||||||
|
if let code = (resp as? HTTPURLResponse)?.statusCode, code >= 400 {
|
||||||
|
NSLog("ipx: %@ -> %d", path, code)
|
||||||
|
} else if let err { NSLog("ipx: %@ -> %@", path, err.localizedDescription) }
|
||||||
|
}.resume()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func esc(_ s: String) -> String {
|
||||||
|
s.addingPercentEncoding(withAllowedCharacters: .alphanumerics.union(.init(charactersIn: "-._~"))) ?? s
|
||||||
|
}
|
||||||
|
}
|
||||||
265
ios/Sources/Playback.swift
Normal file
265
ios/Sources/Playback.swift
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
import AVFoundation
|
||||||
|
import Foundation
|
||||||
|
import MediaPlayer
|
||||||
|
|
||||||
|
/// The host's player: the one CarPlay and a car's own buttons can reach, and the one that keeps
|
||||||
|
/// going when the screen locks. Knows nothing about the web view -- the bridge drives it, and so
|
||||||
|
/// will a CarPlay scene.
|
||||||
|
final class Playback: NSObject {
|
||||||
|
/// What is loaded, enough to keep the car's screen right and to save a position without
|
||||||
|
/// asking anyone.
|
||||||
|
struct Item {
|
||||||
|
var url: URL
|
||||||
|
var feedId: String
|
||||||
|
var guid: String
|
||||||
|
var title: String
|
||||||
|
var feedTitle: String
|
||||||
|
var artwork: String?
|
||||||
|
var position: Int
|
||||||
|
var duration: Int?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where playback state goes: the web page, through the bridge. A CarPlay scene would take
|
||||||
|
/// the same callbacks.
|
||||||
|
var onTime: ((Double, Double?) -> Void)?
|
||||||
|
var onMeta: ((Double) -> Void)?
|
||||||
|
var onState: ((Bool) -> Void)?
|
||||||
|
var onEnded: (() -> Void)?
|
||||||
|
var onError: ((String) -> Void)?
|
||||||
|
|
||||||
|
private let cookies: CookieBridge
|
||||||
|
private let library: Library
|
||||||
|
private var player: AVPlayer?
|
||||||
|
private var item: Item?
|
||||||
|
private var timeObserver: Any?
|
||||||
|
private var statusObservation: NSKeyValueObservation?
|
||||||
|
private var lastSaved: Double = 0
|
||||||
|
private var markedRead = false
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
init(cookies: CookieBridge, library: Library) {
|
||||||
|
self.cookies = cookies
|
||||||
|
self.library = library
|
||||||
|
super.init()
|
||||||
|
configureSession()
|
||||||
|
wireRemoteCommands()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `.spokenAudio` is the podcast mode: it is what tells the system this is speech, so the
|
||||||
|
/// right thing happens when navigation talks over it.
|
||||||
|
private func configureSession() {
|
||||||
|
do {
|
||||||
|
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio, policy: .longFormAudio)
|
||||||
|
} catch {
|
||||||
|
NSLog("ipx: audio session: %@", error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - what the page asks for
|
||||||
|
|
||||||
|
func load(_ item: Item) {
|
||||||
|
stop(notify: false)
|
||||||
|
self.item = item
|
||||||
|
self.markedRead = false
|
||||||
|
self.lastSaved = 0
|
||||||
|
|
||||||
|
// The session cookie is how ipx knows who is asking, and an AVURLAsset does not read the
|
||||||
|
// shared store on its own.
|
||||||
|
let asset = AVURLAsset(url: item.url, options: [
|
||||||
|
AVURLAssetHTTPCookiesKey: cookies.cookies(for: item.url),
|
||||||
|
])
|
||||||
|
let playerItem = AVPlayerItem(asset: asset)
|
||||||
|
let player = AVPlayer(playerItem: playerItem)
|
||||||
|
// Seeking has to land where it was asked to: the default tolerance snaps to keyframes,
|
||||||
|
// which on a long episode is minutes away from where you let go of the scrubber.
|
||||||
|
player.automaticallyWaitsToMinimizeStalling = true
|
||||||
|
self.player = player
|
||||||
|
|
||||||
|
// Whose position this is belongs to the page, on loadedmetadata, so one piece of code
|
||||||
|
// decides it. Here we only report that the length is known.
|
||||||
|
statusObservation = playerItem.observe(\.status, options: [.new]) { [weak self] pi, _ in
|
||||||
|
guard let self else { return }
|
||||||
|
switch pi.status {
|
||||||
|
case .readyToPlay:
|
||||||
|
if let d = self.duration { self.onMeta?(d) }
|
||||||
|
case .failed:
|
||||||
|
let msg = pi.error?.localizedDescription ?? "the file could not be played"
|
||||||
|
self.library.check { good in
|
||||||
|
DispatchQueue.main.async { self.onError?(good ? msg : "sign in again") }
|
||||||
|
}
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
timeObserver = player.addPeriodicTimeObserver(
|
||||||
|
forInterval: CMTime(seconds: 0.5, preferredTimescale: 600), queue: .main
|
||||||
|
) { [weak self] t in self?.tick(t.seconds) }
|
||||||
|
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self, selector: #selector(didEnd),
|
||||||
|
name: .AVPlayerItemDidPlayToEndTime, object: playerItem)
|
||||||
|
|
||||||
|
updateNowPlaying()
|
||||||
|
}
|
||||||
|
|
||||||
|
func play() {
|
||||||
|
guard let player else { return }
|
||||||
|
try? AVAudioSession.sharedInstance().setActive(true)
|
||||||
|
player.play()
|
||||||
|
onState?(true)
|
||||||
|
updateNowPlaying()
|
||||||
|
}
|
||||||
|
|
||||||
|
func pause() {
|
||||||
|
player?.pause()
|
||||||
|
onState?(false)
|
||||||
|
savePosition()
|
||||||
|
updateNowPlaying()
|
||||||
|
}
|
||||||
|
|
||||||
|
func seek(to secs: Double) {
|
||||||
|
guard let player else { return }
|
||||||
|
player.seek(to: CMTime(seconds: max(0, secs), preferredTimescale: 600),
|
||||||
|
toleranceBefore: .zero, toleranceAfter: .zero)
|
||||||
|
updateNowPlaying()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setRate(_ r: Float) {
|
||||||
|
// Assigning to `rate` also starts playback, which is not what changing the speed of a
|
||||||
|
// paused episode should do.
|
||||||
|
player?.defaultRate = r
|
||||||
|
if isPlaying { player?.rate = r }
|
||||||
|
updateNowPlaying()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setVolume(_ v: Float) { player?.volume = v }
|
||||||
|
|
||||||
|
func stop(notify: Bool = true) {
|
||||||
|
savePosition()
|
||||||
|
if let timeObserver { player?.removeTimeObserver(timeObserver) }
|
||||||
|
timeObserver = nil
|
||||||
|
statusObservation = nil
|
||||||
|
NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: nil)
|
||||||
|
player?.pause()
|
||||||
|
player = nil
|
||||||
|
item = nil
|
||||||
|
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
|
||||||
|
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||||
|
if notify { onState?(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The page asking us to save, because its own beacon is ours now. It sends the path rather
|
||||||
|
/// than a time: the time it has may be minutes old if it has been frozen in the background.
|
||||||
|
func savePosition(path: String? = nil) {
|
||||||
|
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)
|
||||||
|
let dur = duration.map { Int($0.rounded()) }
|
||||||
|
if let path {
|
||||||
|
library.savePosition(path: path, secs: secs, duration: dur)
|
||||||
|
} else {
|
||||||
|
library.savePosition(feedId: item.feedId, guid: item.guid, secs: secs, duration: dur)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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() }
|
||||||
|
// Listened to, not merely started -- the same 90% the page uses. Doing it on play made an
|
||||||
|
// item vanish from Unread the instant you pressed it.
|
||||||
|
if let d = duration, d > 0, secs / d >= 0.9 { markRead() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func didEnd() {
|
||||||
|
savePosition()
|
||||||
|
markRead()
|
||||||
|
onState?(false)
|
||||||
|
onEnded?()
|
||||||
|
updateNowPlaying()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func markRead() {
|
||||||
|
guard !markedRead, let item else { return }
|
||||||
|
markedRead = true
|
||||||
|
library.markRead(feedId: item.feedId, guid: item.guid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - the car and the lock screen
|
||||||
|
|
||||||
|
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 same 15 and 30 the page uses, 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 ? (player?.rate ?? 1) : 0,
|
||||||
|
MPNowPlayingInfoPropertyElapsedPlaybackTime: currentTime,
|
||||||
|
]
|
||||||
|
if let d = duration ?? item.duration.map(Double.init) {
|
||||||
|
info[MPMediaItemPropertyPlaybackDuration] = d
|
||||||
|
}
|
||||||
|
let centre = MPNowPlayingInfoCenter.default()
|
||||||
|
centre.nowPlayingInfo = info
|
||||||
|
|
||||||
|
if let art = item.artwork {
|
||||||
|
library.artwork(art) { image in
|
||||||
|
guard let image else { return }
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
// Read back rather than reusing `info`: by the time the picture arrives the
|
||||||
|
// elapsed time in it is stale, and writing it would jerk the car's progress bar.
|
||||||
|
var latest = centre.nowPlayingInfo ?? [:]
|
||||||
|
latest[MPMediaItemPropertyArtwork] =
|
||||||
|
MPMediaItemArtwork(boundsSize: image.size) { _ in image }
|
||||||
|
centre.nowPlayingInfo = latest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Just the clock, every tick. Rewriting the whole dictionary that often makes the car's
|
||||||
|
/// progress bar stutter.
|
||||||
|
private func updateElapsed(_ secs: Double) {
|
||||||
|
guard var info = MPNowPlayingInfoCenter.default().nowPlayingInfo else { return }
|
||||||
|
info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = secs
|
||||||
|
info[MPNowPlayingInfoPropertyPlaybackRate] = isPlaying ? (player?.rate ?? 1) : 0
|
||||||
|
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
|
||||||
|
}
|
||||||
|
}
|
||||||
14
ios/Sources/SceneDelegate.swift
Normal file
14
ios/Sources/SceneDelegate.swift
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||||
|
var window: UIWindow?
|
||||||
|
|
||||||
|
func scene(_ scene: UIScene, willConnectTo session: UISceneSession,
|
||||||
|
options connectionOptions: UIScene.ConnectionOptions) {
|
||||||
|
guard let windowScene = scene as? UIWindowScene else { return }
|
||||||
|
let w = UIWindow(windowScene: windowScene)
|
||||||
|
w.rootViewController = WebViewController()
|
||||||
|
w.makeKeyAndVisible()
|
||||||
|
window = w
|
||||||
|
}
|
||||||
|
}
|
||||||
55
ios/Sources/ServerSettings.swift
Normal file
55
ios/Sources/ServerSettings.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
105
ios/Sources/ServerSetupViewController.swift
Normal file
105
ios/Sources/ServerSetupViewController.swift
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// Asks where ipx is. Shown on first run, and again whenever the address needs changing.
|
||||||
|
final class ServerSetupViewController: UIViewController, UITextFieldDelegate {
|
||||||
|
var onSave: ((URL) -> Void)?
|
||||||
|
|
||||||
|
private let field = UITextField()
|
||||||
|
private let note = UILabel()
|
||||||
|
private let first: Bool
|
||||||
|
|
||||||
|
/// The first run has nothing to go back to, so it gets no Cancel.
|
||||||
|
init(first: Bool) {
|
||||||
|
self.first = first
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
required init?(coder: NSCoder) { fatalError("not from a storyboard") }
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
view.backgroundColor = .systemBackground
|
||||||
|
title = first ? "Where is ipx?" : "Server"
|
||||||
|
|
||||||
|
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||||
|
title: "Save", style: .done, target: self, action: #selector(save))
|
||||||
|
if !first {
|
||||||
|
navigationItem.leftBarButtonItem = UIBarButtonItem(
|
||||||
|
title: "Cancel", style: .plain, target: self, action: #selector(cancel))
|
||||||
|
}
|
||||||
|
|
||||||
|
let blurb = UILabel()
|
||||||
|
blurb.text = "The address of your ipx server. Sign in on the page that follows; the app plays "
|
||||||
|
+ "what you are signed in to."
|
||||||
|
blurb.numberOfLines = 0
|
||||||
|
blurb.font = .preferredFont(forTextStyle: .subheadline)
|
||||||
|
blurb.textColor = .secondaryLabel
|
||||||
|
|
||||||
|
field.text = ServerSettings.base?.absoluteString ?? ServerSettings.suggestion
|
||||||
|
field.placeholder = ServerSettings.suggestion
|
||||||
|
field.borderStyle = .roundedRect
|
||||||
|
field.keyboardType = .URL
|
||||||
|
field.textContentType = .URL
|
||||||
|
field.autocapitalizationType = .none
|
||||||
|
field.autocorrectionType = .no
|
||||||
|
field.spellCheckingType = .no
|
||||||
|
field.clearButtonMode = .whileEditing
|
||||||
|
field.returnKeyType = .done
|
||||||
|
field.delegate = self
|
||||||
|
field.addTarget(self, action: #selector(edited), for: .editingChanged)
|
||||||
|
|
||||||
|
note.numberOfLines = 0
|
||||||
|
note.font = .preferredFont(forTextStyle: .footnote)
|
||||||
|
note.textColor = .secondaryLabel
|
||||||
|
|
||||||
|
let stack = UIStackView(arrangedSubviews: [blurb, field, note])
|
||||||
|
stack.axis = .vertical
|
||||||
|
stack.spacing = 12
|
||||||
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.addSubview(stack)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
stack.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 24),
|
||||||
|
stack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
|
||||||
|
stack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
|
||||||
|
])
|
||||||
|
edited()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
|
super.viewDidAppear(animated)
|
||||||
|
field.becomeFirstResponder()
|
||||||
|
}
|
||||||
|
|
||||||
|
func textFieldShouldReturn(_ textField: UITextField) -> Bool { save(); return true }
|
||||||
|
|
||||||
|
/// Says what is wrong while it is still being typed, rather than after a failed load.
|
||||||
|
@objc private func edited() {
|
||||||
|
let typed = field.text ?? ""
|
||||||
|
guard let url = ServerSettings.parse(typed) else {
|
||||||
|
note.text = typed.isEmpty ? "" : "That is not a web address."
|
||||||
|
note.textColor = .secondaryLabel
|
||||||
|
navigationItem.rightBarButtonItem?.isEnabled = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
navigationItem.rightBarButtonItem?.isEnabled = true
|
||||||
|
if url.scheme == "http" && !ServerSettings.isLocal(url) {
|
||||||
|
// Saying so here beats a blocked request later that reads like the server is down.
|
||||||
|
note.text = "Plain HTTP is only allowed on your own network. This address will not load."
|
||||||
|
note.textColor = .systemOrange
|
||||||
|
} else if url.scheme == "http" {
|
||||||
|
note.text = "On your own network, so plain HTTP is fine."
|
||||||
|
note.textColor = .secondaryLabel
|
||||||
|
} else {
|
||||||
|
note.text = "Will open \(url.absoluteString)"
|
||||||
|
note.textColor = .secondaryLabel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func save() {
|
||||||
|
guard let url = ServerSettings.parse(field.text ?? "") else { return }
|
||||||
|
ServerSettings.base = url
|
||||||
|
field.resignFirstResponder()
|
||||||
|
onSave?(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func cancel() { dismiss(animated: true) }
|
||||||
|
}
|
||||||
203
ios/Sources/WebViewController.swift
Normal file
203
ios/Sources/WebViewController.swift
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import UIKit
|
||||||
|
import WebKit
|
||||||
|
|
||||||
|
/// The app, very nearly: ipx's own page, full screen.
|
||||||
|
///
|
||||||
|
/// What it adds is the bridge to the host's player, a way to say which server, and a line of
|
||||||
|
/// honesty when something is wrong.
|
||||||
|
final class WebViewController: UIViewController, WKNavigationDelegate {
|
||||||
|
private var webView: WKWebView!
|
||||||
|
private var cookies: CookieBridge!
|
||||||
|
private var library: Library!
|
||||||
|
private var playback: Playback!
|
||||||
|
private var bridge: Bridge!
|
||||||
|
|
||||||
|
private let banner = UIView()
|
||||||
|
private let bannerText = UILabel()
|
||||||
|
private var checkedBridge = false
|
||||||
|
/// The page sits below the banner when there is one, and at the top when there is not. Two
|
||||||
|
/// constraints rather than an overlay: the banner covered the page's own toolbar, and ipx
|
||||||
|
/// lays that out itself, so there is nothing to scroll out of the way.
|
||||||
|
private var pageBelowBanner: NSLayoutConstraint!
|
||||||
|
private var pageAtTop: NSLayoutConstraint!
|
||||||
|
|
||||||
|
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 so there are cookies for the player to use at all.
|
||||||
|
cfg.websiteDataStore = .default()
|
||||||
|
cfg.allowsInlineMediaPlayback = true
|
||||||
|
// Video still plays in the page, and it should not need a second tap to start.
|
||||||
|
cfg.mediaTypesRequiringUserActionForPlayback = []
|
||||||
|
|
||||||
|
webView = WKWebView(frame: .zero, configuration: cfg)
|
||||||
|
webView.navigationDelegate = self
|
||||||
|
webView.allowsBackForwardNavigationGestures = false
|
||||||
|
webView.scrollView.contentInsetAdjustmentBehavior = .never
|
||||||
|
webView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.addSubview(webView)
|
||||||
|
pageAtTop = webView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
pageAtTop,
|
||||||
|
webView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||||
|
webView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
|
webView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
|
])
|
||||||
|
|
||||||
|
cookies = CookieBridge(store: cfg.websiteDataStore.httpCookieStore)
|
||||||
|
library = Library(cookies: cookies)
|
||||||
|
playback = Playback(cookies: cookies, library: library)
|
||||||
|
bridge = Bridge(webView: webView, playback: playback)
|
||||||
|
bridge.onReady = { [weak self] _ in
|
||||||
|
self?.checkedBridge = true
|
||||||
|
self?.setBanner(nil)
|
||||||
|
}
|
||||||
|
cfg.userContentController.add(bridge, name: Bridge.handlerName)
|
||||||
|
|
||||||
|
setUpBanner()
|
||||||
|
|
||||||
|
if ServerSettings.isConfigured { load() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - which server
|
||||||
|
|
||||||
|
/// Shake to change it. There is nowhere on screen to put a button -- the page fills it, and
|
||||||
|
/// this is a setting touched about once -- so the way back is a gesture and the banner.
|
||||||
|
override var canBecomeFirstResponder: Bool { true }
|
||||||
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
|
super.viewDidAppear(animated)
|
||||||
|
becomeFirstResponder()
|
||||||
|
// Not from viewDidLoad: presenting before the view is in a window gets you a blank screen
|
||||||
|
// and no error, which is exactly what it did.
|
||||||
|
if !ServerSettings.isConfigured && presentedViewController == nil {
|
||||||
|
showSetup(animated: animated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) {
|
||||||
|
if motion == .motionShake && presentedViewController == nil { showSetup(animated: true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func showSetup() { showSetup(animated: true) }
|
||||||
|
|
||||||
|
private func showSetup(animated: Bool) {
|
||||||
|
let setup = ServerSetupViewController(first: !ServerSettings.isConfigured)
|
||||||
|
setup.onSave = { [weak self] _ in
|
||||||
|
self?.dismiss(animated: true)
|
||||||
|
self?.setBanner(nil)
|
||||||
|
self?.playback.stop()
|
||||||
|
self?.load()
|
||||||
|
}
|
||||||
|
let nav = UINavigationController(rootViewController: setup)
|
||||||
|
// A first run has nothing behind it, so it cannot be swiped away unanswered.
|
||||||
|
nav.isModalInPresentation = !ServerSettings.isConfigured
|
||||||
|
present(nav, animated: animated)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func load() {
|
||||||
|
guard let base = ServerSettings.base else { return showSetup(animated: true) }
|
||||||
|
// Sync first: the page is fetched with the cookies too, and without them the first
|
||||||
|
// request is a redirect to a sign-in that is not actually needed.
|
||||||
|
cookies.sync { [weak self] in
|
||||||
|
DispatchQueue.main.async { self?.webView.load(URLRequest(url: base)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - navigation
|
||||||
|
|
||||||
|
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||||
|
// The bridge announces itself on load. A server whose page predates it never will, and the
|
||||||
|
// app would otherwise look identical while playing nothing in the background.
|
||||||
|
checkedBridge = false
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||||
|
guard let self, !self.checkedBridge else { return }
|
||||||
|
self.webView.evaluateJavaScript("!!(window.ipxNative)") { got, _ in
|
||||||
|
if (got as? Bool) != true {
|
||||||
|
self.setBanner("This server's page has no native bridge. Playback stays in the app; "
|
||||||
|
+ "background and car playback are off.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||||||
|
showFailure(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
||||||
|
showFailure(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showFailure(_ error: Error) {
|
||||||
|
// -999 is a load cancelled because another started, which is not a failure to report.
|
||||||
|
if (error as NSError).code == NSURLErrorCancelled { return }
|
||||||
|
let host = ServerSettings.base?.host ?? "the server"
|
||||||
|
setBanner("Cannot reach \(host): \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - the banner
|
||||||
|
|
||||||
|
private func setUpBanner() {
|
||||||
|
banner.backgroundColor = UIColor(red: 0.55, green: 0.12, blue: 0.12, alpha: 1)
|
||||||
|
banner.isHidden = true
|
||||||
|
banner.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
||||||
|
bannerText.numberOfLines = 0
|
||||||
|
bannerText.font = .preferredFont(forTextStyle: .footnote)
|
||||||
|
bannerText.textColor = .white
|
||||||
|
|
||||||
|
let change = UIButton(type: .system)
|
||||||
|
change.setTitle("Server", for: .normal)
|
||||||
|
change.titleLabel?.font = .preferredFont(forTextStyle: .footnote)
|
||||||
|
change.tintColor = .white
|
||||||
|
change.setContentHuggingPriority(.required, for: .horizontal)
|
||||||
|
// Without this the label takes the whole row and the button truncates to an ellipsis,
|
||||||
|
// which is not a thing anyone will tap.
|
||||||
|
change.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||||
|
change.addTarget(self, action: #selector(showSetup as () -> Void), for: .touchUpInside)
|
||||||
|
|
||||||
|
let dismiss = UIButton(type: .system)
|
||||||
|
dismiss.setImage(UIImage(systemName: "xmark"), for: .normal)
|
||||||
|
dismiss.tintColor = .white
|
||||||
|
dismiss.accessibilityLabel = "Hide this message"
|
||||||
|
dismiss.setContentHuggingPriority(.required, for: .horizontal)
|
||||||
|
dismiss.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||||
|
dismiss.addTarget(self, action: #selector(hideBanner), for: .touchUpInside)
|
||||||
|
|
||||||
|
let row = UIStackView(arrangedSubviews: [bannerText, change, dismiss])
|
||||||
|
row.spacing = 12
|
||||||
|
row.alignment = .center
|
||||||
|
row.isLayoutMarginsRelativeArrangement = true
|
||||||
|
row.layoutMargins = .init(top: 8, left: 12, bottom: 8, right: 12)
|
||||||
|
row.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
banner.addSubview(row)
|
||||||
|
|
||||||
|
view.addSubview(banner)
|
||||||
|
pageBelowBanner = webView.topAnchor.constraint(equalTo: banner.bottomAnchor)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
banner.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
|
||||||
|
banner.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
|
banner.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
|
row.topAnchor.constraint(equalTo: banner.topAnchor),
|
||||||
|
row.bottomAnchor.constraint(equalTo: banner.bottomAnchor),
|
||||||
|
row.leadingAnchor.constraint(equalTo: banner.leadingAnchor),
|
||||||
|
row.trailingAnchor.constraint(equalTo: banner.trailingAnchor),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dismissed by hand. It says something true, but it says it once -- a permanent stripe
|
||||||
|
/// across a page that otherwise works is worse than the thing it is warning about.
|
||||||
|
@objc private func hideBanner() { setBanner(nil) }
|
||||||
|
|
||||||
|
private func setBanner(_ text: String?) {
|
||||||
|
bannerText.text = text
|
||||||
|
banner.isHidden = text == nil
|
||||||
|
pageAtTop.isActive = text == nil
|
||||||
|
pageBelowBanner.isActive = text != nil
|
||||||
|
view.layoutIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
override var preferredStatusBarStyle: UIStatusBarStyle { .lightContent }
|
||||||
|
}
|
||||||
77
ios/UITests/PlaybackTests.swift
Normal file
77
ios/UITests/PlaybackTests.swift
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import XCTest
|
||||||
|
|
||||||
|
/// Playing an episode has to leave the page, reach the host's player, and be written back.
|
||||||
|
///
|
||||||
|
/// Point it at the daemon the browser suite uses, which has fixture feeds and real files:
|
||||||
|
///
|
||||||
|
/// node -e "require('./tests/ui/global-setup').prepare()" # in ipodderx-rs
|
||||||
|
/// node tests/ui/fixtures/serve.js &
|
||||||
|
/// IPX_CONFIG=$ROOT/config/config.toml IPX_DATA_DIR=$ROOT/data ./target/debug/ipx daemon &
|
||||||
|
/// TEST_RUNNER_IPX_SERVER="http://127.0.0.1:8791/?token=<the fixture token>" xcodebuild test ...
|
||||||
|
///
|
||||||
|
/// TEST_RUNNER_ is not decoration: the test runs in its own process on the simulator, and that
|
||||||
|
/// prefix is what carries a variable across to it.
|
||||||
|
final class PlaybackTests: XCTestCase {
|
||||||
|
/// Not "First Episode". The fixture caps downloads at one per scan and takes the newest, so
|
||||||
|
/// the second is the one with a file behind it -- and an episode with nothing downloaded
|
||||||
|
/// offers Download rather than Play.
|
||||||
|
let feed = "test-show", guid = "ui-2", title = "Second Episode"
|
||||||
|
|
||||||
|
func testPlayingAnEpisodeReachesTheHostAndComesBack() throws {
|
||||||
|
let server = ProcessInfo.processInfo.environment["IPX_SERVER"] ?? ""
|
||||||
|
try XCTSkipIf(server.isEmpty, "set TEST_RUNNER_IPX_SERVER to a daemon with an episode downloaded")
|
||||||
|
|
||||||
|
let app = XCUIApplication()
|
||||||
|
app.launchArguments = ["-ipx.server", server]
|
||||||
|
app.launch()
|
||||||
|
|
||||||
|
let episode = app.staticTexts[title]
|
||||||
|
XCTAssertTrue(episode.waitForExistence(timeout: 30), "the page never listed the fixture episode")
|
||||||
|
episode.tap()
|
||||||
|
|
||||||
|
// On a phone the files sit inside the item; the row's own buttons are hidden by the
|
||||||
|
// stylesheet, so this is the only play button on screen.
|
||||||
|
// Unread first, so that becoming read again can only be playback's doing -- opening an
|
||||||
|
// item marks it read by itself, which would otherwise answer the question before the
|
||||||
|
// test asked it.
|
||||||
|
let markUnread = app.buttons["Mark unread"].firstMatch
|
||||||
|
if markUnread.waitForExistence(timeout: 10) { markUnread.tap() }
|
||||||
|
|
||||||
|
// On a phone the files sit inside the item; the row's own buttons are hidden by the
|
||||||
|
// stylesheet, so this is the only play button on screen.
|
||||||
|
let play = app.buttons["Play"].firstMatch
|
||||||
|
XCTAssertTrue(play.waitForExistence(timeout: 15),
|
||||||
|
"the item offers no way to play it, so it has no downloaded file")
|
||||||
|
play.tap()
|
||||||
|
|
||||||
|
// Read again, rather than a position: the browser suite's fixture is a sixth of a second
|
||||||
|
// long, and a position that rounds to zero is never written. Read is set at the end of an
|
||||||
|
// episode, and inside the shell the page only learns an episode ended because the host
|
||||||
|
// said so -- nothing here is playing the file itself.
|
||||||
|
|
||||||
|
XCTAssertTrue(waitForRead(in: URL(string: server)!, timeout: 30),
|
||||||
|
"the episode never came back read, so the host never played it through: "
|
||||||
|
+ "either the message did not reach it, it could not fetch /media with the "
|
||||||
|
+ "session cookie, or its answer never came back")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func entry(in server: URL) -> [String: Any]? {
|
||||||
|
var components = URLComponents(url: server, resolvingAgainstBaseURL: false)!
|
||||||
|
components.path = "/api/entries"
|
||||||
|
guard let url = components.url, let data = try? Data(contentsOf: url),
|
||||||
|
let page = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
|
let entries = page["entries"] as? [[String: Any]] else { return nil }
|
||||||
|
return entries.first { $0["guid"] as? String == guid }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Polls until the server says the episode is read. Its own request, not the app's -- the
|
||||||
|
/// point is to ask the server what it ended up with.
|
||||||
|
private func waitForRead(in server: URL, timeout: TimeInterval) -> Bool {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
if entry(in: server)?["read"] as? Bool == true { return true }
|
||||||
|
Thread.sleep(forTimeInterval: 1)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
86
ios/project.yml
Normal file
86
ios/project.yml
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# XcodeGen makes the .xcodeproj from this, so the project is something you can read in a diff
|
||||||
|
# rather than a pbxproj nobody can review.
|
||||||
|
#
|
||||||
|
# brew install xcodegen && xcodegen generate && open iPodderX.xcodeproj
|
||||||
|
name: iPodderX
|
||||||
|
options:
|
||||||
|
bundleIdPrefix: net.sdf1
|
||||||
|
deploymentTarget:
|
||||||
|
iOS: "16.0"
|
||||||
|
macOS: "13.0"
|
||||||
|
createIntermediateGroups: true
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
SWIFT_VERSION: "5.9"
|
||||||
|
TARGETED_DEVICE_FAMILY: "1,2"
|
||||||
|
CODE_SIGN_STYLE: Automatic
|
||||||
|
targets:
|
||||||
|
iPodderX:
|
||||||
|
type: application
|
||||||
|
platform: iOS
|
||||||
|
# The same UIKit app on the Mac. Everything that matters -- Playback, Library, Bridge,
|
||||||
|
# CookieBridge -- never touches a view, so only the shell is platform-specific.
|
||||||
|
supportsMacCatalyst: true
|
||||||
|
sources:
|
||||||
|
- Sources
|
||||||
|
info:
|
||||||
|
path: Resources/Info.plist
|
||||||
|
properties:
|
||||||
|
CFBundleDisplayName: iPodderX
|
||||||
|
CFBundleShortVersionString: "0.1.0"
|
||||||
|
CFBundleVersion: "1"
|
||||||
|
# Playing on with the screen locked. This is a plist key, not a signed entitlement, so it
|
||||||
|
# works on a free personal team -- unlike CarPlay, which Apple has to grant.
|
||||||
|
UIBackgroundModes: [audio]
|
||||||
|
UILaunchScreen: {}
|
||||||
|
# iOS 27 will not run an app that has not adopted the scene lifecycle. A CarPlay app is a
|
||||||
|
# second scene beside this one, so its role goes here too when the entitlement exists.
|
||||||
|
UIApplicationSceneManifest:
|
||||||
|
UIApplicationSupportsMultipleScenes: false
|
||||||
|
UISceneConfigurations:
|
||||||
|
UIWindowSceneSessionRoleApplication:
|
||||||
|
- UISceneConfigurationName: Default Configuration
|
||||||
|
UISceneDelegateClassName: $(PRODUCT_MODULE_NAME).SceneDelegate
|
||||||
|
UIRequiresFullScreen: false
|
||||||
|
UISupportedInterfaceOrientations:
|
||||||
|
- UIInterfaceOrientationPortrait
|
||||||
|
- UIInterfaceOrientationLandscapeLeft
|
||||||
|
- UIInterfaceOrientationLandscapeRight
|
||||||
|
UIStatusBarStyle: UIStatusBarStyleLightContent
|
||||||
|
# Only needed to point the app at the LAN address, which is plain HTTP. The tunnel is
|
||||||
|
# HTTPS and needs none of this.
|
||||||
|
NSAppTransportSecurity:
|
||||||
|
NSAllowsLocalNetworking: true
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER: net.sdf1.ipodderx
|
||||||
|
MARKETING_VERSION: "0.1.0"
|
||||||
|
# Set as build settings rather than xcodegen's supportsMacCatalyst:, which this version
|
||||||
|
# accepts and then writes nothing for -- the generated project had no SUPPORTS_MACCATALYST
|
||||||
|
# at all and the Mac destination simply did not exist.
|
||||||
|
SUPPORTS_MACCATALYST: "YES"
|
||||||
|
DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER: "NO"
|
||||||
|
|
||||||
|
iPodderXUITests:
|
||||||
|
type: bundle.ui-testing
|
||||||
|
platform: iOS
|
||||||
|
sources:
|
||||||
|
- UITests
|
||||||
|
dependencies:
|
||||||
|
- target: iPodderX
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER: net.sdf1.ipodderx.uitests
|
||||||
|
|
||||||
|
schemes:
|
||||||
|
iPodderX:
|
||||||
|
build:
|
||||||
|
targets:
|
||||||
|
iPodderX: all
|
||||||
|
iPodderXUITests: [test]
|
||||||
|
run:
|
||||||
|
config: Debug
|
||||||
|
test:
|
||||||
|
config: Debug
|
||||||
|
targets:
|
||||||
|
- iPodderXUITests
|
||||||
Reference in New Issue
Block a user