Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
ef20adf
✨ Send to several destinations, from Shortcuts and in the background
Programcode Oct 22, 2026
a8ae6c0
📝 Document the v2 payload and destinations
Programcode Oct 22, 2026
4e5854c
♻️ Version resent snapshot records the same way
Programcode Oct 22, 2026
4adc4e9
📝 Describe the fork: coverage, destinations, Shortcuts, unsigned IPA
Programcode Oct 22, 2026
ce72d9f
Merge branch 'feat/all-healthkit-types' into feat/destinations
Programcode Oct 22, 2026
d0a8d3c
📝 Describe snapshot version uuids
Programcode Oct 22, 2026
1bae340
Merge branch 'feat/all-healthkit-types' into feat/destinations
Programcode Oct 22, 2026
06a91f6
Merge branch 'feat/all-healthkit-types' into feat/destinations
Programcode Oct 22, 2026
38e26be
Merge branch 'feat/all-healthkit-types' into feat/destinations
Programcode Oct 22, 2026
ed3af2f
🐛 Fix catch-up across destinations, mid-drain disable, MQTT ids, shar…
Programcode Oct 22, 2026
63ba74a
🐛 Key catch-up hints to the destinations that gave them; drop unparse…
Programcode Oct 22, 2026
dd1ae8b
🐛 Address the Opus review of PR #3
Programcode Oct 22, 2026
cc07d1d
🔒 Move header values left in the stored list into the Keychain at launch
Programcode Oct 22, 2026
c4380d1
🐛 Address the second Opus review of PR #3
Programcode Oct 22, 2026
d57ffc2
🔒 Refuse redirects on /latest; describe per-destination queues in the…
Programcode Oct 22, 2026
59165a7
🐛 Remove a stray brace left by the activeDestinations removal
Programcode Oct 22, 2026
910f1f3
🐛 Keep wasConfigured once any saved copy had it
Programcode Oct 22, 2026
945340a
🐛 Use catch-up hints only for the destination set they came from
Programcode Oct 22, 2026
428d58d
🐛 Ask /latest and key the hints from one destination snapshot
Programcode Oct 22, 2026
57924ff
🐛 Decide enqueue from one destination snapshot; say when Sync can't run
Programcode Oct 22, 2026
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
40 changes: 40 additions & 0 deletions Pulso.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,14 @@
B20000000000000000000005 /* ObjC.swift in Sources */ = {isa = PBXBuildFile; fileRef = F20000000000000000000005 /* ObjC.swift */; };
B20000000000000000000006 /* ObjCExceptions.m in Sources */ = {isa = PBXBuildFile; fileRef = F20000000000000000000006 /* ObjCExceptions.m */; };
B20000000000000000000007 /* HealthKitCatalogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F20000000000000000000009 /* HealthKitCatalogTests.swift */; };
B20000000000000000000008 /* Destination.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2000000000000000000000A /* Destination.swift */; };
B20000000000000000000009 /* Transports.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2000000000000000000000B /* Transports.swift */; };
B2000000000000000000000A /* MQTTPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2000000000000000000000C /* MQTTPublisher.swift */; };
B2000000000000000000000B /* OutboxHub.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2000000000000000000000D /* OutboxHub.swift */; };
B2000000000000000000000C /* DestinationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2000000000000000000000E /* DestinationsView.swift */; };
B2000000000000000000000D /* MQTTPacketTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2000000000000000000000F /* MQTTPacketTests.swift */; };
B2000000000000000000000E /* DestinationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F20000000000000000000010 /* DestinationTests.swift */; };
B2000000000000000000000F /* Intents.swift in Sources */ = {isa = PBXBuildFile; fileRef = F20000000000000000000011 /* Intents.swift */; };
/* End PBXBuildFile section */

/* Begin PBXContainerItemProxy section */
Expand Down Expand Up @@ -98,6 +106,14 @@
F20000000000000000000007 /* ObjCExceptions.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ObjCExceptions.h; sourceTree = "<group>"; };
F20000000000000000000008 /* Pulso-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Pulso-Bridging-Header.h; sourceTree = "<group>"; };
F20000000000000000000009 /* HealthKitCatalogTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthKitCatalogTests.swift; sourceTree = "<group>"; };
F2000000000000000000000A /* Destination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Destination.swift; sourceTree = "<group>"; };
F2000000000000000000000B /* Transports.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Transports.swift; sourceTree = "<group>"; };
F2000000000000000000000C /* MQTTPublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MQTTPublisher.swift; sourceTree = "<group>"; };
F2000000000000000000000D /* OutboxHub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutboxHub.swift; sourceTree = "<group>"; };
F2000000000000000000000E /* DestinationsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DestinationsView.swift; sourceTree = "<group>"; };
F2000000000000000000000F /* MQTTPacketTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MQTTPacketTests.swift; sourceTree = "<group>"; };
F20000000000000000000010 /* DestinationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DestinationTests.swift; sourceTree = "<group>"; };
F20000000000000000000011 /* Intents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Intents.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
Expand Down Expand Up @@ -135,6 +151,7 @@
A10000000000000000000004 /* Model */,
A10000000000000000000005 /* Health */,
A10000000000000000000006 /* Sync */,
A2000000000000000000000B /* Destinations */,
A10000000000000000000007 /* Support */,
A10000000000000000000008 /* UI */,
F10000000000000000000017 /* Assets.xcassets */,
Expand All @@ -147,6 +164,7 @@
A10000000000000000000003 /* App */ = {
isa = PBXGroup;
children = (
F20000000000000000000011 /* Intents.swift */,
F10000000000000000000001 /* PulsoApp.swift */,
F10000000000000000000002 /* AppServices.swift */,
);
Expand Down Expand Up @@ -182,6 +200,7 @@
A10000000000000000000006 /* Sync */ = {
isa = PBXGroup;
children = (
F2000000000000000000000D /* OutboxHub.swift */,
F1000000000000000000000B /* SyncEngine.swift */,
F1000000000000000000000C /* Outbox.swift */,
F1000000000000000000000D /* Uploader.swift */,
Expand All @@ -207,6 +226,7 @@
A10000000000000000000008 /* UI */ = {
isa = PBXGroup;
children = (
F2000000000000000000000E /* DestinationsView.swift */,
F10000000000000000000013 /* RootView.swift */,
F10000000000000000000014 /* StatusView.swift */,
F10000000000000000000015 /* SettingsView.swift */,
Expand All @@ -218,6 +238,8 @@
A10000000000000000000009 /* PulsoTests */ = {
isa = PBXGroup;
children = (
F20000000000000000000010 /* DestinationTests.swift */,
F2000000000000000000000F /* MQTTPacketTests.swift */,
F20000000000000000000009 /* HealthKitCatalogTests.swift */,
F1000000000000000000001C /* SerializerTests.swift */,
F1000000000000000000001D /* OutboxTests.swift */,
Expand All @@ -238,6 +260,16 @@
name = Products;
sourceTree = "<group>";
};
A2000000000000000000000B /* Destinations */ = {
isa = PBXGroup;
children = (
F2000000000000000000000C /* MQTTPublisher.swift */,
F2000000000000000000000B /* Transports.swift */,
F2000000000000000000000A /* Destination.swift */,
);
path = Destinations;
sourceTree = "<group>";
};
/* End PBXGroup section */

/* Begin PBXNativeTarget section */
Expand Down Expand Up @@ -329,6 +361,12 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
B2000000000000000000000F /* Intents.swift in Sources */,
B2000000000000000000000C /* DestinationsView.swift in Sources */,
B2000000000000000000000B /* OutboxHub.swift in Sources */,
B2000000000000000000000A /* MQTTPublisher.swift in Sources */,
B20000000000000000000009 /* Transports.swift in Sources */,
B20000000000000000000008 /* Destination.swift in Sources */,
B20000000000000000000006 /* ObjCExceptions.m in Sources */,
B20000000000000000000005 /* ObjC.swift in Sources */,
B20000000000000000000004 /* HealthAuthorization.swift in Sources */,
Expand Down Expand Up @@ -364,6 +402,8 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
B2000000000000000000000E /* DestinationTests.swift in Sources */,
B2000000000000000000000D /* MQTTPacketTests.swift in Sources */,
B20000000000000000000007 /* HealthKitCatalogTests.swift in Sources */,
B1000000000000000000001C /* SerializerTests.swift in Sources */,
B1000000000000000000001D /* OutboxTests.swift in Sources */,
Expand Down
198 changes: 181 additions & 17 deletions Pulso/App/AppServices.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,16 @@ import SwiftUI
/// Composition root: owns the four components and the lifecycle glue.
@MainActor
final class AppServices {
/// One instance per process, shared by the app delegate and App
/// Intents (which can run with no UI shown).
static let shared = AppServices()

let healthStore = HKHealthStore()
let settings = AppSettings()
let status = AppStatus()
let log: LogStore
let anchors: AnchorStore
let outbox: Outbox
let outbox: OutboxHub
let engine: SyncEngine
let hub: TriggerHub
private let uploader = HTTPUploader()
Expand All @@ -29,25 +33,39 @@ final class AppServices {

log = LogStore(directory: root)
anchors = AnchorStore(directory: root.appendingPathComponent("Anchors", isDirectory: true))
outbox = Outbox(
directory: root.appendingPathComponent("Outbox", isDirectory: true),
anchors: anchors,
uploader: uploader,
target: { AppSettings.currentTarget() },
log: log,
status: status
)
let queues = root.appendingPathComponent("Outboxes", isDirectory: true)
Self.adoptLegacyOutbox(from: root.appendingPathComponent("Outbox", isDirectory: true), into: queues)
outbox = OutboxHub(root: queues, log: log, status: status)
let uploader = uploader
engine = SyncEngine(
healthStore: healthStore, anchors: anchors, outbox: outbox, log: log, status: status,
latestHints: {
guard let base = AppSettings.currentBase() else { return nil }
return await uploader.fetchLatest(base: base.url, token: base.token)
latestHints: { destinations in
// The anchor is shared, so skipping history is only safe when
// every destination already has it: all must answer, and the
// earliest answer per type wins.
guard !destinations.isEmpty, destinations.allSatisfy({ $0.latestURL != nil }) else { return nil }
var answers: [[String: String]] = []
for destination in destinations {
guard let url = destination.latestURL,
let hints = await uploader.fetchLatest(url: url, token: destination.bearerToken) else { return nil }
answers.append(hints)
}
return CatchUp.earliest(answers)
}
)
hub = TriggerHub(healthStore: healthStore, engine: engine, log: log)

status.load()
let device = UIDevice.current
let info = Bundle.main.infoDictionary ?? [:]
DeviceInfo.current = DeviceInfo(
id: device.identifierForVendor?.uuidString ?? "unknown",
name: device.name,
model: device.model,
systemVersion: device.systemVersion,
appVersion: "\(info["CFBundleShortVersionString"] as? String ?? "?") (\(info["CFBundleVersion"] as? String ?? "?"))",
bundleId: Bundle.main.bundleIdentifier ?? ""
)
let status = status
let log = log
Task {
Expand All @@ -59,20 +77,53 @@ final class AppServices {
}
}

/// Batches queued by the single-server version belong to the migrated
/// "Pulso server" destination.
private static func adoptLegacyOutbox(from legacy: URL, into queues: URL) {
let fm = FileManager.default
guard let files = try? fm.contentsOfDirectory(at: legacy, includingPropertiesForKeys: nil) else { return }
// Without a migrated server there is nowhere to deliver them yet:
// leave the folder alone rather than lose queued data.
guard let destination = AppSettings.currentDestinations().first(where: { $0.kind == .pulso }) else { return }
// Never move a queue's sequence backwards, or new batches would sort
// ahead of ones already waiting.
let defaults = UserDefaults.standard
let sequenceKey = "\(SettingsKeys.outboxSequence).\(destination.id.uuidString)"
defaults.set(
max(defaults.integer(forKey: SettingsKeys.outboxSequence), defaults.integer(forKey: sequenceKey)),
forKey: sequenceKey
)
do {
let target = queues.appendingPathComponent(destination.id.uuidString, isDirectory: true)
try fm.createDirectory(at: target, withIntermediateDirectories: true)
for file in files where file.pathExtension == "json" {
try fm.moveItem(at: file, to: target.appendingPathComponent(file.lastPathComponent))
}
} catch {
return // whatever didn't move stays put; the next launch tries again
}
try? fm.removeItem(at: legacy)
}

private var isTestRun: Bool {
ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil
}

private var launched = false

/// Called from application(_:didFinishLaunchingWithOptions:). Also runs
/// when HealthKit relaunches the app in the background for delivery.
func launch() {
guard !launched else { return }
launched = true
guard HKHealthStore.isHealthDataAvailable() else {
status.healthUnavailable = true
return
}
guard !isTestRun else { return }
hub.registerBackgroundRefresh()
hub.registerBackgroundTasks()
hub.scheduleNextRefresh()
hub.scheduleProcessing()
Task {
await refreshAuthStatus()
await hub.startObservers()
Expand All @@ -90,6 +141,7 @@ final class AppServices {
}
case .background:
hub.scheduleNextRefresh()
hub.scheduleProcessing()
default:
break
}
Expand Down Expand Up @@ -188,10 +240,122 @@ final class AppServices {
await syncNow(.reimport)
}

func testConnection() async -> String {
guard let url = settings.healthURL else {
return "Enter a valid server URL first"
// MARK: - Shortcuts

/// Intents may run before the delegate did (or in a process HealthKit
/// woke); launching twice is a no-op.
func launchIfNeeded() {
launch()
}

func syncFromIntent(keys: Set<String>?) async -> String {
// Otherwise the pass returns early and the shortcut would report the
// previous sync's summary as if this one had run.
guard await outbox.hasActiveDestination() else { return "No destination is configured." }
await withBackgroundTime("pulso-shortcut-sync") {
await engine.requestSync(.intent("Sync Health Data"), only: keys)
}
await refreshDerived()
return status.lastSyncSummary ?? "Sync finished."
}

func resend(keys: Set<String>?, start: Date?, end: Date?, reason: SyncReason) async -> String {
var summary = ""
await withBackgroundTime("pulso-shortcut-resend") {
summary = await engine.resend(only: keys, start: start, end: end, reason: reason)
}
await refreshDerived()
return summary
}

func statusReport() async -> String {
await outbox.refreshPendingStatus()
var lines: [String] = []
if let finished = status.lastSyncFinished {
lines.append("Last sync finished \(finished.formatted(date: .abbreviated, time: .shortened)).")
} else {
lines.append("No sync has finished yet.")
}
if let summary = status.lastSyncSummary { lines.append(summary) }
for destination in settings.destinations where destination.enabled {
let state = status.destinationStatus[destination.id] ?? DestinationStatus()
var line = "\(destination.name): \(state.pending) batch(es) queued"
if let success = state.lastSuccess {
line += ", last delivery \(success.formatted(date: .abbreviated, time: .shortened))"
}
if let failure = state.lastFailure, failure > (state.lastSuccess ?? .distantPast), let response = state.lastResponse {
line += ", failing: \(response)"
}
lines.append(line + ".")
}
if settings.destinations.allSatisfy({ !$0.enabled }) {
lines.append("No destination is enabled.")
}
return lines.joined(separator: "\n")
}

/// Keeps the process alive while a shortcut's work runs in the background.
private func withBackgroundTime(_ name: String, _ work: () async -> Void) async {
// Ended either when the work finishes or when iOS says time is up,
// whichever comes first; an outstanding assertion gets the app killed.
final class Assertion: @unchecked Sendable {
private let lock = NSLock()
private var id: UIBackgroundTaskIdentifier = .invalid
func set(_ value: UIBackgroundTaskIdentifier) { lock.lock(); id = value; lock.unlock() }
func end() {
lock.lock()
let current = id
id = .invalid
lock.unlock()
if current != .invalid { UIApplication.shared.endBackgroundTask(current) }
}
}
let assertion = Assertion()
assertion.set(UIApplication.shared.beginBackgroundTask(withName: name) { assertion.end() })
await work()
assertion.end()
}

// MARK: - Destinations

func destinationsChanged() {
Task {
await outbox.refreshPendingStatus()
}
}

func removeDestination(_ destination: Destination) {
settings.destinations.removeAll { $0.id == destination.id }
destination.deleteSecrets()
Task { await outbox.forget(destination.id) }
}

/// Sends something harmless the destination must accept: an empty test
/// batch over HTTP (signed and authenticated like real ones), a message
/// on <prefix>/test over MQTT, a small file for file export.
func testDestination(_ destination: Destination) async -> String {
do {
switch destination.kind {
case .pulso:
guard let base = AppSettings.baseURL(from: destination.url),
let url = URL(string: base.absoluteString + "/health") else { return "Enter a valid URL first" }
return await uploader.checkHealth(url)
case .rest, .homeAssistant:
let transport = HTTPTransport(destination: destination, session: HTTPTransport.makeSession())
let format: Destination.BodyFormat = destination.kind == .homeAssistant ? .envelope : destination.format
let result = try await transport.post(try BatchBody.test(device: DeviceInfo.current, format: format))
return result.isSuccess
? "OK — HTTP \(result.status)"
: "HTTP \(result.status): \(result.bodySnippet.prefix(120))"
case .mqtt:
try await MQTTTransport(destination: destination).test()
return "OK — published to \(MQTTTransport(destination: destination).topic("test"))"
case .file:
try FileTransport(destination: destination).test()
return "OK — wrote pulso-test.txt"
}
} catch {
return "Failed: \(error.localizedDescription)"
}
return await uploader.checkHealth(url)
}
}
Loading
Loading