Skip to content
Merged
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
31 changes: 28 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,14 @@ jobs:

app:
name: App build & unit tests
runs-on: macos-15
runs-on: macos-26
steps:
- uses: actions/checkout@v4
- name: Select the newest stable Xcode
run: |
XCODE=$(ls -d /Applications/Xcode_*.app | grep -viE 'beta|_rc|release_candidate' | sort -V | tail -1)
sudo xcode-select -s "$XCODE"
xcodebuild -version
- name: Pick a simulator
id: sim
run: |
Expand All @@ -49,10 +54,30 @@ jobs:
print(sorted(names)[-1])')
echo "name=$NAME" >> "$GITHUB_OUTPUT"
echo "Using simulator: $NAME"
# Signed ad-hoc ("Sign to Run Locally") rather than unsigned, so the
# test host carries the HealthKit entitlement and the read-set probe
# exercises HealthKit's real validation.
- name: Build and test
env:
SIMULATOR: ${{ steps.sim.outputs.name }}
run: |
status=0
xcodebuild test \
-project Pulso.xcodeproj \
-scheme Pulso \
-destination "platform=iOS Simulator,name=${{ steps.sim.outputs.name }}" \
CODE_SIGNING_ALLOWED=NO
-destination "platform=iOS Simulator,name=$SIMULATOR" \
CODE_SIGN_IDENTITY=- \
CODE_SIGN_STYLE=Manual \
DEVELOPMENT_TEAM= \
> test.log 2>&1 || status=$?
grep -E '(error:|PULSO-|Test Case .* failed|Executed [0-9]+ tests|\*\* TEST)' test.log | head -200 || true
if [ "$status" -ne 0 ]; then
tail -80 test.log
exit "$status"
fi
- uses: actions/upload-artifact@v4
if: always()
with:
name: app-test-log
path: test.log
if-no-files-found: ignore
27 changes: 19 additions & 8 deletions .github/workflows/ipa.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,25 @@ jobs:
echo "BUILD_NUMBER=$GITHUB_RUN_NUMBER" >> "$GITHUB_ENV"
echo "Version $MARKETING ($GITHUB_RUN_NUMBER), bundle id $BUNDLE_ID"

# The catalog of HealthKit types is generated from the SDK headers, so
# the IPA covers whatever this SDK declares. A stale committed copy
# only produces a warning: regenerate it with the same script.
- name: Generate the HealthKit type catalog from this SDK
run: |
SDK=$(xcrun --sdk iphoneos --show-sdk-path)
mkdir -p sdk-headers
cp -R "$SDK/System/Library/Frameworks/HealthKit.framework/Headers" sdk-headers/HealthKit
find "$SDK/System/Library/Frameworks/HealthKit.framework/Modules" -name 'arm64-apple-ios.swiftinterface' \
-exec cp {} sdk-headers/HealthKit.swiftinterface \; || true
xcrun --sdk iphoneos --show-sdk-version > sdk-headers/SDK_VERSION
xcodebuild -version > sdk-headers/XCODE_VERSION
python3 scripts/gen_healthkit_catalog.py sdk-headers/HealthKit Pulso/Health/HealthKitCatalog.generated.swift
if ! git diff --quiet -- Pulso/Health/HealthKitCatalog.generated.swift; then
echo "::warning::HealthKitCatalog.generated.swift is stale for iOS SDK $(cat sdk-headers/SDK_VERSION); this build uses the regenerated copy (uploaded as an artifact)."
git diff --stat -- Pulso/Health/HealthKitCatalog.generated.swift
fi
cp Pulso/Health/HealthKitCatalog.generated.swift sdk-headers/

- name: Build for device without signing
run: |
if ! xcodebuild build \
Expand Down Expand Up @@ -100,14 +119,6 @@ jobs:
unzip -l "dist/$NAME" | tail -3
echo "IPA_NAME=$NAME" >> "$GITHUB_ENV"

- name: Dump the SDK's HealthKit headers
run: |
SDK=$(xcrun --sdk iphoneos --show-sdk-path)
mkdir -p sdk-headers
cp -R "$SDK/System/Library/Frameworks/HealthKit.framework/Headers" sdk-headers/HealthKit
xcrun --sdk iphoneos --show-sdk-version > sdk-headers/SDK_VERSION
xcodebuild -version > sdk-headers/XCODE_VERSION

- uses: actions/upload-artifact@v4
with:
name: Pulso-unsigned-ipa
Expand Down
34 changes: 34 additions & 0 deletions Pulso.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@
B1000000000000000000001F /* AnchorStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1000000000000000000001F /* AnchorStoreTests.swift */; };
B10000000000000000000020 /* WireFormatTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F10000000000000000000020 /* WireFormatTests.swift */; };
B10000000000000000000021 /* TestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = F10000000000000000000021 /* TestSupport.swift */; };
B20000000000000000000001 /* HealthKitCatalog.generated.swift in Sources */ = {isa = PBXBuildFile; fileRef = F20000000000000000000001 /* HealthKitCatalog.generated.swift */; };
B20000000000000000000002 /* HealthQueries.swift in Sources */ = {isa = PBXBuildFile; fileRef = F20000000000000000000002 /* HealthQueries.swift */; };
B20000000000000000000003 /* Snapshots.swift in Sources */ = {isa = PBXBuildFile; fileRef = F20000000000000000000003 /* Snapshots.swift */; };
B20000000000000000000004 /* HealthAuthorization.swift in Sources */ = {isa = PBXBuildFile; fileRef = F20000000000000000000004 /* HealthAuthorization.swift */; };
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 */; };
/* End PBXBuildFile section */

/* Begin PBXContainerItemProxy section */
Expand Down Expand Up @@ -82,6 +89,15 @@
F1000000000000000000001F /* AnchorStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnchorStoreTests.swift; sourceTree = "<group>"; };
F10000000000000000000020 /* WireFormatTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WireFormatTests.swift; sourceTree = "<group>"; };
F10000000000000000000021 /* TestSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestSupport.swift; sourceTree = "<group>"; };
F20000000000000000000001 /* HealthKitCatalog.generated.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthKitCatalog.generated.swift; sourceTree = "<group>"; };
F20000000000000000000002 /* HealthQueries.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthQueries.swift; sourceTree = "<group>"; };
F20000000000000000000003 /* Snapshots.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Snapshots.swift; sourceTree = "<group>"; };
F20000000000000000000004 /* HealthAuthorization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthAuthorization.swift; sourceTree = "<group>"; };
F20000000000000000000005 /* ObjC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObjC.swift; sourceTree = "<group>"; };
F20000000000000000000006 /* ObjCExceptions.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ObjCExceptions.m; sourceTree = "<group>"; };
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>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
Expand Down Expand Up @@ -114,6 +130,7 @@
A10000000000000000000002 /* Pulso */ = {
isa = PBXGroup;
children = (
F20000000000000000000008 /* Pulso-Bridging-Header.h */,
A10000000000000000000003 /* App */,
A10000000000000000000004 /* Model */,
A10000000000000000000005 /* Health */,
Expand Down Expand Up @@ -150,6 +167,10 @@
A10000000000000000000005 /* Health */ = {
isa = PBXGroup;
children = (
F20000000000000000000004 /* HealthAuthorization.swift */,
F20000000000000000000003 /* Snapshots.swift */,
F20000000000000000000002 /* HealthQueries.swift */,
F20000000000000000000001 /* HealthKitCatalog.generated.swift */,
F10000000000000000000007 /* TypeRegistry.swift */,
F10000000000000000000008 /* Serializers.swift */,
F10000000000000000000009 /* WorkoutActivityName.swift */,
Expand All @@ -172,6 +193,9 @@
A10000000000000000000007 /* Support */ = {
isa = PBXGroup;
children = (
F20000000000000000000007 /* ObjCExceptions.h */,
F20000000000000000000006 /* ObjCExceptions.m */,
F20000000000000000000005 /* ObjC.swift */,
F1000000000000000000000F /* Gzip.swift */,
F10000000000000000000010 /* Keychain.swift */,
F10000000000000000000011 /* LogStore.swift */,
Expand All @@ -194,6 +218,7 @@
A10000000000000000000009 /* PulsoTests */ = {
isa = PBXGroup;
children = (
F20000000000000000000009 /* HealthKitCatalogTests.swift */,
F1000000000000000000001C /* SerializerTests.swift */,
F1000000000000000000001D /* OutboxTests.swift */,
F1000000000000000000001E /* GzipTests.swift */,
Expand Down Expand Up @@ -304,6 +329,12 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
B20000000000000000000006 /* ObjCExceptions.m in Sources */,
B20000000000000000000005 /* ObjC.swift in Sources */,
B20000000000000000000004 /* HealthAuthorization.swift in Sources */,
B20000000000000000000003 /* Snapshots.swift in Sources */,
B20000000000000000000002 /* HealthQueries.swift in Sources */,
B20000000000000000000001 /* HealthKitCatalog.generated.swift in Sources */,
B10000000000000000000001 /* PulsoApp.swift in Sources */,
B10000000000000000000002 /* AppServices.swift in Sources */,
B10000000000000000000003 /* SampleDTO.swift in Sources */,
Expand Down Expand Up @@ -333,6 +364,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
B20000000000000000000007 /* HealthKitCatalogTests.swift in Sources */,
B1000000000000000000001C /* SerializerTests.swift in Sources */,
B1000000000000000000001D /* OutboxTests.swift in Sources */,
B1000000000000000000001E /* GzipTests.swift in Sources */,
Expand Down Expand Up @@ -478,6 +510,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Pulso/Pulso.entitlements;
SWIFT_OBJC_BRIDGING_HEADER = "Pulso/Pulso-Bridging-Header.h";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
Expand Down Expand Up @@ -505,6 +538,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Pulso/Pulso.entitlements;
SWIFT_OBJC_BRIDGING_HEADER = "Pulso/Pulso-Bridging-Header.h";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
Expand Down
39 changes: 35 additions & 4 deletions Pulso/App/AppServices.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,8 @@ final class AppServices {
hub.registerBackgroundRefresh()
hub.scheduleNextRefresh()
Task {
await hub.startObservers()
await refreshAuthStatus()
await hub.startObservers()
await refreshDerived()
}
}
Expand Down Expand Up @@ -103,20 +103,47 @@ final class AppServices {
await refreshDerived()
}

/// The read set this OS accepts, probed once per launch (cheap, no UI).
private lazy var readSet: HealthAuthorization.ReadSet = {
let set = HealthAuthorization.readSet(TypeRegistry.readTypes, store: healthStore)
let log = log
let rejected = set.rejected
Task {
await log.info("Health read set: \(set.accepted.count) type(s) accepted")
for (identifier, reason) in rejected.sorted(by: { $0.key < $1.key }) {
await log.warn("HealthKit refuses read access to \(identifier) on this iOS — skipped (\(reason.prefix(160)))")
}
}
return set
}()

func requestHealthAccess() async {
do {
try await healthStore.requestAuthorization(toShare: [], read: TypeRegistry.readTypes)
try await HealthAuthorization.request(readSet.accepted, store: healthStore)
} catch {
await log.error("authorization request failed: \(error.localizedDescription)")
}
await refreshAuthStatus()
await syncNow(.manual)
}

/// Vision prescriptions and medications are shared object by object:
/// HealthKit shows its own picker, which needs a user tap to open.
func requestPerObjectAccess(_ key: String) async {
guard let entry = TypeRegistry.perObjectTypes.first(where: { $0.key == key }) else { return }
do {
try await healthStore.requestPerObjectReadAuthorization(for: entry.type, predicate: nil)
await log.info("\(key): per-object access updated")
} catch {
await log.error("\(key): per-object access failed: \(error.localizedDescription)")
}
await syncNow(.manual)
}

func refreshAuthStatus() async {
guard !status.healthUnavailable else { return }
do {
let request = try await healthStore.statusForAuthorizationRequest(toShare: [], read: TypeRegistry.readTypes)
let request = try await HealthAuthorization.requestStatus(readSet.accepted, store: healthStore)
status.authNeeded = request == .shouldRequest
} catch {
// Typically a signing problem (missing HealthKit entitlement) —
Expand All @@ -128,7 +155,11 @@ final class AppServices {
}

func refreshDerived() async {
status.needsInitialImport = AppSettings.currentEnabledTypes().contains { !anchors.hasAnchor($0.key) }
let refused = HealthAuthorization.lastRejected()
let pendingKeys = AppSettings.currentEnabledTypes()
.filter { !refused.contains($0.sampleType.identifier) }
.map(\.key) + AppSettings.currentEnabledSnapshots().map(\.key)
status.needsInitialImport = pendingKeys.contains { !anchors.hasAnchor($0) }
await outbox.refreshPendingStatus()
}

Expand Down
11 changes: 11 additions & 0 deletions Pulso/Health/AnchorStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ struct AnchorStore: Sendable {
return try? NSKeyedUnarchiver.unarchivedObject(ofClass: HKQueryAnchor.self, from: data)
}

/// The stored bytes as-is: snapshot sources keep their delivered-state
/// fingerprints here instead of an HKQueryAnchor.
func loadRaw(_ key: String) -> Data? {
try? Data(contentsOf: url(for: key))
}

func archive(_ anchor: HKQueryAnchor) -> Data? {
try? NSKeyedArchiver.archivedData(withRootObject: anchor, requiringSecureCoding: true)
}
Expand All @@ -34,6 +40,11 @@ struct AnchorStore: Sendable {
try? data.write(to: url(for: key), options: .atomic)
}

/// Like saveRaw, but a failed write is the caller's to handle.
func writeRaw(_ data: Data, for key: String) throws {
try data.write(to: url(for: key), options: .atomic)
}

func hasAnchor(_ key: String) -> Bool {
FileManager.default.fileExists(atPath: url(for: key).path)
}
Expand Down
99 changes: 99 additions & 0 deletions Pulso/Health/HealthAuthorization.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import Foundation
import HealthKit

/// HealthKit raises an Objective-C exception (which would crash the app)
/// when an authorization request names a type it won't let third-party
/// apps read on this OS. The read set is therefore probed first and any
/// refused type dropped, so one bad identifier can't cost all the others.
enum HealthAuthorization {
struct ReadSet {
let accepted: Set<HKObjectType>
/// Identifier -> the exception HealthKit raised for it alone.
let rejected: [String: String]
}

static let rejectedKey = "auth.rejectedTypes"

/// Types refused on the last probe, for SyncEngine to skip.
static func lastRejected() -> Set<String> {
Set(UserDefaults.standard.stringArray(forKey: rejectedKey) ?? [])
}

/// Probes `candidates` as one request; on an exception, splits the set
/// and probes the halves until each refused type is isolated.
static func readSet(_ candidates: Set<HKObjectType>, store: HKHealthStore) -> ReadSet {
var rejected: [String: String] = [:]
func accepted(_ types: [HKObjectType]) -> [HKObjectType] {
guard !types.isEmpty else { return [] }
guard let failure = probe(types, store: store) else { return types }
if types.count == 1 {
rejected[types[0].identifier] = failure
return []
}
let middle = types.count / 2
return accepted(Array(types[..<middle])) + accepted(Array(types[middle...]))
}
let ordered = candidates.sorted { $0.identifier < $1.identifier }
var kept = accepted(ordered)
// Some types are only accepted alongside another (heartbeat series
// needs HRV in the same request), so an isolated refusal gets one
// more try next to everything that passed.
for identifier in rejected.keys.sorted() {
guard let type = ordered.first(where: { $0.identifier == identifier }),
probe(kept + [type], store: store) == nil else { continue }
kept.append(type)
rejected[identifier] = nil
}
let result = ReadSet(accepted: Set(kept), rejected: rejected)
UserDefaults.standard.set(result.rejected.keys.sorted(), forKey: rejectedKey)
return result
}

/// nil when HealthKit accepts the set; otherwise the exception text.
private static func probe(_ types: [HKObjectType], store: HKHealthStore) -> String? {
do {
try ObjC.catching {
store.getRequestStatusForAuthorization(toShare: [], read: Set(types)) { _, _ in }
}
return nil
} catch {
return String(describing: error)
}
}

static func requestStatus(_ types: Set<HKObjectType>, store: HKHealthStore) async throws -> HKAuthorizationRequestStatus {
try await withCheckedThrowingContinuation { continuation in
do {
try ObjC.catching {
store.getRequestStatusForAuthorization(toShare: [], read: types) { status, error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume(returning: status)
}
}
}
} catch {
continuation.resume(throwing: error)
}
}
}

static func request(_ types: Set<HKObjectType>, store: HKHealthStore) async throws {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
do {
try ObjC.catching {
store.requestAuthorization(toShare: [], read: types) { _, error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume()
}
}
}
} catch {
continuation.resume(throwing: error)
}
}
}
}
Loading
Loading