Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,10 @@ Release and transactionally installs it to `/Applications` (ad-hoc signed, no
team needed). Its `--dry-run` validates and reports the replacement without
building, stopping, replacing, or launching anything.

The **Daylight** camera app uses the same iOS build and test schemes. Use
[`./Daylight/install`](Daylight/install) for a physical device. Its dry run resolves
the device without generating, building, installing, or launching.

## Per-module docs

Shared modules live under `Shared/`. Feature modules live under a top-level folder
Expand Down
11 changes: 11 additions & 0 deletions Daylight/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Daylight

Read [the repository contract](../AGENTS.md). [README.md](README.md) owns setup and operation.

Keep the app composition root above DaylightUI, DaylightMastodon, and DaylightCore. Core never imports UI or destination modules. Register publishing adapters at the root; never switch over destination names in capture code.

Keep each RAW/JPEG pair in one Photos asset. Expose both originals to adapters until their dependent work finishes. Do not add photographic edits.

Freeze capture settings per sequence. Persist image bytes before Photos or network side effects. Preserve ambiguous outcomes for review instead of blindly duplicating assets or posts. Never remove Photos assets. Exact location and credentials never enter social image metadata or logs.

Run unit suites through ./test and UI snapshots through StuffSnapshotTests. Camera, Photos saving, and unattended thermal behavior require physical-device acceptance.
11 changes: 11 additions & 0 deletions Daylight/Daylight/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Daylight

Read [the root contract](../../../AGENTS.md) and [Daylight](../AGENTS.md). See [README.md](README.md) for operation.

The application composition root and bundled attribution.

Create the camera, archive, logging system, and destination once. Inject the same instances into every consumer. Keep screen implementations in DaylightUI.

Use ./test for unit tests. UI snapshots belong to DaylightUISnapshotTests in the shared StuffSnapshotTests scheme. Device camera and Photos checks require the physical iPhone.

Keep publishing configuration failure isolated from capture startup. Surface adapter issues through its configuration contract.
9 changes: 9 additions & 0 deletions Daylight/Daylight/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Daylight

The application composition root and bundled attribution. [Daylight](../README.md) describes setup and operation. The root Package.swift and Project.swift define the targets.

Create the camera, archive, logging system, and destination once. Inject the same instances into every consumer. Keep screen implementations in DaylightUI.

Tests use injected services. The application remains disarmed on a fresh installation. Camera access and Photos access require the system permission prompts.

Mastodon configuration failures remain isolated to publishing. They do not prevent the camera and local capture services from starting.
124 changes: 124 additions & 0 deletions Daylight/Daylight/Resources/attribution.json

Large diffs are not rendered by default.

63 changes: 63 additions & 0 deletions Daylight/Daylight/Sources/DaylightApp.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import DaylightCore
import DaylightMastodon
import DaylightUI
import PeriscopeCore
import SwiftUI

@main
struct DaylightApp: App {
private let startup: Result<DaylightModel, any Error>
init() {
do {
let root = try FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true,
).appendingPathComponent("Daylight", isDirectory: true)
let store = try CaptureStore(root: root)
let camera = CameraService()
let photos = PhotosLibrary()
let mastodon = MastodonDestination(
settingsURL: root.appendingPathComponent("mastodon.json"),
transport: URLSessionTransport(),
credentials: KeychainMastodonCredentials(),
now: { Date() },
uptime: { ProcessInfo.processInfo.systemUptime },
)
let logging = Periscope(
configuration: .init(),
sinks: [OSLogSink(subsystem: "com.stuff.daylight")],
)
let engine = CaptureEngine(
store: store,
camera: camera,
solar: SolarCalculator(),
photos: photos,
scorer: VisionImageScorer(),
destinations: [mastodon],
log: Log<DaylightLogEvent>(system: logging),
now: { Date() },
)
startup = .success(DaylightModel(
engine: engine,
camera: camera,
photos: photos,
mastodon: mastodon,
readiness: SystemCaptureReadiness(),
))
} catch { startup = .failure(error) }
}

var body: some Scene {
WindowGroup {
switch startup {
case let .success(model): DaylightRootView(model: model)
case let .failure(error):
VStack {
Text("Daylight").font(.title); Text(error.localizedDescription).padding()
}
}
}
}
}
16 changes: 16 additions & 0 deletions Daylight/Daylight/Tests/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Device acceptance

The root composition is built by Stuff-iOS-Tests. Core, adapter, and UI tests exercise the injected services.

1. Install with `./Daylight/install --device <identifier>`.
2. Allow Camera and Photos access.
3. Take a test photo.
4. Open the image in Photos. On a supported lens, confirm the asset contains RAW and JPEG resources.
5. Check the saved image orientation and full capture resolution.
6. Take another test shot and check that it creates exactly one new Photos asset.
7. On the eventual camera phone, run one complete sunrise and sunset cycle.
8. Check 13 scheduled photos per event, heat behavior, and screen restoration.
9. Disconnect Wi-Fi during a sequence.
10. Restore Wi-Fi and check that exactly one selected image posts.

The final cycle requires the mounted camera phone and a configured Mastodon account. Simulator tests cannot prove these hardware behaviors.
23 changes: 23 additions & 0 deletions Daylight/Daylight/attribution-sources.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"output": "Daylight/Daylight/Resources/attribution.json",
"sources": [
{
"type": "swiftPackageManager",
"manifest": "Package.swift",
"resolved": "Package.resolved",
"shippedFrom": [
"DaylightUI"
]
},
{
"type": "agentSkills",
"kind": "developmentTool",
"manifest": ".agents/external-skills.json"
},
{
"type": "developmentTools",
"kind": "developmentTool",
"manifest": ".agents/development-tools.json"
}
]
}
11 changes: 11 additions & 0 deletions Daylight/DaylightCore/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# DaylightCore

Read [the root contract](../../../AGENTS.md) and [Daylight](../AGENTS.md). See [README.md](README.md) for API and behavior.

Keep domain and service code independent of UI, Where, and Ledger. Create collaborators once in the app root and inject them. Persist transitions before external side effects. Keep staged images until all consumers finish. Use 1:1 Swift Testing files and injected protocol implementations.

Preserve pre-RAW capture records with absent format and delivery checkpoints. Keep saved Photos identifiers intact; see CapturedImageTests and ManualCaptureServiceTests.

Persist highlight deliveries before marking selection complete. Cleanup must not observe a selected image whose deliveries are still being registered.

Treat only PhotosSaveFailure as proof that no asset was created. Preserve uncertain-save receipts. Require confirmed absence before replacing uncertain work. Keep delivery IDs and prior checkpoints through recovery.
11 changes: 11 additions & 0 deletions Daylight/DaylightCore/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# DaylightCore

Daylight's capture domain, solar scheduling, image selection, durable staging, and publishing interfaces. Library targets live in the root Package.swift. See [Daylight](../README.md) for operation and limits.

`CaptureSettings.standard` specifies San Francisco and 13 slots per event. `SolarCalculator` computes events offline. `CaptureStore` atomically persists versioned records; unknown versions and damaged records throw. `PublishingDestination` consumes typed image events and persists adapter checkpoints through the supplied callback.

Capture records from before RAW support can omit format and delivery checkpoints. Reading these records preserves their Photos identifiers. Missing format means unknown; a missing delivery checkpoint means no recorded completion.

Highlight selection remains pending until delivery registration completes. Capture readiness is injected separately from camera access requests so lifecycle tests can exercise permission and thermal failures.

Photos savers report `PhotosSaveFailure` only when they can prove no asset was created. These failures retry after a persisted delay. Other failures retain receipts for reconciliation. `CaptureControlling.resolvePhotos` checks existing receipts before retrying; uncertain saves require confirmed absence. Publishing adapters own recovery decisions through `recover`. The engine preserves delivery identity and previous checkpoints when requeuing work.
10 changes: 10 additions & 0 deletions Daylight/DaylightCore/Sources/CameraCapture.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import Foundation

/// A single shutter event contains a JPEG and, when supported, its unmodified RAW companion.
public struct CameraCapture: Sendable {
public let jpeg: Data
public let raw: Data?
public init(jpeg: Data, raw: Data?) {
self.jpeg = jpeg; self.raw = raw
}
}
10 changes: 10 additions & 0 deletions Daylight/DaylightCore/Sources/CameraCapturing.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import Foundation

public protocol CameraCapturing: Sendable {
func requestAccess() async -> Bool
func availableLenses() async -> [CaptureSettings.Camera.Lens]
func capture(settings: CaptureSettings.Camera) async throws -> CameraCapture
func preview(settings: CaptureSettings.Camera) async throws
-> AsyncThrowingStream<Data, any Error>
func stop() async
}
181 changes: 181 additions & 0 deletions Daylight/DaylightCore/Sources/CameraService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import AVFoundation
import Foundation

/// AVFoundation is confined to a serial executor, including blocking session startup/shutdown.
public actor CameraService: CameraCapturing {
private let queue = DispatchSerialQueue(label: "com.stuff.daylight.camera")
public nonisolated var unownedExecutor: UnownedSerialExecutor {
queue.asUnownedSerialExecutor()
}

private let session = AVCaptureSession()
private let photos = AVCapturePhotoOutput()
private let video = AVCaptureVideoDataOutput()
private var device: AVCaptureDevice?
private var photoDelegate: PhotoCaptureDelegate?
private var previewToken: UUID?
private var previewDelegate: PreviewCaptureDelegate?
public init() {}

public func requestAccess() async -> Bool {
await AVCaptureDevice.requestAccess(for: .video)
}

public func availableLenses() -> [CaptureSettings.Camera.Lens] {
CaptureSettings.Camera.Lens.allCases.filter { camera(for: $0) != nil }
}

private func camera(for lens: CaptureSettings.Camera.Lens) -> AVCaptureDevice? {
let type: AVCaptureDevice.DeviceType
switch lens {
case .main: type = .builtInWideAngleCamera; case .ultraWide: type =
.builtInUltraWideCamera; case .telephoto: type = .builtInTelephotoCamera
}
return AVCaptureDevice.default(type, for: .video, position: .back)
}

private func configure(_ settings: CaptureSettings.Camera, preview: Bool) throws {
guard AVCaptureDevice.authorizationStatus(for: .video) == .authorized
else { throw DaylightError.cameraPermission }
guard let camera = camera(for: settings.lens) else { throw DaylightError.unavailableCamera }
session.beginConfiguration()
defer { session.commitConfiguration() }
session.sessionPreset = .photo
for input in session.inputs {
session.removeInput(input)
}
let input = try AVCaptureDeviceInput(device: camera)
guard session.canAddInput(input) else { throw DaylightError.unavailableCamera }
session.addInput(input)
if !session.outputs.contains(photos) {
guard session.canAddOutput(photos) else { throw DaylightError.unavailableCamera }
session.addOutput(photos)
}
photos.isAppleProRAWEnabled = photos.isAppleProRAWSupported
if session.outputs.contains(video) { session.removeOutput(video) }
if preview {
video.alwaysDiscardsLateVideoFrames = true
guard session.canAddOutput(video) else { throw DaylightError.unavailableCamera }
session.addOutput(video)
video.setSampleBufferDelegate(previewDelegate, queue: queue)
}
try camera.lockForConfiguration()
defer { camera.unlockForConfiguration() }
camera.videoZoomFactor = min(
camera.maxAvailableVideoZoomFactor,
max(camera.minAvailableVideoZoomFactor, settings.zoom),
)
camera.setExposureTargetBias(min(
camera.maxExposureTargetBias,
max(camera.minExposureTargetBias, settings.exposureBias),
))
if camera
.isFocusModeSupported(.continuousAutoFocus) { camera.focusMode = .continuousAutoFocus }
if camera
.isExposureModeSupported(.continuousAutoExposure)
{
camera.exposureMode = .continuousAutoExposure
}
if camera
.isWhiteBalanceModeSupported(.continuousAutoWhiteBalance)
{
camera.whiteBalanceMode = .continuousAutoWhiteBalance
}
// A mounted landscape camera keeps preview and saved frames in the same
// orientation.
for output in session.outputs {
if let connection = output.connection(with: .video),
connection.isVideoRotationAngleSupported(0) { connection.videoRotationAngle = 0 }
}
device = camera
}

public func capture(settings: CaptureSettings.Camera) async throws -> CameraCapture {
guard photoDelegate == nil else { throw DaylightError.interrupted }
previewToken = nil
previewDelegate?.finish(); previewDelegate = nil
try configure(settings, preview: false)
session.startRunning()
defer { photoDelegate = nil; session.stopRunning() }
let deadline = ContinuousClock.now.advanced(by: .seconds(4))
repeat {
try Task.checkCancellation()
try await Task.sleep(for: .milliseconds(100))
} while ContinuousClock
.now < deadline &&
(device?.isAdjustingExposure == true || device?.isAdjustingFocus == true || device?
.isAdjustingWhiteBalance == true)
guard session.isRunning, !session.isInterrupted else {
throw DaylightError.interrupted
}
return try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { continuation in
let formats = photos.availableRawPhotoPixelFormatTypes
let rawFormat = formats.first(where: AVCapturePhotoOutput.isAppleProRAWPixelFormat)
??
(device?.videoZoomFactor == 1 ? formats
.first(where: AVCapturePhotoOutput.isBayerRAWPixelFormat) : nil)
let delegate = PhotoCaptureDelegate(
expectsRAW: rawFormat != nil,
continuation: continuation,
)
photoDelegate = delegate
let processed = [AVVideoCodecKey: AVVideoCodecType.jpeg]
let options: AVCapturePhotoSettings
if let rawFormat {
options = AVCapturePhotoSettings(
rawPixelFormatType: rawFormat,
processedFormat: processed,
)
options.photoQualityPrioritization = .speed
} else { options = AVCapturePhotoSettings(format: processed) }
options.flashMode = .off
photos.capturePhoto(with: options, delegate: delegate)
Task { [weak self] in
do { try await Task.sleep(for: .seconds(20)); await self?.timeout(delegate) }
catch { delegate.cancel() }
}
}
} onCancel: { Task { await self.cancelCapture() } }
}

private func timeout(_ delegate: PhotoCaptureDelegate) {
if photoDelegate === delegate { delegate.cancel() }
}

private func cancelCapture() {
photoDelegate?.cancel()
}

public func preview(
settings: CaptureSettings.Camera,
) throws -> AsyncThrowingStream<Data, any Error> {
guard photoDelegate == nil else { throw DaylightError.interrupted }
previewToken = nil
previewDelegate?.finish()
let stream = AsyncThrowingStream<Data, any Error>
.makeStream(bufferingPolicy: .bufferingNewest(1))
let token = UUID()
previewToken = token
stream.continuation
.onTermination = { [weak self] _ in Task { await self?.endPreview(token) } }
previewDelegate = PreviewCaptureDelegate(continuation: stream.continuation)
try configure(settings, preview: true)
session.startRunning()
return stream.stream
}

private func endPreview(_ token: UUID) {
guard previewToken == token else { return }
previewToken = nil
previewDelegate = nil
if photoDelegate == nil { session.stopRunning() }
}

public func stop() {
photoDelegate?.cancel()
previewToken = nil
previewDelegate?.finish(); previewDelegate = nil
session.stopRunning()
}
}
21 changes: 21 additions & 0 deletions Daylight/DaylightCore/Sources/CaptureControlling.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import Foundation

public protocol CaptureControlling: Sendable {
func armedIntent() async throws -> Bool
func setArmedIntent(_ armed: Bool) async throws
func load() async throws -> CaptureSettings
func configure(_ settings: CaptureSettings) async throws
func plan() async throws
func tick(canCapture: Bool) async throws
func publishPending() async throws
func history() async -> [CaptureSequence]
func nextCapture() async -> Date?
func manualHistory() async throws -> [ManualCapture]
func recoverDelivery(
sequenceID: SolarEvent.ID,
deliveryID: PublishingDelivery.ID,
action: PublishingRecoveryAction,
) async throws
func resolvePhotos(imageID: CaptureSequence.Slot.ID, resolution: PhotosResolution) async throws
func manualCapture() async throws
}
Loading
Loading