Commit a11ba2e1ca
Unsigned
Layout: unified · split
App/Sources/AuroraAlerts.swift +26 −12
| @@ -4,17 +4,20 @@ import SkyEngine | |||
| 4 | import UIKit | 4 | import UIKit |
| 5 | import UserNotifications | 5 | import UserNotifications |
| 6 | 6 | ||
| 7 | /// Registers this device with the backend for aurora pushes. Sends only the push | 7 | /// Registers this device with the backend for aurora and news pushes. Sends only the push |
| 8 | /// token, a 5° geomagnetic latitude band and the UTC offset. | 8 | /// token, a 5° geomagnetic latitude band, the UTC offset and which alerts are on. |
| 9 | @MainActor | 9 | @MainActor |
| 10 | @Observable | 10 | @Observable |
| 11 | final class AuroraAlerts { | 11 | final class AuroraAlerts { |
| 12 | static let shared = AuroraAlerts() | 12 | static let shared = AuroraAlerts() |
| 13 | 13 | ||
| 14 | private static let enabledKey = "auroraAlerts" | 14 | private static let enabledKey = "auroraAlerts" |
| 15 | private static let newsKey = "newsAlerts" | ||
| 15 | private static let tokenKey = "apnsToken" | 16 | private static let tokenKey = "apnsToken" |
| 16 | 17 | ||
| 17 | private(set) var enabled = UserDefaults.standard.bool(forKey: AuroraAlerts.enabledKey) | 18 | private(set) var enabled = UserDefaults.standard.bool(forKey: AuroraAlerts.enabledKey) |
| 19 | /// The daily news digest at 09:00. | ||
| 20 | private(set) var news = UserDefaults.standard.bool(forKey: AuroraAlerts.newsKey) | ||
| 18 | private(set) var status: String? | 21 | private(set) var status: String? |
| 19 | private var observer: Observer? | 22 | private var observer: Observer? |
| 20 | 23 | ||
| @@ -40,30 +43,41 @@ final class AuroraAlerts { | |||
| 40 | } | 43 | } |
| 41 | 44 | ||
| 42 | func setEnabled(_ on: Bool) async { | 45 | func setEnabled(_ on: Bool) async { |
| 43 | if on { | 46 | if on, !(await permitted()) { return } |
| 44 | let granted = (try? await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound])) ?? false | ||
| 45 | guard granted else { | ||
| 46 | status = "Notifications are off for Moonrise. Turn them on in the Settings app." | ||
| 47 | return | ||
| 48 | } | ||
| 49 | UIApplication.shared.registerForRemoteNotifications() | ||
| 50 | } | ||
| 51 | enabled = on | 47 | enabled = on |
| 52 | UserDefaults.standard.set(on, forKey: Self.enabledKey) | 48 | UserDefaults.standard.set(on, forKey: Self.enabledKey) |
| 53 | await sync() | 49 | await sync() |
| 54 | } | 50 | } |
| 55 | 51 | ||
| 52 | func setNews(_ on: Bool) async { | ||
| 53 | if on, !(await permitted()) { return } | ||
| 54 | news = on | ||
| 55 | UserDefaults.standard.set(on, forKey: Self.newsKey) | ||
| 56 | await sync() | ||
| 57 | } | ||
| 58 | |||
| 59 | private func permitted() async -> Bool { | ||
| 60 | let granted = (try? await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound])) ?? false | ||
| 61 | if granted { | ||
| 62 | UIApplication.shared.registerForRemoteNotifications() | ||
| 63 | } else { | ||
| 64 | status = "Notifications are off for Moonrise. Turn them on in the Settings app." | ||
| 65 | } | ||
| 66 | return granted | ||
| 67 | } | ||
| 68 | |||
| 56 | /// Registers or unregisters to match `enabled`. | 69 | /// Registers or unregisters to match `enabled`. |
| 57 | func sync() async { | 70 | func sync() async { |
| 58 | guard let apiBase, let token else { return } | 71 | guard let apiBase, let token else { return } |
| 59 | if enabled { | 72 | if enabled || news { |
| 60 | guard let observer else { return } | 73 | guard let observer else { return } |
| 61 | let body: [String: Any] = [ | 74 | let body: [String: Any] = [ |
| 62 | "token": token, | 75 | "token": token, |
| 63 | "environment": environment, | 76 | "environment": environment, |
| 64 | "band": Geomagnetic.band(observer), | 77 | "band": Geomagnetic.band(observer), |
| 65 | "utcOffsetMinutes": TimeZone.current.secondsFromGMT() / 60, | 78 | "utcOffsetMinutes": TimeZone.current.secondsFromGMT() / 60, |
| 66 | "aurora": true, | 79 | "aurora": enabled, |
| 80 | "news": news, | ||
| 67 | ] | 81 | ] |
| 68 | var req = URLRequest(url: apiBase.appending(path: "v1/devices")) | 82 | var req = URLRequest(url: apiBase.appending(path: "v1/devices")) |
| 69 | req.httpMethod = "POST" | 83 | req.httpMethod = "POST" |
App/Sources/LaunchesScreen.swift +37 −11
| @@ -6,10 +6,21 @@ import SwiftUI | |||
| 6 | let apiBase: URL? = (Bundle.main.object(forInfoDictionaryKey: "MoonriseAPIURL") as? String) | 6 | let apiBase: URL? = (Bundle.main.object(forInfoDictionaryKey: "MoonriseAPIURL") as? String) |
| 7 | .flatMap { $0.isEmpty ? nil : URL(string: $0) } | 7 | .flatMap { $0.isEmpty ? nil : URL(string: $0) } |
| 8 | 8 | ||
| 9 | /// Tab and News/Launches switch that a notification tap can change. | ||
| 10 | @MainActor | ||
| 11 | @Observable | ||
| 12 | final class Router { | ||
| 13 | static let shared = Router() | ||
| 14 | var tab = "sky" | ||
| 15 | var showNews = false | ||
| 16 | } | ||
| 17 | |||
| 9 | struct LaunchesScreen: View { | 18 | struct LaunchesScreen: View { |
| 19 | @State private var router = Router.shared | ||
| 10 | @State private var list: LaunchList? | 20 | @State private var list: LaunchList? |
| 11 | @State private var error: String? | 21 | @State private var error: String? |
| 12 | @State private var selected: Launch? | 22 | @State private var selected: Launch? |
| 23 | @State private var newsReload = 0 | ||
| 13 | 24 | ||
| 14 | private let ink = Color(red: 0.95, green: 0.95, blue: 0.93) | 25 | private let ink = Color(red: 0.95, green: 0.95, blue: 0.93) |
| 15 | private let accent = Color(red: 0.88, green: 0.24, blue: 0.19) | 26 | private let accent = Color(red: 0.88, green: 0.24, blue: 0.19) |
| @@ -18,9 +29,33 @@ struct LaunchesScreen: View { | |||
| 18 | var body: some View { | 29 | var body: some View { |
| 19 | ScrollView { | 30 | ScrollView { |
| 20 | VStack(alignment: .leading, spacing: 0) { | 31 | VStack(alignment: .leading, spacing: 0) { |
| 21 | Text("Launches").font(.title.weight(.medium)) | 32 | Text("News & launches").font(.title.weight(.medium)) |
| 22 | Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 8).padding(.bottom, 8) | 33 | Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 8).padding(.bottom, 12) |
| 34 | Picker("Show", selection: $router.showNews) { | ||
| 35 | Text("Launches").tag(false) | ||
| 36 | Text("News").tag(true) | ||
| 37 | } | ||
| 38 | .pickerStyle(.segmented) | ||
| 39 | .padding(.bottom, 8) | ||
| 40 | |||
| 41 | if router.showNews { | ||
| 42 | NewsList(ink: ink, accent: accent, reload: newsReload) | ||
| 43 | } else { | ||
| 44 | launches | ||
| 45 | } | ||
| 46 | } | ||
| 47 | .padding(.horizontal, 20) | ||
| 48 | .readableWidth() | ||
| 49 | } | ||
| 50 | .foregroundStyle(ink) | ||
| 51 | .background(background.ignoresSafeArea()) | ||
| 52 | .refreshable { if router.showNews { newsReload += 1 } else { await load() } } | ||
| 53 | .task { await load() } | ||
| 54 | .sheet(item: $selected) { LaunchDetail(launch: $0, ink: ink, accent: accent, background: background) } | ||
| 55 | } | ||
| 23 | 56 | ||
| 57 | @ViewBuilder | ||
| 58 | private var launches: some View { | ||
| 24 | if let error { | 59 | if let error { |
| 25 | Text(error).font(.footnote).foregroundStyle(accent).padding(.vertical, 12) | 60 | Text(error).font(.footnote).foregroundStyle(accent).padding(.vertical, 12) |
| 26 | } | 61 | } |
| @@ -34,15 +69,6 @@ struct LaunchesScreen: View { | |||
| 34 | } else if error == nil { | 69 | } else if error == nil { |
| 35 | ProgressView().tint(ink).padding(.top, 40).frame(maxWidth: .infinity) | 70 | ProgressView().tint(ink).padding(.top, 40).frame(maxWidth: .infinity) |
| 36 | } | 71 | } |
| 37 | } | ||
| 38 | .padding(.horizontal, 20) | ||
| 39 | .readableWidth() | ||
| 40 | } | ||
| 41 | .foregroundStyle(ink) | ||
| 42 | .background(background.ignoresSafeArea()) | ||
| 43 | .refreshable { await load() } | ||
| 44 | .task { await load() } | ||
| 45 | .sheet(item: $selected) { LaunchDetail(launch: $0, ink: ink, accent: accent, background: background) } | ||
| 46 | } | 72 | } |
| 47 | 73 | ||
| 48 | private func load() async { | 74 | private func load() async { |
App/Sources/MoonriseApp.swift +13 −4
| @@ -11,20 +11,20 @@ struct MoonriseApp: App { | |||
| 11 | @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate | 11 | @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate |
| 12 | @AppStorage(SettingsKey.onboarded) private var onboarded = false | 12 | @AppStorage(SettingsKey.onboarded) private var onboarded = false |
| 13 | @AppStorage(SettingsKey.nightActive) private var nightActive = false | 13 | @AppStorage(SettingsKey.nightActive) private var nightActive = false |
| 14 | @State private var tab = "sky" | 14 | @State private var router = Router.shared |
| 15 | 15 | ||
| 16 | var body: some Scene { | 16 | var body: some Scene { |
| 17 | WindowGroup { | 17 | WindowGroup { |
| 18 | TabView(selection: $tab) { | 18 | TabView(selection: $router.tab) { |
| 19 | Tab("Sky", systemImage: "sparkles", value: "sky") { SkyScreen() } | 19 | Tab("Sky", systemImage: "sparkles", value: "sky") { SkyScreen() } |
| 20 | Tab("System", systemImage: "circle.circle", value: "system") { SystemScreen() } | 20 | Tab("System", systemImage: "circle.circle", value: "system") { SystemScreen() } |
| 21 | Tab("Sun", systemImage: "sun.max", value: "sun") { SunScreen() } | 21 | Tab("Sun", systemImage: "sun.max", value: "sun") { SunScreen() } |
| 22 | Tab("Launches", systemImage: "airplane.departure", value: "launches") { LaunchesScreen() } | 22 | Tab("News & launches", systemImage: "airplane.departure", value: "launches") { LaunchesScreen() } |
| 23 | Tab("Library", systemImage: "books.vertical", value: "library") { LibraryScreen() } | 23 | Tab("Library", systemImage: "books.vertical", value: "library") { LibraryScreen() } |
| 24 | } | 24 | } |
| 25 | .tabViewStyle(.sidebarAdaptable) | 25 | .tabViewStyle(.sidebarAdaptable) |
| 26 | // Red tab highlight while the Sky tab is in night mode. | 26 | // Red tab highlight while the Sky tab is in night mode. |
| 27 | .tint(tab == "sky" && nightActive ? SkyStyle.nightRed : nil) | 27 | .tint(router.tab == "sky" && nightActive ? SkyStyle.nightRed : nil) |
| 28 | .preferredColorScheme(.dark) | 28 | .preferredColorScheme(.dark) |
| 29 | .fullScreenCover(isPresented: .constant(!onboarded)) { | 29 | .fullScreenCover(isPresented: .constant(!onboarded)) { |
| 30 | OnboardingScreen().preferredColorScheme(.dark) | 30 | OnboardingScreen().preferredColorScheme(.dark) |
| @@ -46,6 +46,15 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent | |||
| 46 | Task { @MainActor in AuroraAlerts.shared.receivedToken(token) } | 46 | Task { @MainActor in AuroraAlerts.shared.receivedToken(token) } |
| 47 | } | 47 | } |
| 48 | 48 | ||
| 49 | /// A news digest opens News & launches on the News switch. | ||
| 50 | nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async { | ||
| 51 | guard response.notification.request.content.userInfo["tab"] as? String == "news" else { return } | ||
| 52 | await MainActor.run { | ||
| 53 | Router.shared.tab = "launches" | ||
| 54 | Router.shared.showNews = true | ||
| 55 | } | ||
| 56 | } | ||
| 57 | |||
| 49 | /// Show alerts as banners while the app is open. | 58 | /// Show alerts as banners while the app is open. |
| 50 | nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions { | 59 | nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions { |
| 51 | [.banner, .sound] | 60 | [.banner, .sound] |
App/Sources/NewsList.swift added +62
| @@ -0,0 +1,62 @@ | |||
| 1 | import Feeds | ||
| 2 | import SwiftUI | ||
| 3 | |||
| 4 | /// Latest space news headlines; tapping one opens the article in Safari. | ||
| 5 | struct NewsList: View { | ||
| 6 | var ink: Color | ||
| 7 | var accent: Color | ||
| 8 | var reload = 0 | ||
| 9 | @State private var articles: [NewsArticle]? | ||
| 10 | @State private var failed = false | ||
| 11 | @Environment(\.openURL) private var openURL | ||
| 12 | |||
| 13 | var body: some View { | ||
| 14 | VStack(alignment: .leading, spacing: 0) { | ||
| 15 | if let articles { | ||
| 16 | ForEach(articles) { a in | ||
| 17 | Button { openURL(a.url) } label: { row(a) } | ||
| 18 | .buttonStyle(.plain) | ||
| 19 | .accessibilityHint("Opens the article in Safari") | ||
| 20 | } | ||
| 21 | Text("Headlines: Spaceflight News API, The Space Devs. Articles belong to their publishers.") | ||
| 22 | .font(.caption2).dimmed(0.5).padding(.vertical, 16) | ||
| 23 | } else if failed { | ||
| 24 | Text("Couldn't load news. Pull to retry.").font(.footnote).foregroundStyle(accent).padding(.vertical, 12) | ||
| 25 | } else { | ||
| 26 | ProgressView().tint(ink).padding(.top, 40).frame(maxWidth: .infinity) | ||
| 27 | } | ||
| 28 | } | ||
| 29 | .task(id: reload) { await load() } | ||
| 30 | } | ||
| 31 | |||
| 32 | func load() async { | ||
| 33 | do { | ||
| 34 | articles = try await NewsArticle.latest() | ||
| 35 | failed = false | ||
| 36 | } catch { | ||
| 37 | failed = articles == nil | ||
| 38 | } | ||
| 39 | } | ||
| 40 | |||
| 41 | private func row(_ a: NewsArticle) -> some View { | ||
| 42 | HStack(alignment: .top, spacing: 12) { | ||
| 43 | VStack(alignment: .leading, spacing: 6) { | ||
| 44 | Text("\(a.site) · \(a.published.formatted(.relative(presentation: .named)))").font(.caption2).dimmed(0.7) | ||
| 45 | Text(a.title).font(.headline) | ||
| 46 | Text(a.summary).font(.footnote).dimmed(0.8).lineLimit(3) | ||
| 47 | } | ||
| 48 | Spacer(minLength: 0) | ||
| 49 | if let image = a.image { | ||
| 50 | AsyncImage(url: image) { phase in | ||
| 51 | if case .success(let img) = phase { img.resizable().scaledToFill() } else { ink.opacity(0.06) } | ||
| 52 | } | ||
| 53 | .frame(width: 72, height: 72) | ||
| 54 | .clipped() | ||
| 55 | .accessibilityHidden(true) | ||
| 56 | } | ||
| 57 | } | ||
| 58 | .padding(.vertical, 14) | ||
| 59 | .overlay(alignment: .top) { Rectangle().fill(ink.opacity(0.25)).frame(height: 0.5) } | ||
| 60 | .contentShape(Rectangle()) | ||
| 61 | } | ||
| 62 | } | ||
App/Sources/SettingsScreen.swift +6 −2
| @@ -87,7 +87,11 @@ struct SettingsScreen: View { | |||
| 87 | get: { alerts.enabled }, | 87 | get: { alerts.enabled }, |
| 88 | set: { on in Task { await alerts.setEnabled(on) } } | 88 | set: { on in Task { await alerts.setEnabled(on) } } |
| 89 | )) | 89 | )) |
| 90 | if alerts.enabled { | 90 | Toggle("News alerts", isOn: Binding( |
| 91 | get: { alerts.news }, | ||
| 92 | set: { on in Task { await alerts.setNews(on) } } | ||
| 93 | )) | ||
| 94 | if alerts.enabled || alerts.news { | ||
| 91 | Button("Send a test alert") { Task { await alerts.sendTest() } } | 95 | Button("Send a test alert") { Task { await alerts.sendTest() } } |
| 92 | } | 96 | } |
| 93 | if let status = alerts.status { | 97 | if let status = alerts.status { |
| @@ -96,7 +100,7 @@ struct SettingsScreen: View { | |||
| 96 | } header: { | 100 | } header: { |
| 97 | Text("Alerts") | 101 | Text("Alerts") |
| 98 | } footer: { | 102 | } footer: { |
| 99 | Text("Moonrise notifies you when the aurora may be visible from your area at night. To do this it sends our server this device's notification token, a 5° band of geomagnetic latitude and your time zone offset, not your location.") | 103 | Text("Aurora alerts tell you when the aurora may be visible from your area at night. News alerts send three of the day's space headlines at 9 a.m. For these, Moonrise sends our server this device's notification token, a 5° band of geomagnetic latitude, your time zone offset and which alerts are on, not your location.") |
| 100 | } | 104 | } |
| 101 | Section { | 105 | Section { |
| 102 | Toggle("Daily summary", isOn: Binding( | 106 | Toggle("Daily summary", isOn: Binding( |
Packages/MoonriseKit/Sources/Feeds/News.swift added +46
| @@ -0,0 +1,46 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | /// Space news from the Spaceflight News API (The Space Devs): headline, summary and a link to the article. | ||
| 4 | public struct NewsArticle: Sendable, Identifiable, Hashable, Decodable { | ||
| 5 | public var id: Int | ||
| 6 | public var title: String | ||
| 7 | public var summary: String | ||
| 8 | public var url: URL | ||
| 9 | public var image: URL? | ||
| 10 | public var site: String | ||
| 11 | public var published: Date | ||
| 12 | |||
| 13 | public static let url = URL(string: "https://api.spaceflightnewsapi.net/v4/articles/?limit=40")! | ||
| 14 | |||
| 15 | public static func latest(session: URLSession = .shared) async throws -> [NewsArticle] { | ||
| 16 | try decode(await session.data(from: url).0) | ||
| 17 | } | ||
| 18 | |||
| 19 | /// Decodes a page, leaving out Astronomy Picture of the Day posts, whose summaries are page navigation. | ||
| 20 | static func decode(_ data: Data) throws -> [NewsArticle] { | ||
| 21 | let d = JSONDecoder() | ||
| 22 | d.dateDecodingStrategy = .iso8601 | ||
| 23 | return try d.decode(Page.self, from: data).results.filter { !$0.title.hasPrefix("APOD:") } | ||
| 24 | } | ||
| 25 | |||
| 26 | private struct Page: Decodable { var results: [NewsArticle] } | ||
| 27 | |||
| 28 | private enum CodingKeys: String, CodingKey { | ||
| 29 | case id, title, summary, url | ||
| 30 | case image = "image_url", site = "news_site", published = "published_at" | ||
| 31 | } | ||
| 32 | |||
| 33 | public init(from decoder: Decoder) throws { | ||
| 34 | let c = try decoder.container(keyedBy: CodingKeys.self) | ||
| 35 | id = try c.decode(Int.self, forKey: .id) | ||
| 36 | title = try c.decode(String.self, forKey: .title) | ||
| 37 | summary = try c.decode(String.self, forKey: .summary).trimmingCharacters(in: .whitespacesAndNewlines) | ||
| 38 | url = try c.decode(URL.self, forKey: .url) | ||
| 39 | site = try c.decode(String.self, forKey: .site) | ||
| 40 | published = try c.decode(Date.self, forKey: .published) | ||
| 41 | // Some sites give http image URLs, which App Transport Security blocks. | ||
| 42 | image = (try? c.decodeIfPresent(String.self, forKey: .image)).flatMap { s in | ||
| 43 | URL(string: s.hasPrefix("http://") ? "https://" + s.dropFirst(7) : s) | ||
| 44 | } | ||
| 45 | } | ||
| 46 | } | ||
Packages/MoonriseKit/Tests/FeedsTests/Fixtures/snapi.json added +1
| @@ -0,0 +1 @@ | |||
| 1 | {"count":36523,"next":"https://api.spaceflightnewsapi.net/v4/articles/?limit=5&offset=5","previous":null,"results":[{"id":40426,"title":"Live coverage: SpaceX to launch Starfall reentry capsule, 31 customer payloads on Bandwagon-5 rideshare mission","authors":[{"name":"Will Robinson-Smith","socials":null}],"url":"https://spaceflightnow.com/2026/10/18/live-coverage-spacex-to-launch-starfall-reentry-capsule-31-customer-payloads-on-bandwagon-5-rideshare-mission/","image_url":"http://spaceflightnow.com/wp-content/uploads/2026/10/20261017_Bandwagon-5_payloads.jpeg","news_site":"Spaceflight Now","summary":"This will be the second launch of SpaceX’s uncrewed reentry capsule, Starfall. Liftoff from pad 40 at Cape Canaveral Space Force Station is scheduled during a one-hour window that opens at 6:30 a.m. EDT (1030 UTC). ","published_at":"2026-10-18T16:32:02Z","updated_at":"2026-10-18T16:40:28.841793Z","featured":false,"launches":[],"events":[]},{"id":40425,"title":"SpaceX working through Starship Flight 15 and 16 milestones","authors":[{"name":"Chris Bergin and Arran Reid","socials":null}],"url":"https://www.nasaspaceflight.com/2026/10/spacex-flight-15-16-milestones/","image_url":"https://www.nasaspaceflight.com/wp-content/uploads/2026/10/NSF-2026-10-18-17-03-53-605.jpg","news_site":"NASASpaceflight","summary":"SpaceX is advancing toward its next two Starship missions as the company looks to close…\nThe post SpaceX working through Starship Flight 15 and 16 milestones appeared first on NASASpaceFlight.com.","published_at":"2026-10-18T16:31:04Z","updated_at":"2026-10-18T16:40:18.042903Z","featured":false,"launches":[],"events":[]},{"id":40424,"title":"APOD: 2026 October 18 – Sonified: Eagle Nebula Pillars","authors":[{"name":"NASA","socials":null}],"url":"https://science.nasa.gov/image-article/apod-2026-october-18-sonified-eagle-nebula-pillars/","image_url":"https://blogs.nasa.gov/spacestation/wp-content/uploads/sites/240/2023/01/nasa-logo-web-rgb.png","news_site":"NASA","summary":"APOD Science APOD APOD: 2026 October 18 –… Today’s APOD Archive Submissions Index Search Calendar RSS Education About Discuss APOD Astronomy Picture of the Day Discover the cosmos! Each day a different image or photograph of our fascinating universe is featured, along with a brief explanation written by a professional astronomer. Sonified: Eagle Nebula […]","published_at":"2026-10-18T04:05:00Z","updated_at":"2026-10-18T04:50:14.949909Z","featured":false,"launches":[],"events":[]},{"id":40423,"title":"APOD: 2026 October 17 – 3D 67P","authors":[{"name":"NASA","socials":null}],"url":"https://science.nasa.gov/image-article/apod-2026-october-17-3d-67p/","image_url":"https://assets.science.nasa.gov/dynamicimage/assets/science/cds/apod/apod/2026/october/anaglyph-NAC_2015-07-25T06.28.48.742Z_lamy.jpg","news_site":"NASA","summary":"APOD Science APOD APOD: 2026 October 17… Today’s APOD Archive Submissions Index Search Calendar RSS Education About Discuss APOD Astronomy Picture of the Day Discover the cosmos! Each day a different image or photograph of our fascinating universe is featured, along with a brief explanation written by a professional astronomer. 3D 67P Explanation: Put […]","published_at":"2026-10-17T04:05:00Z","updated_at":"2026-10-17T04:40:16.233688Z","featured":false,"launches":[],"events":[]},{"id":40422,"title":"NASA Names Stephen Cash Marshall Space Flight Center Director","authors":[{"name":"NASA","socials":null}],"url":"https://www.nasa.gov/news-release/nasa-names-stephen-cash-marshall-space-flight-center-director/","image_url":"https://www.nasa.gov/wp-content/uploads/2026/10/steve-cash-photo.jpg","news_site":"NASA","summary":"NASA Administrator Jared Isaacman has named Stephen Cash director of the agency’s Marshall Space Flight Center in Huntsville, Alabama, effective Monday, Oct. 19. Cash, a veteran NASA engineer and aerospace executive, returns to lead the center where he began his career more than four decades ago. “Marshall contributions to NASA’s missions are historic and it’s […]","published_at":"2026-10-16T20:14:39Z","updated_at":"2026-10-16T20:20:06.780652Z","featured":false,"launches":[],"events":[]}]}\ No newline at end of file | ||
Packages/MoonriseKit/Tests/FeedsTests/NewsTests.swift added +16
| @@ -0,0 +1,16 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import Feeds | ||
| 4 | |||
| 5 | @Test func decodesNewsArticles() throws { | ||
| 6 | // snapi.json saved from api.spaceflightnewsapi.net/v4/articles/?limit=5 on 2026-10-18. | ||
| 7 | let url = try #require(Bundle.module.url(forResource: "Fixtures/snapi", withExtension: "json")) | ||
| 8 | let articles = try NewsArticle.decode(Data(contentsOf: url)) | ||
| 9 | #expect(articles.count == 3) | ||
| 10 | #expect(!articles.contains { $0.title.hasPrefix("APOD:") }) | ||
| 11 | #expect(articles[0].id == 40426) | ||
| 12 | #expect(articles[0].site == "Spaceflight Now") | ||
| 13 | #expect(articles[0].image?.scheme == "https") | ||
| 14 | #expect(articles[0].published == Date(timeIntervalSince1970: 1_792_341_122)) | ||
| 15 | #expect(!articles[0].summary.hasSuffix(" ")) | ||
| 16 | } | ||
server/migrations/0003_news.sql added +2
| @@ -0,0 +1,2 @@ | |||
| 1 | -- Daily news digest opt-in. | ||
| 2 | ALTER TABLE devices ADD COLUMN news INTEGER NOT NULL DEFAULT 0; | ||
server/src/index.ts +37 −5
| @@ -2,6 +2,7 @@ import { send, type ApnsConfig } from "./apns"; | |||
| 2 | import { actionableKp, alertBands, eventKey, isNight } from "./aurora"; | 2 | import { actionableKp, alertBands, eventKey, isNight } from "./aurora"; |
| 3 | import { fetchElements } from "./celestrak"; | 3 | import { fetchElements } from "./celestrak"; |
| 4 | import { fetchUpcoming, type Launch } from "./ll2"; | 4 | import { fetchUpcoming, type Launch } from "./ll2"; |
| 5 | import { digestDue, digestPayload, fetchLatest, localDate, pickHeadlines } from "./news"; | ||
| 5 | import { PRIVACY_HTML } from "./privacy"; | 6 | import { PRIVACY_HTML } from "./privacy"; |
| 6 | import { fetchSpaceWeather, next24h, type SpaceWeather } from "./swpc"; | 7 | import { fetchSpaceWeather, next24h, type SpaceWeather } from "./swpc"; |
| 7 | 8 | ||
| @@ -21,6 +22,7 @@ interface DeviceRequest { | |||
| 21 | band: number; | 22 | band: number; |
| 22 | utcOffsetMinutes: number; | 23 | utcOffsetMinutes: number; |
| 23 | aurora?: boolean; | 24 | aurora?: boolean; |
| 25 | news?: boolean; | ||
| 24 | } | 26 | } |
| 25 | 27 | ||
| 26 | const LAUNCH_CRON = "*/10 * * * *"; | 28 | const LAUNCH_CRON = "*/10 * * * *"; |
| @@ -69,9 +71,9 @@ export default { | |||
| 69 | const d = (await request.json().catch(() => ({}))) as Partial<DeviceRequest>; | 71 | const d = (await request.json().catch(() => ({}))) as Partial<DeviceRequest>; |
| 70 | if (!validDevice(d)) return json({ error: "invalid device" }, 400); | 72 | if (!validDevice(d)) return json({ error: "invalid device" }, 400); |
| 71 | await env.DB.prepare( | 73 | await env.DB.prepare( |
| 72 | `INSERT INTO devices (token, environment, band, utc_offset_minutes, aurora, last_seen) VALUES (?1, ?2, ?3, ?4, ?5, ?6) | 74 | `INSERT INTO devices (token, environment, band, utc_offset_minutes, aurora, news, last_seen) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) |
| 73 | ON CONFLICT (token) DO UPDATE SET environment = ?2, band = ?3, utc_offset_minutes = ?4, aurora = ?5, last_seen = ?6`, | 75 | ON CONFLICT (token) DO UPDATE SET environment = ?2, band = ?3, utc_offset_minutes = ?4, aurora = ?5, news = ?6, last_seen = ?7`, |
| 74 | ).bind(d.token.toLowerCase(), d.environment, d.band, d.utcOffsetMinutes, d.aurora === false ? 0 : 1, now.toISOString()).run(); | 76 | ).bind(d.token.toLowerCase(), d.environment, d.band, d.utcOffsetMinutes, d.aurora === false ? 0 : 1, d.news === true ? 1 : 0, now.toISOString()).run(); |
| 75 | return json({ ok: true }); | 77 | return json({ ok: true }); |
| 76 | } | 78 | } |
| 77 | 79 | ||
| @@ -90,7 +92,7 @@ export default { | |||
| 90 | async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> { | 92 | async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> { |
| 91 | const now = new Date(controller.scheduledTime); | 93 | const now = new Date(controller.scheduledTime); |
| 92 | // Launches every 10 minutes: 6 of the 15 anonymous Launch Library 2 requests per hour. | 94 | // Launches every 10 minutes: 6 of the 15 anonymous Launch Library 2 requests per hour. |
| 93 | if (controller.cron === LAUNCH_CRON) ctx.waitUntil(pollLaunches(env, now)); | 95 | if (controller.cron === LAUNCH_CRON) ctx.waitUntil(Promise.all([pollLaunches(env, now), sendNewsDigests(env, now)])); |
| 94 | else if (controller.cron === SATELLITE_CRON) ctx.waitUntil(pollSatellites(env, now)); | 96 | else if (controller.cron === SATELLITE_CRON) ctx.waitUntil(pollSatellites(env, now)); |
| 95 | else ctx.waitUntil(pollSpaceWeather(env, now)); | 97 | else ctx.waitUntil(pollSpaceWeather(env, now)); |
| 96 | }, | 98 | }, |
| @@ -105,7 +107,7 @@ async function sendTest(env: Env, token: string, now: Date): Promise<Response> { | |||
| 105 | .bind(window, now.toISOString()).run(); | 107 | .bind(window, now.toISOString()).run(); |
| 106 | if (!claimed.meta.changes) return json({ error: "try again in a few minutes" }, 429); | 108 | if (!claimed.meta.changes) return json({ error: "try again in a few minutes" }, 429); |
| 107 | const config: ApnsConfig = { keyId: env.APNS_KEY_ID, teamId: env.APNS_TEAM_ID, privateKey: env.APNS_KEY, topic: env.APNS_TOPIC }; | 109 | const config: ApnsConfig = { keyId: env.APNS_KEY_ID, teamId: env.APNS_TEAM_ID, privateKey: env.APNS_KEY, topic: env.APNS_TOPIC }; |
| 108 | const payload = { aps: { alert: { title: "Moonrise test alert", body: "Aurora alerts reach this device." }, sound: "default" } }; | 110 | const payload = { aps: { alert: { title: "Moonrise test alert", body: "Alerts reach this device." }, sound: "default" } }; |
| 109 | const r = await send(config, token, payload, device.environment === "sandbox"); | 111 | const r = await send(config, token, payload, device.environment === "sandbox"); |
| 110 | if (r.status === 410) await env.DB.prepare("DELETE FROM devices WHERE token = ?1").bind(token).run(); | 112 | if (r.status === 410) await env.DB.prepare("DELETE FROM devices WHERE token = ?1").bind(token).run(); |
| 111 | return json({ apnsStatus: r.status, reason: r.reason }, r.status === 200 ? 200 : 502); | 113 | return json({ apnsStatus: r.status, reason: r.reason }, r.status === 200 ? 200 : 502); |
| @@ -199,3 +201,33 @@ async function sendAuroraAlerts(env: Env, w: SpaceWeather, now: Date): Promise<v | |||
| 199 | } | 201 | } |
| 200 | } | 202 | } |
| 201 | } | 203 | } |
| 204 | |||
| 205 | /** The daily news digest, sent at 09:00 local time to devices that opted in, once per local day. */ | ||
| 206 | async function sendNewsDigests(env: Env, now: Date): Promise<void> { | ||
| 207 | if (!env.APNS_KEY) return; | ||
| 208 | const rows = await env.DB.prepare("SELECT token, environment, utc_offset_minutes FROM devices WHERE news = 1") | ||
| 209 | .all<{ token: string; environment: string; utc_offset_minutes: number }>(); | ||
| 210 | const due = rows.results.filter((d) => digestDue(now, d.utc_offset_minutes)); | ||
| 211 | if (!due.length) return; | ||
| 212 | |||
| 213 | let payload; | ||
| 214 | try { | ||
| 215 | const headlines = pickHeadlines(await fetchLatest(), now); | ||
| 216 | if (!headlines.length) return; | ||
| 217 | payload = digestPayload(headlines); | ||
| 218 | } catch (e) { | ||
| 219 | console.error("snapi", String(e)); | ||
| 220 | return; | ||
| 221 | } | ||
| 222 | |||
| 223 | const config: ApnsConfig = { keyId: env.APNS_KEY_ID, teamId: env.APNS_TEAM_ID, privateKey: env.APNS_KEY, topic: env.APNS_TOPIC }; | ||
| 224 | for (const d of due) { | ||
| 225 | const claimed = await env.DB.prepare("INSERT OR IGNORE INTO alerts_sent (kind, band, event, sent_at) VALUES ('news', 0, ?1, ?2)") | ||
| 226 | .bind(`${d.token}:${localDate(now, d.utc_offset_minutes)}`, now.toISOString()).run(); | ||
| 227 | if (!claimed.meta.changes) continue; | ||
| 228 | const r = await send(config, d.token, payload, d.environment === "sandbox"); | ||
| 229 | if (r.status === 410) await env.DB.prepare("DELETE FROM devices WHERE token = ?1").bind(d.token).run(); | ||
| 230 | else if (r.status !== 200) console.error("apns", r.status, r.reason); | ||
| 231 | } | ||
| 232 | await env.DB.prepare("DELETE FROM alerts_sent WHERE kind = 'news' AND sent_at < ?1").bind(new Date(now.getTime() - 3 * 86400_000).toISOString()).run(); | ||
| 233 | } | ||
server/src/news.ts added +53
| @@ -0,0 +1,53 @@ | |||
| 1 | // Spaceflight News API (The Space Devs). Free, no key. Used only for the daily news digest push. | ||
| 2 | |||
| 3 | export interface Article { | ||
| 4 | id: number; | ||
| 5 | title: string; | ||
| 6 | news_site: string; | ||
| 7 | published_at: string; | ||
| 8 | } | ||
| 9 | |||
| 10 | export const DIGEST_HOUR = 9; | ||
| 11 | |||
| 12 | /** Whether the device's local time is in the first 10 minutes of the digest hour (the 10-minute cron fires once in it). */ | ||
| 13 | export function digestDue(now: Date, utcOffsetMinutes: number): boolean { | ||
| 14 | const minutes = (((now.getUTCHours() * 60 + now.getUTCMinutes() + utcOffsetMinutes) % 1440) + 1440) % 1440; | ||
| 15 | return minutes >= DIGEST_HOUR * 60 && minutes < DIGEST_HOUR * 60 + 10; | ||
| 16 | } | ||
| 17 | |||
| 18 | /** The device's local date, so each device gets one digest per local day. */ | ||
| 19 | export function localDate(now: Date, utcOffsetMinutes: number): string { | ||
| 20 | return new Date(now.getTime() + utcOffsetMinutes * 60_000).toISOString().slice(0, 10); | ||
| 21 | } | ||
| 22 | |||
| 23 | /** Up to three of the newest headlines from the last 24 hours, one per site, without APOD. */ | ||
| 24 | export function pickHeadlines(articles: Article[], now: Date): Article[] { | ||
| 25 | const since = now.getTime() - 86_400_000; | ||
| 26 | const sites = new Set<string>(); | ||
| 27 | const out: Article[] = []; | ||
| 28 | for (const a of [...articles].sort((x, y) => y.published_at.localeCompare(x.published_at))) { | ||
| 29 | if (Date.parse(a.published_at) < since || a.title.startsWith("APOD:") || sites.has(a.news_site)) continue; | ||
| 30 | sites.add(a.news_site); | ||
| 31 | out.push(a); | ||
| 32 | if (out.length === 3) break; | ||
| 33 | } | ||
| 34 | return out; | ||
| 35 | } | ||
| 36 | |||
| 37 | export function digestPayload(headlines: Article[]) { | ||
| 38 | return { | ||
| 39 | aps: { | ||
| 40 | alert: { title: "Space news", body: headlines.map((a) => `${a.title} (${a.news_site})`).join("\n") }, | ||
| 41 | sound: "default", | ||
| 42 | }, | ||
| 43 | tab: "news", | ||
| 44 | }; | ||
| 45 | } | ||
| 46 | |||
| 47 | export async function fetchLatest(): Promise<Article[]> { | ||
| 48 | const res = await fetch("https://api.spaceflightnewsapi.net/v4/articles/?limit=50", { | ||
| 49 | headers: { "user-agent": "moonrise-server (sh.krz.moonrise)" }, | ||
| 50 | }); | ||
| 51 | if (res.status !== 200) throw new Error(`SNAPI ${res.status}`); | ||
| 52 | return ((await res.json()) as { results: Article[] }).results; | ||
| 53 | } | ||
server/src/privacy.ts +9 −6
| @@ -20,7 +20,7 @@ h2 { font-weight: 500; font-size: 1.1em; margin-top: 1.8em; border-top: 1px soli | |||
| 20 | <p>Last updated 18 October 2026.</p> | 20 | <p>Last updated 18 October 2026.</p> |
| 21 | 21 | ||
| 22 | <h2>Summary</h2> | 22 | <h2>Summary</h2> |
| 23 | <p>Moonrise has no accounts, no advertising, no analytics and no tracking. If you turn on aurora alerts, it sends our server three things needed to deliver them, described below. Nothing else is collected.</p> | 23 | <p>Moonrise has no accounts, no advertising, no analytics and no tracking. If you turn on aurora alerts or news alerts, it sends our server four things needed to deliver them, described below. Nothing else is collected.</p> |
| 24 | 24 | ||
| 25 | <h2>Location</h2> | 25 | <h2>Location</h2> |
| 26 | <p>If you allow it, Moonrise uses your location to work out where the Sun, Moon, planets and stars are in your sky. This happens on your device. Your location is not sent to us or to anyone else. You can choose a city instead in Settings.</p> | 26 | <p>If you allow it, Moonrise uses your location to work out where the Sun, Moon, planets and stars are in your sky. This happens on your device. Your location is not sent to us or to anyone else. You can choose a city instead in Settings.</p> |
| @@ -31,18 +31,21 @@ h2 { font-weight: 500; font-size: 1.1em; margin-top: 1.8em; border-top: 1px soli | |||
| 31 | <li>space weather and launch schedules from our server, moonrise-server.audit-labs.workers.dev, which runs on Cloudflare Workers;</li> | 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> | 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> | 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> | 34 | <li>images in the Library from NASA, ESA, ESO, NOIRLab and the Library of Congress;</li> |
| 35 | <li>Deep Space Network status and Mars rover raw images from NASA/JPL;</li> | ||
| 36 | <li>space news headlines from the Spaceflight News API, run by The Space Devs.</li> | ||
| 35 | </ul> | 37 | </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> | 38 | <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 | 39 | ||
| 38 | <h2>Aurora alerts</h2> | 40 | <h2>Aurora and news alerts</h2> |
| 39 | <p>Aurora alerts are off until you turn them on in Settings. When they are on, the app sends our server:</p> | 41 | <p>Aurora alerts and news alerts are off until you turn them on in Settings. When they are on, the app sends our server:</p> |
| 40 | <ul> | 42 | <ul> |
| 41 | <li>this device's push notification token, which Apple issues so a server can send it notifications;</li> | 43 | <li>this device's push notification token, which Apple issues so a server can send it notifications;</li> |
| 42 | <li>a 5° band of geomagnetic latitude worked out from your location on the device (for example "50° to 55° north"), not your location itself;</li> | 44 | <li>a 5° band of geomagnetic latitude worked out from your location on the device (for example "50° to 55° north"), not your location itself;</li> |
| 43 | <li>your time zone offset from UTC, so alerts arrive at night.</li> | 45 | <li>your time zone offset from UTC, so aurora alerts arrive at night and the news digest at 9 a.m.;</li> |
| 46 | <li>which of the two alerts you turned on.</li> | ||
| 44 | </ul> | 47 | </ul> |
| 45 | <p>These are stored with the time the device last checked in, and used only to send aurora alerts. They are not linked to your identity or shared. Turning alerts off deletes them from the server. A token that Apple reports as no longer valid is deleted automatically.</p> | 48 | <p>These are stored with the time the device last checked in, and used only to send those alerts. They are not linked to your identity or shared. Turning both alerts off deletes them from the server. A token that Apple reports as no longer valid is deleted automatically.</p> |
| 46 | 49 | ||
| 47 | <h2>Live Activities</h2> | 50 | <h2>Live Activities</h2> |
| 48 | <p>A launch countdown you add to the Lock Screen runs on your device and sends nothing to our server.</p> | 51 | <p>A launch countdown you add to the Lock Screen runs on your device and sends nothing to our server.</p> |
server/test/news.test.ts added +33
| @@ -0,0 +1,33 @@ | |||
| 1 | import assert from "node:assert/strict"; | ||
| 2 | import { test } from "node:test"; | ||
| 3 | import { digestDue, digestPayload, localDate, pickHeadlines, type Article } from "../src/news.ts"; | ||
| 4 | |||
| 5 | test("digest is due in the first 10 minutes of 09:00 local", () => { | ||
| 6 | const at = (iso: string) => new Date(iso); | ||
| 7 | assert.equal(digestDue(at("2026-10-18T14:00:00Z"), -300), true); // 09:00 CDT | ||
| 8 | assert.equal(digestDue(at("2026-10-18T14:09:59Z"), -300), true); | ||
| 9 | assert.equal(digestDue(at("2026-10-18T14:10:00Z"), -300), false); | ||
| 10 | assert.equal(digestDue(at("2026-10-17T23:30:00Z"), 570), true); // 09:00 ACST, next day | ||
| 11 | }); | ||
| 12 | |||
| 13 | test("local date follows the offset", () => { | ||
| 14 | assert.equal(localDate(new Date("2026-10-17T23:30:00Z"), 570), "2026-10-18"); | ||
| 15 | assert.equal(localDate(new Date("2026-10-18T02:00:00Z"), -300), "2026-10-17"); | ||
| 16 | }); | ||
| 17 | |||
| 18 | test("picks three recent headlines from different sites, skipping APOD", () => { | ||
| 19 | const now = new Date("2026-10-18T16:00:00Z"); | ||
| 20 | const a = (id: number, site: string, hoursAgo: number, title = `t${id}`): Article => | ||
| 21 | ({ id, title, news_site: site, published_at: new Date(now.getTime() - hoursAgo * 3600_000).toISOString() }); | ||
| 22 | const picked = pickHeadlines([ | ||
| 23 | a(1, "NASA", 1, "APOD: 2026 October 18"), | ||
| 24 | a(2, "SpaceNews", 2), | ||
| 25 | a(3, "SpaceNews", 1.5), | ||
| 26 | a(4, "ESA", 5), | ||
| 27 | a(5, "NASA", 30), | ||
| 28 | a(6, "Spaceflight Now", 20), | ||
| 29 | a(7, "NASASpaceflight", 23), | ||
| 30 | ], now); | ||
| 31 | assert.deepEqual(picked.map((p) => p.id), [3, 4, 6]); | ||
| 32 | assert.match(digestPayload(picked).aps.alert.body, /^t3 \(SpaceNews\)\nt4 \(ESA\)\nt6 \(Spaceflight Now\)$/); | ||
| 33 | }); | ||