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 a11ba2e1ca

a11ba2e1ca5ac7d4cf85f46ce1b4a2bc7cd4adfb

parent: 8bc05d7e5c

Unsigned

cmc <hello@cleberg.net> · 2026-10-18 21:12 UTC

v2: News & launches, news alerts (!30)

Adds space news to the Launches tab, renamed News & launches, with a Launches/News switch, and an optional daily news digest push.

* App
- =NewsArticle= (Feeds) reads the Spaceflight News API (The Space Devs) directly. APOD posts are left out; http image URLs are upgraded to https.
- Tapping an article opens it in Safari. Pull to refresh reloads whichever list is shown.
- Settings: News alerts toggle next to Aurora alerts. The device registers while either is on and sends =news= with the existing fields.
- Tapping a digest opens News & launches on News (=Router=).

* Server
- Migration 0003 adds =devices.news= (default 0). Applied to production; worker deployed.
- On the 10-minute cron, devices with news on whose local time is 09:00 to 09:09 get one digest per local day: up to three headlines from the last 24 hours, one per site, no APOD. Sent-markers older than 3 days are pruned.
- Privacy page: news alerts, and DSN, Mars images and news added to the network request list.

* Checks
- Package and server tests pass.
- Production sent a digest to a simulator token at 21:10 UTC (alerts_sent row; APNs returned 200 for the test push to the same token). Simulators don't display real APNs pushes, so the banner was checked with =simctl push= using the same payload.
- Not checked: tapping the digest to open News (simulator taps on banners did not register).

Layout: unified · split

App/Sources/AuroraAlerts.swift +26 −12
@@ -4,17 +4,20 @@ import SkyEngine
4import UIKit 4import UIKit
5import UserNotifications 5import 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
11final class AuroraAlerts { 11final 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
6let apiBase: URL? = (Bundle.main.object(forInfoDictionaryKey: "MoonriseAPIURL") as? String) 6let 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
12final class Router {
13 static let shared = Router()
14 var tab = "sky"
15 var showNews = false
16}
17
9struct LaunchesScreen: View { 18struct 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 @@
1import Feeds
2import SwiftUI
3
4/// Latest space news headlines; tapping one opens the article in Safari.
5struct 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 @@
1import Foundation
2
3/// Space news from the Spaceflight News API (The Space Devs): headline, summary and a link to the article.
4public 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 @@
1import Foundation
2import 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.
2ALTER 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";
2import { actionableKp, alertBands, eventKey, isNight } from "./aurora"; 2import { actionableKp, alertBands, eventKey, isNight } from "./aurora";
3import { fetchElements } from "./celestrak"; 3import { fetchElements } from "./celestrak";
4import { fetchUpcoming, type Launch } from "./ll2"; 4import { fetchUpcoming, type Launch } from "./ll2";
5import { digestDue, digestPayload, fetchLatest, localDate, pickHeadlines } from "./news";
5import { PRIVACY_HTML } from "./privacy"; 6import { PRIVACY_HTML } from "./privacy";
6import { fetchSpaceWeather, next24h, type SpaceWeather } from "./swpc"; 7import { 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
26const LAUNCH_CRON = "*/10 * * * *"; 28const 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. */
206async 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
3export interface Article {
4 id: number;
5 title: string;
6 news_site: string;
7 published_at: string;
8}
9
10export 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). */
13export 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. */
19export 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. */
24export 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
37export 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
47export 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 @@
1import assert from "node:assert/strict";
2import { test } from "node:test";
3import { digestDue, digestPayload, localDate, pickHeadlines, type Article } from "../src/news.ts";
4
5test("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
13test("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
18test("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});