M3: launches and countdown Live Activity !10
16 files changed, +584 −5
Layout: unified · split
App/Shared/LaunchActivity.swift added +16
| @@ -0,0 +1,16 @@ | |||
| 1 | import ActivityKit | ||
| 2 | import Foundation | ||
| 3 | |||
| 4 | /// Live Activity for a followed launch. Shared by the app and the widget extension. | ||
| 5 | struct LaunchActivityAttributes: ActivityAttributes { | ||
| 6 | struct ContentState: Codable, Hashable { | ||
| 7 | var net: Date | ||
| 8 | /// Launch Library 2 status abbreviation. | ||
| 9 | var status: String | ||
| 10 | } | ||
| 11 | |||
| 12 | var launchID: String | ||
| 13 | var rocket: String | ||
| 14 | var mission: String | ||
| 15 | var location: String | ||
| 16 | } | ||
App/Sources/Info.plist added +29
| @@ -0,0 +1,29 @@ | |||
| 1 | <?xml version="1.0" encoding="UTF-8"?> | ||
| 2 | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | ||
| 3 | <plist version="1.0"> | ||
| 4 | <dict> | ||
| 5 | <key>CFBundleDevelopmentRegion</key> | ||
| 6 | <string>$(DEVELOPMENT_LANGUAGE)</string> | ||
| 7 | <key>CFBundleExecutable</key> | ||
| 8 | <string>$(EXECUTABLE_NAME)</string> | ||
| 9 | <key>CFBundleIdentifier</key> | ||
| 10 | <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> | ||
| 11 | <key>CFBundleInfoDictionaryVersion</key> | ||
| 12 | <string>6.0</string> | ||
| 13 | <key>CFBundleName</key> | ||
| 14 | <string>$(PRODUCT_NAME)</string> | ||
| 15 | <key>CFBundlePackageType</key> | ||
| 16 | <string>APPL</string> | ||
| 17 | <key>CFBundleShortVersionString</key> | ||
| 18 | <string>1.0</string> | ||
| 19 | <key>CFBundleVersion</key> | ||
| 20 | <string>1</string> | ||
| 21 | <key>MoonriseAPIURL</key> | ||
| 22 | <string>$(MOONRISE_API_URL)</string> | ||
| 23 | <key>NSAppTransportSecurity</key> | ||
| 24 | <dict> | ||
| 25 | <key>NSAllowsLocalNetworking</key> | ||
| 26 | <true/> | ||
| 27 | </dict> | ||
| 28 | </dict> | ||
| 29 | </plist> | ||
App/Sources/LaunchesScreen.swift added +207
| @@ -0,0 +1,207 @@ | |||
| 1 | import ActivityKit | ||
| 2 | import Feeds | ||
| 3 | import SwiftUI | ||
| 4 | |||
| 5 | /// Backend base URL from Info.plist (MOONRISE_API_URL build setting); nil when unset. | ||
| 6 | let apiBase: URL? = (Bundle.main.object(forInfoDictionaryKey: "MoonriseAPIURL") as? String) | ||
| 7 | .flatMap { $0.isEmpty ? nil : URL(string: $0) } | ||
| 8 | |||
| 9 | struct LaunchesScreen: View { | ||
| 10 | @State private var list: LaunchList? | ||
| 11 | @State private var error: String? | ||
| 12 | @State private var selected: Launch? | ||
| 13 | |||
| 14 | 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) | ||
| 16 | private let background = Color(red: 0.043, green: 0.078, blue: 0.141) | ||
| 17 | |||
| 18 | var body: some View { | ||
| 19 | ScrollView { | ||
| 20 | VStack(alignment: .leading, spacing: 0) { | ||
| 21 | Text("Launches").font(.system(size: 28, weight: .medium)) | ||
| 22 | Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 8).padding(.bottom, 8) | ||
| 23 | |||
| 24 | if let error { | ||
| 25 | Text(error).font(.system(size: 13)).foregroundStyle(accent).padding(.vertical, 12) | ||
| 26 | } | ||
| 27 | if let list { | ||
| 28 | ForEach(Array(list.launches.enumerated()), id: \.element.id) { i, launch in | ||
| 29 | Button { selected = launch } label: { row(launch, index: i + 1) } | ||
| 30 | .buttonStyle(.plain) | ||
| 31 | } | ||
| 32 | Text("Launch data: The Space Devs, Launch Library 2.") | ||
| 33 | .font(.system(size: 10)).opacity(0.5).padding(.vertical, 16) | ||
| 34 | } else if error == nil { | ||
| 35 | ProgressView().tint(ink).padding(.top, 40).frame(maxWidth: .infinity) | ||
| 36 | } | ||
| 37 | } | ||
| 38 | .padding(.horizontal, 20) | ||
| 39 | } | ||
| 40 | .foregroundStyle(ink) | ||
| 41 | .background(background.ignoresSafeArea()) | ||
| 42 | .refreshable { await load() } | ||
| 43 | .task { await load() } | ||
| 44 | .sheet(item: $selected) { LaunchDetail(launch: $0, ink: ink, accent: accent, background: background) } | ||
| 45 | } | ||
| 46 | |||
| 47 | private func load() async { | ||
| 48 | guard let apiBase else { error = "No backend configured for this build."; return } | ||
| 49 | do { | ||
| 50 | list = try await LaunchFeed.fetch(base: apiBase) | ||
| 51 | error = nil | ||
| 52 | } catch { | ||
| 53 | self.error = "Couldn't load launches. Pull to retry." | ||
| 54 | } | ||
| 55 | } | ||
| 56 | |||
| 57 | private func row(_ l: Launch, index: Int) -> some View { | ||
| 58 | VStack(alignment: .leading, spacing: 6) { | ||
| 59 | HStack(alignment: .firstTextBaseline) { | ||
| 60 | Text(String(format: "%02d", index)).font(.system(size: 10).monospacedDigit()).opacity(0.5) | ||
| 61 | Text(l.net.formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated).hour().minute())) | ||
| 62 | .font(.system(size: 11)).opacity(0.7) | ||
| 63 | Spacer() | ||
| 64 | StatusTag(status: l.status, ink: ink, accent: accent) | ||
| 65 | } | ||
| 66 | Text(l.rocket).font(.system(size: 22, weight: .medium)) | ||
| 67 | if let m = l.mission { Text(m).font(.system(size: 14)).lineLimit(1) } | ||
| 68 | HStack { | ||
| 69 | Text("\(l.provider) · \(l.location)").font(.system(size: 11)).opacity(0.6).lineLimit(1) | ||
| 70 | Spacer() | ||
| 71 | Countdown(to: l.net).font(.system(size: 13, weight: .medium).monospacedDigit()) | ||
| 72 | } | ||
| 73 | } | ||
| 74 | .padding(.vertical, 14) | ||
| 75 | .overlay(alignment: .top) { Rectangle().fill(ink.opacity(0.25)).frame(height: 0.5) } | ||
| 76 | .contentShape(Rectangle()) | ||
| 77 | } | ||
| 78 | } | ||
| 79 | |||
| 80 | struct StatusTag: View { | ||
| 81 | var status: String | ||
| 82 | var ink: Color | ||
| 83 | var accent: Color | ||
| 84 | |||
| 85 | var body: some View { | ||
| 86 | Text(status.uppercased()) | ||
| 87 | .font(.system(size: 10, weight: .medium)) | ||
| 88 | .tracking(0.6) | ||
| 89 | .padding(.horizontal, 6).padding(.vertical, 2) | ||
| 90 | .foregroundStyle(status == "Go" ? Color.black : ink) | ||
| 91 | .background(status == "Go" ? ink : (status == "Hold" ? accent : ink.opacity(0.15)), in: .rect(cornerRadius: 2)) | ||
| 92 | } | ||
| 93 | } | ||
| 94 | |||
| 95 | /// T− countdown, ticking each second; T+ once the time has passed. | ||
| 96 | struct Countdown: View { | ||
| 97 | var to: Date | ||
| 98 | |||
| 99 | var body: some View { | ||
| 100 | TimelineView(.periodic(from: .now, by: 1)) { ctx in | ||
| 101 | Text(Self.format(to.timeIntervalSince(ctx.date))) | ||
| 102 | } | ||
| 103 | } | ||
| 104 | |||
| 105 | static func format(_ seconds: TimeInterval) -> String { | ||
| 106 | let s = Int(abs(seconds)) | ||
| 107 | let sign = seconds >= 0 ? "T−" : "T+" | ||
| 108 | let d = s / 86_400, h = (s % 86_400) / 3600, m = (s % 3600) / 60, sec = s % 60 | ||
| 109 | return d > 0 ? String(format: "%@%dd %02d:%02d:%02d", sign, d, h, m, sec) : String(format: "%@%02d:%02d:%02d", sign, h, m, sec) | ||
| 110 | } | ||
| 111 | } | ||
| 112 | |||
| 113 | struct LaunchDetail: View { | ||
| 114 | var launch: Launch | ||
| 115 | var ink: Color | ||
| 116 | var accent: Color | ||
| 117 | var background: Color | ||
| 118 | |||
| 119 | var body: some View { | ||
| 120 | ScrollView { | ||
| 121 | VStack(alignment: .leading, spacing: 10) { | ||
| 122 | HStack { | ||
| 123 | StatusTag(status: launch.status, ink: ink, accent: accent) | ||
| 124 | Text(launch.statusName).font(.system(size: 12)).opacity(0.7) | ||
| 125 | } | ||
| 126 | Text(launch.rocket).font(.system(size: 28, weight: .medium)) | ||
| 127 | if let m = launch.mission { Text(m).font(.system(size: 17)) } | ||
| 128 | Countdown(to: launch.net).font(.system(size: 34, weight: .medium).monospacedDigit()).padding(.vertical, 6) | ||
| 129 | Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) { | ||
| 130 | fact("Provider", launch.provider) | ||
| 131 | fact("Launch time", launch.net.formatted(date: .abbreviated, time: .shortened)) | ||
| 132 | if let a = launch.windowStart, let b = launch.windowEnd, a != b { | ||
| 133 | fact("Window", "\(a.formatted(date: .omitted, time: .shortened))–\(b.formatted(date: .omitted, time: .shortened))") | ||
| 134 | } | ||
| 135 | if let t = launch.missionType { fact("Mission", t) } | ||
| 136 | if let o = launch.orbit { fact("Orbit", o) } | ||
| 137 | fact("Pad", launch.pad) | ||
| 138 | fact("Location", launch.location) | ||
| 139 | } | ||
| 140 | .font(.system(size: 14)) | ||
| 141 | followButton.padding(.top, 8) | ||
| 142 | if let message { Text(message).font(.system(size: 12)).opacity(0.7) } | ||
| 143 | if let d = launch.missionDescription { | ||
| 144 | Text(d).font(.system(size: 14)).opacity(0.85).padding(.top, 8) | ||
| 145 | } | ||
| 146 | } | ||
| 147 | .padding(20) | ||
| 148 | .frame(maxWidth: .infinity, alignment: .leading) | ||
| 149 | } | ||
| 150 | .foregroundStyle(ink) | ||
| 151 | .presentationBackground(background) | ||
| 152 | .presentationDetents([.medium, .large]) | ||
| 153 | } | ||
| 154 | |||
| 155 | @State private var message: String? | ||
| 156 | @State private var following = false | ||
| 157 | |||
| 158 | private var followButton: some View { | ||
| 159 | Button { | ||
| 160 | following ? unfollow() : follow() | ||
| 161 | } label: { | ||
| 162 | Label(following ? "Stop following" : "Follow on Lock Screen", systemImage: following ? "bell.slash" : "bell") | ||
| 163 | .font(.system(size: 15, weight: .medium)) | ||
| 164 | .frame(maxWidth: .infinity) | ||
| 165 | .padding(.vertical, 10) | ||
| 166 | .overlay(RoundedRectangle(cornerRadius: 6).stroke(ink.opacity(0.5), lineWidth: 0.5)) | ||
| 167 | } | ||
| 168 | .buttonStyle(.plain) | ||
| 169 | .onAppear { | ||
| 170 | following = Activity<LaunchActivityAttributes>.activities.contains { $0.attributes.launchID == launch.id } | ||
| 171 | } | ||
| 172 | } | ||
| 173 | |||
| 174 | private func follow() { | ||
| 175 | guard ActivityAuthorizationInfo().areActivitiesEnabled else { | ||
| 176 | message = "Live Activities are turned off for Moonrise in Settings." | ||
| 177 | return | ||
| 178 | } | ||
| 179 | let attributes = LaunchActivityAttributes(launchID: launch.id, rocket: launch.rocket, mission: launch.mission ?? launch.name, location: launch.location) | ||
| 180 | let state = LaunchActivityAttributes.ContentState(net: launch.net, status: launch.status) | ||
| 181 | do { | ||
| 182 | _ = try Activity.request(attributes: attributes, content: .init(state: state, staleDate: launch.net.addingTimeInterval(3600))) | ||
| 183 | following = true | ||
| 184 | message = "Countdown added to the Lock Screen." | ||
| 185 | } catch { | ||
| 186 | message = "Couldn't start the countdown: \(error.localizedDescription)" | ||
| 187 | } | ||
| 188 | } | ||
| 189 | |||
| 190 | private func unfollow() { | ||
| 191 | let id = launch.id | ||
| 192 | Task { | ||
| 193 | for a in Activity<LaunchActivityAttributes>.activities where a.attributes.launchID == id { | ||
| 194 | await a.end(nil, dismissalPolicy: .immediate) | ||
| 195 | } | ||
| 196 | following = false | ||
| 197 | message = nil | ||
| 198 | } | ||
| 199 | } | ||
| 200 | |||
| 201 | private func fact(_ label: String, _ value: String) -> some View { | ||
| 202 | GridRow { | ||
| 203 | Text(label).opacity(0.6) | ||
| 204 | Text(value) | ||
| 205 | } | ||
| 206 | } | ||
| 207 | } | ||
App/Sources/MoonriseApp.swift +1
| @@ -13,6 +13,7 @@ struct MoonriseApp: App { | |||
| 13 | TabView { | 13 | TabView { |
| 14 | Tab("Sky", systemImage: "sparkles") { SkyScreen() } | 14 | Tab("Sky", systemImage: "sparkles") { SkyScreen() } |
| 15 | Tab("Sun", systemImage: "sun.max") { SunScreen() } | 15 | Tab("Sun", systemImage: "sun.max") { SunScreen() } |
| 16 | Tab("Launches", systemImage: "airplane.departure") { LaunchesScreen() } | ||
| 16 | } | 17 | } |
| 17 | .preferredColorScheme(.dark) | 18 | .preferredColorScheme(.dark) |
| 18 | } | 19 | } |
App/Widgets/LaunchLiveActivity.swift added +56
| @@ -0,0 +1,56 @@ | |||
| 1 | import ActivityKit | ||
| 2 | import SwiftUI | ||
| 3 | import WidgetKit | ||
| 4 | |||
| 5 | struct LaunchLiveActivity: Widget { | ||
| 6 | private let ink = Color(red: 0.95, green: 0.95, blue: 0.93) | ||
| 7 | private let accent = Color(red: 0.88, green: 0.24, blue: 0.19) | ||
| 8 | |||
| 9 | var body: some WidgetConfiguration { | ||
| 10 | ActivityConfiguration(for: LaunchActivityAttributes.self) { context in | ||
| 11 | HStack(alignment: .center, spacing: 12) { | ||
| 12 | VStack(alignment: .leading, spacing: 2) { | ||
| 13 | Text(context.attributes.rocket).font(.system(size: 17, weight: .medium)) | ||
| 14 | Text(context.attributes.mission).font(.system(size: 13)).lineLimit(1).opacity(0.8) | ||
| 15 | Text(context.attributes.location).font(.system(size: 11)).lineLimit(1).opacity(0.6) | ||
| 16 | } | ||
| 17 | Spacer() | ||
| 18 | VStack(alignment: .trailing, spacing: 2) { | ||
| 19 | Text(context.state.status.uppercased()).font(.system(size: 10, weight: .medium)).tracking(0.6) | ||
| 20 | .foregroundStyle(context.state.status == "Hold" ? accent : ink) | ||
| 21 | HStack(spacing: 2) { | ||
| 22 | Text(context.state.net > .now ? "T−" : "T+") | ||
| 23 | countdown(context.state.net) | ||
| 24 | } | ||
| 25 | .font(.system(size: 22, weight: .medium).monospacedDigit()) | ||
| 26 | } | ||
| 27 | } | ||
| 28 | .padding(16) | ||
| 29 | .foregroundStyle(ink) | ||
| 30 | .activityBackgroundTint(Color(red: 0.043, green: 0.078, blue: 0.141)) | ||
| 31 | } dynamicIsland: { context in | ||
| 32 | DynamicIsland { | ||
| 33 | DynamicIslandExpandedRegion(.leading) { | ||
| 34 | Text(context.attributes.rocket).font(.system(size: 14, weight: .medium)) | ||
| 35 | } | ||
| 36 | DynamicIslandExpandedRegion(.trailing) { | ||
| 37 | countdown(context.state.net).font(.system(size: 14, weight: .medium).monospacedDigit()) | ||
| 38 | } | ||
| 39 | DynamicIslandExpandedRegion(.bottom) { | ||
| 40 | Text(context.attributes.mission).font(.system(size: 12)).lineLimit(1).opacity(0.8) | ||
| 41 | } | ||
| 42 | } compactLeading: { | ||
| 43 | Text("T−").font(.system(size: 12, weight: .medium)) | ||
| 44 | } compactTrailing: { | ||
| 45 | countdown(context.state.net).font(.system(size: 12).monospacedDigit()).frame(maxWidth: 64) | ||
| 46 | } minimal: { | ||
| 47 | Image(systemName: "arrow.up") | ||
| 48 | } | ||
| 49 | } | ||
| 50 | } | ||
| 51 | |||
| 52 | /// Counts down to T-0, then shows elapsed time. | ||
| 53 | private func countdown(_ net: Date) -> Text { | ||
| 54 | net > .now ? Text(timerInterval: Date.now...net, countsDown: true) : Text(net, style: .timer) | ||
| 55 | } | ||
| 56 | } | ||
App/Widgets/MoonriseWidgets.swift +1
| @@ -9,6 +9,7 @@ struct MoonriseWidgets: WidgetBundle { | |||
| 9 | var body: some Widget { | 9 | var body: some Widget { |
| 10 | MoonWidget() | 10 | MoonWidget() |
| 11 | PlanetsWidget() | 11 | PlanetsWidget() |
| 12 | LaunchLiveActivity() | ||
| 12 | } | 13 | } |
| 13 | } | 14 | } |
| 14 | 15 | ||
App/project.yml +14 −2
| @@ -10,7 +10,7 @@ targets: | |||
| 10 | Moonrise: | 10 | Moonrise: |
| 11 | type: application | 11 | type: application |
| 12 | platform: iOS | 12 | platform: iOS |
| 13 | sources: [Sources] | 13 | sources: [Sources, Shared] |
| 14 | dependencies: | 14 | dependencies: |
| 15 | - package: MoonriseKit | 15 | - package: MoonriseKit |
| 16 | product: SkyEngine | 16 | product: SkyEngine |
| @@ -23,7 +23,18 @@ targets: | |||
| 23 | path: Sources/Moonrise.entitlements | 23 | path: Sources/Moonrise.entitlements |
| 24 | properties: | 24 | properties: |
| 25 | aps-environment: development | 25 | aps-environment: development |
| 26 | info: | ||
| 27 | path: Sources/Info.plist | ||
| 28 | properties: | ||
| 29 | MoonriseAPIURL: $(MOONRISE_API_URL) | ||
| 30 | NSAppTransportSecurity: | ||
| 31 | NSAllowsLocalNetworking: true | ||
| 26 | settings: | 32 | settings: |
| 33 | configs: | ||
| 34 | Debug: | ||
| 35 | MOONRISE_API_URL: http://localhost:8787 | ||
| 36 | Release: | ||
| 37 | MOONRISE_API_URL: "" | ||
| 27 | base: | 38 | base: |
| 28 | PRODUCT_BUNDLE_IDENTIFIER: sh.krz.moonrise | 39 | PRODUCT_BUNDLE_IDENTIFIER: sh.krz.moonrise |
| 29 | DEVELOPMENT_TEAM: ZCNAX3VL9D | 40 | DEVELOPMENT_TEAM: ZCNAX3VL9D |
| @@ -34,6 +45,7 @@ targets: | |||
| 34 | CURRENT_PROJECT_VERSION: "1" | 45 | CURRENT_PROJECT_VERSION: "1" |
| 35 | GENERATE_INFOPLIST_FILE: YES | 46 | GENERATE_INFOPLIST_FILE: YES |
| 36 | INFOPLIST_KEY_CFBundleDisplayName: Moonrise | 47 | INFOPLIST_KEY_CFBundleDisplayName: Moonrise |
| 48 | INFOPLIST_KEY_NSSupportsLiveActivities: YES | ||
| 37 | INFOPLIST_KEY_UILaunchScreen_Generation: YES | 49 | INFOPLIST_KEY_UILaunchScreen_Generation: YES |
| 38 | INFOPLIST_KEY_UIStatusBarStyle: UIStatusBarStyleLightContent | 50 | INFOPLIST_KEY_UIStatusBarStyle: UIStatusBarStyleLightContent |
| 39 | INFOPLIST_KEY_UISupportedInterfaceOrientations: UIInterfaceOrientationPortrait | 51 | INFOPLIST_KEY_UISupportedInterfaceOrientations: UIInterfaceOrientationPortrait |
| @@ -41,7 +53,7 @@ targets: | |||
| 41 | MoonriseWidgets: | 53 | MoonriseWidgets: |
| 42 | type: app-extension | 54 | type: app-extension |
| 43 | platform: iOS | 55 | platform: iOS |
| 44 | sources: [Widgets] | 56 | sources: [Widgets, Shared] |
| 45 | dependencies: | 57 | dependencies: |
| 46 | - package: MoonriseKit | 58 | - package: MoonriseKit |
| 47 | product: SkyEngine | 59 | product: SkyEngine |
PLAN.org +15
| @@ -346,6 +346,21 @@ Stacked MRs: !7 Sun tab, !8 backend, !9 aurora map. | |||
| 346 | - Not done: app registration with the backend (needs a deployed URL), | 346 | - Not done: app registration with the backend (needs a deployed URL), |
| 347 | launch caching, Live Activities. | 347 | launch caching, Live Activities. |
| 348 | 348 | ||
| 349 | ** M3 status (2026-10-17) | ||
| 350 | MR !10. | ||
| 351 | |||
| 352 | - Backend: Launch Library 2 upcoming list every 10 minutes (separate cron, | ||
| 353 | 6 of 15 anonymous requests per hour) into D1; =GET /v1/launches=. | ||
| 354 | Smoke-tested locally with live LL2 data. LL2 images are not used | ||
| 355 | (licence often "Unknown"). | ||
| 356 | - App: Launches tab (list with T− countdowns, detail sheet); Debug builds | ||
| 357 | read the API from =http://localhost:8787= (=MOONRISE_API_URL=). | ||
| 358 | - Live Activity: "Follow on Lock Screen" starts a local countdown | ||
| 359 | activity; verified on the simulator Lock Screen. Updates by push | ||
| 360 | (holds, scrubs, liftoff) not done. | ||
| 361 | - Not done: rocket silhouettes to scale, push-to-start and push updates, | ||
| 362 | "launch in one hour" notification. | ||
| 363 | |||
| 349 | * roadmap | 364 | * roadmap |
| 350 | ** v1: now | 365 | ** v1: now |
| 351 | Sky, System, Sun, Launches, Library core (above). | 366 | Sky, System, Sun, Launches, Library core (above). |
Packages/MoonriseKit/Sources/Feeds/Launches.swift added +49
| @@ -0,0 +1,49 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | /// An upcoming launch as served by the Moonrise backend (from Launch Library 2). | ||
| 4 | public struct Launch: Codable, Sendable, Identifiable, Hashable { | ||
| 5 | public var id: String | ||
| 6 | public var name: String | ||
| 7 | public var net: Date | ||
| 8 | public var windowStart: Date? | ||
| 9 | public var windowEnd: Date? | ||
| 10 | /// Launch Library 2 status abbreviation: Go, TBC, TBD, Hold, Success, Failure, … | ||
| 11 | public var status: String | ||
| 12 | public var statusName: String | ||
| 13 | public var provider: String | ||
| 14 | public var rocket: String | ||
| 15 | public var mission: String? | ||
| 16 | public var missionType: String? | ||
| 17 | public var missionDescription: String? | ||
| 18 | public var orbit: String? | ||
| 19 | public var pad: String | ||
| 20 | public var location: String | ||
| 21 | public var padLatitude: Double? | ||
| 22 | public var padLongitude: Double? | ||
| 23 | } | ||
| 24 | |||
| 25 | public struct LaunchList: Codable, Sendable { | ||
| 26 | public var fetchedAt: Date? | ||
| 27 | public var launches: [Launch] | ||
| 28 | } | ||
| 29 | |||
| 30 | public enum LaunchFeed { | ||
| 31 | static let decoder: JSONDecoder = { | ||
| 32 | let d = JSONDecoder() | ||
| 33 | d.dateDecodingStrategy = .custom { decoder in | ||
| 34 | let s = try decoder.singleValueContainer().decode(String.self) | ||
| 35 | if let date = try? Date(s, strategy: .iso8601) { return date } | ||
| 36 | if let date = try? Date(s, strategy: Date.ISO8601FormatStyle(includingFractionalSeconds: true)) { return date } | ||
| 37 | throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: "bad date \(s)")) | ||
| 38 | } | ||
| 39 | return d | ||
| 40 | }() | ||
| 41 | |||
| 42 | public static func decode(_ data: Data) throws -> LaunchList { | ||
| 43 | try decoder.decode(LaunchList.self, from: data) | ||
| 44 | } | ||
| 45 | |||
| 46 | public static func fetch(base: URL, session: URLSession = .shared) async throws -> LaunchList { | ||
| 47 | try decode(await session.data(from: base.appending(path: "v1/launches")).0) | ||
| 48 | } | ||
| 49 | } | ||
Packages/MoonriseKit/Tests/FeedsTests/LaunchTests.swift added +21
| @@ -0,0 +1,21 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import Feeds | ||
| 4 | |||
| 5 | @Test func decodesBackendLaunches() throws { | ||
| 6 | // Shape of GET /v1/launches from the local Worker, 2026-10-17. | ||
| 7 | let data = Data(""" | ||
| 8 | {"fetchedAt":"2026-10-17T23:54:22.559Z","launches":[{"id":"3b430f81","name":"Falcon 9 Block 5 | Bandwagon 5","net":"2026-10-19T10:30:00Z", | ||
| 9 | "windowStart":"2026-10-19T10:30:00Z","windowEnd":"2026-10-19T11:30:00Z","status":"Go","statusName":"Go for Launch","provider":"SpaceX", | ||
| 10 | "rocket":"Falcon 9 Block 5","mission":"Bandwagon 5","missionType":"Dedicated Rideshare","missionDescription":"Rideshare.","orbit":"LEO", | ||
| 11 | "pad":"Space Launch Complex 40","location":"Cape Canaveral SFS, FL, USA","padLatitude":28.56194122,"padLongitude":-80.57735736}, | ||
| 12 | {"id":"x","name":"Electron | HawkEye","net":"2026-10-21T05:00:00Z","windowStart":null,"windowEnd":null,"status":"TBC","statusName":"To Be Confirmed", | ||
| 13 | "provider":"Rocket Lab","rocket":"Electron","mission":null,"missionType":null,"missionDescription":null,"orbit":null,"pad":"LC-1B","location":"Mahia","padLatitude":null,"padLongitude":null}]} | ||
| 14 | """.utf8) | ||
| 15 | let list = try LaunchFeed.decode(data) | ||
| 16 | #expect(list.launches.count == 2) | ||
| 17 | #expect(list.launches[0].rocket == "Falcon 9 Block 5") | ||
| 18 | #expect(list.launches[0].net == (try Date("2026-10-19T10:30:00Z", strategy: .iso8601))) | ||
| 19 | #expect(list.launches[1].mission == nil) | ||
| 20 | #expect(list.fetchedAt != nil) | ||
| 21 | } | ||
THIRD_PARTY.org +1
| @@ -6,3 +6,4 @@ | |||
| 6 | | Yale Bright Star Catalogue, 5th rev. ed. (Hoffleit & Warren 1991) | =Packages/MoonriseKit/Sources/SkyRenderer/Resources/bsc5.json= | See VizieR policy; licence statement pending | https://cdsarc.cds.unistra.fr/viz-bin/cat/V/50 | | 6 | | Yale Bright Star Catalogue, 5th rev. ed. (Hoffleit & Warren 1991) | =Packages/MoonriseKit/Sources/SkyRenderer/Resources/bsc5.json= | See VizieR policy; licence statement pending | https://cdsarc.cds.unistra.fr/viz-bin/cat/V/50 | |
| 7 | | ConstellationLines v1.4, Copyright (c) 2005-2026 Marc van der Sluys | =Packages/MoonriseKit/Sources/SkyRenderer/Resources/constellations.json= | CC BY 4.0 | https://github.com/MarcvdSluys/ConstellationLines, doi:10.5281/zenodo.10397192 | | 7 | | ConstellationLines v1.4, Copyright (c) 2005-2026 Marc van der Sluys | =Packages/MoonriseKit/Sources/SkyRenderer/Resources/constellations.json= | CC BY 4.0 | https://github.com/MarcvdSluys/ConstellationLines, doi:10.5281/zenodo.10397192 | |
| 8 | | Natural Earth 1:110m coastline v5.1.2 | =Packages/MoonriseKit/Sources/SkyRenderer/Resources/coastline.json= | Public domain | https://www.naturalearthdata.com | | 8 | | Natural Earth 1:110m coastline v5.1.2 | =Packages/MoonriseKit/Sources/SkyRenderer/Resources/coastline.json= | Public domain | https://www.naturalearthdata.com | |
| 9 | | Launch Library 2 (The Space Devs) | fetched at runtime by =server/= | Free API; credit shown in the app | https://thespacedevs.com/llapi | | ||
server/migrations/0002_launches.sql added +23
| @@ -0,0 +1,23 @@ | |||
| 1 | -- Upcoming launches, normalized from Launch Library 2. | ||
| 2 | CREATE TABLE launches ( | ||
| 3 | id TEXT PRIMARY KEY, | ||
| 4 | name TEXT NOT NULL, | ||
| 5 | net TEXT NOT NULL, | ||
| 6 | window_start TEXT, | ||
| 7 | window_end TEXT, | ||
| 8 | status TEXT NOT NULL, | ||
| 9 | status_name TEXT NOT NULL, | ||
| 10 | provider TEXT NOT NULL, | ||
| 11 | rocket TEXT NOT NULL, | ||
| 12 | mission TEXT, | ||
| 13 | mission_type TEXT, | ||
| 14 | mission_description TEXT, | ||
| 15 | orbit TEXT, | ||
| 16 | pad TEXT NOT NULL, | ||
| 17 | location TEXT NOT NULL, | ||
| 18 | pad_latitude REAL, | ||
| 19 | pad_longitude REAL, | ||
| 20 | last_updated TEXT NOT NULL | ||
| 21 | ); | ||
| 22 | |||
| 23 | CREATE INDEX launches_net ON launches (net); | ||
server/src/index.ts +46 −2
| @@ -1,5 +1,6 @@ | |||
| 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 { fetchSpaceWeather, next24h, type SpaceWeather } from "./swpc"; | 4 | import { fetchSpaceWeather, next24h, type SpaceWeather } from "./swpc"; |
| 4 | 5 | ||
| 5 | interface Env { | 6 | interface Env { |
| @@ -18,6 +19,8 @@ interface DeviceRequest { | |||
| 18 | aurora?: boolean; | 19 | aurora?: boolean; |
| 19 | } | 20 | } |
| 20 | 21 | ||
| 22 | const LAUNCH_CRON = "*/10 * * * *"; | ||
| 23 | |||
| 21 | const json = (body: unknown, status = 200) => Response.json(body, { status }); | 24 | const json = (body: unknown, status = 200) => Response.json(body, { status }); |
| 22 | 25 | ||
| 23 | function validDevice(d: Partial<DeviceRequest>): d is DeviceRequest { | 26 | function validDevice(d: Partial<DeviceRequest>): d is DeviceRequest { |
| @@ -49,6 +52,7 @@ export default { | |||
| 49 | return json({ ok: true, feeds: rows.results }); | 52 | return json({ ok: true, feeds: rows.results }); |
| 50 | } | 53 | } |
| 51 | if (request.method === "GET" && url.pathname === "/v1/today") return today(env, now); | 54 | if (request.method === "GET" && url.pathname === "/v1/today") return today(env, now); |
| 55 | if (request.method === "GET" && url.pathname === "/v1/launches") return launches(env, now); | ||
| 52 | 56 | ||
| 53 | if (request.method === "POST" && url.pathname === "/v1/devices") { | 57 | if (request.method === "POST" && url.pathname === "/v1/devices") { |
| 54 | const d = (await request.json().catch(() => ({}))) as Partial<DeviceRequest>; | 58 | const d = (await request.json().catch(() => ({}))) as Partial<DeviceRequest>; |
| @@ -69,11 +73,51 @@ export default { | |||
| 69 | return json({ error: "not found" }, 404); | 73 | return json({ error: "not found" }, 404); |
| 70 | }, | 74 | }, |
| 71 | 75 | ||
| 72 | async scheduled(_controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> { | 76 | async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> { |
| 73 | ctx.waitUntil(pollSpaceWeather(env, new Date())); | 77 | const now = new Date(controller.scheduledTime); |
| 78 | // Launches every 10 minutes: 6 of the 15 anonymous Launch Library 2 requests per hour. | ||
| 79 | if (controller.cron === LAUNCH_CRON) ctx.waitUntil(pollLaunches(env, now)); | ||
| 80 | else ctx.waitUntil(pollSpaceWeather(env, now)); | ||
| 74 | }, | 81 | }, |
| 75 | } satisfies ExportedHandler<Env>; | 82 | } satisfies ExportedHandler<Env>; |
| 76 | 83 | ||
| 84 | async function launches(env: Env, now: Date): Promise<Response> { | ||
| 85 | const since = new Date(now.getTime() - 6 * 3600_000).toISOString(); | ||
| 86 | const rows = await env.DB.prepare("SELECT * FROM launches WHERE net >= ?1 ORDER BY net LIMIT 20").bind(since).all<Record<string, unknown>>(); | ||
| 87 | const fetched = await env.DB.prepare("SELECT fetched_at FROM feed_snapshots WHERE feed = 'll2' AND ok = 1").first<{ fetched_at: string }>(); | ||
| 88 | return json({ | ||
| 89 | fetchedAt: fetched?.fetched_at ?? null, | ||
| 90 | launches: rows.results.map((r) => ({ | ||
| 91 | id: r.id, name: r.name, net: r.net, windowStart: r.window_start, windowEnd: r.window_end, | ||
| 92 | status: r.status, statusName: r.status_name, provider: r.provider, rocket: r.rocket, | ||
| 93 | mission: r.mission, missionType: r.mission_type, missionDescription: r.mission_description, orbit: r.orbit, | ||
| 94 | pad: r.pad, location: r.location, padLatitude: r.pad_latitude, padLongitude: r.pad_longitude, | ||
| 95 | })), | ||
| 96 | }); | ||
| 97 | } | ||
| 98 | |||
| 99 | async function pollLaunches(env: Env, now: Date): Promise<void> { | ||
| 100 | let list: Launch[]; | ||
| 101 | try { | ||
| 102 | list = await fetchUpcoming(); | ||
| 103 | } catch (e) { | ||
| 104 | console.error("ll2", String(e)); | ||
| 105 | await env.DB.prepare("INSERT INTO feed_snapshots (feed, fetched_at, ok) VALUES ('ll2', ?1, 0) ON CONFLICT (feed) DO UPDATE SET fetched_at = ?1, ok = 0").bind(now.toISOString()).run(); | ||
| 106 | return; | ||
| 107 | } | ||
| 108 | const upsert = env.DB.prepare( | ||
| 109 | `INSERT INTO launches (id, name, net, window_start, window_end, status, status_name, provider, rocket, mission, mission_type, mission_description, orbit, pad, location, pad_latitude, pad_longitude, last_updated) | ||
| 110 | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18) | ||
| 111 | ON CONFLICT (id) DO UPDATE SET name = ?2, net = ?3, window_start = ?4, window_end = ?5, status = ?6, status_name = ?7, provider = ?8, rocket = ?9, | ||
| 112 | mission = ?10, mission_type = ?11, mission_description = ?12, orbit = ?13, pad = ?14, location = ?15, pad_latitude = ?16, pad_longitude = ?17, last_updated = ?18`, | ||
| 113 | ); | ||
| 114 | await env.DB.batch([ | ||
| 115 | ...list.map((l) => upsert.bind(l.id, l.name, l.net, l.windowStart, l.windowEnd, l.status, l.statusName, l.provider, l.rocket, l.mission, l.missionType, l.missionDescription, l.orbit, l.pad, l.location, l.padLatitude, l.padLongitude, l.lastUpdated)), | ||
| 116 | env.DB.prepare("DELETE FROM launches WHERE net < ?1").bind(new Date(now.getTime() - 7 * 86400_000).toISOString()), | ||
| 117 | env.DB.prepare("INSERT INTO feed_snapshots (feed, fetched_at, ok) VALUES ('ll2', ?1, 1) ON CONFLICT (feed) DO UPDATE SET fetched_at = ?1, ok = 1").bind(now.toISOString()), | ||
| 118 | ]); | ||
| 119 | } | ||
| 120 | |||
| 77 | async function pollSpaceWeather(env: Env, now: Date): Promise<void> { | 121 | async function pollSpaceWeather(env: Env, now: Date): Promise<void> { |
| 78 | let w: SpaceWeather; | 122 | let w: SpaceWeather; |
| 79 | try { | 123 | try { |
server/src/ll2.ts added +71
| @@ -0,0 +1,71 @@ | |||
| 1 | // Launch Library 2 (The Space Devs). Anonymous limit: 15 requests per hour. | ||
| 2 | // Images are not used: their licences are often "Unknown". | ||
| 3 | |||
| 4 | export const UPCOMING_URL = "https://ll.thespacedevs.com/2.3.0/launches/upcoming/?limit=20&mode=normal"; | ||
| 5 | |||
| 6 | export interface Launch { | ||
| 7 | id: string; | ||
| 8 | name: string; | ||
| 9 | net: string; | ||
| 10 | windowStart: string | null; | ||
| 11 | windowEnd: string | null; | ||
| 12 | status: string; | ||
| 13 | statusName: string; | ||
| 14 | provider: string; | ||
| 15 | rocket: string; | ||
| 16 | mission: string | null; | ||
| 17 | missionType: string | null; | ||
| 18 | missionDescription: string | null; | ||
| 19 | orbit: string | null; | ||
| 20 | pad: string; | ||
| 21 | location: string; | ||
| 22 | padLatitude: number | null; | ||
| 23 | padLongitude: number | null; | ||
| 24 | lastUpdated: string; | ||
| 25 | } | ||
| 26 | |||
| 27 | interface Raw { | ||
| 28 | id: string; | ||
| 29 | name: string; | ||
| 30 | net: string; | ||
| 31 | window_start?: string | null; | ||
| 32 | window_end?: string | null; | ||
| 33 | last_updated: string; | ||
| 34 | status: { abbrev: string; name: string }; | ||
| 35 | launch_service_provider: { name: string }; | ||
| 36 | rocket: { configuration: { full_name?: string; name: string } }; | ||
| 37 | mission?: { name?: string; type?: string; description?: string; orbit?: { abbrev?: string } | null } | null; | ||
| 38 | pad: { name: string; latitude?: string | number | null; longitude?: string | number | null; location: { name: string } }; | ||
| 39 | } | ||
| 40 | |||
| 41 | const num = (v: string | number | null | undefined) => (v == null || v === "" ? null : Number(v)); | ||
| 42 | |||
| 43 | export function normalize(r: Raw): Launch { | ||
| 44 | return { | ||
| 45 | id: r.id, | ||
| 46 | name: r.name, | ||
| 47 | net: r.net, | ||
| 48 | windowStart: r.window_start ?? null, | ||
| 49 | windowEnd: r.window_end ?? null, | ||
| 50 | status: r.status.abbrev, | ||
| 51 | statusName: r.status.name, | ||
| 52 | provider: r.launch_service_provider.name, | ||
| 53 | rocket: r.rocket.configuration.full_name ?? r.rocket.configuration.name, | ||
| 54 | mission: r.mission?.name ?? null, | ||
| 55 | missionType: r.mission?.type ?? null, | ||
| 56 | missionDescription: r.mission?.description?.trim() ?? null, | ||
| 57 | orbit: r.mission?.orbit?.abbrev ?? null, | ||
| 58 | pad: r.pad.name, | ||
| 59 | location: r.pad.location.name, | ||
| 60 | padLatitude: num(r.pad.latitude), | ||
| 61 | padLongitude: num(r.pad.longitude), | ||
| 62 | lastUpdated: r.last_updated, | ||
| 63 | }; | ||
| 64 | } | ||
| 65 | |||
| 66 | export async function fetchUpcoming(): Promise<Launch[]> { | ||
| 67 | const res = await fetch(UPCOMING_URL, { headers: { "user-agent": "moonrise-server (sh.krz.moonrise)" } }); | ||
| 68 | if (!res.ok) throw new Error(`LL2 ${res.status}`); | ||
| 69 | const body = (await res.json()) as { results: Raw[] }; | ||
| 70 | return body.results.map(normalize); | ||
| 71 | } | ||
server/test/ll2.test.ts added +33
| @@ -0,0 +1,33 @@ | |||
| 1 | import assert from "node:assert/strict"; | ||
| 2 | import { test } from "node:test"; | ||
| 3 | import { normalize } from "../src/ll2.ts"; | ||
| 4 | |||
| 5 | // Trimmed from /2.3.0/launches/upcoming/?mode=normal on 2026-10-17. | ||
| 6 | const raw = { | ||
| 7 | id: "3b430f81-0ff1-4d85-8a1a-cef0de326198", | ||
| 8 | name: "Falcon 9 Block 5 | Bandwagon 5 (Dedicated Mid-Inclination Rideshare)", | ||
| 9 | net: "2026-10-19T10:30:00Z", | ||
| 10 | window_start: "2026-10-19T10:30:00Z", | ||
| 11 | window_end: "2026-10-19T11:30:00Z", | ||
| 12 | last_updated: "2026-10-17T20:00:00Z", | ||
| 13 | status: { id: 1, name: "Go for Launch", abbrev: "Go" }, | ||
| 14 | launch_service_provider: { name: "SpaceX", abbrev: "SpX" }, | ||
| 15 | rocket: { configuration: { name: "Falcon 9", full_name: "Falcon 9 Block 5" } }, | ||
| 16 | mission: { name: "Bandwagon 5", type: "Dedicated Rideshare", description: " Rideshare. ", orbit: { abbrev: "LEO" } }, | ||
| 17 | pad: { name: "Space Launch Complex 40", latitude: "28.56194122", longitude: "-80.57735736", location: { name: "Cape Canaveral SFS, FL, USA" } }, | ||
| 18 | }; | ||
| 19 | |||
| 20 | test("normalizes a launch", () => { | ||
| 21 | const l = normalize(raw); | ||
| 22 | assert.equal(l.rocket, "Falcon 9 Block 5"); | ||
| 23 | assert.equal(l.status, "Go"); | ||
| 24 | assert.equal(l.orbit, "LEO"); | ||
| 25 | assert.equal(l.missionDescription, "Rideshare."); | ||
| 26 | assert.equal(l.padLatitude, 28.56194122); | ||
| 27 | }); | ||
| 28 | |||
| 29 | test("tolerates a missing mission", () => { | ||
| 30 | const l = normalize({ ...raw, mission: null }); | ||
| 31 | assert.equal(l.mission, null); | ||
| 32 | assert.equal(l.orbit, null); | ||
| 33 | }); | ||
server/wrangler.jsonc +1 −1
| @@ -2,7 +2,7 @@ | |||
| 2 | "name": "moonrise-server", | 2 | "name": "moonrise-server", |
| 3 | "main": "src/index.ts", | 3 | "main": "src/index.ts", |
| 4 | "compatibility_date": "2026-10-01", | 4 | "compatibility_date": "2026-10-01", |
| 5 | "triggers": { "crons": ["*/5 * * * *"] }, | 5 | "triggers": { "crons": ["*/5 * * * *", "*/10 * * * *"] }, |
| 6 | "d1_databases": [ | 6 | "d1_databases": [ |
| 7 | { | 7 | { |
| 8 | "binding": "DB", | 8 | "binding": "DB", |