krz/moonrise

Night-sky companion for iPhone, Apple Watch and Vision Pro: what's up tonight, the Moon, planets, launches and missions, and a Library of the solar system. SwiftUI app with a Cloudflare Workers backend. astronomy cloudflare-workers ios night-sky space swiftui visionos watchos

Commit 5f336837c1

5f336837c11e140cd55c8861cb651f9db08b035f

parent: 5b4208dbee

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-18 05:56 UTC

Add opt-in aurora alerts

- SkyEngine: geomagnetic latitude (centred dipole, IGRF-14 2025 pole)
  and the backend's 5-degree band, tested.
- App: AuroraAlerts registers the APNs token, band and UTC offset with
  the backend when the Settings switch is on (sandbox in Debug,
  production otherwise), re-registers when the location changes, and
  deletes the registration when switched off. Banners show while the
  app is open. Settings has a test-alert button.
- server: POST /v1/devices/{token}/test sends one push, at most once per
  10 minutes per device.
- Privacy policy and docs/app-store.org describe the data sent when
  alerts are on.

Layout: unified · split

App/Sources/AuroraAlerts.swift added +102
@@ -0,0 +1,102 @@
1import Foundation
2import Observation
3import SkyEngine
4import UIKit
5import UserNotifications
6
7/// Registers this device with the backend for aurora pushes. Sends only the push
8/// token, a 5° geomagnetic latitude band and the UTC offset.
9@MainActor
10@Observable
11final class AuroraAlerts {
12 static let shared = AuroraAlerts()
13
14 private static let enabledKey = "auroraAlerts"
15 private static let tokenKey = "apnsToken"
16
17 private(set) var enabled = UserDefaults.standard.bool(forKey: AuroraAlerts.enabledKey)
18 private(set) var status: String?
19 private var observer: Observer?
20
21 #if DEBUG
22 private let environment = "sandbox"
23 #else
24 private let environment = "production"
25 #endif
26
27 private var token: String? { UserDefaults.standard.string(forKey: Self.tokenKey) }
28
29 /// Called by the app delegate whenever iOS hands over a device token.
30 func receivedToken(_ token: String) {
31 UserDefaults.standard.set(token, forKey: Self.tokenKey)
32 Task { await sync() }
33 }
34
35 /// Called when the sky's location changes, so the band stays current.
36 func update(observer: Observer) {
37 guard observer != self.observer else { return }
38 self.observer = observer
39 Task { await sync() }
40 }
41
42 func setEnabled(_ on: Bool) async {
43 if on {
44 let granted = (try? await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound])) ?? false
45 guard granted else {
46 status = "Notifications are off for Moonrise. Turn them on in the Settings app."
47 return
48 }
49 UIApplication.shared.registerForRemoteNotifications()
50 }
51 enabled = on
52 UserDefaults.standard.set(on, forKey: Self.enabledKey)
53 await sync()
54 }
55
56 /// Registers or unregisters to match `enabled`.
57 func sync() async {
58 guard let apiBase, let token else { return }
59 if enabled {
60 guard let observer else { return }
61 let body: [String: Any] = [
62 "token": token,
63 "environment": environment,
64 "band": Geomagnetic.band(observer),
65 "utcOffsetMinutes": TimeZone.current.secondsFromGMT() / 60,
66 "aurora": true,
67 ]
68 var req = URLRequest(url: apiBase.appending(path: "v1/devices"))
69 req.httpMethod = "POST"
70 req.setValue("application/json", forHTTPHeaderField: "content-type")
71 req.httpBody = try? JSONSerialization.data(withJSONObject: body)
72 let ok = await send(req)
73 status = ok ? nil : "Couldn't reach the Moonrise server. Alerts will register next time."
74 } else {
75 var req = URLRequest(url: apiBase.appending(path: "v1/devices/\(token)"))
76 req.httpMethod = "DELETE"
77 _ = await send(req)
78 status = nil
79 }
80 }
81
82 func sendTest() async {
83 guard let apiBase, let token else { status = "No device token yet."; return }
84 var req = URLRequest(url: apiBase.appending(path: "v1/devices/\(token)/test"))
85 req.httpMethod = "POST"
86 guard let (_, response) = try? await URLSession.shared.data(for: req), let http = response as? HTTPURLResponse else {
87 status = "Couldn't reach the Moonrise server."
88 return
89 }
90 status = switch http.statusCode {
91 case 200: "Test alert sent."
92 case 429: "A test was sent recently. Try again in a few minutes."
93 case 404: "This device isn't registered yet."
94 default: "The test alert failed (\(http.statusCode))."
95 }
96 }
97
98 private func send(_ req: URLRequest) async -> Bool {
99 guard let (_, response) = try? await URLSession.shared.data(for: req) else { return false }
100 return (response as? HTTPURLResponse)?.statusCode == 200
101 }
102}
App/Sources/Info.plist +16
@@ -2,6 +2,22 @@
2<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 2<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3<plist version="1.0"> 3<plist version="1.0">
4<dict> 4<dict>
5 <key>CFBundleDevelopmentRegion</key>
6 <string>$(DEVELOPMENT_LANGUAGE)</string>
7 <key>CFBundleExecutable</key>
8 <string>$(EXECUTABLE_NAME)</string>
9 <key>CFBundleIdentifier</key>
10 <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
11 <key>CFBundleInfoDictionaryVersion</key>
12 <string>6.0</string>
13 <key>CFBundleName</key>
14 <string>$(PRODUCT_NAME)</string>
15 <key>CFBundlePackageType</key>
16 <string>APPL</string>
17 <key>CFBundleShortVersionString</key>
18 <string>1.0</string>
19 <key>CFBundleVersion</key>
20 <string>1</string>
5 <key>MoonriseAPIURL</key> 21 <key>MoonriseAPIURL</key>
6 <string>$(MOONRISE_API_URL)</string> 22 <string>$(MOONRISE_API_URL)</string>
7 <key>NSAppTransportSecurity</key> 23 <key>NSAppTransportSecurity</key>
App/Sources/MoonriseApp.swift +9 −1
@@ -1,5 +1,6 @@
1import SwiftUI 1import SwiftUI
2import UIKit 2import UIKit
3import UserNotifications
3import os 4import os
4 5
5let log = Logger(subsystem: "sh.krz.moonrise", category: "app") 6let log = Logger(subsystem: "sh.krz.moonrise", category: "app")
@@ -30,8 +31,9 @@ struct MoonriseApp: App {
30 } 31 }
31} 32}
32 33
33final class AppDelegate: NSObject, UIApplicationDelegate { 34final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
34 func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { 35 func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
36 UNUserNotificationCenter.current().delegate = self
35 application.registerForRemoteNotifications() 37 application.registerForRemoteNotifications()
36 return true 38 return true
37 } 39 }
@@ -39,6 +41,12 @@ final class AppDelegate: NSObject, UIApplicationDelegate {
39 func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { 41 func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
40 let token = deviceToken.map { String(format: "%02x", $0) }.joined() 42 let token = deviceToken.map { String(format: "%02x", $0) }.joined()
41 log.info("APNs device token: \(token, privacy: .public)") 43 log.info("APNs device token: \(token, privacy: .public)")
44 Task { @MainActor in AuroraAlerts.shared.receivedToken(token) }
45 }
46
47 /// Show alerts as banners while the app is open.
48 nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions {
49 [.banner, .sound]
42 } 50 }
43 51
44 func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { 52 func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
App/Sources/OnboardingScreen.swift +1 −1
@@ -20,7 +20,7 @@ struct OnboardingScreen: View {
20 Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 10).padding(.bottom, 24) 20 Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 10).padding(.bottom, 24)
21 VStack(alignment: .leading, spacing: 14) { 21 VStack(alignment: .leading, spacing: 14) {
22 Text("The sky above you tonight, the Sun today, launches, the solar system, and a library of everything up there.") 22 Text("The sky above you tonight, the Sun today, launches, the solar system, and a library of everything up there.")
23 Text("To draw the sky for where you are, Moonrise uses your location. It is used on this phone to work out positions and is not sent anywhere.") 23 Text("To draw the sky for where you are, Moonrise uses your location. It is used on this phone to work out positions and is not sent to us or anyone else.")
24 .foregroundStyle(ink.opacity(0.75)) 24 .foregroundStyle(ink.opacity(0.75))
25 } 25 }
26 .font(.body) 26 .font(.body)
App/Sources/SettingsScreen.swift +17
@@ -58,6 +58,7 @@ struct SettingsScreen: View {
58 @AppStorage(SettingsKey.cityLongitude) private var cityLongitude = -87.630 58 @AppStorage(SettingsKey.cityLongitude) private var cityLongitude = -87.630
59 @AppStorage(SettingsKey.nightMode) private var nightMode = "auto" 59 @AppStorage(SettingsKey.nightMode) private var nightMode = "auto"
60 @Environment(\.dismiss) private var dismiss 60 @Environment(\.dismiss) private var dismiss
61 @State private var alerts = AuroraAlerts.shared
61 var onChange: () -> Void 62 var onChange: () -> Void
62 63
63 var body: some View { 64 var body: some View {
@@ -74,6 +75,22 @@ struct SettingsScreen: View {
74 } 75 }
75 } 76 }
76 } 77 }
78 Section {
79 Toggle("Aurora alerts", isOn: Binding(
80 get: { alerts.enabled },
81 set: { on in Task { await alerts.setEnabled(on) } }
82 ))
83 if alerts.enabled {
84 Button("Send a test alert") { Task { await alerts.sendTest() } }
85 }
86 if let status = alerts.status {
87 Text(status).font(.footnote).foregroundStyle(.secondary)
88 }
89 } header: {
90 Text("Alerts")
91 } footer: {
92 Text("Moonrise notifies you when the aurora may be visible from your area at night. To do this it sends our server this device's notification token, a 5° band of geomagnetic latitude and your time zone offset, not your location.")
93 }
77 Section { 94 Section {
78 Picker("Night mode", selection: $nightMode) { 95 Picker("Night mode", selection: $nightMode) {
79 Text("Off").tag("off") 96 Text("Off").tag("off")
App/Sources/SkyModel.swift +1
@@ -91,6 +91,7 @@ final class SkyModel: NSObject, CLLocationManagerDelegate {
91 /// Rebuilds the scene for the displayed time. Requests made during a build 91 /// Rebuilds the scene for the displayed time. Requests made during a build
92 /// collapse into one rebuild when it finishes. 92 /// collapse into one rebuild when it finishes.
93 func refresh() { 93 func refresh() {
94 AuroraAlerts.shared.update(observer: observer)
94 guard !building else { pending = true; return } 95 guard !building else { pending = true; return }
95 building = true 96 building = true
96 let date = displayedDate, observer = observer, astronomer = astronomer 97 let date = displayedDate, observer = observer, astronomer = astronomer
Packages/MoonriseKit/Sources/SkyEngine/Geomagnetic.swift added +24
@@ -0,0 +1,24 @@
1import Foundation
2
3/// Geomagnetic latitude from a centred-dipole model of Earth's field.
4public enum Geomagnetic {
5 /// North geomagnetic pole, IGRF-14 for 2025 (approximately 80.8° N, 72.8° W).
6 public static let poleLatitude = 80.8
7 public static let poleLongitude = -72.8
8
9 /// Geomagnetic latitude in degrees for a geographic position.
10 public static func latitude(_ observer: Observer) -> Double {
11 let r = Double.pi / 180
12 let s = sin(observer.latitude * r) * sin(poleLatitude * r)
13 + cos(observer.latitude * r) * cos(poleLatitude * r) * cos((observer.longitude - poleLongitude) * r)
14 return asin(max(-1, min(1, s))) / r
15 }
16
17 /// The backend's 5° band, named by its equatorward edge: 50 covers 50–55° N, -55 covers 50–55° S.
18 /// Must match `bandFor` in server/src/aurora.ts.
19 public static func band(_ observer: Observer) -> Int {
20 let m = latitude(observer)
21 let b = Int((abs(m) / 5).rounded(.down)) * 5
22 return m < 0 ? -(b + 5) : b
23 }
24}
Packages/MoonriseKit/Tests/SkyEngineTests/GeomagneticTests.swift added +21
@@ -0,0 +1,21 @@
1import Testing
2@testable import SkyEngine
3
4@Test func chicagoIsAboutFiftyOneDegreesGeomagnetic() {
5 let m = Geomagnetic.latitude(chicago)
6 #expect(m > 49 && m < 53, "\(m)")
7 #expect(Geomagnetic.band(chicago) == 50)
8}
9
10@Test func southernBandsAreNegative() {
11 // Hobart, Tasmania: the dipole model gives about 49.6° S, so the 45–50° S band.
12 let hobart = Observer(latitude: -42.88, longitude: 147.33)
13 let m = Geomagnetic.latitude(hobart)
14 #expect(m < -45 && m > -55, "\(m)")
15 #expect(Geomagnetic.band(hobart) == -50)
16}
17
18@Test func poleIsNinety() {
19 let pole = Observer(latitude: Geomagnetic.poleLatitude, longitude: Geomagnetic.poleLongitude)
20 #expect(abs(Geomagnetic.latitude(pole) - 90) < 1e-6)
21}
docs/app-store.org +9 −8
@@ -11,17 +11,18 @@
11| Privacy policy URL | https://moonrise-server.audit-labs.workers.dev/privacy | 11| Privacy policy URL | https://moonrise-server.audit-labs.workers.dev/privacy |
12 12
13* App Privacy ("nutrition label") 13* App Privacy ("nutrition label")
14Answer: *Data Not Collected*. 14Aurora alerts (opt-in) send the push token, a 5° geomagnetic latitude
15band and the UTC offset to the backend, so the answer is *Data Collected*:
15 16
16- Location: used on device only; never sent. Apple's definition of 17| Data type | Collected | Linked to identity | Tracking | Purpose |
17 "collect" is transmitting data off the device, so it is not collected. 18|-
18- Network requests (backend, NOAA, NASA, image hosts) carry no 19| Coarse Location | Yes (5° geomagnetic band) | No | No | App Functionality |
20| Device ID | Yes (APNs push token) | No | No | App Functionality |
21
22- Precise location: used on device only, never sent.
23- Other network requests (backend, NOAA, NASA, image hosts) carry no
19 identifiers. 24 identifiers.
20- No analytics, advertising, accounts, crash reporting SDKs or tracking. 25- No analytics, advertising, accounts, crash reporting SDKs or tracking.
21- Revisit when aurora alerts ship: the push token, a 5° geomagnetic
22 latitude band and UTC offset are sent to the backend. That is
23 likely "Coarse Location" and "Device ID", used for "App
24 Functionality", not linked to identity, not used for tracking.
25 26
26* Export compliance 27* Export compliance
27=ITSAppUsesNonExemptEncryption = NO= (HTTPS only), set in =project.yml=. 28=ITSAppUsesNonExemptEncryption = NO= (HTTPS only), set in =project.yml=.
server/src/index.ts +19 −1
@@ -65,10 +65,13 @@ export default {
65 await env.DB.prepare( 65 await env.DB.prepare(
66 `INSERT INTO devices (token, environment, band, utc_offset_minutes, aurora, last_seen) VALUES (?1, ?2, ?3, ?4, ?5, ?6) 66 `INSERT INTO devices (token, environment, band, utc_offset_minutes, aurora, last_seen) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
67 ON CONFLICT (token) DO UPDATE SET environment = ?2, band = ?3, utc_offset_minutes = ?4, aurora = ?5, last_seen = ?6`, 67 ON CONFLICT (token) DO UPDATE SET environment = ?2, band = ?3, utc_offset_minutes = ?4, aurora = ?5, last_seen = ?6`,
68 ).bind(d.token, d.environment, d.band, d.utcOffsetMinutes, d.aurora === false ? 0 : 1, now.toISOString()).run(); 68 ).bind(d.token.toLowerCase(), d.environment, d.band, d.utcOffsetMinutes, d.aurora === false ? 0 : 1, now.toISOString()).run();
69 return json({ ok: true }); 69 return json({ ok: true });
70 } 70 }
71 71
72 const test = url.pathname.match(/^\/v1\/devices\/([0-9a-f]{64,200})\/test$/i);
73 if (request.method === "POST" && test) return sendTest(env, test[1].toLowerCase(), now);
74
72 const del = url.pathname.match(/^\/v1\/devices\/([0-9a-f]{64,200})$/i); 75 const del = url.pathname.match(/^\/v1\/devices\/([0-9a-f]{64,200})$/i);
73 if (request.method === "DELETE" && del) { 76 if (request.method === "DELETE" && del) {
74 await env.DB.prepare("DELETE FROM devices WHERE token = ?1").bind(del[1]).run(); 77 await env.DB.prepare("DELETE FROM devices WHERE token = ?1").bind(del[1]).run();
@@ -86,6 +89,21 @@ export default {
86 }, 89 },
87} satisfies ExportedHandler<Env>; 90} satisfies ExportedHandler<Env>;
88 91
92/** One test push to a registered device, at most once per 10 minutes per device. */
93async function sendTest(env: Env, token: string, now: Date): Promise<Response> {
94 const device = await env.DB.prepare("SELECT environment FROM devices WHERE token = ?1").bind(token).first<{ environment: string }>();
95 if (!device) return json({ error: "not registered" }, 404);
96 const window = `${token}:${now.toISOString().slice(0, 15)}`; // 10-minute bucket
97 const claimed = await env.DB.prepare("INSERT OR IGNORE INTO alerts_sent (kind, band, event, sent_at) VALUES ('test', 0, ?1, ?2)")
98 .bind(window, now.toISOString()).run();
99 if (!claimed.meta.changes) return json({ error: "try again in a few minutes" }, 429);
100 const config: ApnsConfig = { keyId: env.APNS_KEY_ID, teamId: env.APNS_TEAM_ID, privateKey: env.APNS_KEY, topic: env.APNS_TOPIC };
101 const payload = { aps: { alert: { title: "Moonrise test alert", body: "Aurora alerts reach this device." }, sound: "default" } };
102 const r = await send(config, token, payload, device.environment === "sandbox");
103 if (r.status === 410) await env.DB.prepare("DELETE FROM devices WHERE token = ?1").bind(token).run();
104 return json({ apnsStatus: r.status, reason: r.reason }, r.status === 200 ? 200 : 502);
105}
106
89async function launches(env: Env, now: Date): Promise<Response> { 107async function launches(env: Env, now: Date): Promise<Response> {
90 const since = new Date(now.getTime() - 6 * 3600_000).toISOString(); 108 const since = new Date(now.getTime() - 6 * 3600_000).toISOString();
91 const rows = await env.DB.prepare("SELECT * FROM launches WHERE net >= ?1 ORDER BY net LIMIT 20").bind(since).all<Record<string, unknown>>(); 109 const rows = await env.DB.prepare("SELECT * FROM launches WHERE net >= ?1 ORDER BY net LIMIT 20").bind(since).all<Record<string, unknown>>();
server/src/privacy.ts +12 −3
@@ -20,7 +20,7 @@ h2 { font-weight: 500; font-size: 1.1em; margin-top: 1.8em; border-top: 1px soli
20<p>Last updated 18 October 2026.</p> 20<p>Last updated 18 October 2026.</p>
21 21
22<h2>Summary</h2> 22<h2>Summary</h2>
23<p>Moonrise has no accounts, no advertising, no analytics and no tracking. It does not collect personal data.</p> 23<p>Moonrise has no accounts, no advertising, no analytics and no tracking. If you turn on aurora alerts, it sends our server three things needed to deliver them, described below. Nothing else is collected.</p>
24 24
25<h2>Location</h2> 25<h2>Location</h2>
26<p>If you allow it, Moonrise uses your location to work out where the Sun, Moon, planets and stars are in your sky. This happens on your device. Your location is not sent to us or to anyone else. You can choose a city instead in Settings.</p> 26<p>If you allow it, Moonrise uses your location to work out where the Sun, Moon, planets and stars are in your sky. This happens on your device. Your location is not sent to us or to anyone else. You can choose a city instead in Settings.</p>
@@ -35,8 +35,17 @@ h2 { font-weight: 500; font-size: 1.1em; margin-top: 1.8em; border-top: 1px soli
35</ul> 35</ul>
36<p>These requests contain no identifiers. As with any internet request, the servers receive your IP address. Cloudflare may keep short-lived logs; see Cloudflare's privacy policy.</p> 36<p>These requests contain no identifiers. As with any internet request, the servers receive your IP address. Cloudflare may keep short-lived logs; see Cloudflare's privacy policy.</p>
37 37
38<h2>Notifications and Live Activities</h2> 38<h2>Aurora alerts</h2>
39<p>A launch countdown you add to the Lock Screen runs on your device. The app does not currently register your device with our server.</p> 39<p>Aurora alerts are off until you turn them on in Settings. When they are on, the app sends our server:</p>
40<ul>
41<li>this device's push notification token, which Apple issues so a server can send it notifications;</li>
42<li>a 5° band of geomagnetic latitude worked out from your location on the device (for example "50° to 55° north"), not your location itself;</li>
43<li>your time zone offset from UTC, so alerts arrive at night.</li>
44</ul>
45<p>These are stored with the time the device last checked in, and used only to send aurora alerts. They are not linked to your identity or shared. Turning alerts off deletes them from the server. A token that Apple reports as no longer valid is deleted automatically.</p>
46
47<h2>Live Activities</h2>
48<p>A launch countdown you add to the Lock Screen runs on your device and sends nothing to our server.</p>
40 49
41<h2>Changes</h2> 50<h2>Changes</h2>
42<p>If this policy changes, the new version will be posted here with a new date.</p> 51<p>If this policy changes, the new version will be posted here with a new date.</p>