Commit c6eec98084
Verified · cmc
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 |
| 8 | struct MoonriseApp: App { | 8 | struct 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 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | /// First launch: what the app does and why it asks for location, before iOS asks. | ||
| 4 | struct 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 @@ | |||
| 1 | import Library | ||
| 2 | import SwiftUI | ||
| 3 | |||
| 4 | /// UserDefaults keys shared by the settings screen and the models that read them. | ||
| 5 | enum 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 | |||
| 14 | struct 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. | ||
| 50 | let privacyPolicyURL = URL(string: "https://moonrise-server.audit-labs.workers.dev/privacy")! | ||
| 51 | |||
| 52 | struct 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 | |||
| 109 | struct 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 | |||
| 167 | extension 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") | ||
| 14 | Answer: *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 | ||
| 30 | No 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 | ||
| 2 | import AppKit | ||
| 3 | import SwiftUI | ||
| 4 | |||
| 5 | struct 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 | |||
| 36 | let out = URL(fileURLWithPath: CommandLine.arguments[1]) | ||
| 37 | MainActor.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 | } | ||
| 44 | print("wrote \(out.path)") | ||
server/src/index.ts +3
| @@ -1,6 +1,7 @@ | |||
| 1 | import { send, type ApnsConfig } from "./apns"; | 1 | import { send, type ApnsConfig } from "./apns"; |
| 2 | import { actionableKp, alertBands, eventKey, isNight } from "./aurora"; | 2 | import { actionableKp, alertBands, eventKey, isNight } from "./aurora"; |
| 3 | import { fetchUpcoming, type Launch } from "./ll2"; | 3 | import { fetchUpcoming, type Launch } from "./ll2"; |
| 4 | import { PRIVACY_HTML } from "./privacy"; | ||
| 4 | import { fetchSpaceWeather, next24h, type SpaceWeather } from "./swpc"; | 5 | import { fetchSpaceWeather, next24h, type SpaceWeather } from "./swpc"; |
| 5 | 6 | ||
| 6 | interface Env { | 7 | interface 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 | |||
| 3 | export 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> | ||
| 10 | body { 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; } | ||
| 11 | h1 { font-weight: 500; margin-bottom: 0.2em; } | ||
| 12 | h2 { 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>`; | ||