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 ece69b49e1

ece69b49e1ebc033066180f687e595cd81aee592

parent: 23365270e4

Unsigned

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

v2: Apple Watch and Mac (!34)

- Apple Watch app (=MoonriseWatch=, sh.krz.moonrise.watchkitapp): Moon phase, sunset and sunrise, planets well placed tonight, from SkyEngine on the watch with its own location. Embedded in the iPhone app; runs independently.
- Complications (=MoonriseWatchWidgets=): the existing Moon widget (circular, inline, corner) and Planets widget (rectangular), compiled from Widgets/MoonriseWidgets.swift; the Live Activity is iOS only.
- MoonriseKit declares watchOS 26; SkyEngine and SkyRenderer build for it unchanged.
- Mac: the iPhone/iPad app compiles for "Designed for iPad" on Apple silicon (checked unsigned). Running it here needs this Mac registered as a device; offering it on Macs is an App Store Connect setting.
- Checked: watch app on the watchOS 27 simulator. Not checked: complications on a watch face.

Layout: unified · split

App/Watch/Assets.xcassets/AppIcon.appiconset/Contents.json added +6
@@ -0,0 +1,6 @@
1{
2 "images" : [
3 { "filename" : "icon-1024.png", "idiom" : "universal", "platform" : "watchos", "size" : "1024x1024" }
4 ],
5 "info" : { "author" : "xcode", "version" : 1 }
6}
App/Watch/Assets.xcassets/AppIcon.appiconset/icon-1024.png added

Binary file not shown.

App/Watch/Assets.xcassets/Contents.json added +3
@@ -0,0 +1,3 @@
1{
2 "info" : { "author" : "xcode", "version" : 1 }
3}
App/Watch/MoonriseWatchApp.swift added +96
@@ -0,0 +1,96 @@
1import CoreLocation
2import SkyEngine
3import SkyRenderer
4import SwiftUI
5
6@main
7struct MoonriseWatchApp: App {
8 var body: some Scene {
9 WindowGroup { TonightView() }
10 }
11}
12
13/// Moon phase, sunset and sunrise, and the planets up tonight.
14struct TonightView: View {
15 @State private var location = WatchLocation()
16 @State private var tonight: Tonight?
17
18 var body: some View {
19 ScrollView {
20 VStack(alignment: .leading, spacing: 10) {
21 if let t = tonight {
22 HStack(spacing: 10) {
23 MoonDisc(fraction: t.moonIllumination, waxing: t.moonPhase < 180)
24 .frame(width: 44, height: 44)
25 .accessibilityHidden(true)
26 VStack(alignment: .leading) {
27 Text(DailySummary.phaseName(t.moonPhase)).font(.headline)
28 Text("\(Int((t.moonIllumination * 100).rounded()))% lit").font(.footnote).foregroundStyle(.secondary)
29 }
30 }
31 .accessibilityElement(children: .combine)
32 if let s = t.sunset { row("Sunset", time(s)) }
33 if let r = t.sunrise { row("Sunrise", time(r)) }
34 Text("Planets").font(.footnote.weight(.medium)).foregroundStyle(.secondary).padding(.top, 4)
35 let up = t.planets.filter { $0.best != nil }
36 if up.isEmpty { Text("None well placed tonight").font(.footnote) }
37 ForEach(up, id: \.body) { p in
38 row(p.body.rawValue.capitalized, p.best.map { "best \(time($0.date))" } ?? "")
39 }
40 if !location.located {
41 Text("Using Chicago until location is allowed.").font(.caption2).foregroundStyle(.secondary)
42 }
43 } else {
44 ProgressView()
45 }
46 }
47 .frame(maxWidth: .infinity, alignment: .leading)
48 }
49 .navigationTitle("Tonight")
50 .task(id: location.observer) {
51 let observer = location.observer
52 tonight = await Task.detached { try? Tonight(at: .now, from: observer) }.value
53 }
54 .onAppear { location.start() }
55 }
56
57 private func row(_ label: String, _ value: String) -> some View {
58 HStack {
59 Text(label)
60 Spacer()
61 Text(value).monospacedDigit().foregroundStyle(.secondary)
62 }
63 .font(.footnote)
64 }
65
66 private func time(_ d: Date) -> String { d.formatted(date: .omitted, time: .shortened) }
67}
68
69@MainActor
70@Observable
71final class WatchLocation: NSObject, CLLocationManagerDelegate {
72 private(set) var observer = Observer(latitude: 41.8781, longitude: -87.6298)
73 private(set) var located = false
74 private let manager = CLLocationManager()
75
76 func start() {
77 manager.delegate = self
78 manager.desiredAccuracy = kCLLocationAccuracyKilometer
79 manager.requestWhenInUseAuthorization()
80 manager.requestLocation()
81 }
82
83 nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
84 guard let c = locations.last?.coordinate else { return }
85 Task { @MainActor in
86 observer = Observer(latitude: c.latitude, longitude: c.longitude)
87 located = true
88 }
89 }
90
91 nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {}
92
93 nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
94 Task { @MainActor in self.manager.requestLocation() }
95 }
96}
App/Watch/Widgets-Info.plist added +31
@@ -0,0 +1,31 @@
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>CFBundleDisplayName</key>
8 <string>Moonrise</string>
9 <key>CFBundleExecutable</key>
10 <string>$(EXECUTABLE_NAME)</string>
11 <key>CFBundleIdentifier</key>
12 <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
13 <key>CFBundleInfoDictionaryVersion</key>
14 <string>6.0</string>
15 <key>CFBundleName</key>
16 <string>$(PRODUCT_NAME)</string>
17 <key>CFBundlePackageType</key>
18 <string>XPC!</string>
19 <key>CFBundleShortVersionString</key>
20 <string>1.0</string>
21 <key>CFBundleVersion</key>
22 <string>1</string>
23 <key>NSExtension</key>
24 <dict>
25 <key>NSExtensionPointIdentifier</key>
26 <string>com.apple.widgetkit-extension</string>
27 </dict>
28 <key>NSWidgetWantsLocation</key>
29 <true/>
30</dict>
31</plist>
App/Widgets/MoonriseWidgets.swift +35
@@ -9,7 +9,9 @@ struct MoonriseWidgets: WidgetBundle {
99 var body: some Widget {
1010 MoonWidget()
1111 PlanetsWidget()
12 #if os(iOS)
1213 LaunchLiveActivity()
14 #endif
1315 }
1416}
1517
@@ -86,6 +88,12 @@ struct MoonWidgetView: View {
8688 }
8789 case .accessoryInline:
8890 Text("Moon \(percent) \(entry.waxing ? "waxing" : "waning")")
91 #if os(watchOS)
92 case .accessoryCorner:
93 MoonDisc(fraction: entry.illumination, waxing: entry.waxing, light: .white, dark: .white.opacity(0.15))
94 .padding(4)
95 .widgetLabel(percent)
96 #endif
8997 default:
9098 VStack(alignment: .leading, spacing: 6) {
9199 MoonDisc(fraction: entry.illumination, waxing: entry.waxing)
@@ -111,7 +119,11 @@ struct MoonWidget: Widget {
111119 }
112120 .configurationDisplayName("Moon")
113121 .description("Tonight's moon phase.")
122 #if os(watchOS)
123 .supportedFamilies([.accessoryCircular, .accessoryInline, .accessoryCorner])
124 #else
114125 .supportedFamilies([.systemSmall, .accessoryCircular, .accessoryInline])
126 #endif
115127 }
116128}
117129
@@ -158,9 +170,28 @@ struct PlanetsProvider: TimelineProvider {
158170}
159171
160172struct PlanetsWidgetView: View {
173 @Environment(\.widgetFamily) var family
161174 var entry: PlanetsEntry
162175
163176 var body: some View {
177 if family == .accessoryRectangular { compact } else { full }
178 }
179
180 /// Watch complication and Lock Screen: up to three planets, no header.
181 private var compact: some View {
182 VStack(alignment: .leading, spacing: 1) {
183 if entry.rows.isEmpty { Text("No planets tonight").font(.system(size: 13)) }
184 ForEach(entry.rows.prefix(3), id: \.self) { row in
185 HStack {
186 Text(row.name).font(.system(size: 13, weight: .medium))
187 Spacer()
188 Text(row.detail).font(.system(size: 12).monospacedDigit())
189 }
190 }
191 }
192 }
193
194 private var full: some View {
164195 VStack(alignment: .leading, spacing: 4) {
165196 Text("Planets tonight").font(.system(size: 11, weight: .medium)).textCase(.uppercase).opacity(0.6)
166197 if entry.rows.isEmpty {
@@ -190,6 +221,10 @@ struct PlanetsWidget: Widget {
190221 }
191222 .configurationDisplayName("Planets tonight")
192223 .description("Which planets are up tonight and when they rise.")
224 #if os(watchOS)
225 .supportedFamilies([.accessoryRectangular])
226 #else
193227 .supportedFamilies([.systemSmall, .systemMedium])
228 #endif
194229 }
195230}
App/project.yml +53
@@ -3,6 +3,7 @@ options:
33 bundleIdPrefix: sh.krz
44 deploymentTarget:
55 iOS: "26.0"
6 watchOS: "26.0"
67packages:
78 MoonriseKit:
89 path: ../Packages/MoonriseKit
@@ -25,6 +26,7 @@ targets:
2526 - package: MoonriseKit
2627 product: ObservingLog
2728 - target: MoonriseWidgets
29 - target: MoonriseWatch
2830 entitlements:
2931 path: Sources/Moonrise.entitlements
3032 properties:
@@ -88,6 +90,57 @@ targets:
8890 TARGETED_DEVICE_FAMILY: "1,2"
8991 MARKETING_VERSION: "1.0"
9092 CURRENT_PROJECT_VERSION: "1"
93 MoonriseWatch:
94 type: application
95 platform: watchOS
96 sources: [Watch]
97 dependencies:
98 - package: MoonriseKit
99 product: SkyEngine
100 - package: MoonriseKit
101 product: SkyRenderer
102 - target: MoonriseWatchWidgets
103 settings:
104 base:
105 PRODUCT_BUNDLE_IDENTIFIER: sh.krz.moonrise.watchkitapp
106 DEVELOPMENT_TEAM: ZCNAX3VL9D
107 CODE_SIGN_STYLE: Automatic
108 SWIFT_VERSION: "6.0"
109 MARKETING_VERSION: "1.0"
110 CURRENT_PROJECT_VERSION: "1"
111 GENERATE_INFOPLIST_FILE: YES
112 INFOPLIST_KEY_CFBundleDisplayName: Moonrise
113 INFOPLIST_KEY_WKCompanionAppBundleIdentifier: sh.krz.moonrise
114 INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp: YES
115 INFOPLIST_KEY_NSLocationWhenInUseUsageDescription: Moonrise uses your location to work out tonight's sky.
116 ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
117 MoonriseWatchWidgets:
118 type: app-extension
119 platform: watchOS
120 sources:
121 - Widgets/MoonriseWidgets.swift
122 dependencies:
123 - package: MoonriseKit
124 product: SkyEngine
125 - package: MoonriseKit
126 product: SkyRenderer
127 - sdk: WidgetKit.framework
128 - sdk: SwiftUI.framework
129 info:
130 path: Watch/Widgets-Info.plist
131 properties:
132 CFBundleDisplayName: Moonrise
133 NSWidgetWantsLocation: true
134 NSExtension:
135 NSExtensionPointIdentifier: com.apple.widgetkit-extension
136 settings:
137 base:
138 PRODUCT_BUNDLE_IDENTIFIER: sh.krz.moonrise.watchkitapp.widgets
139 DEVELOPMENT_TEAM: ZCNAX3VL9D
140 CODE_SIGN_STYLE: Automatic
141 SWIFT_VERSION: "6.0"
142 MARKETING_VERSION: "1.0"
143 CURRENT_PROJECT_VERSION: "1"
91144 MoonriseUITests:
92145 type: bundle.ui-testing
93146 platform: iOS
PLAN.org +2 −1
@@ -403,7 +403,8 @@ Sky, System, Sun, Launches, Library core (above).
403403- Mars: latest rover raw images, local time at each rover.
404404- Library: exoplanets, rockets to scale, exploration timeline, scale
405405 view. Done.
406- iPad, Mac, Apple Watch complications.
406- iPad, Mac, Apple Watch complications. Done: Mac as "Designed for iPad";
407 Watch app (Moon, Sun, planets tonight) with Moon and Planets complications.
407408
408409** v3: across time and cultures
409410- Sky cultures: Chinese, Polynesian, Arabic, Norse and other
Packages/MoonriseKit/Package.swift +1 −1
@@ -3,7 +3,7 @@ import PackageDescription
33
44let package = Package(
55 name: "MoonriseKit",
6 platforms: [.iOS(.v26), .macOS(.v26)],
6 platforms: [.iOS(.v26), .macOS(.v26), .watchOS(.v26)],
77 products: [
88 .library(name: "SkyEngine", targets: ["SkyEngine"]),
99 .library(name: "SkyRenderer", targets: ["SkyRenderer"]),