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 4cb642b1b7

4cb642b1b72edc0674d3a0a4950d77ec1e58a5a1

parent: 014330d266

Unsigned

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

Night tab tint, Dynamic Type, aurora alerts (!18)

- Tab bar: selected tab tinted red while the Sky tab is in night mode.
- Dynamic Type: fixed sizes replaced with text styles across the app (drawings excepted); Today summary wraps to two lines; draw-time readout only in Debug builds.
- Aurora alerts (opt-in, Settings): registers push token, 5° geomagnetic band and UTC offset; test-alert button; =POST /v1/devices/{token}/test= on the Worker (rate-limited).
- Privacy policy and App Store answers updated: alerts mean "Coarse Location" and "Device ID", not linked, not tracking.

Tests: =swift test= 37 pass, =node --test= 10 pass. End-to-end push check in the simulator follows the merge and deploy.

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/LaunchesScreen.swift +18 −18
@@ -18,11 +18,11 @@ struct LaunchesScreen: View {
1818 var body: some View {
1919 ScrollView {
2020 VStack(alignment: .leading, spacing: 0) {
21 Text("Launches").font(.system(size: 28, weight: .medium))
21 Text("Launches").font(.title.weight(.medium))
2222 Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 8).padding(.bottom, 8)
2323
2424 if let error {
25 Text(error).font(.system(size: 13)).foregroundStyle(accent).padding(.vertical, 12)
25 Text(error).font(.footnote).foregroundStyle(accent).padding(.vertical, 12)
2626 }
2727 if let list {
2828 ForEach(Array(list.launches.enumerated()), id: \.element.id) { i, launch in
@@ -30,7 +30,7 @@ struct LaunchesScreen: View {
3030 .buttonStyle(.plain)
3131 }
3232 Text("Launch data: The Space Devs, Launch Library 2.")
33 .font(.system(size: 10)).opacity(0.5).padding(.vertical, 16)
33 .font(.caption2).opacity(0.5).padding(.vertical, 16)
3434 } else if error == nil {
3535 ProgressView().tint(ink).padding(.top, 40).frame(maxWidth: .infinity)
3636 }
@@ -57,18 +57,18 @@ struct LaunchesScreen: View {
5757 private func row(_ l: Launch, index: Int) -> some View {
5858 VStack(alignment: .leading, spacing: 6) {
5959 HStack(alignment: .firstTextBaseline) {
60 Text(String(format: "%02d", index)).font(.system(size: 10).monospacedDigit()).opacity(0.5)
60 Text(String(format: "%02d", index)).font(.caption2.monospacedDigit()).opacity(0.5)
6161 Text(l.net.formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated).hour().minute()))
62 .font(.system(size: 11)).opacity(0.7)
62 .font(.caption2).opacity(0.7)
6363 Spacer()
6464 StatusTag(status: l.status, ink: ink, accent: accent)
6565 }
66 Text(l.rocket).font(.system(size: 22, weight: .medium))
67 if let m = l.mission { Text(m).font(.system(size: 14)).lineLimit(1) }
66 Text(l.rocket).font(.title2.weight(.medium))
67 if let m = l.mission { Text(m).font(.subheadline).lineLimit(1) }
6868 HStack {
69 Text("\(l.provider) · \(l.location)").font(.system(size: 11)).opacity(0.6).lineLimit(1)
69 Text("\(l.provider) · \(l.location)").font(.caption2).opacity(0.6).lineLimit(1)
7070 Spacer()
71 Countdown(to: l.net).font(.system(size: 13, weight: .medium).monospacedDigit())
71 Countdown(to: l.net).font(.footnote.weight(.medium).monospacedDigit())
7272 }
7373 }
7474 .padding(.vertical, 14)
@@ -84,7 +84,7 @@ struct StatusTag: View {
8484
8585 var body: some View {
8686 Text(status.uppercased())
87 .font(.system(size: 10, weight: .medium))
87 .font(.caption2.weight(.medium))
8888 .tracking(0.6)
8989 .padding(.horizontal, 6).padding(.vertical, 2)
9090 .foregroundStyle(status == "Go" ? Color.black : ink)
@@ -121,11 +121,11 @@ struct LaunchDetail: View {
121121 VStack(alignment: .leading, spacing: 10) {
122122 HStack {
123123 StatusTag(status: launch.status, ink: ink, accent: accent)
124 Text(launch.statusName).font(.system(size: 12)).opacity(0.7)
124 Text(launch.statusName).font(.caption).opacity(0.7)
125125 }
126 Text(launch.rocket).font(.system(size: 28, weight: .medium))
127 if let m = launch.mission { Text(m).font(.system(size: 17)) }
128 Countdown(to: launch.net).font(.system(size: 34, weight: .medium).monospacedDigit()).padding(.vertical, 6)
126 Text(launch.rocket).font(.title.weight(.medium))
127 if let m = launch.mission { Text(m).font(.body) }
128 Countdown(to: launch.net).font(.largeTitle.weight(.medium).monospacedDigit()).padding(.vertical, 6)
129129 Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) {
130130 fact("Provider", launch.provider)
131131 fact("Launch time", launch.net.formatted(date: .abbreviated, time: .shortened))
@@ -137,11 +137,11 @@ struct LaunchDetail: View {
137137 fact("Pad", launch.pad)
138138 fact("Location", launch.location)
139139 }
140 .font(.system(size: 14))
140 .font(.subheadline)
141141 followButton.padding(.top, 8)
142 if let message { Text(message).font(.system(size: 12)).opacity(0.7) }
142 if let message { Text(message).font(.caption).opacity(0.7) }
143143 if let d = launch.missionDescription {
144 Text(d).font(.system(size: 14)).opacity(0.85).padding(.top, 8)
144 Text(d).font(.subheadline).opacity(0.85).padding(.top, 8)
145145 }
146146 }
147147 .padding(20)
@@ -160,7 +160,7 @@ struct LaunchDetail: View {
160160 following ? unfollow() : follow()
161161 } label: {
162162 Label(following ? "Stop following" : "Follow on Lock Screen", systemImage: following ? "bell.slash" : "bell")
163 .font(.system(size: 15, weight: .medium))
163 .font(.subheadline.weight(.medium))
164164 .frame(maxWidth: .infinity)
165165 .padding(.vertical, 10)
166166 .overlay(RoundedRectangle(cornerRadius: 6).stroke(ink.opacity(0.5), lineWidth: 0.5))
App/Sources/LibraryScreen.swift +8 −8
@@ -17,9 +17,9 @@ struct LibraryScreen: View {
1717 if !pages.isEmpty {
1818 NavigationLink(value: section) {
1919 HStack {
20 Text(section.title).font(.system(size: 17))
20 Text(section.title).font(.body)
2121 Spacer()
22 Text("\(pages.count)").font(.system(size: 13).monospacedDigit()).opacity(0.5)
22 Text("\(pages.count)").font(.footnote.monospacedDigit()).opacity(0.5)
2323 }
2424 }
2525 .listRowBackground(pageBackground)
@@ -66,7 +66,7 @@ struct PageRow: View {
6666 var body: some View {
6767 VStack(alignment: .leading, spacing: 2) {
6868 Text(page.title).font(.body)
69 Text(page.subtitle).font(.system(size: 12)).opacity(0.6).lineLimit(1)
69 Text(page.subtitle).font(.caption).opacity(0.6).lineLimit(1)
7070 }
7171 .padding(.vertical, 2)
7272 }
@@ -78,7 +78,7 @@ struct LibraryPageView: View {
7878 var body: some View {
7979 ScrollView {
8080 VStack(alignment: .leading, spacing: 0) {
81 Text(page.section.title).font(.system(size: 11, weight: .medium)).textCase(.uppercase).tracking(0.8).opacity(0.6)
81 Text(page.section.title).font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).opacity(0.6)
8282 Text(page.title).font(.largeTitle.weight(.medium)).padding(.top, 4)
8383 Text(page.subtitle).font(.subheadline).opacity(0.75).padding(.top, 2)
8484 Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 10).padding(.bottom, 16)
@@ -121,23 +121,23 @@ struct LibraryPageView: View {
121121 AsyncImage(url: image.url) { phase in
122122 switch phase {
123123 case .success(let img): img.resizable().scaledToFit()
124 case .failure: Color.black.frame(height: 120).overlay(Text("Image unavailable").font(.system(size: 12)).opacity(0.6))
124 case .failure: Color.black.frame(height: 120).overlay(Text("Image unavailable").font(.caption).opacity(0.6))
125125 default: Color.black.frame(height: 200).overlay(ProgressView().tint(ink))
126126 }
127127 }
128128 Text(image.caption).font(.footnote).opacity(0.75)
129129 Link(destination: image.page) {
130 Text("\(image.credit) · \(image.licence)").font(.system(size: 10)).opacity(0.55)
130 Text("\(image.credit) · \(image.licence)").font(.caption2).opacity(0.55)
131131 }
132132 }
133133 }
134134
135135 private var sources: some View {
136136 VStack(alignment: .leading, spacing: 6) {
137 Text("Sources").font(.system(size: 11, weight: .medium)).textCase(.uppercase).tracking(0.8).opacity(0.6)
137 Text("Sources").font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).opacity(0.6)
138138 ForEach(Array(page.sources.enumerated()), id: \.offset) { i, s in
139139 Link(destination: s.url) {
140 Text("\(i + 1). \(s.title)").font(.system(size: 12)).underline().opacity(0.8)
140 Text("\(i + 1). \(s.title)").font(.caption).underline().opacity(0.8)
141141 }
142142 }
143143 }
App/Sources/MoonriseApp.swift +19 −7
@@ -1,5 +1,6 @@
11import SwiftUI
22import UIKit
3import UserNotifications
34import os
45
56let log = Logger(subsystem: "sh.krz.moonrise", category: "app")
@@ -8,16 +9,20 @@ let log = Logger(subsystem: "sh.krz.moonrise", category: "app")
89struct MoonriseApp: App {
910 @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
1011 @AppStorage(SettingsKey.onboarded) private var onboarded = false
12 @AppStorage(SettingsKey.nightActive) private var nightActive = false
13 @State private var tab = "sky"
1114
1215 var body: some Scene {
1316 WindowGroup {
14 TabView {
15 Tab("Sky", systemImage: "sparkles") { SkyScreen() }
16 Tab("System", systemImage: "circle.circle") { SystemScreen() }
17 Tab("Sun", systemImage: "sun.max") { SunScreen() }
18 Tab("Launches", systemImage: "airplane.departure") { LaunchesScreen() }
19 Tab("Library", systemImage: "books.vertical") { LibraryScreen() }
17 TabView(selection: $tab) {
18 Tab("Sky", systemImage: "sparkles", value: "sky") { SkyScreen() }
19 Tab("System", systemImage: "circle.circle", value: "system") { SystemScreen() }
20 Tab("Sun", systemImage: "sun.max", value: "sun") { SunScreen() }
21 Tab("Launches", systemImage: "airplane.departure", value: "launches") { LaunchesScreen() }
22 Tab("Library", systemImage: "books.vertical", value: "library") { LibraryScreen() }
2023 }
24 // Red tab highlight while the Sky tab is in night mode.
25 .tint(tab == "sky" && nightActive ? Color(red: 0.85, green: 0.12, blue: 0.08) : nil)
2126 .preferredColorScheme(.dark)
2227 .fullScreenCover(isPresented: .constant(!onboarded)) {
2328 OnboardingScreen().preferredColorScheme(.dark)
@@ -26,8 +31,9 @@ struct MoonriseApp: App {
2631 }
2732}
2833
29final class AppDelegate: NSObject, UIApplicationDelegate {
34final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
3035 func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
36 UNUserNotificationCenter.current().delegate = self
3137 application.registerForRemoteNotifications()
3238 return true
3339 }
@@ -35,6 +41,12 @@ final class AppDelegate: NSObject, UIApplicationDelegate {
3541 func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
3642 let token = deviceToken.map { String(format: "%02x", $0) }.joined()
3743 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]
3850 }
3951
4052 func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
App/Sources/OnboardingScreen.swift +1 −1
@@ -20,7 +20,7 @@ struct OnboardingScreen: View {
2020 Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 10).padding(.bottom, 24)
2121 VStack(alignment: .leading, spacing: 14) {
2222 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.")
2424 .foregroundStyle(ink.opacity(0.75))
2525 }
2626 .font(.body)
App/Sources/SettingsScreen.swift +19
@@ -9,6 +9,8 @@ enum SettingsKey {
99 static let cityLongitude = "cityLongitude"
1010 static let nightMode = "nightMode" // "off", "on" or "auto"
1111 static let onboarded = "onboarded"
12 /// Whether the Sky tab is currently drawn in night mode (written by the Sky tab).
13 static let nightActive = "nightActive"
1214}
1315
1416struct City: Hashable, Identifiable {
@@ -56,6 +58,7 @@ struct SettingsScreen: View {
5658 @AppStorage(SettingsKey.cityLongitude) private var cityLongitude = -87.630
5759 @AppStorage(SettingsKey.nightMode) private var nightMode = "auto"
5860 @Environment(\.dismiss) private var dismiss
61 @State private var alerts = AuroraAlerts.shared
5962 var onChange: () -> Void
6063
6164 var body: some View {
@@ -72,6 +75,22 @@ struct SettingsScreen: View {
7275 }
7376 }
7477 }
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 }
7594 Section {
7695 Picker("Night mode", selection: $nightMode) {
7796 Text("Off").tag("off")
App/Sources/SkyModel.swift +1
@@ -91,6 +91,7 @@ final class SkyModel: NSObject, CLLocationManagerDelegate {
9191 /// Rebuilds the scene for the displayed time. Requests made during a build
9292 /// collapse into one rebuild when it finishes.
9393 func refresh() {
94 AuroraAlerts.shared.update(observer: observer)
9495 guard !building else { pending = true; return }
9596 building = true
9697 let date = displayedDate, observer = observer, astronomer = astronomer
App/Sources/SkyScreen.swift +14 −10
@@ -12,6 +12,7 @@ struct SkyScreen: View {
1212 @AppStorage(SettingsKey.nightMode) private var nightMode = "auto"
1313 @State private var showSettings = false
1414 @AppStorage(SettingsKey.onboarded) private var onboarded = false
15 @AppStorage(SettingsKey.nightActive) private var nightActive = false
1516 @State private var showToday = false
1617 @State private var libraryPage: LibraryPage?
1718 @State private var selection: SkyObject?
@@ -68,6 +69,7 @@ struct SkyScreen: View {
6869 }
6970 .onChange(of: model.offsetHours) { select(selection) }
7071 .onChange(of: onboarded) { if onboarded { model.start() } }
72 .onChange(of: night, initial: true) { nightActive = night }
7173 .task {
7274 if onboarded { model.start() }
7375 while !Task.isCancelled {
@@ -121,13 +123,15 @@ struct SkyScreen: View {
121123 HStack(alignment: .top) {
122124 VStack(alignment: .leading, spacing: 4) {
123125 Text(model.offsetHours == 0 ? "Tonight" : "Sky at \(model.displayedDate.formatted(date: .omitted, time: .shortened))")
124 .font(.system(size: 22, weight: .medium))
126 .font(.title2.weight(.medium))
125127 Text(model.placeName)
126 .font(.system(size: 11)).opacity(0.7)
128 .font(.caption2).opacity(0.7)
129 #if DEBUG
127130 TimelineView(.periodic(from: .now, by: 1)) { _ in
128131 Text(stats.summary(build: model.lastBuild))
129 .font(.system(size: 10).monospacedDigit()).opacity(0.7)
132 .font(.caption2.monospacedDigit()).opacity(0.7)
130133 }
134 #endif
131135 }
132136 Spacer()
133137 if pointer.available {
@@ -135,14 +139,14 @@ struct SkyScreen: View {
135139 pointer.active ? pointer.stop() : pointer.start()
136140 } label: {
137141 Image(systemName: pointer.active ? "scope" : "hand.draw")
138 .font(.system(size: 17))
142 .font(.body)
139143 .frame(width: 44, height: 44)
140144 }
141145 .accessibilityLabel(pointer.active ? "Stop following the phone" : "Point the phone at the sky")
142146 }
143147 Button { showSettings = true } label: {
144148 Image(systemName: "gearshape")
145 .font(.system(size: 17))
149 .font(.body)
146150 .frame(width: 44, height: 44)
147151 }
148152 .accessibilityLabel("Settings")
@@ -150,7 +154,7 @@ struct SkyScreen: View {
150154 nightMode = night ? "off" : "on"
151155 } label: {
152156 Image(systemName: night ? "eye.fill" : "eye")
153 .font(.system(size: 17))
157 .font(.body)
154158 .frame(width: 44, height: 44)
155159 }
156160 .accessibilityLabel(night ? "Turn off night mode" : "Turn on night mode")
@@ -163,7 +167,7 @@ struct SkyScreen: View {
163167 private func card(for object: SkyObject) -> some View {
164168 VStack(alignment: .leading, spacing: 8) {
165169 HStack {
166 Text(title(for: object)).font(.system(size: 20, weight: .medium))
170 Text(title(for: object)).font(.title3.weight(.medium))
167171 Spacer()
168172 Button { selection = nil } label: { Image(systemName: "xmark").frame(width: 32, height: 32) }
169173 .accessibilityLabel("Close")
@@ -172,11 +176,11 @@ struct SkyScreen: View {
172176 case .body:
173177 if let d = details { bodyFacts(d) } else { ProgressView().tint(ink) }
174178 case .constellation(let id):
175 Text("Constellation · \(id)").font(.system(size: 13)).opacity(0.7)
179 Text("Constellation · \(id)").font(.footnote).opacity(0.7)
176180 }
177181 if let page = libraryPage(for: object) {
178182 Button { libraryPage = page } label: {
179 Label("Read more", systemImage: "book").font(.system(size: 13, weight: .medium))
183 Label("Read more", systemImage: "book").font(.footnote.weight(.medium))
180184 }
181185 .buttonStyle(.plain)
182186 .padding(.top, 4)
@@ -221,7 +225,7 @@ struct SkyScreen: View {
221225 Text(row.0).opacity(0.7)
222226 Text(row.1).monospacedDigit()
223227 }
224 .font(.system(size: 13))
228 .font(.footnote)
225229 }
226230 }
227231 }
App/Sources/SunScreen.swift +15 −15
@@ -19,7 +19,7 @@ struct SunScreen: View {
1919 var body: some View {
2020 ScrollView {
2121 VStack(alignment: .leading, spacing: 0) {
22 Text("Sun").font(.system(size: 28, weight: .medium))
22 Text("Sun").font(.title.weight(.medium))
2323 Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 8).padding(.bottom, 16)
2424
2525 image
@@ -28,17 +28,17 @@ struct SunScreen: View {
2828 }
2929 .pickerStyle(.segmented)
3030 .padding(.top, 12)
31 Text(wavelength.caption).font(.system(size: 13)).opacity(0.75).padding(.top, 8)
32 Text(SolarImage.credit).font(.system(size: 10)).opacity(0.5).padding(.top, 4)
31 Text(wavelength.caption).font(.footnote).opacity(0.75).padding(.top, 8)
32 Text(SolarImage.credit).font(.caption2).opacity(0.5).padding(.top, 4)
3333
3434 section("Aurora") { auroraMap }
3535 section("Solar flares, last 24 h") { flares }
3636 section("Geomagnetic activity (Kp)") { kpChart }
3737
3838 if failed {
39 Text("Couldn't load space weather. Pull to retry.").font(.system(size: 13)).foregroundStyle(accent).padding(.top, 12)
39 Text("Couldn't load space weather. Pull to retry.").font(.footnote).foregroundStyle(accent).padding(.top, 12)
4040 }
41 Text("Space weather data: NOAA Space Weather Prediction Center. Coastlines: Natural Earth.").font(.system(size: 10)).opacity(0.5).padding(.vertical, 16)
41 Text("Space weather data: NOAA Space Weather Prediction Center. Coastlines: Natural Earth.").font(.caption2).opacity(0.5).padding(.vertical, 16)
4242 }
4343 .padding(.horizontal, 20)
4444 }
@@ -64,7 +64,7 @@ struct SunScreen: View {
6464 AsyncImage(url: wavelength.url(size: 1024)) { phase in
6565 switch phase {
6666 case .success(let img): img.resizable().scaledToFit()
67 case .failure: Color.black.overlay(Text("Image unavailable").font(.system(size: 13)).opacity(0.6))
67 case .failure: Color.black.overlay(Text("Image unavailable").font(.footnote).opacity(0.6))
6868 default: Color.black.overlay(ProgressView().tint(ink))
6969 }
7070 }
@@ -75,7 +75,7 @@ struct SunScreen: View {
7575
7676 private func section(_ title: String, @ViewBuilder content: () -> some View) -> some View {
7777 VStack(alignment: .leading, spacing: 10) {
78 Text(title).font(.system(size: 11, weight: .medium)).textCase(.uppercase).tracking(0.8).opacity(0.6)
78 Text(title).font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).opacity(0.6)
7979 content()
8080 }
8181 .padding(.top, 24)
@@ -94,9 +94,9 @@ struct SunScreen: View {
9494 .accessibilityElement()
9595 .accessibilityLabel(auroraDescription(ovation))
9696 Text("Chance of visible aurora overhead, OVATION model, for \(ovation.forecastTime.formatted(date: .omitted, time: .shortened)). Brighter green is more likely.")
97 .font(.system(size: 11)).opacity(0.6)
97 .font(.caption2).opacity(0.6)
9898 } else {
99 Text("Loading").font(.system(size: 13)).opacity(0.6)
99 Text("Loading").font(.footnote).opacity(0.6)
100100 }
101101 }
102102
@@ -127,7 +127,7 @@ struct SunScreen: View {
127127 .foregroundStyle(c.0 == "C" ? ink.opacity(0.25) : accent.opacity(0.6))
128128 .lineStyle(StrokeStyle(lineWidth: 0.5, dash: [3, 3]))
129129 .annotation(position: .top, alignment: .leading) {
130 Text(c.0).font(.system(size: 10)).opacity(0.7)
130 Text(c.0).font(.caption2).opacity(0.7)
131131 }
132132 }
133133 }
@@ -141,14 +141,14 @@ struct SunScreen: View {
141141 }
142142 .frame(height: 160)
143143 } else {
144 Text("Loading").font(.system(size: 13)).opacity(0.6)
144 Text("Loading").font(.footnote).opacity(0.6)
145145 }
146146 }
147147
148148 @ViewBuilder
149149 private var kpChart: some View {
150150 if kp.isEmpty {
151 Text("Loading").font(.system(size: 13)).opacity(0.6)
151 Text("Loading").font(.footnote).opacity(0.6)
152152 } else {
153153 Chart(kp) { bar in
154154 BarMark(x: .value("Start", bar.start, unit: .hour), y: .value("Kp", bar.kp), width: .ratio(0.8))
@@ -169,14 +169,14 @@ struct SunScreen: View {
169169 }
170170 .frame(height: 140)
171171 Text("Solid: observed. Faint: forecast. Red: storm level (Kp 5 or more).")
172 .font(.system(size: 11)).opacity(0.6)
172 .font(.caption2).opacity(0.6)
173173 }
174174 }
175175
176176 private func stat(_ label: String, _ value: String) -> some View {
177177 VStack(alignment: .leading, spacing: 2) {
178 Text(label).font(.system(size: 10)).opacity(0.6)
179 Text(value).font(.system(size: 20, weight: .medium).monospacedDigit())
178 Text(label).font(.caption2).opacity(0.6)
179 Text(value).font(.title3.weight(.medium).monospacedDigit())
180180 }
181181 }
182182}
App/Sources/SystemScreen.swift +8 −8
@@ -22,7 +22,7 @@ struct SystemScreen: View {
2222 var body: some View {
2323 ScrollView {
2424 VStack(alignment: .leading, spacing: 0) {
25 Text("Solar system").font(.system(size: 28, weight: .medium))
25 Text("Solar system").font(.title.weight(.medium))
2626 Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 8).padding(.bottom, 16)
2727
2828 OrreryView(date: date, ink: ink, accent: accent)
@@ -35,17 +35,17 @@ struct SystemScreen: View {
3535 }
3636 .accessibilityLabel(playing ? "Pause" : "Play")
3737 Text(date.formatted(.dateTime.year().month(.abbreviated)))
38 .font(.system(size: 15, weight: .medium).monospacedDigit())
38 .font(.subheadline.weight(.medium).monospacedDigit())
3939 .frame(width: 80, alignment: .leading)
4040 Slider(value: $offsetDays, in: range).tint(ink)
4141 Button("Today") { playing = false; offsetDays = 0 }
42 .font(.system(size: 13, weight: .medium))
42 .font(.footnote.weight(.medium))
4343 .opacity(abs(offsetDays) < 1 ? 0.4 : 1)
4444 }
4545 .padding(.top, 12)
4646
4747 Text("Distances beyond 1 AU are drawn on a logarithmic scale. Rings: planet orbits, then 50, 100 and 200 AU.")
48 .font(.system(size: 11)).opacity(0.6).padding(.top, 8)
48 .font(.caption2).opacity(0.6).padding(.top, 8)
4949
5050 section("Spacecraft") {
5151 Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) {
@@ -54,7 +54,7 @@ struct SystemScreen: View {
5454 Text("From Sun").opacity(0.6)
5555 Text("Light time").opacity(0.6)
5656 }
57 .font(.system(size: 11))
57 .font(.caption2)
5858 ForEach(Spacecraft.all) { craft in
5959 GridRow {
6060 if let page = Library.page(spacecraft: craft.name) {
@@ -70,12 +70,12 @@ struct SystemScreen: View {
7070 Text("")
7171 }
7272 }
73 .font(.system(size: 14))
73 .font(.subheadline)
7474 }
7575 }
7676 }
7777 Text("Spacecraft positions: NASA/JPL Horizons. Pioneer 10 and 11 positions after contact was lost are predictions.")
78 .font(.system(size: 10)).opacity(0.5).padding(.vertical, 16)
78 .font(.caption2).opacity(0.5).padding(.vertical, 16)
7979 }
8080 .padding(.horizontal, 20)
8181 }
@@ -102,7 +102,7 @@ struct SystemScreen: View {
102102
103103 private func section(_ title: String, @ViewBuilder content: () -> some View) -> some View {
104104 VStack(alignment: .leading, spacing: 10) {
105 Text(title).font(.system(size: 11, weight: .medium)).textCase(.uppercase).tracking(0.8).opacity(0.6)
105 Text(title).font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).opacity(0.6)
106106 content()
107107 }
108108 .padding(.top, 24)
App/Sources/TodaySheet.swift +8 −8
@@ -18,7 +18,7 @@ struct TodaySheet: View {
1818 }
1919 section("Space weather") { weather }
2020 Text("Space weather data: NOAA Space Weather Prediction Center.")
21 .font(.system(size: 11)).opacity(0.5).padding(.top, 8)
21 .font(.caption2).opacity(0.5).padding(.top, 8)
2222 }
2323 .padding(.horizontal, 20)
2424 .padding(.top, 18)
@@ -29,7 +29,7 @@ struct TodaySheet: View {
2929
3030 private func section(_ title: String, @ViewBuilder content: () -> some View) -> some View {
3131 VStack(alignment: .leading, spacing: 8) {
32 Text(title).font(.system(size: 11, weight: .medium)).textCase(.uppercase).tracking(0.8).opacity(0.6)
32 Text(title).font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).opacity(0.6)
3333 content()
3434 }
3535 .frame(maxWidth: .infinity, alignment: .leading)
@@ -86,7 +86,7 @@ struct TodaySheet: View {
8686 ("Updated", time(w.observedAt)),
8787 ])
8888 } else {
89 Text("Loading").font(.system(size: 13)).opacity(0.6)
89 Text("Loading").font(.footnote).opacity(0.6)
9090 }
9191 }
9292
@@ -97,7 +97,7 @@ struct TodaySheet: View {
9797 Text(item.0).opacity(0.7)
9898 Text(item.1).monospacedDigit()
9999 }
100 .font(.system(size: 14))
100 .font(.subheadline)
101101 }
102102 }
103103 }
@@ -121,9 +121,9 @@ struct TodayBar: View {
121121 scrubber
122122 Button(action: onExpand) {
123123 HStack {
124 Text(summary).font(.system(size: 13)).opacity(0.75).lineLimit(1)
124 Text(summary).font(.footnote).opacity(0.75).lineLimit(2)
125125 Spacer()
126 Image(systemName: "chevron.up").font(.system(size: 12, weight: .medium)).opacity(0.6)
126 Image(systemName: "chevron.up").font(.caption.weight(.medium)).opacity(0.6)
127127 }
128128 }
129129 .buttonStyle(.plain)
@@ -154,11 +154,11 @@ struct TodayBar: View {
154154 private var scrubber: some View {
155155 HStack(spacing: 12) {
156156 Text(model.displayedDate.formatted(date: .omitted, time: .shortened))
157 .font(.system(size: 13).monospacedDigit())
157 .font(.footnote.monospacedDigit())
158158 .frame(width: 70, alignment: .leading)
159159 Slider(value: $model.offsetHours, in: -12...12, step: 0.25).tint(ink)
160160 Button("Now") { model.offsetHours = 0 }
161 .font(.system(size: 13, weight: .medium))
161 .font(.footnote.weight(.medium))
162162 .opacity(model.offsetHours == 0 ? 0.4 : 1)
163163 }
164164 }
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 @@
1111| Privacy policy URL | https://moonrise-server.audit-labs.workers.dev/privacy |
1212
1313* 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*:
1516
16- Location: used on device only; never sent. Apple's definition of
17 "collect" is transmitting data off the device, so it is not collected.
18- Network requests (backend, NOAA, NASA, image hosts) carry no
17| Data type | Collected | Linked to identity | Tracking | Purpose |
18|-
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
1924 identifiers.
2025- 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.
2526
2627* Export compliance
2728=ITSAppUsesNonExemptEncryption = NO= (HTTPS only), set in =project.yml=.
server/src/index.ts +19 −1
@@ -65,10 +65,13 @@ export default {
6565 await env.DB.prepare(
6666 `INSERT INTO devices (token, environment, band, utc_offset_minutes, aurora, last_seen) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
6767 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();
6969 return json({ ok: true });
7070 }
7171
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
7275 const del = url.pathname.match(/^\/v1\/devices\/([0-9a-f]{64,200})$/i);
7376 if (request.method === "DELETE" && del) {
7477 await env.DB.prepare("DELETE FROM devices WHERE token = ?1").bind(del[1]).run();
@@ -86,6 +89,21 @@ export default {
8689 },
8790} satisfies ExportedHandler<Env>;
8891
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
89107async function launches(env: Env, now: Date): Promise<Response> {
90108 const since = new Date(now.getTime() - 6 * 3600_000).toISOString();
91109 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
2020<p>Last updated 18 October 2026.</p>
2121
2222<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>
2424
2525<h2>Location</h2>
2626<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
3535</ul>
3636<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>
3737
38<h2>Notifications and Live Activities</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>
38<h2>Aurora alerts</h2>
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>
4049
4150<h2>Changes</h2>
4251<p>If this policy changes, the new version will be posted here with a new date.</p>