项目文件夹

文件
wehub-resource-sync 426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

1135 行
43 KiB
Swift

import Foundation
import Capacitor
import HealthKit
import UIKit
import UserNotifications
@objc(MobileSignalsPlugin)
public class MobileSignalsPlugin: CAPPlugin, CAPBridgedPlugin {
public let identifier = "MobileSignalsPlugin"
public let jsName = "MobileSignals"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "checkPermissions", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "requestPermissions", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "openSettings", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "startMonitoring", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "stopMonitoring", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "getSnapshot", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "scheduleBackgroundRefresh", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "cancelBackgroundRefresh", returnType: CAPPluginReturnPromise),
]
private struct HealthCapture {
let source: String
let screenTime: [String: Any]
let permissions: [String: Bool]
let sleep: [String: Any]
let biometrics: [String: Any]
let warnings: [String]
}
private struct SleepEpisode {
let startDate: Date
let endDate: Date
let durationMinutes: Double
let latestStageValue: Int
}
private var monitoring = false
private var observers: [NSObjectProtocol] = []
private let healthStore = HKHealthStore()
private let healthQueue = DispatchQueue(label: "ai.eliza.mobile-signals.health", qos: .utility)
public override func load() {
UIDevice.current.isBatteryMonitoringEnabled = true
// Re-arm HealthKit background delivery on every cold boot. Apple's
// background-delivery registration does NOT persist across uninstalls
// and re-installations of the app, but it does persist across simple
// launches — the redundant call is cheap and ensures the foreground
// requestAuthorization → arm flow isn't the only path that turns it on.
enableHealthBackgroundDelivery()
}
@objc func scheduleBackgroundRefresh(_ call: CAPPluginCall) {
call.resolve([
"scheduled": false,
"reason": "iOS mobile signals use foreground monitoring; background scheduled work is routed through the eliza-tasks BackgroundRunner.",
])
}
@objc func cancelBackgroundRefresh(_ call: CAPPluginCall) {
call.resolve([
"cancelled": false,
"reason": "iOS mobile signals do not register a BGTaskScheduler background refresh task.",
])
}
deinit {
stopInternal()
UIDevice.current.isBatteryMonitoringEnabled = false
}
@objc func startMonitoring(_ call: CAPPluginCall) {
if monitoring {
call.resolve(buildStartResult())
return
}
monitoring = true
registerObservers()
call.resolve(buildStartResult())
if call.getBool("emitInitial") ?? true {
emitSignal(reason: "start")
emitHealthSignal(reason: "start")
}
}
@objc func stopMonitoring(_ call: CAPPluginCall) {
stopInternal()
call.resolve(["stopped": true])
}
@objc public override func checkPermissions(_ call: CAPPluginCall) {
buildPermissionResult { result in
call.resolve(result)
}
}
@objc public override func requestPermissions(_ call: CAPPluginCall) {
let target = call.getString("target") ?? "all"
if target == "screenTime" {
resolvePermissionAfterScreenTimeRequest(call)
return
}
if target == "notifications" {
requestNotificationPermissions(call)
return
}
let shouldRequestScreenTime = target != "health"
let types = requestedHealthTypes()
guard !types.isEmpty else {
resolvePermissionResult(
call,
status: "not-applicable",
canRequest: false,
reason: "HealthKit sleep and biometric types are unavailable on this device.",
requestScreenTime: shouldRequestScreenTime
)
return
}
healthStore.requestAuthorization(toShare: nil, read: Set(types)) { [weak self] success, error in
DispatchQueue.main.async {
guard let self = self else { return }
let healthReason = !success
? "HealthKit permission request failed: \(error?.localizedDescription ?? "unknown error")"
: nil
if success {
self.enableHealthBackgroundDelivery()
}
self.resolvePermissionResult(
call,
reason: healthReason,
requestScreenTime: shouldRequestScreenTime
)
}
}
}
/// Turn on `HKHealthStore.enableBackgroundDelivery(for:frequency:)` for
/// the sleep + biometric sample types we already requested authorization
/// for. iOS will then wake the app — via the `com.apple.developer.healthkit.background-delivery`
/// entitlement that ships with `App.entitlements` — whenever HealthKit
/// has a new sample to deliver. The wake itself does not run our code:
/// it flips the WebView's app state to background-with-network-OK, which
/// is what the runtime's `HKObserverQuery` / next pull will pick up.
///
/// `.immediate` is the only honest choice for sleep + heart-rate signals;
/// HealthKit clamps observation cadence to whatever the underlying sensor
/// chose, so anything coarser would just delay the wake.
///
/// This method is intentionally fire-and-forget — a failure to enable
/// background delivery is not user-actionable; the foreground monitoring
/// path already works. We log and move on.
///
/// Entitlement probe: iOS exposes no public API to read the running
/// binary's code-signing entitlements, so the first sample type doubles as
/// the capability probe. When the binary is not signed with
/// `com.apple.developer.healthkit` (simulator lanes built with code
/// signing disabled, sideload/dev builds signed without the capability)
/// EVERY call fails identically with "Missing
/// com.apple.developer.healthkit entitlement" — so the probe failing that
/// way means the remaining registrations are skipped behind a single info
/// line instead of one warning per type.
private func enableHealthBackgroundDelivery() {
guard HKHealthStore.isHealthDataAvailable() else { return }
let sampleTypes = backgroundDeliverySampleTypes()
guard let probeType = sampleTypes.first else { return }
healthStore.enableBackgroundDelivery(
for: probeType,
frequency: .immediate
) { [weak self] success, error in
guard let self = self else { return }
let outcome = HealthBackgroundDeliveryGate.probeOutcome(
success: success,
errorMessage: error?.localizedDescription
)
switch outcome {
case .entitlementMissing:
NSLog(
"[MobileSignalsPlugin] HealthKit background delivery skipped: binary lacks the com.apple.developer.healthkit entitlement (expected for simulator and non-store dev builds); foreground health monitoring is unaffected"
)
return
case .probeFailed:
Self.logBackgroundDeliveryFailure(probeType, error)
case .succeeded:
break
}
for sampleType in sampleTypes.dropFirst() {
self.healthStore.enableBackgroundDelivery(
for: sampleType,
frequency: .immediate
) { ok, err in
if !ok {
Self.logBackgroundDeliveryFailure(sampleType, err)
}
}
}
}
}
/// Sleep + biometric sample types eligible for background delivery, in
/// probe order (the first entry is the entitlement probe).
private func backgroundDeliverySampleTypes() -> [HKSampleType] {
var sampleTypes: [HKSampleType] = []
if let sleepType = HKObjectType.categoryType(forIdentifier: .sleepAnalysis) {
sampleTypes.append(sleepType)
}
for identifier in [
HKQuantityTypeIdentifier.heartRate,
HKQuantityTypeIdentifier.restingHeartRate,
HKQuantityTypeIdentifier.heartRateVariabilitySDNN,
HKQuantityTypeIdentifier.respiratoryRate,
HKQuantityTypeIdentifier.oxygenSaturation,
] {
if let qt = HKObjectType.quantityType(forIdentifier: identifier) {
sampleTypes.append(qt)
}
}
return sampleTypes
}
private static func logBackgroundDeliveryFailure(
_ sampleType: HKSampleType,
_ error: Error?
) {
NSLog(
"[MobileSignalsPlugin] enableBackgroundDelivery(%@) failed: %@",
sampleType.identifier,
error?.localizedDescription ?? "unknown"
)
}
private func requestNotificationPermissions(_ call: CAPPluginCall) {
readNotificationPermission { [weak self] notification in
guard let self = self else { return }
guard notification.canRequest else {
self.buildPermissionResult(reason: notification.reason) { result in
call.resolve(result)
}
return
}
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { [weak self] _, error in
DispatchQueue.main.async {
guard let self = self else { return }
self.buildPermissionResult(reason: error?.localizedDescription) { result in
call.resolve(result)
}
}
}
}
}
@objc func openSettings(_ call: CAPPluginCall) {
let target = call.getString("target") ?? "app"
let reason: String?
let actualTarget: String
let urlString: String
if target == "notification", #available(iOS 16.0, *) {
actualTarget = "notification"
urlString = UIApplication.openNotificationSettingsURLString
reason = nil
} else {
actualTarget = "app"
urlString = UIApplication.openSettingsURLString
reason = target == "app" || target == "health" || target == "localNetwork"
? nil
: "iOS only supports stable public deep links to this app's Settings screen."
}
guard let url = URL(string: urlString) else {
call.resolve([
"opened": false,
"target": target,
"actualTarget": actualTarget,
"reason": "Unable to build iOS settings URL.",
])
return
}
DispatchQueue.main.async {
UIApplication.shared.open(url, options: [:]) { opened in
let resolvedReason: Any
if opened {
if let reason {
resolvedReason = reason
} else {
resolvedReason = NSNull()
}
} else {
resolvedReason = "iOS declined to open Settings."
}
call.resolve([
"opened": opened,
"target": target,
"actualTarget": actualTarget,
"reason": resolvedReason,
])
}
}
}
@objc func getSnapshot(_ call: CAPPluginCall) {
let device = buildSnapshot(reason: "snapshot")
buildHealthSnapshot(reason: "snapshot") { health in
call.resolve([
"supported": true,
"snapshot": device,
"healthSnapshot": health,
])
}
}
private func registerObservers() {
let center = NotificationCenter.default
let names: [Notification.Name] = [
UIApplication.didBecomeActiveNotification,
UIApplication.willResignActiveNotification,
UIApplication.didEnterBackgroundNotification,
UIApplication.willEnterForegroundNotification,
UIApplication.protectedDataDidBecomeAvailableNotification,
UIApplication.protectedDataWillBecomeUnavailableNotification,
Notification.Name.NSProcessInfoPowerStateDidChange,
UIDevice.batteryStateDidChangeNotification,
]
for name in names {
let observer = center.addObserver(
forName: name,
object: nil,
queue: .main
) { [weak self] _ in
self?.emitSignal(reason: name.rawValue)
if name == UIApplication.didBecomeActiveNotification ||
name == UIApplication.willEnterForegroundNotification ||
name == UIApplication.protectedDataDidBecomeAvailableNotification {
self?.emitHealthSignal(reason: name.rawValue)
}
}
observers.append(observer)
}
}
private func stopInternal() {
let center = NotificationCenter.default
for observer in observers {
center.removeObserver(observer)
}
observers.removeAll()
monitoring = false
}
private func buildStartResult() -> [String: Any] {
[
"enabled": monitoring,
"supported": true,
"platform": "ios",
"snapshot": buildSnapshot(reason: "start"),
"healthSnapshot": NSNull(),
]
}
private func requestedHealthTypes() -> [HKObjectType] {
var types: [HKObjectType] = []
if let sleepType = self.sleepHealthType() {
types.append(sleepType)
}
types.append(contentsOf: biometricHealthTypes())
return types
}
private func sleepHealthType() -> HKObjectType? {
HKObjectType.categoryType(forIdentifier: .sleepAnalysis)
}
private func biometricHealthTypes() -> [HKObjectType] {
let biometricIdentifiers: [HKQuantityTypeIdentifier] = [
.heartRate,
.restingHeartRate,
.heartRateVariabilitySDNN,
.respiratoryRate,
.oxygenSaturation,
]
return biometricIdentifiers.compactMap {
HKObjectType.quantityType(forIdentifier: $0)
}
}
private struct NotificationPermissionCapture {
let status: String
let canRequest: Bool
let reason: String?
}
private func readNotificationPermission(
completion: @escaping (NotificationPermissionCapture) -> Void
) {
UNUserNotificationCenter.current().getNotificationSettings { settings in
let capture: NotificationPermissionCapture
switch settings.authorizationStatus {
case .authorized, .provisional, .ephemeral:
capture = NotificationPermissionCapture(
status: "granted",
canRequest: false,
reason: nil
)
case .denied:
capture = NotificationPermissionCapture(
status: "denied",
canRequest: false,
reason: "Notifications are disabled for Eliza. Open Settings to enable reminders and prompts."
)
case .notDetermined:
capture = NotificationPermissionCapture(
status: "not-determined",
canRequest: true,
reason: "Allow notifications when LifeOps needs to remind or prompt you."
)
@unknown default:
capture = NotificationPermissionCapture(
status: "restricted",
canRequest: false,
reason: "iOS notification authorization is restricted by this device."
)
}
DispatchQueue.main.async {
completion(capture)
}
}
}
private func buildPermissionResult(
status overrideStatus: String? = nil,
canRequest overrideCanRequest: Bool? = nil,
reason overrideReason: String? = nil,
completion: @escaping ([String: Any]) -> Void
) {
readNotificationPermission { [weak self] notification in
guard let self = self else { return }
completion(self.buildPermissionResultPayload(
status: overrideStatus,
canRequest: overrideCanRequest,
reason: overrideReason,
notification: notification
))
}
}
private func buildPermissionResultPayload(
status overrideStatus: String? = nil,
canRequest overrideCanRequest: Bool? = nil,
reason overrideReason: String? = nil,
notification: NotificationPermissionCapture
) -> [String: Any] {
let screenTimeStatus = ScreenTimeSupport.buildStatus()
guard HKHealthStore.isHealthDataAvailable() else {
return [
"status": overrideStatus ?? "not-applicable",
"canRequest": overrideCanRequest ?? false,
"canOpenSettings": true,
"settingsTarget": "app",
"engine": "healthkit-screen-time",
"capabilities": mobileSignalsCapabilities(),
"reason": overrideReason ?? "HealthKit is not available on this device.",
"permissions": [
"sleep": false,
"biometrics": false,
],
"screenTime": screenTimeStatus,
"setupActions": buildSetupActions(
healthStatus: overrideStatus ?? "not-applicable",
healthCanRequest: overrideCanRequest ?? false,
screenTimeStatus: screenTimeStatus,
notification: notification
),
]
}
let sleepType = sleepHealthType()
let biometricTypes = biometricHealthTypes()
let sleepGranted = sleepType.map { healthStore.authorizationStatus(for: $0) == .sharingAuthorized } ?? false
let biometricGranted = biometricTypes.isEmpty
? false
: biometricTypes.allSatisfy { healthStore.authorizationStatus(for: $0) == .sharingAuthorized }
let hasRequestedTypes = sleepType != nil || !biometricTypes.isEmpty
let hasDenied = (sleepType.map { healthStore.authorizationStatus(for: $0) == .sharingDenied } ?? false) ||
biometricTypes.contains { healthStore.authorizationStatus(for: $0) == .sharingDenied }
let hasPending = (sleepType.map { healthStore.authorizationStatus(for: $0) == .notDetermined } ?? false) ||
biometricTypes.contains { healthStore.authorizationStatus(for: $0) == .notDetermined }
let status = overrideStatus ?? {
if !hasRequestedTypes {
return "not-applicable"
}
if sleepGranted || biometricGranted {
return "granted"
}
if hasDenied {
return "denied"
}
if hasPending {
return "not-determined"
}
return "not-determined"
}()
let settingsTarget: Any = status == "granted" ? NSNull() : "health"
return [
"status": status,
"canRequest": overrideCanRequest ?? (status != "granted" && hasRequestedTypes),
"canOpenSettings": true,
"settingsTarget": settingsTarget,
"engine": "healthkit-screen-time",
"capabilities": mobileSignalsCapabilities(),
"reason": overrideReason ?? NSNull(),
"screenTime": screenTimeStatus,
"setupActions": buildSetupActions(
healthStatus: status,
healthCanRequest: overrideCanRequest ?? (status != "granted" && hasRequestedTypes),
screenTimeStatus: screenTimeStatus,
notification: notification
),
"permissions": [
"sleep": sleepGranted,
"biometrics": biometricGranted,
],
]
}
private func mobileSignalsCapabilities() -> [String: Any] {
[
"health": HKHealthStore.isHealthDataAvailable(),
"screenTime": true,
"notifications": true,
"settings": true,
]
}
private func resolvePermissionAfterScreenTimeRequest(
_ call: CAPPluginCall,
status: String? = nil,
canRequest: Bool? = nil,
reason: String? = nil
) {
ScreenTimeSupport.requestAuthorizationIfAvailable { [weak self] screenTimeReason in
guard let self = self else { return }
self.buildPermissionResult(
status: status,
canRequest: canRequest,
reason: reason
) { result in
var next = result
if let screenTimeReason {
if let existingReason = next["reason"] as? String, !existingReason.isEmpty {
next["reason"] = "\(existingReason) \(screenTimeReason)"
} else {
next["reason"] = screenTimeReason
}
}
call.resolve(next)
}
}
}
private func resolvePermissionResult(
_ call: CAPPluginCall,
status: String? = nil,
canRequest: Bool? = nil,
reason: String? = nil,
requestScreenTime: Bool
) {
if requestScreenTime {
resolvePermissionAfterScreenTimeRequest(
call,
status: status,
canRequest: canRequest,
reason: reason
)
return
}
buildPermissionResult(
status: status,
canRequest: canRequest,
reason: reason
) { result in
call.resolve(result)
}
}
private func buildSetupActions(
healthStatus: String,
healthCanRequest: Bool,
screenTimeStatus: [String: Any],
notification: NotificationPermissionCapture
) -> [[String: Any]] {
let healthReady = healthStatus == "granted"
let authorization = screenTimeStatus["authorization"] as? [String: Any] ?? [:]
let screenTimeAuthStatus = authorization["status"] as? String ?? "unavailable"
let screenTimeCanRequest = authorization["canRequest"] as? Bool ?? false
let screenTimeSupported = screenTimeStatus["supported"] as? Bool ?? false
let screenTimeReady = screenTimeAuthStatus == "approved"
let screenTimeReason = screenTimeStatus["reason"] ?? NSNull()
let notificationsReady = notification.status == "granted"
return [
[
"id": "health_permissions",
"label": "HealthKit",
"status": healthReady
? "ready"
: (healthStatus == "not-applicable" ? "unavailable" : "needs-action"),
"canRequest": healthCanRequest,
"canOpenSettings": true,
"settingsTarget": "health",
"reason": healthReady
? NSNull()
: "Grant Health read access for sleep, heart rate, HRV, respiratory rate, and oxygen saturation.",
],
[
"id": "screen_time_authorization",
"label": "Screen Time",
"status": screenTimeReady
? "ready"
: (screenTimeSupported ? "needs-action" : "unavailable"),
"canRequest": screenTimeCanRequest,
"canOpenSettings": true,
"settingsTarget": "screenTime",
"reason": screenTimeReady ? NSNull() : screenTimeReason,
],
[
"id": "local_network",
"label": "Local Network",
"status": "needs-action",
"canRequest": false,
"canOpenSettings": true,
"settingsTarget": "localNetwork",
"reason": "Allow Local Network when this phone sends data to a Mac or LAN agent.",
],
[
"id": "notification_settings",
"label": "Notifications",
"status": notificationsReady ? "ready" : "needs-action",
"canRequest": notification.canRequest,
"canOpenSettings": true,
"settingsTarget": "notification",
"reason": notificationsReady ? NSNull() : (notification.reason ?? "Open notification settings if reminders or telemetry prompts are muted."),
],
]
}
private func buildSnapshot(reason: String) -> [String: Any] {
let app = UIApplication.shared
let protectedAvailable = app.isProtectedDataAvailable
let lowPower = ProcessInfo.processInfo.isLowPowerModeEnabled
let batteryState = UIDevice.current.batteryState
let batteryLevel = UIDevice.current.batteryLevel
let onBattery: Bool? = {
switch batteryState {
case .charging, .full:
return false
case .unplugged:
return true
case .unknown:
return nil
@unknown default:
return nil
}
}()
let state: String = {
if !protectedAvailable {
return "locked"
}
switch app.applicationState {
case .active:
return lowPower ? "idle" : "active"
case .inactive:
return "idle"
case .background:
return "background"
@unknown default:
return "background"
}
}()
let idleState: String = {
if !protectedAvailable {
return "locked"
}
if lowPower {
return "idle"
}
return state == "active" ? "active" : "idle"
}()
let level = batteryLevel >= 0 ? Double(batteryLevel) : nil
let onBatteryValue: Any = onBattery ?? NSNull()
let levelValue: Any = level ?? NSNull()
return [
"source": "mobile_device",
"platform": "ios",
"state": state,
"observedAt": Int64(Date().timeIntervalSince1970 * 1000),
"idleState": idleState,
"idleTimeSeconds": NSNull(),
"onBattery": onBatteryValue,
"metadata": [
"reason": reason,
"applicationState": app.applicationState.rawValue,
"isProtectedDataAvailable": protectedAvailable,
"isLowPowerModeEnabled": lowPower,
"batteryState": batteryState.rawValue,
"batteryLevel": levelValue,
],
]
}
private func emitSignal(reason: String) {
guard monitoring else { return }
notifyListeners("signal", data: buildSnapshot(reason: reason))
}
private func emitHealthSignal(reason: String) {
guard monitoring else { return }
buildHealthSnapshot(reason: reason) { [weak self] healthSnapshot in
guard let self = self, self.monitoring else { return }
self.notifyListeners("signal", data: healthSnapshot)
}
}
private func buildHealthSnapshot(
reason: String,
completion: @escaping ([String: Any]) -> Void
) {
guard HKHealthStore.isHealthDataAvailable() else {
completion(makeHealthSnapshot(
reason: reason,
capture: HealthCapture(
source: "healthkit",
screenTime: ScreenTimeSupport.buildStatus(),
permissions: ["sleep": false, "biometrics": false],
sleep: [
"available": false,
"isSleeping": false,
"asleepAt": NSNull(),
"awakeAt": NSNull(),
"durationMinutes": NSNull(),
"stage": NSNull(),
],
biometrics: [
"sampleAt": NSNull(),
"heartRateBpm": NSNull(),
"restingHeartRateBpm": NSNull(),
"heartRateVariabilityMs": NSNull(),
"respiratoryRate": NSNull(),
"bloodOxygenPercent": NSNull(),
],
warnings: ["HealthKit is not available on this device"]
)
))
return
}
healthQueue.async {
let group = DispatchGroup()
var sleepSummary: HealthCapture?
var biometricsSummary: HealthCapture?
var warnings: [String] = []
group.enter()
self.fetchSleepSummary { capture, fetchWarning in
sleepSummary = capture
if let fetchWarning {
warnings.append(fetchWarning)
}
group.leave()
}
group.enter()
self.fetchBiometrics { capture, fetchWarning in
biometricsSummary = capture
if let fetchWarning {
warnings.append(fetchWarning)
}
group.leave()
}
group.notify(queue: .main) {
let capture = HealthCapture(
source: "healthkit",
screenTime: ScreenTimeSupport.buildStatus(),
permissions: [
"sleep": sleepSummary?.permissions["sleep"] ?? false,
"biometrics": biometricsSummary?.permissions["biometrics"] ?? false,
],
sleep: sleepSummary?.sleep ?? [
"available": false,
"isSleeping": false,
"asleepAt": NSNull(),
"awakeAt": NSNull(),
"durationMinutes": NSNull(),
"stage": NSNull(),
],
biometrics: biometricsSummary?.biometrics ?? [
"sampleAt": NSNull(),
"heartRateBpm": NSNull(),
"restingHeartRateBpm": NSNull(),
"heartRateVariabilityMs": NSNull(),
"respiratoryRate": NSNull(),
"bloodOxygenPercent": NSNull(),
],
warnings: warnings
)
completion(
self.makeHealthSnapshot(
reason: reason,
capture: capture
)
)
}
}
}
private func makeHealthSnapshot(
reason: String,
capture: HealthCapture
) -> [String: Any] {
let deviceBatteryState = UIDevice.current.batteryState
let onBattery: Bool? = {
switch deviceBatteryState {
case .charging, .full:
return false
case .unplugged:
return true
case .unknown:
return nil
@unknown default:
return nil
}
}()
let state = (capture.sleep["isSleeping"] as? Bool) == true ? "sleeping" : "idle"
return [
"source": "mobile_health",
"platform": "ios",
"state": state,
"observedAt": Int64(Date().timeIntervalSince1970 * 1000),
"idleState": NSNull(),
"idleTimeSeconds": NSNull(),
"onBattery": onBattery ?? NSNull(),
"healthSource": capture.source,
"screenTime": capture.screenTime,
"permissions": capture.permissions,
"sleep": capture.sleep,
"biometrics": capture.biometrics,
"warnings": capture.warnings,
"metadata": [
"reason": reason,
"healthSource": capture.source,
"deviceState": UIApplication.shared.applicationState.rawValue,
],
]
}
private func fetchSleepSummary(
completion: @escaping (HealthCapture?, String?) -> Void
) {
guard let sampleType = HKObjectType.categoryType(forIdentifier: .sleepAnalysis) else {
completion(nil, "Sleep analysis type unavailable")
return
}
let startDate = Calendar.current.date(byAdding: .day, value: -7, to: Date()) ?? Date().addingTimeInterval(-7 * 24 * 60 * 60)
let predicate = HKQuery.predicateForSamples(
withStart: startDate,
end: nil,
options: .strictStartDate
)
let sortDescriptors = [
NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)
]
let query = HKSampleQuery(
sampleType: sampleType,
predicate: predicate,
limit: HKObjectQueryNoLimit,
sortDescriptors: sortDescriptors
) { _, samples, error in
guard error == nil else {
completion(nil, "Sleep analysis query failed")
return
}
let categories = (samples as? [HKCategorySample]) ?? []
guard !categories.isEmpty else {
completion(
HealthCapture(
source: "healthkit",
screenTime: ScreenTimeSupport.buildStatus(),
permissions: ["sleep": false, "biometrics": false],
sleep: [
"available": false,
"isSleeping": false,
"asleepAt": NSNull(),
"awakeAt": NSNull(),
"durationMinutes": NSNull(),
"stage": NSNull(),
],
biometrics: [
"sampleAt": NSNull(),
"heartRateBpm": NSNull(),
"restingHeartRateBpm": NSNull(),
"heartRateVariabilityMs": NSNull(),
"respiratoryRate": NSNull(),
"bloodOxygenPercent": NSNull(),
],
warnings: []
),
nil
)
return
}
let latestEpisode = Self.latestSleepEpisode(from: categories)
let latestAwake = categories.last(where: { $0.value == HKCategoryValueSleepAnalysis.awake.rawValue })
let now = Date()
let sleepFreshnessWindow: TimeInterval = 15 * 60
let isSleeping =
latestEpisode != nil &&
latestEpisode!.endDate >= now.addingTimeInterval(-sleepFreshnessWindow) &&
(latestAwake == nil || latestAwake!.endDate <= latestEpisode!.endDate)
let asleepAt = latestEpisode?.startDate
let awakeAt = isSleeping ? nil : latestEpisode?.endDate
let durationMinutes = latestEpisode?.durationMinutes
let stage = latestEpisode.map { episode in
isSleeping ? Self.sleepStageName(for: episode.latestStageValue) : "awake"
} ?? "awake"
completion(
HealthCapture(
source: "healthkit",
screenTime: ScreenTimeSupport.buildStatus(),
permissions: ["sleep": true, "biometrics": false],
sleep: [
"available": true,
"isSleeping": isSleeping,
"asleepAt": asleepAt.map { Int64($0.timeIntervalSince1970 * 1000) } ?? NSNull(),
"awakeAt": awakeAt.map { Int64($0.timeIntervalSince1970 * 1000) } ?? NSNull(),
"durationMinutes": durationMinutes.map { Int64($0.rounded()) } ?? NSNull(),
"stage": stage,
],
biometrics: [
"sampleAt": NSNull(),
"heartRateBpm": NSNull(),
"restingHeartRateBpm": NSNull(),
"heartRateVariabilityMs": NSNull(),
"respiratoryRate": NSNull(),
"bloodOxygenPercent": NSNull(),
],
warnings: []
),
nil
)
}
healthStore.execute(query)
}
private func fetchBiometrics(
completion: @escaping (HealthCapture?, String?) -> Void
) {
let startDate = Calendar.current.date(byAdding: .day, value: -7, to: Date()) ?? Date().addingTimeInterval(-7 * 24 * 60 * 60)
let endDate = Date()
let predicate = HKQuery.predicateForSamples(
withStart: startDate,
end: endDate,
options: .strictStartDate
)
let group = DispatchGroup()
var latestHeartRate: (value: Double, at: Date)?
var latestRestingHeartRate: (value: Double, at: Date)?
var latestHrv: (value: Double, at: Date)?
var latestRespiratoryRate: (value: Double, at: Date)?
var latestBloodOxygen: (value: Double, at: Date)?
func fetchLatest(
identifier: HKQuantityTypeIdentifier,
unit: HKUnit,
assign: @escaping (Double, Date) -> Void
) {
guard let sampleType = HKObjectType.quantityType(forIdentifier: identifier) else {
return
}
group.enter()
let query = HKSampleQuery(
sampleType: sampleType,
predicate: predicate,
limit: 1,
sortDescriptors: [
NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false)
]
) { _, samples, error in
defer { group.leave() }
guard error == nil,
let sample = samples?.first as? HKQuantitySample else {
return
}
assign(sample.quantity.doubleValue(for: unit), sample.startDate)
}
healthStore.execute(query)
}
fetchLatest(identifier: .heartRate, unit: HKUnit(from: "count/min")) { value, at in
latestHeartRate = (value, at)
}
fetchLatest(identifier: .restingHeartRate, unit: HKUnit(from: "count/min")) { value, at in
latestRestingHeartRate = (value, at)
}
fetchLatest(identifier: .heartRateVariabilitySDNN, unit: HKUnit.secondUnit(with: .milli)) { value, at in
latestHrv = (value, at)
}
fetchLatest(identifier: .respiratoryRate, unit: HKUnit(from: "count/min")) { value, at in
latestRespiratoryRate = (value, at)
}
fetchLatest(identifier: .oxygenSaturation, unit: HKUnit.percent()) { value, at in
latestBloodOxygen = (value * 100.0, at)
}
group.notify(queue: .main) {
let sampleAt = [
latestHeartRate?.at,
latestRestingHeartRate?.at,
latestHrv?.at,
latestRespiratoryRate?.at,
latestBloodOxygen?.at,
].compactMap { $0 }.sorted().last
let hasBiometrics =
latestHeartRate != nil ||
latestRestingHeartRate != nil ||
latestHrv != nil ||
latestRespiratoryRate != nil ||
latestBloodOxygen != nil
let sleep: [String: Any] = [
"available": false,
"isSleeping": false,
"asleepAt": NSNull(),
"awakeAt": NSNull(),
"durationMinutes": NSNull(),
"stage": NSNull(),
]
let biometrics: [String: Any] = [
"sampleAt": sampleAt.map { Int64($0.timeIntervalSince1970 * 1000) } ?? NSNull(),
"heartRateBpm": latestHeartRate.map { Int64($0.value.rounded()) } ?? NSNull(),
"restingHeartRateBpm": latestRestingHeartRate.map { Int64($0.value.rounded()) } ?? NSNull(),
"heartRateVariabilityMs": latestHrv?.value ?? NSNull(),
"respiratoryRate": latestRespiratoryRate?.value ?? NSNull(),
"bloodOxygenPercent": latestBloodOxygen?.value ?? NSNull(),
]
completion(
HealthCapture(
source: "healthkit",
screenTime: ScreenTimeSupport.buildStatus(),
permissions: [
"sleep": false,
"biometrics": hasBiometrics,
],
sleep: sleep,
biometrics: biometrics,
warnings: []
),
nil
)
}
}
private static func isSleepSample(_ value: Int) -> Bool {
value != HKCategoryValueSleepAnalysis.awake.rawValue &&
value != HKCategoryValueSleepAnalysis.inBed.rawValue
}
private static func latestSleepEpisode(from categories: [HKCategorySample]) -> SleepEpisode? {
let sleepSamples = categories
.filter { isSleepSample($0.value) }
.sorted { left, right in left.startDate < right.startDate }
guard let first = sleepSamples.first else {
return nil
}
let maxStageGap: TimeInterval = 90 * 60
var episodes: [SleepEpisode] = []
var episodeStart = first.startDate
var episodeEnd = first.endDate
var episodeDuration = first.endDate.timeIntervalSince(first.startDate) / 60.0
var latestStageValue = first.value
for sample in sleepSamples.dropFirst() {
if sample.startDate.timeIntervalSince(episodeEnd) <= maxStageGap {
episodeEnd = max(episodeEnd, sample.endDate)
episodeDuration += sample.endDate.timeIntervalSince(sample.startDate) / 60.0
latestStageValue = sample.value
continue
}
episodes.append(SleepEpisode(
startDate: episodeStart,
endDate: episodeEnd,
durationMinutes: episodeDuration,
latestStageValue: latestStageValue
))
episodeStart = sample.startDate
episodeEnd = sample.endDate
episodeDuration = sample.endDate.timeIntervalSince(sample.startDate) / 60.0
latestStageValue = sample.value
}
episodes.append(SleepEpisode(
startDate: episodeStart,
endDate: episodeEnd,
durationMinutes: episodeDuration,
latestStageValue: latestStageValue
))
return episodes.sorted { left, right in left.endDate < right.endDate }.last
}
private static func sleepStageName(for value: Int) -> String {
switch value {
case HKCategoryValueSleepAnalysis.awake.rawValue:
return "awake"
case HKCategoryValueSleepAnalysis.inBed.rawValue:
return "in_bed"
default:
return "asleep"
}
}
}