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 {
65 65
66 var body: some View { 66 var body: some View {
67 VStack(alignment: .leading, spacing: 2) { 67 VStack(alignment: .leading, spacing: 2) {
68 Text(page.title).font(.system(size: 17)) 68 Text(page.title).font(.body)
69 Text(page.subtitle).font(.system(size: 12)).opacity(0.6).lineLimit(1) 69 Text(page.subtitle).font(.system(size: 12)).opacity(0.6).lineLimit(1)
70 } 70 }
71 .padding(.vertical, 2) 71 .padding(.vertical, 2)
@@ -79,8 +79,8 @@ struct LibraryPageView: View {
79 ScrollView { 79 ScrollView {
80 VStack(alignment: .leading, spacing: 0) { 80 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(.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) 82 Text(page.title).font(.largeTitle.weight(.medium)).padding(.top, 4)
83 Text(page.subtitle).font(.system(size: 15)).opacity(0.75).padding(.top, 2) 83 Text(page.subtitle).font(.subheadline).opacity(0.75).padding(.top, 2)
84 Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 10).padding(.bottom, 16) 84 Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 10).padding(.bottom, 16)
85 85
86 PageDrawing(page: page, ink: ink, accent: accent) 86 PageDrawing(page: page, ink: ink, accent: accent)
@@ -88,7 +88,7 @@ struct LibraryPageView: View {
88 facts.padding(.top, 16) 88 facts.padding(.top, 16)
89 89
90 VStack(alignment: .leading, spacing: 12) { 90 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) }
92 } 92 }
93 .padding(.top, 20) 93 .padding(.top, 20)
94 94
@@ -108,8 +108,8 @@ struct LibraryPageView: View {
108 Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) { 108 Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) {
109 ForEach(page.facts, id: \.self) { f in 109 ForEach(page.facts, id: \.self) { f in
110 GridRow(alignment: .firstTextBaseline) { 110 GridRow(alignment: .firstTextBaseline) {
111 Text(f.label).font(.system(size: 12)).opacity(0.6) 111 Text(f.label).font(.footnote).opacity(0.6)
112 Text(f.value).font(.system(size: 15).monospacedDigit()) 112 Text(f.value).font(.subheadline.monospacedDigit())
113 } 113 }
114 Divider().overlay(ink.opacity(0.15)) 114 Divider().overlay(ink.opacity(0.15))
115 } 115 }
@@ -125,7 +125,7 @@ struct LibraryPageView: View {
125 default: Color.black.frame(height: 200).overlay(ProgressView().tint(ink)) 125 default: Color.black.frame(height: 200).overlay(ProgressView().tint(ink))
126 } 126 }
127 } 127 }
128 Text(image.caption).font(.system(size: 12)).opacity(0.75) 128 Text(image.caption).font(.footnote).opacity(0.75)
129 Link(destination: image.page) { 129 Link(destination: image.page) {
130 Text("\(image.credit) · \(image.licence)").font(.system(size: 10)).opacity(0.55) 130 Text("\(image.credit) · \(image.licence)").font(.system(size: 10)).opacity(0.55)
131 } 131 }
App/Sources/MoonriseApp.swift +4
@@ -7,6 +7,7 @@ let log = Logger(subsystem: "sh.krz.moonrise", category: "app")
7@main 7@main
8struct MoonriseApp: App { 8struct MoonriseApp: App {
9 @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate 9 @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
10 @AppStorage(SettingsKey.onboarded) private var onboarded = false
10 11
11 var body: some Scene { 12 var body: some Scene {
12 WindowGroup { 13 WindowGroup {
@@ -18,6 +19,9 @@ struct MoonriseApp: App {
18 Tab("Library", systemImage: "books.vertical") { LibraryScreen() } 19 Tab("Library", systemImage: "books.vertical") { LibraryScreen() }
19 } 20 }
20 .preferredColorScheme(.dark) 21 .preferredColorScheme(.dark)
22 .fullScreenCover(isPresented: .constant(!onboarded)) {
23 OnboardingScreen().preferredColorScheme(.dark)
24 }
21 } 25 }
22 } 26 }
23} 27}
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 {
43 43
44 private(set) var observer = SkyModel.fallback 44 private(set) var observer = SkyModel.fallback
45 private(set) var hasLocation = false 45 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
46 private(set) var scene: SkyScene? 50 private(set) var scene: SkyScene?
47 private(set) var lastBuild: Duration = .zero 51 private(set) var lastBuild: Duration = .zero
48 private(set) var tonight: Tonight? 52 private(set) var tonight: Tonight?
@@ -64,8 +68,23 @@ final class SkyModel: NSObject, CLLocationManagerDelegate {
64 } 68 }
65 69
66 func start() { 70 func start() {
67 manager.requestWhenInUseAuthorization() 71 applyLocationSettings()
68 manager.requestLocation() 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 }
69 refresh() 88 refresh()
70 } 89 }
71 90
@@ -83,6 +102,7 @@ final class SkyModel: NSObject, CLLocationManagerDelegate {
83 lastBuild = clock.now - start 102 lastBuild = clock.now - start
84 if let scene { self.scene = scene } 103 if let scene { self.scene = scene }
85 if offsetHours == 0 { tonight = try? await astronomer.tonight(at: date, from: observer) } 104 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
86 building = false 106 building = false
87 if pending { pending = false; refresh() } 107 if pending { pending = false; refresh() }
88 } 108 }
@@ -106,8 +126,10 @@ final class SkyModel: NSObject, CLLocationManagerDelegate {
106 nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { 126 nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
107 guard let c = locations.last?.coordinate else { return } 127 guard let c = locations.last?.coordinate else { return }
108 Task { @MainActor in 128 Task { @MainActor in
129 guard UserDefaults.standard.string(forKey: SettingsKey.locationMode) != "city" else { return }
109 self.observer = Observer(latitude: c.latitude, longitude: c.longitude) 130 self.observer = Observer(latitude: c.latitude, longitude: c.longitude)
110 self.hasLocation = true 131 self.hasLocation = true
132 self.placeName = "Your location"
111 self.refresh() 133 self.refresh()
112 } 134 }
113 } 135 }
App/Sources/SkyScreen.swift +34 −4
@@ -9,7 +9,9 @@ struct SkyScreen: View {
9 @State private var azimuth = 180.0 9 @State private var azimuth = 180.0
10 @State private var altitude = 45.0 10 @State private var altitude = 45.0
11 @State private var fieldOfView = 100.0 11 @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
13 @State private var showToday = false 15 @State private var showToday = false
14 @State private var libraryPage: LibraryPage? 16 @State private var libraryPage: LibraryPage?
15 @State private var selection: SkyObject? 17 @State private var selection: SkyObject?
@@ -18,6 +20,7 @@ struct SkyScreen: View {
18 @GestureState private var zoom: CGFloat = 1 20 @GestureState private var zoom: CGFloat = 1
19 private let stats = DrawStats() 21 private let stats = DrawStats()
20 22
23 private var night: Bool { nightMode == "on" || (nightMode == "auto" && model.sunAltitude < -12) }
21 private var style: SkyStyle { night ? .night : SkyStyle() } 24 private var style: SkyStyle { night ? .night : SkyStyle() }
22 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) } 25 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) }
23 26
@@ -36,6 +39,12 @@ struct SkyScreen: View {
36 let p = Projection(centerAzimuth: view.azimuth, centerAltitude: view.altitude, fieldOfView: view.fieldOfView, size: geo.size) 39 let p = Projection(centerAzimuth: view.azimuth, centerAltitude: view.altitude, fieldOfView: view.fieldOfView, size: geo.size)
37 select(scene.object(at: location, projection: p)) 40 select(scene.object(at: location, projection: p))
38 } 41 }
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 }
39 } 48 }
40 VStack(spacing: 0) { 49 VStack(spacing: 0) {
41 header 50 header
@@ -46,6 +55,10 @@ struct SkyScreen: View {
46 } 55 }
47 } 56 }
48 .foregroundStyle(ink) 57 .foregroundStyle(ink)
58 .sheet(isPresented: $showSettings) {
59 SettingsScreen { model.applyLocationSettings() }
60 .preferredColorScheme(.dark)
61 }
49 .sheet(item: $libraryPage) { page in 62 .sheet(item: $libraryPage) { page in
50 NavigationStack { LibraryPageView(page: page) } 63 NavigationStack { LibraryPageView(page: page) }
51 } 64 }
@@ -54,8 +67,9 @@ struct SkyScreen: View {
54 .presentationDetents([.medium, .large]) 67 .presentationDetents([.medium, .large])
55 } 68 }
56 .onChange(of: model.offsetHours) { select(selection) } 69 .onChange(of: model.offsetHours) { select(selection) }
70 .onChange(of: onboarded) { if onboarded { model.start() } }
57 .task { 71 .task {
58 model.start() 72 if onboarded { model.start() }
59 while !Task.isCancelled { 73 while !Task.isCancelled {
60 try? await Task.sleep(for: .seconds(30)) 74 try? await Task.sleep(for: .seconds(30))
61 model.refresh() 75 model.refresh()
@@ -108,7 +122,7 @@ struct SkyScreen: View {
108 VStack(alignment: .leading, spacing: 4) { 122 VStack(alignment: .leading, spacing: 4) {
109 Text(model.offsetHours == 0 ? "Tonight" : "Sky at \(model.displayedDate.formatted(date: .omitted, time: .shortened))") 123 Text(model.offsetHours == 0 ? "Tonight" : "Sky at \(model.displayedDate.formatted(date: .omitted, time: .shortened))")
110 .font(.system(size: 22, weight: .medium)) 124 .font(.system(size: 22, weight: .medium))
111 Text(model.hasLocation ? "Your location" : "Chicago (location unavailable)") 125 Text(model.placeName)
112 .font(.system(size: 11)).opacity(0.7) 126 .font(.system(size: 11)).opacity(0.7)
113 TimelineView(.periodic(from: .now, by: 1)) { _ in 127 TimelineView(.periodic(from: .now, by: 1)) { _ in
114 Text(stats.summary(build: model.lastBuild)) 128 Text(stats.summary(build: model.lastBuild))
@@ -126,8 +140,14 @@ struct SkyScreen: View {
126 } 140 }
127 .accessibilityLabel(pointer.active ? "Stop following the phone" : "Point the phone at the sky") 141 .accessibilityLabel(pointer.active ? "Stop following the phone" : "Point the phone at the sky")
128 } 142 }
143 Button { showSettings = true } label: {
144 Image(systemName: "gearshape")
145 .font(.system(size: 17))
146 .frame(width: 44, height: 44)
147 }
148 .accessibilityLabel("Settings")
129 Button { 149 Button {
130 night.toggle() 150 nightMode = night ? "off" : "on"
131 } label: { 151 } label: {
132 Image(systemName: night ? "eye.fill" : "eye") 152 Image(systemName: night ? "eye.fill" : "eye")
133 .font(.system(size: 17)) 153 .font(.system(size: 17))
@@ -206,6 +226,16 @@ struct SkyScreen: View {
206 } 226 }
207 } 227 }
208 228
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
209 private func compass(_ azimuth: Double) -> String { 239 private func compass(_ azimuth: Double) -> String {
210 let points = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"] 240 let points = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]
211 return points[Int(((azimuth + 22.5) / 45).rounded(.down)) % 8] 241 return points[Int(((azimuth + 22.5) / 45).rounded(.down)) % 8]
App/Sources/SunScreen.swift +9
@@ -91,6 +91,8 @@ struct SunScreen: View {
91 private var auroraMap: some View { 91 private var auroraMap: some View {
92 if let ovation { 92 if let ovation {
93 AuroraMap(cells: ovation.cells.map { GridValue(lon: $0.lon, lat: $0.lat, value: $0.probability) }, observer: observer, ink: ink) 93 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))
94 Text("Chance of visible aurora overhead, OVATION model, for \(ovation.forecastTime.formatted(date: .omitted, time: .shortened)). Brighter green is more likely.") 96 Text("Chance of visible aurora overhead, OVATION model, for \(ovation.forecastTime.formatted(date: .omitted, time: .shortened)). Brighter green is more likely.")
95 .font(.system(size: 11)).opacity(0.6) 97 .font(.system(size: 11)).opacity(0.6)
96 } else { 98 } else {
@@ -98,6 +100,13 @@ struct SunScreen: View {
98 } 100 }
99 } 101 }
100 102
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
101 @ViewBuilder 110 @ViewBuilder
102 private var flares: some View { 111 private var flares: some View {
103 if let last = xray.last { 112 if let last = xray.last {
App/Sources/SystemScreen.swift +2
@@ -26,6 +26,8 @@ struct SystemScreen: View {
26 Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 8).padding(.bottom, 16) 26 Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 8).padding(.bottom, 16)
27 27
28 OrreryView(date: date, ink: ink, accent: accent) 28 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.")
29 31
30 HStack(spacing: 12) { 32 HStack(spacing: 12) {
31 Button { playing.toggle() } label: { 33 Button { playing.toggle() } label: {
App/project.yml +4 −2
@@ -43,11 +43,13 @@ targets:
43 CODE_SIGN_STYLE: Automatic 43 CODE_SIGN_STYLE: Automatic
44 SWIFT_VERSION: "6.0" 44 SWIFT_VERSION: "6.0"
45 TARGETED_DEVICE_FAMILY: "1" 45 TARGETED_DEVICE_FAMILY: "1"
46 MARKETING_VERSION: "0.1" 46 MARKETING_VERSION: "1.0"
47 CURRENT_PROJECT_VERSION: "1" 47 CURRENT_PROJECT_VERSION: "1"
48 GENERATE_INFOPLIST_FILE: YES 48 GENERATE_INFOPLIST_FILE: YES
49 INFOPLIST_KEY_CFBundleDisplayName: Moonrise 49 INFOPLIST_KEY_CFBundleDisplayName: Moonrise
50 INFOPLIST_KEY_NSSupportsLiveActivities: YES 50 INFOPLIST_KEY_NSSupportsLiveActivities: YES
51 INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO
52 ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
51 INFOPLIST_KEY_UILaunchScreen_Generation: YES 53 INFOPLIST_KEY_UILaunchScreen_Generation: YES
52 INFOPLIST_KEY_UIStatusBarStyle: UIStatusBarStyleLightContent 54 INFOPLIST_KEY_UIStatusBarStyle: UIStatusBarStyleLightContent
53 INFOPLIST_KEY_UISupportedInterfaceOrientations: UIInterfaceOrientationPortrait 55 INFOPLIST_KEY_UISupportedInterfaceOrientations: UIInterfaceOrientationPortrait
@@ -77,5 +79,5 @@ targets:
77 CODE_SIGN_STYLE: Automatic 79 CODE_SIGN_STYLE: Automatic
78 SWIFT_VERSION: "6.0" 80 SWIFT_VERSION: "6.0"
79 TARGETED_DEVICE_FAMILY: "1" 81 TARGETED_DEVICE_FAMILY: "1"
80 MARKETING_VERSION: "0.1" 82 MARKETING_VERSION: "1.0"
81 CURRENT_PROJECT_VERSION: "1" 83 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 @@
1import { send, type ApnsConfig } from "./apns"; 1import { send, type ApnsConfig } from "./apns";
2import { actionableKp, alertBands, eventKey, isNight } from "./aurora"; 2import { actionableKp, alertBands, eventKey, isNight } from "./aurora";
3import { fetchUpcoming, type Launch } from "./ll2"; 3import { fetchUpcoming, type Launch } from "./ll2";
4import { PRIVACY_HTML } from "./privacy";
4import { fetchSpaceWeather, next24h, type SpaceWeather } from "./swpc"; 5import { fetchSpaceWeather, next24h, type SpaceWeather } from "./swpc";
5 6
6interface Env { 7interface Env {
@@ -53,6 +54,8 @@ export default {
53 const rows = await env.DB.prepare("SELECT feed, fetched_at, ok FROM feed_snapshots").all(); 54 const rows = await env.DB.prepare("SELECT feed, fetched_at, ok FROM feed_snapshots").all();
54 return json({ ok: true, feeds: rows.results }); 55 return json({ ok: true, feeds: rows.results });
55 } 56 }
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" } });
56 if (request.method === "GET" && url.pathname === "/v1/today") return today(env, now); 59 if (request.method === "GET" && url.pathname === "/v1/today") return today(env, now);
57 if (request.method === "GET" && url.pathname === "/v1/launches") return launches(env, now); 60 if (request.method === "GET" && url.pathname === "/v1/launches") return launches(env, now);
58 61
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>`;