The iOS app's icon count, pushed by ipx (#433)
Background refresh runs when iOS chooses, and for this app that has been rarely, so the icon's unread count lagged until the app was opened (#421). Now ipx sends the count: - ipx: a push_devices table and POST/DELETE /api/push/devices. With [push] set to an APNs key, a task compares each phone's All Subscriptions unread count with the last sent, once a minute, and sends a changed one to Apple as a badge-only push: no alert, no sound. A token Apple says is gone is forgotten. The signing token is ES256 over the team and time, made again every forty minutes, inside Apple's limits. - iOS: the aps-environment entitlement. The app registers for push once badge permission is given, each launch, and hands its token to the page. - Page: registers the token with ipx as the person signed in, since the player's address (ipxapi) lets through only the player's paths, and removes it on signing out. jsonwebtoken here is built without PEM support, so the .p8 key's base64 is decoded in push.rs rather than turning on a feature and changing Cargo.lock for one file. cargo test -p ipx on macOS: 182 pass; the 3 that fail read /proc and fail there without this change too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,4 +12,5 @@ The iOS app, `ios/`. Format: [Keep a Changelog](https://keepachangelog.com/en/1.
|
||||
- Episodes kept on the phone, with the page's Keep button, play with no connection: the app fetches the file in the background, even with the app closed, and plays it from the phone; where you got to and what you finished offline is sent once the connection is back. Signing out removes them (#425).
|
||||
- A sleep timer in the player, kept by the phone, so it pauses on time with the screen locked, fading out over the last ten seconds; and an AirPlay button beside it, for another speaker or a TV (#429).
|
||||
- iPX opens with no connection, showing the lists it last had, as it does in a browser: the page's service worker now runs in the app. This is for `ipodderx.sdf1.net` only, and links to other sites open in Safari (#435).
|
||||
- The icon's count stays right with the app closed: ipx sends it to the phone when it changes, once its `[push]` is set up, in place of waiting for iOS's background refresh (#433).
|
||||
- Presses are felt: a light tap on play, pause, flag and pull to refresh, a success on marking read, a warning with an error (#430).
|
||||
|
||||
@@ -106,6 +106,7 @@ App to page, as `window.ipxNative.on(...)`:
|
||||
| `{t:"kept", urls, keeping}` | what the phone holds and is fetching, at `ready` and after each change; sent whether or not anything plays |
|
||||
| `{t:"keepFailed", url, message}` | one that could not be kept, and why |
|
||||
| `{t:"host", can}` | at `ready`: what this build does itself (`sleep`, `route`), so a page newer than the app offers only those |
|
||||
| `{t:"push", token, sandbox}` | at `ready` and when Apple gives a new one: the phone's push token, for the page to register with ipx (`POST /api/push/devices`) (#433) |
|
||||
| `{t:"sleep", left}` | at `ready`, while the timer runs: seconds left, for a page that reloaded |
|
||||
| `{t:"slept"}` | the timer ran out and playback paused |
|
||||
|
||||
|
||||
@@ -17,6 +17,17 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
Offline.shared.backgroundCompletion = completionHandler
|
||||
Offline.shared.resume()
|
||||
}
|
||||
|
||||
/// Apple's address for this phone's copy of the app (#433), handed to the page to register.
|
||||
func application(_ application: UIApplication,
|
||||
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
|
||||
Push.token = deviceToken.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication,
|
||||
didFailToRegisterForRemoteNotificationsWithError error: Error) {
|
||||
NSLog("ipx: push registration: %@", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/// The one window. iOS will not run an app that has not adopted scenes, and a CarPlay app would
|
||||
|
||||
@@ -35,7 +35,11 @@ enum Badge {
|
||||
// `-ipx.noBadgePrompt YES` on the launch arguments: a simulator run that drives the page
|
||||
// has nobody to tap Allow, and the dialog covers it.
|
||||
if UserDefaults.standard.bool(forKey: "ipx.noBadgePrompt") { return }
|
||||
UNUserNotificationCenter.current().requestAuthorization(options: [.badge]) { _, _ in }
|
||||
UNUserNotificationCenter.current().requestAuthorization(options: [.badge]) { granted, _ in
|
||||
// Each launch, not once: Apple can change the token, and says to ask every time.
|
||||
guard granted else { return }
|
||||
Task { @MainActor in UIApplication.shared.registerForRemoteNotifications() }
|
||||
}
|
||||
}
|
||||
|
||||
/// The page's own count, sent as its list changes (#421).
|
||||
|
||||
@@ -43,6 +43,7 @@ final class Bridge: NSObject, WKScriptMessageHandler {
|
||||
playback.onEnded = { [weak self] in self?.send(["t": "ended"]) }
|
||||
playback.onError = { [weak self] msg in self?.send(["t": "error", "message": msg]) }
|
||||
playback.onSlept = { [weak self] in self?.send(["t": "slept"]) }
|
||||
Push.onToken = { [weak self] _ in self?.sendPush() }
|
||||
Offline.shared.onChange = { [weak self] in self?.sendKept() }
|
||||
Offline.shared.onFail = { [weak self] path, msg in
|
||||
self?.send(["t": "keepFailed", "url": path, "message": msg])
|
||||
@@ -55,6 +56,12 @@ final class Bridge: NSObject, WKScriptMessageHandler {
|
||||
"keeping": Offline.shared.downloading.sorted()])
|
||||
}
|
||||
|
||||
/// For the page to register with ipx (#433), at ready and whenever Apple gives a new one.
|
||||
private func sendPush() {
|
||||
guard let token = Push.token else { return }
|
||||
send(["t": "push", "token": token, "sandbox": Push.sandbox])
|
||||
}
|
||||
|
||||
// MARK: - page to host
|
||||
|
||||
func userContentController(_ controller: WKUserContentController,
|
||||
@@ -69,6 +76,7 @@ final class Bridge: NSObject, WKScriptMessageHandler {
|
||||
// A page reloaded while the timer runs is told how long is left.
|
||||
if let at = playback.sleepAt { send(["t": "sleep", "left": max(0, at.timeIntervalSinceNow)]) }
|
||||
sendKept()
|
||||
sendPush()
|
||||
case "load":
|
||||
guard let path = m["url"] as? String, let url = Server.resolveForPlayer(path),
|
||||
let enc = m["enc"] as? Int,
|
||||
|
||||
23
ios/Sources/Push.swift
Normal file
23
ios/Sources/Push.swift
Normal file
@@ -0,0 +1,23 @@
|
||||
import Foundation
|
||||
|
||||
/// This phone's push token (#433). ipx sends the icon's count to it while the app is closed,
|
||||
/// which background refresh only guessed at. The page registers it with ipx, signed in as the
|
||||
/// page is, since the player's address lets through only the player's own paths.
|
||||
@MainActor
|
||||
enum Push {
|
||||
/// Told when the token arrives or changes, to pass it to the page.
|
||||
static var onToken: ((String) -> Void)?
|
||||
|
||||
static var token: String? {
|
||||
didSet { if let token, token != oldValue { onToken?(token) } }
|
||||
}
|
||||
|
||||
/// A debug build is served by Apple's sandbox, which ipx has to send to instead.
|
||||
static var sandbox: Bool {
|
||||
#if DEBUG
|
||||
true
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ targets:
|
||||
properties:
|
||||
CFBundleDisplayName: iPX
|
||||
CFBundleShortVersionString: $(MARKETING_VERSION)
|
||||
CFBundleVersion: "10"
|
||||
CFBundleVersion: "11"
|
||||
# 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, fetch]
|
||||
@@ -55,6 +55,12 @@ targets:
|
||||
- ipodderx.sdf1.net
|
||||
- rays-sdf1.cloudflareaccess.com
|
||||
- authentik.sdf1.net
|
||||
# The icon's count pushed by ipx (#433). Development here; Xcode makes it production when it
|
||||
# exports for TestFlight.
|
||||
entitlements:
|
||||
path: Resources/iPX.entitlements
|
||||
properties:
|
||||
aps-environment: development
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: net.sdf1.ipx
|
||||
|
||||
Reference in New Issue
Block a user