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 4470fe4d25

4470fe4d2567b762bb581e4afc58c88dfdd50330

parent: 0c3cae6f42

Unsigned

cmc <hello@cleberg.net> · 2026-10-18 04:06 UTC

Prepare for TestFlight (!16)

- App icon (rendered by =scripts/make-icon.swift=, flattened, 1024 px).
- Settings: location (current or one of 25 cities), night mode (off, on, after dark), version, credits and licences, privacy policy.
- First-run screen explaining location before the system prompt; city alternative.
- Worker: =GET /privacy=. =docs/app-store.org=: App Store Connect answers (Data Not Collected, export compliance, age rating).
- Accessibility: VoiceOver descriptions for sky (adjustable action turns 45°), orrery, aurora map; Dynamic Type on Library pages.
- Version 1.0; =ITSAppUsesNonExemptEncryption= NO.

Verified in the iOS 27 simulator: first run, city choice, automatic night mode after dark, settings. =swift test= 34 pass, =node --test= 10 pass.

Layout: unified · split

App/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json added +6
@@ -0,0 +1,6 @@
1{
2 "images" : [
3 { "filename" : "icon-1024.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" }
4 ],
5 "info" : { "author" : "xcode", "version" : 1 }
6}
App/Sources/Assets.xcassets/AppIcon.appiconset/icon-1024.png added

Binary file not shown.

App/Sources/Assets.xcassets/Contents.json added +3
@@ -0,0 +1,3 @@
1{
2 "info" : { "author" : "xcode", "version" : 1 }
3}
App/Sources/LibraryScreen.swift +7 −7
@@ -65,7 +65,7 @@ struct PageRow: View {
6565
6666 var body: some View {
6767 VStack(alignment: .leading, spacing: 2) {
68 Text(page.title).font(.system(size: 17))
68 Text(page.title).font(.body)
6969 Text(page.subtitle).font(.system(size: 12)).opacity(0.6).lineLimit(1)
7070 }
7171 .padding(.vertical, 2)
@@ -79,8 +79,8 @@ struct LibraryPageView: View {
7979 ScrollView {
8080 VStack(alignment: .leading, spacing: 0) {
8181 Text(page.section.title).font(.system(size: 11, weight: .medium)).textCase(.uppercase).tracking(0.8).opacity(0.6)
82 Text(page.title).font(.system(size: 34, weight: .medium)).padding(.top, 4)
83 Text(page.subtitle).font(.system(size: 15)).opacity(0.75).padding(.top, 2)
82 Text(page.title).font(.largeTitle.weight(.medium)).padding(.top, 4)
83 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)
8585
8686 PageDrawing(page: page, ink: ink, accent: accent)
@@ -88,7 +88,7 @@ struct LibraryPageView: View {
8888 facts.padding(.top, 16)
8989
9090 VStack(alignment: .leading, spacing: 12) {
91 ForEach(page.paragraphs, id: \.self) { Text($0).font(.system(size: 16)).lineSpacing(3) }
91 ForEach(page.paragraphs, id: \.self) { Text($0).font(.body).lineSpacing(3) }
9292 }
9393 .padding(.top, 20)
9494
@@ -108,8 +108,8 @@ struct LibraryPageView: View {
108108 Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) {
109109 ForEach(page.facts, id: \.self) { f in
110110 GridRow(alignment: .firstTextBaseline) {
111 Text(f.label).font(.system(size: 12)).opacity(0.6)
112 Text(f.value).font(.system(size: 15).monospacedDigit())
111 Text(f.label).font(.footnote).opacity(0.6)
112 Text(f.value).font(.subheadline.monospacedDigit())
113113 }
114114 Divider().overlay(ink.opacity(0.15))
115115 }
@@ -125,7 +125,7 @@ struct LibraryPageView: View {
125125 default: Color.black.frame(height: 200).overlay(ProgressView().tint(ink))
126126 }
127127 }
128 Text(image.caption).font(.system(size: 12)).opacity(0.75)
128 Text(image.caption).font(.footnote).opacity(0.75)
129129 Link(destination: image.page) {
130130 Text("\(image.credit) · \(image.licence)").font(.system(size: 10)).opacity(0.55)
131131 }
App/Sources/MoonriseApp.swift +4
@@ -7,6 +7,7 @@ let log = Logger(subsystem: "sh.krz.moonrise", category: "app")
77@main
88struct MoonriseApp: App {
99 @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
10 @AppStorage(SettingsKey.onboarded) private var onboarded = false
1011
1112 var body: some Scene {
1213 WindowGroup {
@@ -18,6 +19,9 @@ struct MoonriseApp: App {
1819 Tab("Library", systemImage: "books.vertical") { LibraryScreen() }
1920 }
2021 .preferredColorScheme(.dark)
22 .fullScreenCover(isPresented: .constant(!onboarded)) {
23 OnboardingScreen().preferredColorScheme(.dark)
24 }
2125 }
2226 }
2327}
App/Sources/OnboardingScreen.swift added +69
@@ -0,0 +1,69 @@
1import SwiftUI
2
3/// First launch: what the app does and why it asks for location, before iOS asks.
4struct OnboardingScreen: View {
5 @AppStorage(SettingsKey.onboarded) private var onboarded = false
6 @AppStorage(SettingsKey.locationMode) private var locationMode = "current"
7 @AppStorage(SettingsKey.cityName) private var cityName = "Chicago"
8 @AppStorage(SettingsKey.cityLatitude) private var cityLatitude = 41.878
9 @AppStorage(SettingsKey.cityLongitude) private var cityLongitude = -87.630
10 @State private var choosingCity = false
11
12 private let ink = Color(red: 0.95, green: 0.95, blue: 0.93)
13 private let accent = Color(red: 0.88, green: 0.24, blue: 0.19)
14 private let background = Color(red: 0.043, green: 0.078, blue: 0.141)
15
16 var body: some View {
17 VStack(alignment: .leading, spacing: 0) {
18 Spacer()
19 Text("Moonrise").font(.largeTitle.weight(.medium))
20 Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 10).padding(.bottom, 24)
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.")
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.")
24 .foregroundStyle(ink.opacity(0.75))
25 }
26 .font(.body)
27 Spacer()
28 if choosingCity {
29 Picker("City", selection: $cityName) {
30 ForEach(City.all) { Text($0.name).tag($0.name) }
31 }
32 .pickerStyle(.wheel)
33 .frame(height: 140)
34 button("Use \(cityName)", filled: true) {
35 if let c = City.all.first(where: { $0.name == cityName }) {
36 cityLatitude = c.latitude
37 cityLongitude = c.longitude
38 }
39 locationMode = "city"
40 onboarded = true
41 }
42 } else {
43 button("Use my location", filled: true) {
44 locationMode = "current"
45 onboarded = true
46 }
47 button("Choose a city instead", filled: false) { choosingCity = true }
48 }
49 }
50 .padding(.horizontal, 24)
51 .padding(.bottom, 24)
52 .foregroundStyle(ink)
53 .background(background.ignoresSafeArea())
54 }
55
56 private func button(_ title: String, filled: Bool, action: @escaping () -> Void) -> some View {
57 Button(action: action) {
58 Text(title)
59 .font(.headline)
60 .frame(maxWidth: .infinity)
61 .padding(.vertical, 14)
62 .foregroundStyle(filled ? Color.black : ink)
63 .background(filled ? ink : .clear, in: .rect(cornerRadius: 8))
64 .overlay(RoundedRectangle(cornerRadius: 8).stroke(ink.opacity(filled ? 0 : 0.5), lineWidth: 0.5))
65 }
66 .buttonStyle(.plain)
67 .padding(.top, 10)
68 }
69}
App/Sources/SettingsScreen.swift added +173
@@ -0,0 +1,173 @@
1import Library
2import SwiftUI
3
4/// UserDefaults keys shared by the settings screen and the models that read them.
5enum SettingsKey {
6 static let locationMode = "locationMode" // "current" or "city"
7 static let cityName = "cityName"
8 static let cityLatitude = "cityLatitude"
9 static let cityLongitude = "cityLongitude"
10 static let nightMode = "nightMode" // "off", "on" or "auto"
11 static let onboarded = "onboarded"
12}
13
14struct City: Hashable, Identifiable {
15 var name: String
16 var latitude: Double
17 var longitude: Double
18 var id: String { name }
19
20 static let all: [City] = [
21 City(name: "Anchorage", latitude: 61.218, longitude: -149.900),
22 City(name: "Auckland", latitude: -36.849, longitude: 174.763),
23 City(name: "Berlin", latitude: 52.520, longitude: 13.405),
24 City(name: "Buenos Aires", latitude: -34.604, longitude: -58.382),
25 City(name: "Cairo", latitude: 30.044, longitude: 31.236),
26 City(name: "Cape Town", latitude: -33.925, longitude: 18.424),
27 City(name: "Chicago", latitude: 41.878, longitude: -87.630),
28 City(name: "Denver", latitude: 39.739, longitude: -104.990),
29 City(name: "Honolulu", latitude: 21.307, longitude: -157.858),
30 City(name: "Houston", latitude: 29.760, longitude: -95.370),
31 City(name: "London", latitude: 51.507, longitude: -0.128),
32 City(name: "Los Angeles", latitude: 34.052, longitude: -118.244),
33 City(name: "Mexico City", latitude: 19.433, longitude: -99.133),
34 City(name: "Mumbai", latitude: 19.076, longitude: 72.878),
35 City(name: "New York", latitude: 40.713, longitude: -74.006),
36 City(name: "Paris", latitude: 48.857, longitude: 2.352),
37 City(name: "Reykjavík", latitude: 64.147, longitude: -21.942),
38 City(name: "Santiago", latitude: -33.449, longitude: -70.669),
39 City(name: "São Paulo", latitude: -23.551, longitude: -46.633),
40 City(name: "Seattle", latitude: 47.606, longitude: -122.332),
41 City(name: "Singapore", latitude: 1.352, longitude: 103.820),
42 City(name: "Stockholm", latitude: 59.329, longitude: 18.069),
43 City(name: "Sydney", latitude: -33.869, longitude: 151.209),
44 City(name: "Tokyo", latitude: 35.676, longitude: 139.650),
45 City(name: "Toronto", latitude: 43.653, longitude: -79.383),
46 ]
47}
48
49/// Privacy policy served by the backend.
50let privacyPolicyURL = URL(string: "https://moonrise-server.audit-labs.workers.dev/privacy")!
51
52struct SettingsScreen: View {
53 @AppStorage(SettingsKey.locationMode) private var locationMode = "current"
54 @AppStorage(SettingsKey.cityName) private var cityName = "Chicago"
55 @AppStorage(SettingsKey.cityLatitude) private var cityLatitude = 41.878
56 @AppStorage(SettingsKey.cityLongitude) private var cityLongitude = -87.630
57 @AppStorage(SettingsKey.nightMode) private var nightMode = "auto"
58 @Environment(\.dismiss) private var dismiss
59 var onChange: () -> Void
60
61 var body: some View {
62 NavigationStack {
63 Form {
64 Section("Location") {
65 Picker("Sky for", selection: $locationMode) {
66 Text("Current location").tag("current")
67 Text("A city").tag("city")
68 }
69 if locationMode == "city" {
70 Picker("City", selection: $cityName) {
71 ForEach(City.all) { Text($0.name).tag($0.name) }
72 }
73 }
74 }
75 Section {
76 Picker("Night mode", selection: $nightMode) {
77 Text("Off").tag("off")
78 Text("On").tag("on")
79 Text("After dark").tag("auto")
80 }
81 } header: {
82 Text("Display")
83 } footer: {
84 Text("Night mode draws the sky in red to keep your eyes adapted to the dark. After dark turns it on once the Sun is 12° below the horizon.")
85 }
86 Section("About") {
87 LabeledContent("Version", value: Bundle.main.versionString)
88 NavigationLink("Credits and licences") { CreditsScreen() }
89 Link("Privacy policy", destination: privacyPolicyURL)
90 }
91 }
92 .navigationTitle("Settings")
93 .navigationBarTitleDisplayMode(.inline)
94 .toolbar {
95 ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } }
96 }
97 .onChange(of: cityName) {
98 if let c = City.all.first(where: { $0.name == cityName }) {
99 cityLatitude = c.latitude
100 cityLongitude = c.longitude
101 }
102 onChange()
103 }
104 .onChange(of: locationMode) { onChange() }
105 }
106 }
107}
108
109struct CreditsScreen: View {
110 private struct Credit: Hashable {
111 var name: String
112 var detail: String
113 }
114
115 private let software: [Credit] = [
116 Credit(name: "Astronomy Engine", detail: "Don Cross. MIT licence."),
117 Credit(name: "Yale Bright Star Catalogue, 5th ed.", detail: "Hoffleit & Warren (1991), via VizieR V/50."),
118 Credit(name: "Constellation lines", detail: "Marc van der Sluys, ConstellationLines (doi:10.5281/zenodo.10397192). CC BY 4.0."),
119 Credit(name: "Coastlines", detail: "Natural Earth. Public domain."),
120 Credit(name: "Spacecraft trajectories", detail: "NASA/JPL Horizons."),
121 ]
122
123 private let feeds: [Credit] = [
124 Credit(name: "Space weather and aurora forecast", detail: "NOAA Space Weather Prediction Center."),
125 Credit(name: "Images of the Sun", detail: "Courtesy of NASA/SDO and the AIA, EVE, and HMI science teams."),
126 Credit(name: "Launch schedule", detail: "The Space Devs, Launch Library 2."),
127 ]
128
129 /// Every distinct image credit used by the Library, grouped by licence.
130 private var imageCredits: [(licence: String, credits: [String])] {
131 let images = Library.pages.compactMap(\.image)
132 let grouped = Dictionary(grouping: images, by: \.licence)
133 return grouped.keys.sorted().map { licence in
134 (licence, Array(Set(grouped[licence]!.map(\.credit))).sorted())
135 }
136 }
137
138 var body: some View {
139 List {
140 Section("Data and software") {
141 ForEach(software, id: \.self) { row($0) }
142 }
143 Section("Live data") {
144 ForEach(feeds, id: \.self) { row($0) }
145 }
146 ForEach(imageCredits, id: \.licence) { group in
147 Section("Library images: \(group.licence)") {
148 ForEach(group.credits, id: \.self) { Text($0).font(.footnote) }
149 }
150 }
151 Section {
152 Text("Each Library page lists its sources, and each image links to the page it is published on. CC BY images are used under the Creative Commons Attribution 4.0 licence; ESA images marked CC BY-SA 3.0 IGO under that licence. NASA does not endorse this app.")
153 .font(.footnote)
154 }
155 }
156 .navigationTitle("Credits and licences")
157 }
158
159 private func row(_ c: Credit) -> some View {
160 VStack(alignment: .leading, spacing: 2) {
161 Text(c.name)
162 Text(c.detail).font(.footnote).foregroundStyle(.secondary)
163 }
164 }
165}
166
167extension Bundle {
168 var versionString: String {
169 let v = object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "?"
170 let b = object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "?"
171 return "\(v) (\(b))"
172 }
173}
App/Sources/SkyModel.swift +24 −2
@@ -43,6 +43,10 @@ final class SkyModel: NSObject, CLLocationManagerDelegate {
4343
4444 private(set) var observer = SkyModel.fallback
4545 private(set) var hasLocation = false
46 /// Name shown under the title: "Your location", a chosen city, or the fallback.
47 private(set) var placeName = "Chicago (location unavailable)"
48 /// Sun's altitude at the displayed time, for automatic night mode.
49 private(set) var sunAltitude = 0.0
4650 private(set) var scene: SkyScene?
4751 private(set) var lastBuild: Duration = .zero
4852 private(set) var tonight: Tonight?
@@ -64,8 +68,23 @@ final class SkyModel: NSObject, CLLocationManagerDelegate {
6468 }
6569
6670 func start() {
67 manager.requestWhenInUseAuthorization()
68 manager.requestLocation()
71 applyLocationSettings()
72 }
73
74 /// Uses the chosen city, or asks Core Location for the current position.
75 func applyLocationSettings() {
76 let d = UserDefaults.standard
77 if d.string(forKey: SettingsKey.locationMode) == "city" {
78 let lat = d.object(forKey: SettingsKey.cityLatitude) as? Double ?? Self.fallback.latitude
79 let lon = d.object(forKey: SettingsKey.cityLongitude) as? Double ?? Self.fallback.longitude
80 observer = Observer(latitude: lat, longitude: lon)
81 placeName = d.string(forKey: SettingsKey.cityName) ?? "Chicago"
82 hasLocation = true
83 } else {
84 if !hasLocation { placeName = "Chicago (location unavailable)" }
85 manager.requestWhenInUseAuthorization()
86 manager.requestLocation()
87 }
6988 refresh()
7089 }
7190
@@ -83,6 +102,7 @@ final class SkyModel: NSObject, CLLocationManagerDelegate {
83102 lastBuild = clock.now - start
84103 if let scene { self.scene = scene }
85104 if offsetHours == 0 { tonight = try? await astronomer.tonight(at: date, from: observer) }
105 sunAltitude = (try? SkyEngine.horizontal(.sun, at: date, from: observer, refraction: true).altitude) ?? 0
86106 building = false
87107 if pending { pending = false; refresh() }
88108 }
@@ -106,8 +126,10 @@ final class SkyModel: NSObject, CLLocationManagerDelegate {
106126 nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
107127 guard let c = locations.last?.coordinate else { return }
108128 Task { @MainActor in
129 guard UserDefaults.standard.string(forKey: SettingsKey.locationMode) != "city" else { return }
109130 self.observer = Observer(latitude: c.latitude, longitude: c.longitude)
110131 self.hasLocation = true
132 self.placeName = "Your location"
111133 self.refresh()
112134 }
113135 }
App/Sources/SkyScreen.swift +34 −4
@@ -9,7 +9,9 @@ struct SkyScreen: View {
99 @State private var azimuth = 180.0
1010 @State private var altitude = 45.0
1111 @State private var fieldOfView = 100.0
12 @State private var night = false
12 @AppStorage(SettingsKey.nightMode) private var nightMode = "auto"
13 @State private var showSettings = false
14 @AppStorage(SettingsKey.onboarded) private var onboarded = false
1315 @State private var showToday = false
1416 @State private var libraryPage: LibraryPage?
1517 @State private var selection: SkyObject?
@@ -18,6 +20,7 @@ struct SkyScreen: View {
1820 @GestureState private var zoom: CGFloat = 1
1921 private let stats = DrawStats()
2022
23 private var night: Bool { nightMode == "on" || (nightMode == "auto" && model.sunAltitude < -12) }
2124 private var style: SkyStyle { night ? .night : SkyStyle() }
2225 private var ink: Color { night ? Color(red: 0.85, green: 0.12, blue: 0.08) : Color(red: 0.95, green: 0.95, blue: 0.93) }
2326
@@ -36,6 +39,12 @@ struct SkyScreen: View {
3639 let p = Projection(centerAzimuth: view.azimuth, centerAltitude: view.altitude, fieldOfView: view.fieldOfView, size: geo.size)
3740 select(scene.object(at: location, projection: p))
3841 }
42 .accessibilityElement()
43 .accessibilityLabel(skyDescription(scene, azimuth: view.azimuth))
44 .accessibilityHint("Swipe up or down to turn. Planets and the Moon are listed in the Tonight summary.")
45 .accessibilityAdjustableAction { direction in
46 azimuth = (azimuth + (direction == .increment ? 45 : -45) + 360).truncatingRemainder(dividingBy: 360)
47 }
3948 }
4049 VStack(spacing: 0) {
4150 header
@@ -46,6 +55,10 @@ struct SkyScreen: View {
4655 }
4756 }
4857 .foregroundStyle(ink)
58 .sheet(isPresented: $showSettings) {
59 SettingsScreen { model.applyLocationSettings() }
60 .preferredColorScheme(.dark)
61 }
4962 .sheet(item: $libraryPage) { page in
5063 NavigationStack { LibraryPageView(page: page) }
5164 }
@@ -54,8 +67,9 @@ struct SkyScreen: View {
5467 .presentationDetents([.medium, .large])
5568 }
5669 .onChange(of: model.offsetHours) { select(selection) }
70 .onChange(of: onboarded) { if onboarded { model.start() } }
5771 .task {
58 model.start()
72 if onboarded { model.start() }
5973 while !Task.isCancelled {
6074 try? await Task.sleep(for: .seconds(30))
6175 model.refresh()
@@ -108,7 +122,7 @@ struct SkyScreen: View {
108122 VStack(alignment: .leading, spacing: 4) {
109123 Text(model.offsetHours == 0 ? "Tonight" : "Sky at \(model.displayedDate.formatted(date: .omitted, time: .shortened))")
110124 .font(.system(size: 22, weight: .medium))
111 Text(model.hasLocation ? "Your location" : "Chicago (location unavailable)")
125 Text(model.placeName)
112126 .font(.system(size: 11)).opacity(0.7)
113127 TimelineView(.periodic(from: .now, by: 1)) { _ in
114128 Text(stats.summary(build: model.lastBuild))
@@ -126,8 +140,14 @@ struct SkyScreen: View {
126140 }
127141 .accessibilityLabel(pointer.active ? "Stop following the phone" : "Point the phone at the sky")
128142 }
143 Button { showSettings = true } label: {
144 Image(systemName: "gearshape")
145 .font(.system(size: 17))
146 .frame(width: 44, height: 44)
147 }
148 .accessibilityLabel("Settings")
129149 Button {
130 night.toggle()
150 nightMode = night ? "off" : "on"
131151 } label: {
132152 Image(systemName: night ? "eye.fill" : "eye")
133153 .font(.system(size: 17))
@@ -206,6 +226,16 @@ struct SkyScreen: View {
206226 }
207227 }
208228
229 /// VoiceOver summary: which way the view faces and what bright bodies are up.
230 private func skyDescription(_ scene: SkyScene, azimuth: Double) -> String {
231 let up = scene.bodies.filter { $0.position.altitude > 0 && $0.magnitude <= 6 }.map { b -> String in
232 let name = b.body.rawValue.capitalized
233 return "\(name), \(Int(b.position.altitude.rounded())) degrees up in the \(compass(b.position.azimuth))"
234 }
235 let facing = "Sky facing \(compass(azimuth))."
236 return up.isEmpty ? facing + " No planets or Moon above the horizon." : facing + " Above the horizon: " + up.joined(separator: "; ") + "."
237 }
238
209239 private func compass(_ azimuth: Double) -> String {
210240 let points = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]
211241 return points[Int(((azimuth + 22.5) / 45).rounded(.down)) % 8]
App/Sources/SunScreen.swift +9
@@ -91,6 +91,8 @@ struct SunScreen: View {
9191 private var auroraMap: some View {
9292 if let ovation {
9393 AuroraMap(cells: ovation.cells.map { GridValue(lon: $0.lon, lat: $0.lat, value: $0.probability) }, observer: observer, ink: ink)
94 .accessibilityElement()
95 .accessibilityLabel(auroraDescription(ovation))
9496 Text("Chance of visible aurora overhead, OVATION model, for \(ovation.forecastTime.formatted(date: .omitted, time: .shortened)). Brighter green is more likely.")
9597 .font(.system(size: 11)).opacity(0.6)
9698 } else {
@@ -98,6 +100,13 @@ struct SunScreen: View {
98100 }
99101 }
100102
103 /// Highest aurora probability at or poleward of the observer's latitude in their hemisphere.
104 private func auroraDescription(_ o: Ovation) -> String {
105 let lat = observer.lat
106 let near = o.cells.filter { abs($0.lat - lat) <= 5 && (($0.lat >= 0) == (lat >= 0)) }.map(\.probability).max() ?? 0
107 return "Aurora forecast map. Highest chance within 5 degrees of your latitude: \(Int(near)) percent."
108 }
109
101110 @ViewBuilder
102111 private var flares: some View {
103112 if let last = xray.last {
App/Sources/SystemScreen.swift +2
@@ -26,6 +26,8 @@ struct SystemScreen: View {
2626 Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 8).padding(.bottom, 16)
2727
2828 OrreryView(date: date, ink: ink, accent: accent)
29 .accessibilityElement()
30 .accessibilityLabel("Orrery for \(date.formatted(.dateTime.year().month(.wide))). Planets and spacecraft distances are listed below.")
2931
3032 HStack(spacing: 12) {
3133 Button { playing.toggle() } label: {
App/project.yml +4 −2
@@ -43,11 +43,13 @@ targets:
4343 CODE_SIGN_STYLE: Automatic
4444 SWIFT_VERSION: "6.0"
4545 TARGETED_DEVICE_FAMILY: "1"
46 MARKETING_VERSION: "0.1"
46 MARKETING_VERSION: "1.0"
4747 CURRENT_PROJECT_VERSION: "1"
4848 GENERATE_INFOPLIST_FILE: YES
4949 INFOPLIST_KEY_CFBundleDisplayName: Moonrise
5050 INFOPLIST_KEY_NSSupportsLiveActivities: YES
51 INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO
52 ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
5153 INFOPLIST_KEY_UILaunchScreen_Generation: YES
5254 INFOPLIST_KEY_UIStatusBarStyle: UIStatusBarStyleLightContent
5355 INFOPLIST_KEY_UISupportedInterfaceOrientations: UIInterfaceOrientationPortrait
@@ -77,5 +79,5 @@ targets:
7779 CODE_SIGN_STYLE: Automatic
7880 SWIFT_VERSION: "6.0"
7981 TARGETED_DEVICE_FAMILY: "1"
80 MARKETING_VERSION: "0.1"
82 MARKETING_VERSION: "1.0"
8183 CURRENT_PROJECT_VERSION: "1"
docs/app-store.org added +35
@@ -0,0 +1,35 @@
1#+title: App Store Connect
2
3* Identity
4| Field | Value |
5|-
6| Name | Moonrise |
7| Subtitle | Sky, Sun and Space |
8| Bundle ID | =sh.krz.moonrise= (widgets: =sh.krz.moonrise.widgets=) |
9| Team | ZCNAX3VL9D |
10| Category | Education (secondary: Weather) |
11| Privacy policy URL | https://moonrise-server.audit-labs.workers.dev/privacy |
12
13* App Privacy ("nutrition label")
14Answer: *Data Not Collected*.
15
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
19 identifiers.
20- 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* Export compliance
27=ITSAppUsesNonExemptEncryption = NO= (HTTPS only), set in =project.yml=.
28
29* Age rating
30No objectionable content: 4+.
31
32* Review notes
33- Location is optional; Settings offers a city instead.
34- Launch data and space weather come from the app's own backend and
35 NOAA; no sign-in.
scripts/make-icon.swift added +44
@@ -0,0 +1,44 @@
1// Renders the app icon: swift scripts/make-icon.swift App/Sources/Assets.xcassets/AppIcon.appiconset/icon-1024.png
2import AppKit
3import SwiftUI
4
5struct Icon: View {
6 var body: some View {
7 Canvas { context, size in
8 let w = size.width
9 context.fill(Path(CGRect(origin: .zero, size: size)), with: .color(Color(red: 0.043, green: 0.078, blue: 0.141)))
10 // Stars
11 let stars: [(Double, Double, Double)] = [(0.18, 0.16, 5), (0.32, 0.30, 3), (0.74, 0.14, 4), (0.86, 0.34, 3), (0.12, 0.44, 3), (0.58, 0.24, 2.5), (0.44, 0.10, 3)]
12 for (x, y, r) in stars {
13 context.fill(Path(ellipseIn: CGRect(x: x * w - r, y: y * w - r, width: 2 * r, height: 2 * r)), with: .color(.white.opacity(0.85)))
14 }
15 // Moon, rising: a waxing gibbous disc partly behind the horizon
16 let c = CGPoint(x: w * 0.5, y: w * 0.60), r = w * 0.24
17 context.fill(Path(ellipseIn: CGRect(x: c.x - r, y: c.y - r, width: 2 * r, height: 2 * r)), with: .color(Color(red: 0.16, green: 0.20, blue: 0.27)))
18 var lit = Path()
19 lit.move(to: CGPoint(x: c.x, y: c.y - r))
20 lit.addArc(center: c, radius: r, startAngle: .degrees(-90), endAngle: .degrees(90), clockwise: false)
21 for i in 0...48 {
22 let a = Double.pi / 2 - Double.pi * Double(i) / 48
23 lit.addLine(to: CGPoint(x: c.x - r * 0.45 * cos(a), y: c.y + r * sin(a)))
24 }
25 lit.closeSubpath()
26 context.fill(lit, with: .color(Color(red: 0.93, green: 0.91, blue: 0.85)))
27 // Ground and horizon line
28 let horizon = w * 0.70
29 context.fill(Path(CGRect(x: 0, y: horizon, width: w, height: w - horizon)), with: .color(Color(red: 0.02, green: 0.035, blue: 0.06)))
30 context.fill(Path(CGRect(x: 0, y: horizon - w * 0.006, width: w, height: w * 0.012)), with: .color(Color(red: 0.88, green: 0.24, blue: 0.19)))
31 }
32 .frame(width: 1024, height: 1024)
33 }
34}
35
36let out = URL(fileURLWithPath: CommandLine.arguments[1])
37MainActor.assumeIsolated {
38 let renderer = ImageRenderer(content: Icon())
39 renderer.scale = 1
40 let cg = renderer.cgImage!
41 let rep = NSBitmapImageRep(cgImage: cg)
42 try! rep.representation(using: .png, properties: [:])!.write(to: out)
43}
44print("wrote \(out.path)")
server/src/index.ts +3
@@ -1,6 +1,7 @@
11import { send, type ApnsConfig } from "./apns";
22import { actionableKp, alertBands, eventKey, isNight } from "./aurora";
33import { fetchUpcoming, type Launch } from "./ll2";
4import { PRIVACY_HTML } from "./privacy";
45import { fetchSpaceWeather, next24h, type SpaceWeather } from "./swpc";
56
67interface Env {
@@ -53,6 +54,8 @@ export default {
5354 const rows = await env.DB.prepare("SELECT feed, fetched_at, ok FROM feed_snapshots").all();
5455 return json({ ok: true, feeds: rows.results });
5556 }
57 if (request.method === "GET" && url.pathname === "/privacy")
58 return new Response(PRIVACY_HTML, { headers: { "content-type": "text/html; charset=utf-8", "cache-control": "public, max-age=3600" } });
5659 if (request.method === "GET" && url.pathname === "/v1/today") return today(env, now);
5760 if (request.method === "GET" && url.pathname === "/v1/launches") return launches(env, now);
5861
server/src/privacy.ts added +44
@@ -0,0 +1,44 @@
1// Privacy policy for the Moonrise app, linked from its settings and App Store listing.
2
3export const PRIVACY_HTML = `<!doctype html>
4<html lang="en">
5<head>
6<meta charset="utf-8">
7<meta name="viewport" content="width=device-width, initial-scale=1">
8<title>Moonrise privacy policy</title>
9<style>
10body { font: 17px/1.55 -apple-system, "Helvetica Neue", Helvetica, Arial, sans-serif; max-width: 40em; margin: 2em auto; padding: 0 1em; color: #111; background: #fafaf7; }
11h1 { font-weight: 500; margin-bottom: 0.2em; }
12h2 { font-weight: 500; font-size: 1.1em; margin-top: 1.8em; border-top: 1px solid #111; padding-top: 0.6em; }
13.rule { width: 48px; height: 5px; background: #e03c31; margin-bottom: 1.5em; }
14@media (prefers-color-scheme: dark) { body { color: #f2f2ee; background: #0b1424; } h2 { border-color: #f2f2ee; } }
15</style>
16</head>
17<body>
18<h1>Moonrise privacy policy</h1>
19<div class="rule"></div>
20<p>Last updated 18 October 2026.</p>
21
22<h2>Summary</h2>
23<p>Moonrise has no accounts, no advertising, no analytics and no tracking. It does not collect personal data.</p>
24
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>
27
28<h2>Network requests</h2>
29<p>To show live data, the app requests:</p>
30<ul>
31<li>space weather and launch schedules from our server, moonrise-server.audit-labs.workers.dev, which runs on Cloudflare Workers;</li>
32<li>space weather and aurora data directly from NOAA's Space Weather Prediction Center;</li>
33<li>images of the Sun from NASA's Solar Dynamics Observatory;</li>
34<li>images in the Library from NASA, ESA, ESO, NOIRLab and the Library of Congress.</li>
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>
37
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>
40
41<h2>Changes</h2>
42<p>If this policy changes, the new version will be posted here with a new date.</p>
43</body>
44</html>`;