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 f9b8adc413

f9b8adc4139441bbeeaed722fa5c34ee0a7ceb08

parent: 94470ad726

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-17 23:29 UTC

Add iOS app shell with live sky

- App/project.yml (XcodeGen): sh.krz.moonrise, team ZCNAX3VL9D, iOS 26,
  push entitlement (development), location usage string. Generated
  project is not committed.
- SkyModel: location (falls back to Chicago), scene rebuilt off the main
  thread every 30 s.
- SkyScreen: drag to pan, pinch to zoom (30-140 degrees), draw-time
  readout for device measurement.
- AppDelegate: registers for remote notifications, logs the device token.
- SkyView: onDraw timing callback; all eight compass points.

Layout: unified · split

.gitignore +1
@@ -5,3 +5,4 @@ node_modules/
5.wrangler/ 5.wrangler/
6.dev.vars 6.dev.vars
7*.p8 7*.p8
8App/*.xcodeproj/
App/README.org added +13
@@ -0,0 +1,13 @@
1#+title: Moonrise app
2
3The Xcode project is generated from =project.yml= and not committed.
4
5#+begin_src sh
6brew install xcodegen
7cd App && xcodegen generate
8open Moonrise.xcodeproj
9#+end_src
10
11Simulator: an iOS 26+ runtime is required. Push registration needs a
12signed build (team =ZCNAX3VL9D=); the APNs device token is logged under
13subsystem =sh.krz.moonrise=.
App/Sources/Moonrise.entitlements added +8
@@ -0,0 +1,8 @@
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>aps-environment</key>
6 <string>development</string>
7</dict>
8</plist>
App/Sources/MoonriseApp.swift added +33
@@ -0,0 +1,33 @@
1import SwiftUI
2import UIKit
3import os
4
5let log = Logger(subsystem: "sh.krz.moonrise", category: "app")
6
7@main
8struct MoonriseApp: App {
9 @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
10
11 var body: some Scene {
12 WindowGroup {
13 SkyScreen()
14 .preferredColorScheme(.dark)
15 }
16 }
17}
18
19final class AppDelegate: NSObject, UIApplicationDelegate {
20 func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
21 application.registerForRemoteNotifications()
22 return true
23 }
24
25 func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
26 let token = deviceToken.map { String(format: "%02x", $0) }.joined()
27 log.info("APNs device token: \(token, privacy: .public)")
28 }
29
30 func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
31 log.error("APNs registration failed: \(error.localizedDescription, privacy: .public)")
32 }
33}
App/Sources/SkyModel.swift added +70
@@ -0,0 +1,70 @@
1import CoreLocation
2import Foundation
3import Observation
4import SkyEngine
5import SkyRenderer
6
7/// Current place and the scene computed for it. Scenes are built off the main
8/// thread one at a time, since Astronomy Engine is not thread-safe.
9@MainActor
10@Observable
11final class SkyModel: NSObject, CLLocationManagerDelegate {
12 /// Used until a location fix arrives or when location is denied.
13 static let fallback = Observer(latitude: 41.8781, longitude: -87.6298)
14
15 private(set) var observer = SkyModel.fallback
16 private(set) var hasLocation = false
17 private(set) var scene: SkyScene?
18 private(set) var lastBuild: Duration = .zero
19
20 private let manager = CLLocationManager()
21 private var building = false
22
23 override init() {
24 super.init()
25 manager.delegate = self
26 manager.desiredAccuracy = kCLLocationAccuracyKilometer
27 }
28
29 func start() {
30 manager.requestWhenInUseAuthorization()
31 manager.requestLocation()
32 refresh()
33 }
34
35 func refresh(at date: Date = .now) {
36 guard !building else { return }
37 building = true
38 let observer = observer
39 Task.detached(priority: .userInitiated) {
40 let clock = ContinuousClock()
41 var scene: SkyScene?
42 let elapsed = clock.measure { scene = try? SkyScene(date: date, observer: observer) }
43 await MainActor.run {
44 self.scene = scene
45 self.lastBuild = elapsed
46 self.building = false
47 }
48 }
49 }
50
51 nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
52 guard let c = locations.last?.coordinate else { return }
53 Task { @MainActor in
54 self.observer = Observer(latitude: c.latitude, longitude: c.longitude)
55 self.hasLocation = true
56 self.refresh()
57 }
58 }
59
60 nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
61 Task { @MainActor in log.error("Location failed: \(error.localizedDescription, privacy: .public)") }
62 }
63
64 nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
65 let status = manager.authorizationStatus
66 Task { @MainActor in
67 if [.authorizedWhenInUse, .authorizedAlways].contains(status) { self.manager.requestLocation() }
68 }
69 }
70}
App/Sources/SkyScreen.swift added +97
@@ -0,0 +1,97 @@
1import SkyRenderer
2import SwiftUI
3
4struct SkyScreen: View {
5 @State private var model = SkyModel()
6 @State private var azimuth = 180.0
7 @State private var altitude = 45.0
8 @State private var fieldOfView = 100.0
9 @GestureState private var drag: CGSize = .zero
10 @GestureState private var zoom: CGFloat = 1
11 private let stats = DrawStats()
12
13 var body: some View {
14 GeometryReader { geo in
15 ZStack(alignment: .topLeading) {
16 Color(red: 0.043, green: 0.078, blue: 0.141).ignoresSafeArea()
17 if let scene = model.scene {
18 let view = current(width: geo.size.width)
19 SkyView(scene: scene, centerAzimuth: view.azimuth, centerAltitude: view.altitude, fieldOfView: view.fieldOfView) { [stats] d in
20 stats.record(d)
21 }
22 .ignoresSafeArea()
23 .gesture(panGesture(width: geo.size.width).simultaneously(with: zoomGesture))
24 }
25 header
26 }
27 }
28 .task {
29 model.start()
30 while !Task.isCancelled {
31 try? await Task.sleep(for: .seconds(30))
32 model.refresh()
33 }
34 }
35 }
36
37 /// The view direction with any in-progress gestures applied.
38 private func current(width: CGFloat) -> (azimuth: Double, altitude: Double, fieldOfView: Double) {
39 let fov = min(max(fieldOfView / Double(zoom), 30), 140)
40 let degreesPerPoint = fov / Double(max(width, 1))
41 let az = (azimuth - Double(drag.width) * degreesPerPoint).truncatingRemainder(dividingBy: 360)
42 let alt = min(max(altitude + Double(drag.height) * degreesPerPoint, 0), 90)
43 return ((az + 360).truncatingRemainder(dividingBy: 360), alt, fov)
44 }
45
46 private func panGesture(width: CGFloat) -> some Gesture {
47 DragGesture(minimumDistance: 0)
48 .updating($drag) { value, state, _ in state = value.translation }
49 .onEnded { value in
50 let degreesPerPoint = fieldOfView / Double(max(width, 1))
51 azimuth = ((azimuth - Double(value.translation.width) * degreesPerPoint).truncatingRemainder(dividingBy: 360) + 360).truncatingRemainder(dividingBy: 360)
52 altitude = min(max(altitude + Double(value.translation.height) * degreesPerPoint, 0), 90)
53 }
54 }
55
56 private var zoomGesture: some Gesture {
57 MagnifyGesture()
58 .updating($zoom) { value, state, _ in state = value.magnification }
59 .onEnded { value in fieldOfView = min(max(fieldOfView / Double(value.magnification), 30), 140) }
60 }
61
62 private var header: some View {
63 VStack(alignment: .leading, spacing: 4) {
64 Text("Tonight").font(.system(size: 22, weight: .medium))
65 Text(model.hasLocation ? "Your location" : "Chicago (location unavailable)")
66 .font(.system(size: 11)).foregroundStyle(.secondary)
67 TimelineView(.periodic(from: .now, by: 1)) { _ in
68 Text(stats.summary(build: model.lastBuild))
69 .font(.system(size: 10).monospacedDigit()).foregroundStyle(.secondary)
70 }
71 }
72 .foregroundStyle(Color(red: 0.95, green: 0.95, blue: 0.93))
73 .padding(.horizontal, 20)
74 .padding(.top, 8)
75 }
76}
77
78/// Draw timings for the debug readout. Written from the Canvas closure, read once a second.
79final class DrawStats: @unchecked Sendable {
80 private let lock = NSLock()
81 private var samples: [Duration] = []
82
83 func record(_ d: Duration) {
84 lock.withLock {
85 samples.append(d)
86 if samples.count > 120 { samples.removeFirst(samples.count - 120) }
87 }
88 }
89
90 func summary(build: Duration) -> String {
91 let s = lock.withLock { samples }
92 let ms = s.map { Double($0.components.attoseconds) / 1e15 + Double($0.components.seconds) * 1000 }.sorted()
93 guard let median = ms.isEmpty ? nil : ms[ms.count / 2], let worst = ms.last else { return "draw –" }
94 let b = Double(build.components.attoseconds) / 1e15 + Double(build.components.seconds) * 1000
95 return String(format: "draw %.1f ms median, %.1f ms max · scene %.0f ms", median, worst, b)
96 }
97}
App/project.yml added +37
@@ -0,0 +1,37 @@
1name: Moonrise
2options:
3 bundleIdPrefix: sh.krz
4 deploymentTarget:
5 iOS: "26.0"
6packages:
7 MoonriseKit:
8 path: ../Packages/MoonriseKit
9targets:
10 Moonrise:
11 type: application
12 platform: iOS
13 sources: [Sources]
14 dependencies:
15 - package: MoonriseKit
16 product: SkyEngine
17 - package: MoonriseKit
18 product: SkyRenderer
19 entitlements:
20 path: Sources/Moonrise.entitlements
21 properties:
22 aps-environment: development
23 settings:
24 base:
25 PRODUCT_BUNDLE_IDENTIFIER: sh.krz.moonrise
26 DEVELOPMENT_TEAM: ZCNAX3VL9D
27 CODE_SIGN_STYLE: Automatic
28 SWIFT_VERSION: "6.0"
29 TARGETED_DEVICE_FAMILY: "1"
30 MARKETING_VERSION: "0.1"
31 CURRENT_PROJECT_VERSION: "1"
32 GENERATE_INFOPLIST_FILE: YES
33 INFOPLIST_KEY_CFBundleDisplayName: Moonrise
34 INFOPLIST_KEY_UILaunchScreen_Generation: YES
35 INFOPLIST_KEY_UIStatusBarStyle: UIStatusBarStyleLightContent
36 INFOPLIST_KEY_UISupportedInterfaceOrientations: UIInterfaceOrientationPortrait
37 INFOPLIST_KEY_NSLocationWhenInUseUsageDescription: Moonrise uses your location to draw the sky above you.
Packages/MoonriseKit/Sources/SkyRenderer/SkyView.swift +7 −2
@@ -55,22 +55,27 @@ public struct SkyView: View {
55 public var centerAltitude: Double 55 public var centerAltitude: Double
56 public var fieldOfView: Double 56 public var fieldOfView: Double
57 public var style = SkyStyle() 57 public var style = SkyStyle()
58 /// Called after each draw with the time the draw took.
59 public var onDraw: (@Sendable (Duration) -> Void)?
58 60
59 public init(scene: SkyScene, centerAzimuth: Double = 180, centerAltitude: Double = 55, fieldOfView: Double = 110) { 61 public init(scene: SkyScene, centerAzimuth: Double = 180, centerAltitude: Double = 55, fieldOfView: Double = 110, onDraw: (@Sendable (Duration) -> Void)? = nil) {
60 self.scene = scene 62 self.scene = scene
61 self.centerAzimuth = centerAzimuth 63 self.centerAzimuth = centerAzimuth
62 self.centerAltitude = centerAltitude 64 self.centerAltitude = centerAltitude
63 self.fieldOfView = fieldOfView 65 self.fieldOfView = fieldOfView
66 self.onDraw = onDraw
64 } 67 }
65 68
66 public var body: some View { 69 public var body: some View {
67 Canvas { context, size in 70 Canvas { context, size in
71 let start = ContinuousClock.now
68 let p = Projection(centerAzimuth: centerAzimuth, centerAltitude: centerAltitude, fieldOfView: fieldOfView, size: size) 72 let p = Projection(centerAzimuth: centerAzimuth, centerAltitude: centerAltitude, fieldOfView: fieldOfView, size: size)
69 context.fill(Path(CGRect(origin: .zero, size: size)), with: .color(style.background)) 73 context.fill(Path(CGRect(origin: .zero, size: size)), with: .color(style.background))
70 drawConstellations(&context, p) 74 drawConstellations(&context, p)
71 drawStars(&context, p) 75 drawStars(&context, p)
72 drawBodies(&context, p) 76 drawBodies(&context, p)
73 drawHorizon(&context, p, size: size) 77 drawHorizon(&context, p, size: size)
78 onDraw?(ContinuousClock.now - start)
74 } 79 }
75 } 80 }
76 81
@@ -147,7 +152,7 @@ public struct SkyView: View {
147 ground.addLine(to: CGPoint(x: first.x, y: size.height + 10)) 152 ground.addLine(to: CGPoint(x: first.x, y: size.height + 10))
148 ground.closeSubpath() 153 ground.closeSubpath()
149 context.fill(ground, with: .color(style.ground)) 154 context.fill(ground, with: .color(style.ground))
150 for (name, az) in [("SE", 135.0), ("S", 180.0), ("SW", 225.0), ("E", 90.0), ("W", 270.0)] { 155 for (name, az) in [("N", 0.0), ("NE", 45.0), ("E", 90.0), ("SE", 135.0), ("S", 180.0), ("SW", 225.0), ("W", 270.0), ("NW", 315.0)] {
151 guard let pt = p.point(Horizontal(altitude: 0, azimuth: az)), pt.x > 0, pt.x < size.width else { continue } 156 guard let pt = p.point(Horizontal(altitude: 0, azimuth: az)), pt.x > 0, pt.x < size.width else { continue }
152 label(&context, name, at: CGPoint(x: pt.x, y: pt.y + 18), anchor: .center, color: style.muted) 157 label(&context, name, at: CGPoint(x: pt.x, y: pt.y + 18), anchor: .center, color: style.muted)
153 } 158 }