Files
ipx/ios/Sources/Server.swift
Ray Slakinski 171c77475a iOS: the app's settings in Settings, not behind a shake; iPhone upright (#445)
The server address and the player token were set from alerts that
opened on shaking the phone, which nobody finds. Settings now has a This
device section in the app: the server, the player token and its
address, and a Make button that creates an API token and hands it
straight to the app's keychain. The app sends what it has at ready
(appSettings) and takes setServer, setToken and clearToken; a new
server rebuilds the web view as the alert did. The first-launch prompt
and the Cannot reach alert stay native, since there is no page then.

The iPhone is portrait only; the iPad keeps all four orientations, which
Split View and Slide Over need (#434 checked the iPad's layout).

Build 14.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 08:39:54 -05:00

70 lines
3.2 KiB
Swift

import Foundation
/// Which ipx-web the app shows. Asked for on first launch; changed in the page's Settings (#445).
/// `-ipx.server <url>` 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]))
}
/// The hosts in the Info.plist's WKAppBoundDomains (#435): the server and where its sign-in
/// goes. Fixed when the app is built, as Apple has it; a debug build adds localhost.
static let appBoundHosts = Set(Bundle.main.object(forInfoDictionaryKey: "WKAppBoundDomains") as? [String] ?? [])
static func isAppBound(_ url: URL?) -> Bool {
url?.host.map { appBoundHosts.contains($0.lowercased()) } ?? false
}
/// 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
}
/// Where the player, the badge and CarPlay talk to ipx, which is not where the page is: an
/// address with no Cloudflare Access in front, for the API token alone (#419). Always https,
/// since the token travels in a header.
private static let apiKey = "ipx.api"
static let suggestedAPI = "https://ipxapi.sdf1.net"
static var apiURL: URL? {
get { UserDefaults.standard.string(forKey: apiKey).flatMap(normalise).flatMap { $0.scheme == "https" ? $0 : nil } }
set { UserDefaults.standard.set(newValue?.absoluteString, forKey: apiKey) }
}
/// A path against the player's address when there are credentials to send there, else the
/// page's, where the web view's cookie does the signing in.
static func resolveForPlayer(_ path: String) -> URL? {
guard Credentials.isSet, let base = apiURL else { return resolve(path) }
return URL(string: path, relativeTo: base)?.absoluteURL
}
}