Scaffold a new HealthPush sync destination end-to-end — DestinationType enum case, DestinationConfig fields, Destination struct conforming to SyncDestination, DestinationManager wiring, setup screen, and tests. Use when the user wants to add support for a new destination (generic REST, Google Drive, Google Sheets, MQTT, WebDAV, etc.).
HealthPush is a destination-agnostic health data exporter. Adding a destination touches ~6 coordinated files — this skill walks through the full change set so nothing gets forgotten and the multi-destination abstraction stays clean.
Before writing code, confirm with the user:
googleSheets / "Google Sheets"SF Symbols.app for the destination cardIf any answer is unclear, ask before scaffolding. Guessing here creates rework.
File: ios/HealthPush/Sources/Models/DestinationConfig.swift
enum DestinationType: String, Codable, Sendable, CaseIterable, Identifiable {
case homeAssistant = "Home Assistant"
case s3 = "Amazon S3"
case <newCase> = "<Display Name>"
var displayName: String {
switch self {
case .homeAssistant: return "Home Assistant"
case .s3: return "S3-Compatible Storage"
case .<newCase>: return "<Display Name>"
}
}
var symbolName: String {
switch self {
case .homeAssistant: return "house.fill"
case .s3: return "cloud.fill"
case .<newCase>: return "<sf-symbol-name>"
}
}
}
First try to reuse existing fields. DestinationConfig already has baseURL, apiToken, and the enabledMetrics set, plus a s3* prefix for S3-only fields. Only add new fields if none of the existing ones fit.
If you must add fields:
rest…, gdrive…, mqtt…)s3SecretAccessKeyKeychainKey (secureStoredSecretsIfNeeded, apiTokenValue(migratingIfNeeded:))Never put destination-specific fields on HealthDataPoint, SyncRecord, or ExportFormat — those must stay destination-agnostic.
File: ios/HealthPush/Sources/Destinations/<Name>Destination.swift
Reference: S3Destination.swift (struct, uses HealthDataExporter) or HomeAssistantDestination.swift (more request/response-oriented).
Required:
import Foundation
import os
enum <Name>DestinationError: LocalizedError, Sendable {
case invalidConfiguration(String)
case syncFailed(String)
var errorDescription: String? { /* ... */ }
}
struct <Name>Destination: SyncDestination {
let id: UUID
let name: String
let isEnabled: Bool
private let logger = Logger(subsystem: "app.healthpush", category: "<Name>Destination")
// ... destination-specific client/service
init(config: DestinationConfig) throws {
self.id = config.id
self.name = config.name
self.isEnabled = config.isEnabled
// validate + build client
}
func sync(data: [HealthDataPoint]) async throws -> SyncStats {
// 1. Use HealthDataExporter for grouping/dedup/serialization
// 2. Hand the exported payloads to your transport
// 3. Return SyncStats(processedCount:, newCount:)
}
func testConnection() async throws -> Bool {
// Cheap read-only probe — HEAD, GET /api/, etc.
}
}
Hard rules:
Sendable (struct, let-only, or explicit @unchecked Sendable with justification)HealthDataExporter for grouping, UUID dedup, and format serialization — do not reimplementprivacy-reviewer agent)config — never hardcodedFile: ios/HealthPush/Sources/Destinations/DestinationManager.swift
Add a create<Name>Destination(...) method mirroring createS3Destination:
@discardableResult
func create<Name>Destination(
name: String,
// ... config fields
enabledMetrics: Set<HealthMetricType>,
syncFrequency: SyncFrequency = .oneHour,
modelContext: ModelContext
) throws -> DestinationConfig {
let config = DestinationConfig(
name: name,
destinationType: .<newCase>,
// ... map inputs to DestinationConfig fields
enabledMetrics: enabledMetrics
)
config.syncFrequency = syncFrequency
modelContext.insert(config)
do {
try config.secureStoredSecretsIfNeeded()
try modelContext.save()
loadDestinations(modelContext: modelContext)
onDestinationsChanged?()
logger.info("Created <Name> destination: \(name)")
} catch {
modelContext.delete(config)
if error is KeychainError {
throw DestinationManagerError.secretStorageFailed(error.localizedDescription)
}
throw DestinationManagerError.persistenceFailed(error.localizedDescription)
}
return config
}
Also add a case to testConnection(for:):
case .<newCase>:
let destination = try <Name>Destination(config: config)
success = try await destination.testConnection()
And grep for any other switch config.destinationType — if there are more than 2 switches, stop and ask whether a protocol method would be cleaner. Scattered switches are a destination-abstraction smell.
File: ios/HealthPush/Sources/Views/Screens/<Name>SetupScreen.swift
Reference: S3SetupScreen.swift (610 lines — comprehensive example) or HomeAssistantSetupScreen.swift.
Required sections:
TextField, SecureField, etc.)destinationManager.testConnection(for:) + surface lastTestResultdestinationManager.create<Name>Destination(...)Also update AddDestinationSheet.swift to list the new destination type in the picker.
File: ios/HealthPush/Tests/<Name>DestinationTests.swift
Reference: S3DestinationIntegrationTests.swift.
Minimum coverage:
init(config:) — valid config, invalid config (missing fields, malformed URLs)testConnection() — success path and failure path (use a mock URLSession or a fake client)sync(data:) — empty array, single data point, multiple metrics, duplicate detectiondocs/setup-<name>.mdx (mirror docs/setup-amazon-s3.mdx)docs/docs.jsonREADME.md destination list if the scaffolding adds the first destination of a new categoryRun the tests:
cd ios/HealthPush && xcodegen && xcodebuild test \
-project HealthPush.xcodeproj \
-scheme HealthPush \
-destination 'platform=iOS Simulator,name=iPhone 16'
(Or use the ios-test skill if it's available.)
Then consider asking the privacy-reviewer and destination-abstraction-reviewer agents to bless the change before opening a PR.
switch destinationType to SyncEngine, HealthDataExporter, or BackgroundSyncScheduler. If the shared code needs new behavior, add a method to the SyncDestination protocol instead.DestinationConfig.HealthDataExporter owns both — use it.xcodegen after adding files. (The xcodegen-regen hook handles this automatically when project.yml changes, but new source files are picked up from the Sources/ directory without needing a yml edit.)