diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index 095cf09f80..839c9fc8a4 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -924,6 +924,14 @@ 42C3737F2BC415AC00898990 /* UIViewController+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42C3737E2BC415AC00898990 /* UIViewController+Extensions.swift */; }; 42CB330D2DAE4FD800491DCE /* ServerSelectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CB330C2DAE4FD800491DCE /* ServerSelectView.swift */; }; 42CB330F2DAE530400491DCE /* SettingsButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CB330E2DAE530400491DCE /* SettingsButton.swift */; }; + 42CD571C2EE736250032D7C5 /* COMPLICATION_SYNC_PROTOCOL.md in Resources */ = {isa = PBXBuildFile; fileRef = 42CD571B2EE736250032D7C5 /* COMPLICATION_SYNC_PROTOCOL.md */; }; + 42CD571E2EE738D60032D7C5 /* WatchComplicationSyncMessages.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CD571D2EE738D60032D7C5 /* WatchComplicationSyncMessages.swift */; }; + 42CD57202EE73A6B0032D7C5 /* WatchComplicationSyncMessages.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CD571F2EE73A6B0032D7C5 /* WatchComplicationSyncMessages.swift */; }; + 42CD57212EE73A6B0032D7C5 /* WatchComplicationSyncMessages.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CD571F2EE73A6B0032D7C5 /* WatchComplicationSyncMessages.swift */; }; + 42CD57282EE73E850032D7C5 /* AppWatchComplicationTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CD57222EE73DEC0032D7C5 /* AppWatchComplicationTable.swift */; }; + 42CD57292EE73E850032D7C5 /* AppWatchComplicationTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CD57222EE73DEC0032D7C5 /* AppWatchComplicationTable.swift */; }; + 42CD572A2EE73ECB0032D7C5 /* AppWatchComplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CD57242EE73DFD0032D7C5 /* AppWatchComplication.swift */; }; + 42CD572B2EE73ECB0032D7C5 /* AppWatchComplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CD57242EE73DFD0032D7C5 /* AppWatchComplication.swift */; }; 42CE8FA72B45D1E900C707F9 /* CoreStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CE8FA52B45D1E900C707F9 /* CoreStrings.swift */; }; 42CE8FA82B45D1E900C707F9 /* CoreStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CE8FA52B45D1E900C707F9 /* CoreStrings.swift */; }; 42CE8FA92B45D1E900C707F9 /* FrontendStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CE8FA62B45D1E900C707F9 /* FrontendStrings.swift */; }; @@ -1026,6 +1034,8 @@ 42F5CAE72B10CDC900409816 /* CardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CA28AF2B101D6B0093B31A /* CardView.swift */; }; 42F5CAE82B10CDC900409816 /* HAButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CA28B52B1022680093B31A /* HAButton.swift */; }; 42F5CAED2B10CF3A00409816 /* Assets.swift in Sources */ = {isa = PBXBuildFile; fileRef = B65B15042273188300635D5C /* Assets.swift */; }; + 42F68DAB2EE9BC5200639F48 /* ARCHITECTURE_DIAGRAMS.md in Resources */ = {isa = PBXBuildFile; fileRef = 42F68DAA2EE9BC5200639F48 /* ARCHITECTURE_DIAGRAMS.md */; }; + 42F68DAF2EE9BEE900639F48 /* ComplicationTemplateRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42F68DA62EE9BBC200639F48 /* ComplicationTemplateRenderer.swift */; }; 42F73F562E259A0900B704A9 /* BaseSensorUpdateSignaler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42F73F552E259A0900B704A9 /* BaseSensorUpdateSignaler.swift */; }; 42F73F572E259A0900B704A9 /* BaseSensorUpdateSignaler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42F73F552E259A0900B704A9 /* BaseSensorUpdateSignaler.swift */; }; 42F73F5A2E264A9D00B704A9 /* WebViewControllerButtons.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42F73F592E264A9D00B704A9 /* WebViewControllerButtons.swift */; }; @@ -2607,6 +2617,11 @@ 42CA28B52B1022680093B31A /* HAButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HAButton.swift; sourceTree = ""; }; 42CB330C2DAE4FD800491DCE /* ServerSelectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSelectView.swift; sourceTree = ""; }; 42CB330E2DAE530400491DCE /* SettingsButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsButton.swift; sourceTree = ""; }; + 42CD571B2EE736250032D7C5 /* COMPLICATION_SYNC_PROTOCOL.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = COMPLICATION_SYNC_PROTOCOL.md; sourceTree = ""; }; + 42CD571D2EE738D60032D7C5 /* WatchComplicationSyncMessages.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchComplicationSyncMessages.swift; sourceTree = ""; }; + 42CD571F2EE73A6B0032D7C5 /* WatchComplicationSyncMessages.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchComplicationSyncMessages.swift; sourceTree = ""; }; + 42CD57222EE73DEC0032D7C5 /* AppWatchComplicationTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppWatchComplicationTable.swift; sourceTree = ""; }; + 42CD57242EE73DFD0032D7C5 /* AppWatchComplication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppWatchComplication.swift; sourceTree = ""; }; 42CE8FA52B45D1E900C707F9 /* CoreStrings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CoreStrings.swift; sourceTree = ""; }; 42CE8FA62B45D1E900C707F9 /* FrontendStrings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FrontendStrings.swift; sourceTree = ""; }; 42CE8FAC2B46C12C00C707F9 /* Domain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Domain.swift; sourceTree = ""; }; @@ -2691,6 +2706,8 @@ 42F1DA732B4FF9F8002729BC /* MaterialDesignIcons+CarPlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MaterialDesignIcons+CarPlay.swift"; sourceTree = ""; }; 42F3E1482E1D22B400F4E6FC /* HATextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HATextField.swift; sourceTree = ""; }; 42F5CABB2B10AE1A00409816 /* ServerFixture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerFixture.swift; sourceTree = ""; }; + 42F68DA62EE9BBC200639F48 /* ComplicationTemplateRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComplicationTemplateRenderer.swift; sourceTree = ""; }; + 42F68DAA2EE9BC5200639F48 /* ARCHITECTURE_DIAGRAMS.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = ARCHITECTURE_DIAGRAMS.md; sourceTree = ""; }; 42F73F552E259A0900B704A9 /* BaseSensorUpdateSignaler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseSensorUpdateSignaler.swift; sourceTree = ""; }; 42F73F592E264A9D00B704A9 /* WebViewControllerButtons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewControllerButtons.swift; sourceTree = ""; }; 42F8D8672DC3B0500022DE43 /* GesturesSetupView.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GesturesSetupView.test.swift; sourceTree = ""; }; @@ -4466,6 +4483,8 @@ children = ( 423F451F2C19D88100766A99 /* Assist */, B6CC5D972159D10E00833E5D /* ComplicationController.swift */, + 42F68DA62EE9BBC200639F48 /* ComplicationTemplateRenderer.swift */, + 42F68DAA2EE9BC5200639F48 /* ARCHITECTURE_DIAGRAMS.md */, ); path = Complication; sourceTree = ""; @@ -4596,6 +4615,7 @@ 426266432C11B0070081A818 /* Watch */ = { isa = PBXGroup; children = ( + 42CD571F2EE73A6B0032D7C5 /* WatchComplicationSyncMessages.swift */, 4251AAB82C6CE1B4004CCC9D /* WatchConfig.swift */, 426266442C11B02C0081A818 /* InteractiveImmediateMessages.swift */, 4278C9C02C8F226500A7B5F4 /* GuaranteedMessages.swift */, @@ -5438,6 +5458,10 @@ 428830EA2C6E3A8D0012373D /* WatchHomeView.swift */, 428830EC2C6E3A9A0012373D /* WatchHomeViewModel.swift */, 429764F52E93B21E004C26EE /* CircularGlassOrLegacyBackground.swift */, + 42CD571B2EE736250032D7C5 /* COMPLICATION_SYNC_PROTOCOL.md */, + 42CD571D2EE738D60032D7C5 /* WatchComplicationSyncMessages.swift */, + 42CD57222EE73DEC0032D7C5 /* AppWatchComplicationTable.swift */, + 42CD57242EE73DFD0032D7C5 /* AppWatchComplication.swift */, ); path = Home; sourceTree = ""; @@ -7633,8 +7657,10 @@ buildActionMask = 2147483647; files = ( 426490732C0F1F36002155CC /* Colors.xcassets in Resources */, + 42F68DAB2EE9BC5200639F48 /* ARCHITECTURE_DIAGRAMS.md in Resources */, B6CC5D9A2159D10F00833E5D /* Assets.xcassets in Resources */, FC8E9421FDB864726918B612 /* Pods-watchOS-WatchExtension-Watch-metadata.plist in Resources */, + 42CD571C2EE736250032D7C5 /* COMPLICATION_SYNC_PROTOCOL.md in Resources */, 421BBC702E93A64B00745EC8 /* Interface.storyboard in Resources */, 426266422C11A6700081A818 /* SharedAssets.xcassets in Resources */, ); @@ -8831,6 +8857,7 @@ 11BA5ECA2759AC0300FC40E8 /* XCGLogger+Export.swift in Sources */, 42CE8FAA2B45D1E900C707F9 /* FrontendStrings.swift in Sources */, 11B38EF0275C54A300205C7B /* FireEventIntentHandler.swift in Sources */, + 42CD57292EE73E850032D7C5 /* AppWatchComplicationTable.swift in Sources */, 1120C5852749C6350046C38B /* ServerProviding.swift in Sources */, 429821192CD0DEE2005ECD39 /* HAButtonStyles.swift in Sources */, 11C4628024B04CB800031902 /* Promise+RetryNetworking.swift in Sources */, @@ -8928,6 +8955,7 @@ 11C65CC1249838EB00D07FC7 /* StreamCameraResponse.swift in Sources */, 111858DB24CB7F9900B8CDDC /* SiriIntents+ConvenienceInits.swift in Sources */, 11195F73267F01E4003DF674 /* HACancellable+App.swift in Sources */, + 42CD572B2EE73ECB0032D7C5 /* AppWatchComplication.swift in Sources */, 427A7CE02EBDFB4200D17841 /* AppArea+Queries.swift in Sources */, 42E3B8B92D8AC63300F5D084 /* Float+HA.swift in Sources */, B6B74CBE228399AC00D58A68 /* Action.swift in Sources */, @@ -8989,6 +9017,7 @@ 1121CD4D271295AD0071C2AA /* Style.swift in Sources */, 116570782702B0F6003906A7 /* DiskCache.swift in Sources */, 11657051270188E4003906A7 /* URLComponents+WidgetAuthenticity.swift in Sources */, + 42CD57212EE73A6B0032D7C5 /* WatchComplicationSyncMessages.swift in Sources */, 11F855DD24DF6C7A0018013E /* IconImageView.swift in Sources */, B67CE87922200F220034C1D0 /* RealmZone.swift in Sources */, 420CFC802D3F9D89009A94F3 /* DatabaseTables.swift in Sources */, @@ -9078,12 +9107,14 @@ 426490682C0F1A49002155CC /* WatchAssistView.swift in Sources */, 4264907A2C0F3D97002155CC /* AudioPlayer.swift in Sources */, 429764F62E93B21E004C26EE /* CircularGlassOrLegacyBackground.swift in Sources */, + 42F68DAF2EE9BEE900639F48 /* ComplicationTemplateRenderer.swift in Sources */, B672AB582216B5E000175465 /* Date+ComplicationDivination.swift in Sources */, 423F44F02C17238200766A99 /* ChatBubbleView.swift in Sources */, B6CC5D982159D10E00833E5D /* ComplicationController.swift in Sources */, 42DF98712E93A22C00837EA2 /* NotificationSubControllerMedia.swift in Sources */, 42B1A7432C11E65100904548 /* WatchAssistService.swift in Sources */, 42DF986D2E93A20400837EA2 /* NotificationSubControllerMJPEG.swift in Sources */, + 42CD571E2EE738D60032D7C5 /* WatchComplicationSyncMessages.swift in Sources */, 423F44FF2C186E4500766A99 /* WatchCommunicatorService.swift in Sources */, 42DF98692E93A1E000837EA2 /* DynamicNotificationController.swift in Sources */, 42DF98652E93A11000837EA2 /* HostingController.swift in Sources */, @@ -9179,6 +9210,7 @@ 1133F59C25F1DA5D00AD776F /* CLLocation+Sanitize.swift in Sources */, 11AF4D1C249C8AA0006C74C0 /* BatterySensor.swift in Sources */, D014EEA92128E192008EA6F5 /* ConnectionInfo.swift in Sources */, + 42CD57282EE73E850032D7C5 /* AppWatchComplicationTable.swift in Sources */, 424151FC2CD8F27100D7A6F9 /* CarPlayConfig.swift in Sources */, 11169BC8262BE460005EF90A /* UNNotificationContent+Additions.swift in Sources */, 42A3B63B2BD91854007BC0F3 /* Color+Codable.swift in Sources */, @@ -9298,6 +9330,7 @@ 42A3B63E2BD918D6007BC0F3 /* MaterialDesignIcons+Encodable.swift in Sources */, 427E92BE2D65E43A0001566B /* WidgetInteractionType.swift in Sources */, 420CFC652D3F9C2C009A94F3 /* HAppEntityTable.swift in Sources */, + 42CD572A2EE73ECB0032D7C5 /* AppWatchComplication.swift in Sources */, B658AA7E2250B2A000C9BFE3 /* MobileAppUpdateRegistrationRequest.swift in Sources */, 42F5CAE52B10CDC600409816 /* HACornerRadius.swift in Sources */, 1182620724F9C492000795C6 /* HACoreMediaObjectCamera.swift in Sources */, @@ -9348,6 +9381,7 @@ 420CFC6C2D3F9C6E009A94F3 /* CarPlayConfigTable.swift in Sources */, 114E9B4E24E89B1300B43EED /* INImage+MaterialDesignIcons.swift in Sources */, 118261FD24F9B81A000795C6 /* HACoreBlahProperty.swift in Sources */, + 42CD57202EE73A6B0032D7C5 /* WatchComplicationSyncMessages.swift in Sources */, 42FCCFDA2B9B19F70057783F /* ThreadClientService.swift in Sources */, 1101568724D7712F009424C9 /* TagManagerProtocol.swift in Sources */, 42E9B0002CE63944009DDA46 /* AudioOutputSensor.swift in Sources */, diff --git a/Sources/App/Settings/AppleWatch/ComplicationEditViewController.swift b/Sources/App/Settings/AppleWatch/ComplicationEditViewController.swift index 6e724f218f..1ed9048cc5 100644 --- a/Sources/App/Settings/AppleWatch/ComplicationEditViewController.swift +++ b/Sources/App/Settings/AppleWatch/ComplicationEditViewController.swift @@ -53,10 +53,12 @@ class ComplicationEditViewController: HAFormViewController, TypedRowControllerTy Current.Log.verbose("COMPLICATION \(config) \(config.Data)") realm.add(config, update: .all) - }.then(on: nil) { [server] in - Current.api(for: server)? - .updateComplications(passively: false) ?? .init(error: HomeAssistantAPI.APIError.noAPIAvailable) }.cauterize() + // .then(on: nil) { [server] in +// Current.api(for: server)? +// .updateComplications(passively: false) ?? .init(error: HomeAssistantAPI.APIError.noAPIAvailable) + // TODO: Send send complication to watch so it can save on database + // }.cauterize() onDismissCallback?(self) } @@ -78,10 +80,12 @@ class ComplicationEditViewController: HAFormViewController, TypedRowControllerTy let realm = Current.realm() realm.reentrantWrite { realm.delete(config) - }.then(on: nil) { - Current.api(for: server)? - .updateComplications(passively: false) ?? .init(error: HomeAssistantAPI.APIError.noAPIAvailable) }.cauterize() + // .then(on: nil) { +// Current.api(for: server)? +// .updateComplications(passively: false) ?? .init(error: HomeAssistantAPI.APIError.noAPIAvailable) + // TODO: Send send complication to watch so it can remove from database + // }.cauterize() self.onDismissCallback?(self) } diff --git a/Sources/Extensions/Watch/Complication/ARCHITECTURE_DIAGRAMS.md b/Sources/Extensions/Watch/Complication/ARCHITECTURE_DIAGRAMS.md new file mode 100644 index 0000000000..8e626fc05a --- /dev/null +++ b/Sources/Extensions/Watch/Complication/ARCHITECTURE_DIAGRAMS.md @@ -0,0 +1,302 @@ +# Complication Template Rendering Architecture + +## Component Diagram + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ ClockKit System │ +│ (Requests complication updates from ComplicationController) │ +└────────────────────────────┬────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ ComplicationController │ +│ - CLKComplicationDataSource implementation │ +│ - Manages complication descriptors and timeline │ +│ - Delegates rendering to ComplicationTemplateRenderer │ +│ │ +│ Properties: │ +│ • templateRenderer: ComplicationTemplateRendering │ +│ │ +│ Methods (simplified): │ +│ • getCurrentTimelineEntry(...) - Main ClockKit callback │ +│ • renderTemplatesAndProvideEntry(...) - Delegates to renderer │ +│ • template(for:) - Generates templates │ +└────────────────────────────┬────────────────────────────────────┘ + │ delegates to + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ <> ComplicationTemplateRendering │ +│ • renderAndProvideEntry(for:model:date:completion:) │ +└────────────────────────────┬────────────────────────────────────┘ + │ implemented by + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ ComplicationTemplateRenderer │ +│ Handles complete template rendering lifecycle │ +│ │ +│ Orchestration: │ +│ • renderAndProvideEntry(...) - Main entry point │ +│ • syncNetworkInformation(...) - Network sync │ +│ • validateServerAndConnection(...) - Validation │ +│ • renderTemplates(...) - Rendering coordinator │ +│ │ +│ Template Processing: │ +│ • createCombinedTemplateString(...) - Batch preparation │ +│ • sendRenderRequest(...) - API communication │ +│ • setupTimeout(...) - Timeout handling │ +│ │ +│ Response Handling: │ +│ • handleRenderResponse(...) - Response router │ +│ • handleSuccessResponse(...) - Success processor │ +│ • parseRenderedTemplates(...) - Parse response │ +│ │ +│ Database & Generation: │ +│ • updateDatabaseAndProvideEntry(...) - DB update │ +│ • provideEntry(...) - Generate timeline entry │ +│ • provideFallbackEntry(...) - Fallback on error │ +└────────────────────────────┬────────────────────────────────────┘ + │ uses + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ <> ComplicationTemplateProvider │ +│ • template(for: CLKComplication) -> CLKComplicationTemplate │ +└────────────────────────────┬────────────────────────────────────┘ + │ implemented by + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ DefaultComplicationTemplateProvider │ +│ Generates ClockKit templates from complication models │ +│ │ +│ Methods: │ +│ • template(for:) - Main template generation │ +│ • fetchComplicationModel(for:) - Database fetch │ +│ │ +│ Fallback Chain: │ +│ 1. Model from database → clkComplicationTemplate │ +│ 2. Assist default → AssistDefaultComplication.createTemplate │ +│ 3. Placeholder → ComplicationGroupMember.fallbackTemplate │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## Sequence Diagram: Template Rendering Flow + +``` +ClockKit Controller Renderer Network HAConnection Database + │ │ │ │ │ │ + │ getCurrentTimelineEntry │ │ │ + ├─────────>│ │ │ │ │ + │ │ │ │ │ │ + │ │ renderAndProvideEntry │ │ + │ ├─────────>│ │ │ │ + │ │ │ │ │ │ + │ │ │ Validate server │ │ + │ │ │ identifier │ │ + │ │ │────┐ │ │ │ + │ │ │ │ │ │ │ + │ │ │<───┘ │ │ │ + │ │ │ │ │ │ + │ │ │ syncNetworkInformation │ + │ │ ├─────────>│ │ │ + │ │ │ │ │ │ + │ │ │<─────────┤ │ │ + │ │ │ (complete) │ │ + │ │ │ │ │ │ + │ │ │ Validate server │ │ + │ │ │ & connection │ │ + │ │ │────┐ │ │ │ + │ │ │ │ │ │ │ + │ │ │<───┘ │ │ │ + │ │ │ │ │ │ + │ │ │ Combine templates │ │ + │ │ │────┐ │ │ │ + │ │ │ │ │ │ │ + │ │ │<───┘ │ │ │ + │ │ │ │ │ │ + │ │ │ send(template API) │ │ + │ │ ├─────────────────────> │ + │ │ │ │ │ │ + │ │ │ (5s timeout running) │ + │ │ │ │ │ │ + │ │ │<─────────────────────┤ │ + │ │ │ (response) │ │ + │ │ │ │ │ │ + │ │ │ Parse response │ │ + │ │ │────┐ │ │ │ + │ │ │ │ │ │ │ + │ │ │<───┘ │ │ │ + │ │ │ │ │ │ + │ │ │ Update database │ │ + │ │ ├─────────────────────────────────> + │ │ │ │ │ │ + │ │ │<─────────────────────────────────┤ + │ │ │ (saved) │ │ + │ │ │ │ │ │ + │ │ │ Generate template │ │ + │ │ │────┐ │ │ │ + │ │ │ │ │ │ │ + │ │ │<───┘ │ │ │ + │ │ │ │ │ │ + │ │<─────────┤ │ │ │ + │ │ (entry) │ │ │ │ + │ │ │ │ │ │ + │<─────────┤ │ │ │ │ + │ (entry) │ │ │ │ │ + │ │ │ │ │ │ +``` + +## Error Handling Flow + +``` +┌──────────────────────────┐ +│ Start Rendering │ +└────────────┬─────────────┘ + │ + ▼ +┌──────────────────────────┐ ┌─────────────────────┐ +│ Check Server Identifier │────>│ Missing Identifier │ +└────────────┬─────────────┘ └──────────┬──────────┘ + │ ✓ │ + ▼ ▼ +┌──────────────────────────┐ ┌──────────────┐ +│ Sync Network Info │ │ Fallback │ +└────────────┬─────────────┘ │ Entry │ + │ └──────────────┘ + ▼ ▲ +┌──────────────────────────┐ ┌─────────┴──────────┐ +│ Validate Server & API │────>│ Server/API Missing │ +└────────────┬─────────────┘ └────────────────────┘ + │ ✓ │ + ▼ │ +┌──────────────────────────┐ ┌─────────┴──────────┐ +│ Send API Request │────>│ Timeout (5s) │ +└────────────┬─────────────┘ └────────────────────┘ + │ ✓ │ + ▼ │ +┌──────────────────────────┐ ┌─────────┴──────────┐ +│ Parse Response │────>│ Parse Error │ +└────────────┬─────────────┘ └────────────────────┘ + │ ✓ │ + ▼ │ +┌──────────────────────────┐ ┌─────────┴──────────┐ +│ Update Database │────>│ DB Error │ +└────────────┬─────────────┘ └────────────────────┘ + │ ✓ │ + ▼ │ +┌──────────────────────────┐ │ +│ Generate Template │ │ +└────────────┬─────────────┘ │ + │ │ + ▼ │ +┌──────────────────────────┐ │ +│ Provide Entry │<──────────────┘ +└──────────────────────────┘ +``` + +## Dependency Graph + +``` +ComplicationController + │ + ├─► ComplicationTemplateRendering (protocol) + │ └─► ComplicationTemplateRenderer (implementation) + │ │ + │ ├─► Current.connectivity (network sync) + │ ├─► Current.servers (server management) + │ ├─► Current.api(for:) (API access) + │ ├─► HAConnection (Home Assistant API) + │ ├─► Current.database() (GRDB database) + │ └─► ComplicationTemplateProvider (protocol) + │ └─► DefaultComplicationTemplateProvider + │ │ + │ ├─► AppWatchComplication (model) + │ ├─► MaterialDesignIcons (icons) + │ ├─► AssistDefaultComplication (special) + │ └─► ComplicationGroupMember (fallback) + │ + └─► Direct Dependencies: + ├─► Current.database() (model fetching) + ├─► AppWatchComplication (data model) + ├─► CLKComplicationDataSource (protocol) + └─► ComplicationGroupMember (helpers) +``` + +## Key Responsibilities + +### ComplicationController +- **Primary Role**: CLKComplicationDataSource implementation +- **Responsibilities**: + - Respond to ClockKit callbacks + - Fetch complication models from database + - Provide complication descriptors + - Manage privacy settings + - Delegate rendering to renderer + +### ComplicationTemplateRenderer +- **Primary Role**: Template rendering orchestration +- **Responsibilities**: + - Sync network information + - Validate server availability + - Call Home Assistant API + - Parse and cache rendered values + - Handle timeouts and errors + - Generate final templates + +### ComplicationTemplateProvider +- **Primary Role**: Template generation abstraction +- **Responsibilities**: + - Fetch models from database + - Generate ClockKit templates + - Provide fallback templates + - Handle special cases (Assist, placeholders) + +## Benefits Visualization + +``` +Before: +┌───────────────────────────────────────┐ +│ ComplicationController │ +│ │ +│ ┌──────────────────────────────────┐ │ +│ │ renderTemplatesAndProvideEntry │ │ +│ │ │ │ +│ │ • Network sync │ │ +│ │ • Server validation │ │ +│ │ • Template combining │ │ +│ │ • API communication │ │ +│ │ • Response parsing │ │ +│ │ • Database updates │ │ +│ │ • Template generation │ │ +│ │ • Error handling │ │ +│ │ • Timeout management │ │ +│ │ │ │ +│ │ (~150 lines, mixed concerns) │ │ +│ └──────────────────────────────────┘ │ +└───────────────────────────────────────┘ + +After: +┌──────────────────────┐ ┌────────────────────────────┐ +│ ComplicationController│───>│ ComplicationTemplateRenderer│ +│ │ │ │ +│ • ClockKit callbacks │ │ ┌────────────────────────┐ │ +│ • Model fetching │ │ │ Network Sync │ │ +│ • Descriptors │ │ └────────────────────────┘ │ +│ • Privacy settings │ │ ┌────────────────────────┐ │ +│ │ │ │ Server Validation │ │ +│ (~20 lines rendering)│ │ └────────────────────────┘ │ +└──────────────────────┘ │ ┌────────────────────────┐ │ + │ │ Template Processing │ │ + │ └────────────────────────┘ │ + │ ┌────────────────────────┐ │ + │ │ API Communication │ │ + │ └────────────────────────┘ │ + │ ┌────────────────────────┐ │ + │ │ Response Parsing │ │ + │ └────────────────────────┘ │ + │ ┌────────────────────────┐ │ + │ │ Database Updates │ │ + │ └────────────────────────┘ │ + │ │ + │ (13 focused methods) │ + └────────────────────────────┘ +``` diff --git a/Sources/Extensions/Watch/Complication/ComplicationController.swift b/Sources/Extensions/Watch/Complication/ComplicationController.swift index 40137d812c..2d26c57ca3 100644 --- a/Sources/Extensions/Watch/Complication/ComplicationController.swift +++ b/Sources/Extensions/Watch/Complication/ComplicationController.swift @@ -1,43 +1,139 @@ import ClockKit -import RealmSwift +import Communicator import Shared +/// Controller responsible for providing complication data to ClockKit +/// +/// This class serves as the data source for all complications displayed on watch faces. +/// It fetches complication data from GRDB, converts it to ClockKit templates, and handles +/// privacy settings and complication descriptors. +/// +/// ## Architecture +/// - **Data Source**: GRDB database containing `AppWatchComplication` records +/// - **ClockKit Integration**: Implements `CLKComplicationDataSource` protocol +/// - **Fallbacks**: Provides placeholder templates when no data is available +/// +/// ## Key Responsibilities +/// 1. Fetch complications from GRDB by identifier or family +/// 2. Generate ClockKit templates for rendering on watch faces +/// 3. Manage privacy behavior (show/hide on lock screen) +/// 4. Provide complication descriptors for the watch face editor +/// +/// ## Data Flow +/// ``` +/// ClockKit Request +/// ↓ +/// complicationModel(for:) → Fetch from GRDB +/// ↓ +/// AppWatchComplication.clkComplicationTemplate() +/// ↓ +/// CLKComplicationTemplate → Display on watch face +/// ``` +/// +/// - Note: This controller is called frequently by ClockKit. Ensure database queries are optimized. +/// - Important: Always provide fallback templates to prevent blank complications. +/// +/// ## Related Types +/// - `AppWatchComplication`: GRDB data model for complications with template generation +/// - `ComplicationGroupMember`: Family groupings for complications +/// - `AssistDefaultComplication`: Special complication for launching Assist class ComplicationController: NSObject, CLKComplicationDataSource { // Helpful resources // https://github.com/LoopKit/Loop/issues/816 // https://crunchybagel.com/detecting-which-complication-was-tapped/ - private func complicationModel(for complication: CLKComplication) -> WatchComplication? { + private var renderers: [ComplicationTemplateRenderer] = [] + + // MARK: - Private Helper Methods + + /// Fetches the complication model from GRDB database + /// + /// This method handles two identifier strategies: + /// 1. **Modern approach (watchOS 7+)**: Uses `complication.identifier` to fetch by unique ID + /// 2. **Legacy approach (watchOS 6)**: Uses complication family as the identifier + /// + /// The legacy approach is necessary because: + /// - Pre-watchOS 7 complications don't have unique identifiers + /// - We store them using the family rawValue as the primary key + /// - This maintains backward compatibility with older watch faces + /// + /// - Parameter complication: The `CLKComplication` to fetch data for + /// - Returns: `AppWatchComplication` if found in database, `nil` otherwise + /// + /// ## Example Usage + /// ```swift + /// if let model = complicationModel(for: complication) { + /// let template = model.clkComplicationTemplate(family: complication.family) + /// } + /// ``` + /// + /// - Note: Database errors are logged but don't crash - returns `nil` on failure + /// - Important: This is called frequently by ClockKit, so performance matters + private func complicationModel(for complication: CLKComplication) -> AppWatchComplication? { // Helper function to get a complication using the correct ID depending on watchOS version - let model: WatchComplication? + let model: AppWatchComplication? - if complication.identifier != CLKDefaultComplicationIdentifier { - // existing complications that were configured pre-7 have no identifier set - // so we can only access the value if it's a valid one. otherwise, fall back to old matching behavior. - model = Current.realm().object( - ofType: WatchComplication.self, - forPrimaryKey: complication.identifier - ) - } else { - // we migrate pre-existing complications, and when still using watchOS 6 create new ones, - // with the family as the identifier, so we can rely on this code path for older OS and older complications - let matchedFamily = ComplicationGroupMember(family: complication.family) - model = Current.realm().object( - ofType: WatchComplication.self, - forPrimaryKey: matchedFamily.rawValue - ) + do { + if complication.identifier != CLKDefaultComplicationIdentifier { + // existing complications that were configured pre-7 have no identifier set + // so we can only access the value if it's a valid one. otherwise, fall back to old matching behavior. + + // Fetch from GRDB + model = try Current.database().read { db in + try AppWatchComplication.fetch(identifier: complication.identifier, from: db) + } + } else { + // we migrate pre-existing complications, and when still using watchOS 6 create new ones, + // with the family as the identifier, so we can rely on this code path for older OS and older + // complications + let matchedFamily = ComplicationGroupMember(family: complication.family) + + // Fetch from GRDB using family rawValue + model = try Current.database().read { db in + try AppWatchComplication.fetch(identifier: matchedFamily.rawValue, from: db) + } + } + } catch { + Current.Log.error("Failed to fetch complication from GRDB: \(error.localizedDescription)") + model = nil } return model } + /// Generates a ClockKit template for displaying a complication + /// + /// This method follows a priority-based fallback strategy: + /// 1. **Primary**: Try to generate template from database model + /// 2. **Assist**: Check if it's the default Assist complication + /// 3. **Fallback**: Provide a placeholder template + /// + /// The fallback ensures complications never appear blank, which would be a poor user experience. + /// + /// - Parameter complication: The `CLKComplication` to generate a template for + /// - Returns: A `CLKComplicationTemplate` ready for ClockKit to render + /// + /// ## Template Sources + /// - **Database Model**: Custom user-configured complications from Home Assistant + /// - **Assist Default**: Special complication for launching Assist with one tap + /// - **Placeholder**: Generic fallback showing the complication family name + /// + /// ## Example + /// ```swift + /// let template = template(for: complication) + /// // Always returns a valid template, never nil + /// ``` + /// + /// - Important: MaterialDesignIcons must be registered before generating templates + /// - Note: This method is called every time the watch face updates private func template(for complication: CLKComplication) -> CLKComplicationTemplate { MaterialDesignIcons.register() let template: CLKComplicationTemplate - if let generated = complicationModel(for: complication)?.CLKComplicationTemplate(family: complication.family) { + if let model = complicationModel(for: complication), + let generated = model.clkComplicationTemplate(family: complication.family) { template = generated } else if complication.identifier == AssistDefaultComplication.defaultComplicationId { template = AssistDefaultComplication.createAssistTemplate(for: complication.family) @@ -55,21 +151,114 @@ class ComplicationController: NSObject, CLKComplicationDataSource { // MARK: - Timeline Configuration + /// Determines whether a complication should be visible on the lock screen + /// + /// This ClockKit delegate method is called to determine privacy behavior for each complication. + /// It allows users to hide sensitive information when their watch is locked. + /// + /// ## Privacy Modes + /// - `.showOnLockScreen`: Complication is visible even when locked (default) + /// - `.hideOnLockScreen`: Complication is hidden until watch is unlocked + /// + /// - Parameters: + /// - complication: The complication to check + /// - handler: Completion handler to call with the privacy behavior + /// + /// ## Current Implementation + /// - If model has `isPublic = false`: Hide on lock screen + /// - If model has `isPublic = true` or missing: Show on lock screen + /// - If no model found: Default to showing (fail-safe) + /// + /// - Note: Currently `isPublic` defaults to `true` for all complications + /// - Todo: Add UI to let users configure privacy per-complication func getPrivacyBehavior( for complication: CLKComplication, withHandler handler: @escaping (CLKComplicationPrivacyBehavior) -> Void ) { - let model = complicationModel(for: complication) - - if model?.IsPublic == false { - handler(.hideOnLockScreen) + if let model = complicationModel(for: complication) { + if model.isPublic { + handler(.showOnLockScreen) + } else { + handler(.hideOnLockScreen) + } } else { + // Default to showing on lock screen if no model found handler(.showOnLockScreen) } } + /// Schedules the next requested update date for complications + /// + /// This ClockKit delegate method tells the system when it should request fresh complication data. + /// By returning the soonest reasonable date, we maximize update frequency within watchOS constraints. + /// + /// ## Update Frequency Strategy + /// We request updates as frequently as possible to keep Home Assistant data fresh: + /// - **Current implementation**: Every 15 minutes (matches watchOS budget) + /// - **watchOS budget**: System limits background updates to ~4 per hour maximum + /// - **Actual frequency**: watchOS decides based on battery, usage, and our requests + /// + /// ## Why 15 Minutes? + /// - Aligns with watchOS budget of 4 updates/hour (60 minutes ÷ 4 = 15 minutes) + /// - Avoids wasting requests that would be ignored by the system + /// - Balances freshness with battery efficiency + /// - For complications with templates, this ensures rendered values stay reasonably current + /// - System may extend the interval if battery is low or watch face isn't active + /// + /// ## Real-Time Updates + /// This method handles scheduled updates. For immediate updates when data changes, use: + /// ```swift + /// CLKComplicationServer.sharedInstance().reloadActiveComplications() + /// ``` + /// + /// - Parameter handler: Completion handler to call with the next update date + /// + /// - Note: Returning `nil` tells the system "no more updates needed" + /// - Important: The system may adjust or ignore this date based on resources + /// - SeeAlso: `getCurrentTimelineEntry(for:withHandler:)` which is called when update triggers + func getNextRequestedUpdateDate(handler: @escaping (Date?) -> Void) { + // Request update in 15 minutes - aligns with watchOS budget of ~4 updates per hour + let nextUpdate = Date(timeIntervalSinceNow: 15 * 60) + Current.Log.verbose("Scheduling next complication update for \(nextUpdate)") + + handler(nextUpdate) + } + // MARK: - Timeline Population + /// Provides the current timeline entry for a complication + /// + /// This is the primary method ClockKit calls to get complication content. + /// It's called frequently (every time the watch face updates), so performance is critical. + /// + /// ## Real-Time Template Rendering + /// This method now supports real-time template rendering for complications with Jinja2 templates. + /// It will: + /// 1. Check if the complication has templates that need rendering + /// 2. Attempt to render them via the Home Assistant API + /// 3. Update the stored complication with rendered values + /// 4. Generate the template with fresh data + /// + /// If rendering fails or times out, it falls back to the last cached rendered values. + /// + /// ## Timeline Entry Components + /// - **Date**: When this entry should be displayed (encoded with family info for tracking) + /// - **Template**: The visual representation of the complication + /// + /// - Parameters: + /// - complication: The complication requesting an entry + /// - handler: Completion handler to call with the timeline entry + /// + /// ## Date Encoding + /// The date is encoded with the complication family for debugging purposes: + /// ```swift + /// // Helps identify which family triggered a tap in logs + /// let date = Date().encodedForComplication(family: complication.family) + /// ``` + /// + /// - Important: This method MUST call the handler, or the complication won't update + /// - Note: Currently only provides current entry; could be extended for future/past entries + /// - SeeAlso: `template(for:)` which generates the actual visual content func getCurrentTimelineEntry( for complication: CLKComplication, withHandler handler: @escaping (CLKComplicationTimelineEntry?) -> Void @@ -79,11 +268,81 @@ class ComplicationController: NSObject, CLKComplicationDataSource { } let date = Date().encodedForComplication(family: complication.family) ?? Date() - handler(.init(date: date, complicationTemplate: template(for: complication))) + + // Try to render templates in real-time if needed + if let model = complicationModel(for: complication), + !model.rawRendered().isEmpty { + // This complication has templates that need rendering + Current.Log.info("Rendering templates in real-time for complication \(complication.identifier)") + + renderTemplatesAndProvideEntry( + for: complication, + model: model, + date: date, + handler: handler + ) + } else { + // No templates to render, use existing data + handler(.init(date: date, complicationTemplate: template(for: complication))) + } + } + + /// Renders templates in real-time and provides the complication entry + /// + /// This method delegates to the `ComplicationTemplateRenderer` from the environment which handles: + /// 1. Network synchronization + /// 2. API communication with Home Assistant + /// 3. Template parsing and database updates + /// 4. Error handling and fallbacks + /// + /// - Parameters: + /// - complication: The complication to render + /// - model: The complication model from database + /// - date: The date for the timeline entry + /// - handler: Completion handler to call with the entry + /// + /// - SeeAlso: `ComplicationTemplateRenderer` for implementation details + private func renderTemplatesAndProvideEntry( + for complication: CLKComplication, + model: AppWatchComplication, + date: Date, + handler: @escaping (CLKComplicationTimelineEntry?) -> Void + ) { + let renderer = ComplicationTemplateRenderer() + renderers.append(renderer) + renderer.renderAndProvideEntry( + for: complication, + model: model, + date: date + ) { [weak self] entry in + handler(entry) + self?.renderers.removeAll { $0 === renderer } + } } // MARK: - Placeholder Templates + /// Provides a sample template for the complication editor + /// + /// This ClockKit delegate method is called when: + /// - User is customizing their watch face + /// - Browsing available complications in the editor + /// - Previewing what the complication will look like + /// + /// ## Purpose + /// Shows a representative example of what the complication will display, + /// helping users decide if they want to add it to their watch face. + /// + /// - Parameters: + /// - complication: The complication to provide a sample for + /// - handler: Completion handler to call with the sample template + /// + /// ## Implementation + /// Currently returns the same template as `getCurrentTimelineEntry`, + /// showing real data rather than a static placeholder. + /// + /// - Note: This could be customized to show a specific sample/demo template + /// - SeeAlso: `template(for:)` for the actual template generation func getLocalizableSampleTemplate( for complication: CLKComplication, withHandler handler: @escaping (CLKComplicationTemplate?) -> Void @@ -93,9 +352,54 @@ class ComplicationController: NSObject, CLKComplicationDataSource { // MARK: - Complication Descriptors + /// Provides the list of all available complications for the watch face editor + /// + /// This ClockKit delegate method is called when: + /// - Watch face editor is opened + /// - User wants to add/change a complication + /// - System needs to update the available complication list + /// + /// ## Descriptor Categories + /// 1. **Configured**: User-created complications from Home Assistant (from GRDB) + /// 2. **Placeholders**: Generic placeholders for each family type + /// 3. **Assist Default**: Special complication for launching Assist + /// + /// - Parameter handler: Completion handler to call with the descriptor array + /// + /// ## Data Flow + /// ``` + /// Fetch all AppWatchComplications from GRDB + /// ↓ + /// Map to CLKComplicationDescriptors + /// ↓ + /// Add placeholders + Assist default + /// ↓ + /// Return combined list to ClockKit + /// ``` + /// + /// ## Descriptor Properties + /// Each descriptor contains: + /// - `identifier`: Unique ID for the complication + /// - `displayName`: Human-readable name shown in editor + /// - `supportedFamilies`: Which watch face slots it can fill + /// + /// - Important: This determines what users see in the watch face editor + /// - Note: Errors result in empty configured list, but placeholders are still shown + /// - SeeAlso: `AppWatchComplication.complicationDescriptor` for descriptor creation func getComplicationDescriptors(handler: @escaping ([CLKComplicationDescriptor]) -> Void) { - let configured = Current.realm().objects(WatchComplication.self) - .map(\.complicationDescriptor) + // Fetch complications from GRDB + let configured: [CLKComplicationDescriptor] + do { + let appComplications = try Current.database().read { db in + try AppWatchComplication.fetchAll(from: db) + } + + // Map directly to descriptors - no conversion needed! + configured = appComplications.map(\.complicationDescriptor) + } catch { + Current.Log.error("Failed to fetch complications from GRDB: \(error.localizedDescription)") + configured = [] + } let placeholders = ComplicationGroupMember.allCases .map(\.placeholderComplicationDescriptor) @@ -106,7 +410,45 @@ class ComplicationController: NSObject, CLKComplicationDataSource { } } +// MARK: - CLKComplicationFamily Extension + +/// Extension providing human-readable descriptions for complication families +/// +/// This extension helps with logging and debugging by providing clear names +/// for each complication family type instead of raw enum values. +/// +/// ## Complication Families +/// ClockKit supports various complication families, each designed for +/// different watch face slots and sizes: +/// +/// ### Classic Families (Pre-watchOS 7) +/// - **Circular Small**: Small circular slot +/// - **Modular Small/Large**: Modular watch face slots +/// - **Utilitarian Small/Large**: Utility-focused watch faces +/// - **Extra Large**: Large central complication +/// +/// ### Graphic Families (watchOS 7+) +/// - **Graphic Corner**: Corner slot on Infograph faces +/// - **Graphic Circular**: Circular graphic complication +/// - **Graphic Rectangular**: Rectangular graphic complication +/// - **Graphic Bezel**: Bezel around circular complications +/// +/// - Note: The `default` case handles future family types Apple may add +/// - SeeAlso: [CLKComplicationFamily +/// Documentation](https://developer.apple.com/documentation/clockkit/clkcomplicationfamily) extension CLKComplicationFamily { + /// Human-readable description of the complication family + /// + /// Provides clear, user-friendly names for each family type, + /// useful for logging and debugging. + /// + /// - Returns: A string description of the family (e.g., "Graphic Corner") + /// + /// ## Example Usage + /// ```swift + /// Current.Log.verbose("Providing template for \(complication.family.description)") + /// // Logs: "Providing template for Graphic Corner" + /// ``` var description: String { switch self { case .circularSmall: diff --git a/Sources/Extensions/Watch/Complication/ComplicationTemplateRenderer.swift b/Sources/Extensions/Watch/Complication/ComplicationTemplateRenderer.swift new file mode 100644 index 0000000000..3ed6f41d8f --- /dev/null +++ b/Sources/Extensions/Watch/Complication/ComplicationTemplateRenderer.swift @@ -0,0 +1,575 @@ +import ClockKit +import Foundation +import HAKit +import Shared + +// MARK: - Protocol + +/// Protocol for rendering complication templates with real-time data from Home Assistant +/// +/// This protocol defines the contract for services that can render Jinja2 templates +/// stored in complication models by calling the Home Assistant API. +/// +/// ## Purpose +/// Separates the concerns of template rendering from complication data source logic, +/// making the code more testable, maintainable, and focused. +/// +/// ## Implementation Requirements +/// Implementers must: +/// 1. Sync network information before rendering +/// 2. Call Home Assistant's template API endpoint +/// 3. Parse and update the database with rendered values +/// 4. Handle timeouts and errors gracefully +/// 5. Always call the completion handler (even on failure) +/// +/// ## Example Usage +/// ```swift +/// let renderer: ComplicationTemplateRendering = ComplicationTemplateRenderer() +/// +/// renderer.renderAndProvideEntry( +/// for: complication, +/// model: watchComplication, +/// date: Date() +/// ) { entry in +/// handler(entry) +/// } +/// ``` +protocol ComplicationTemplateRendering { + /// Renders templates for a complication and provides a timeline entry + /// + /// This method orchestrates the entire template rendering flow: + /// 1. Validates preconditions (server identifier exists) + /// 2. Syncs network information for accurate URL selection + /// 3. Fetches server and API connection + /// 4. Renders templates via Home Assistant API + /// 5. Updates database with rendered values + /// 6. Generates final ClockKit template + /// + /// - Parameters: + /// - complication: The ClockKit complication to render + /// - model: The complication model containing templates to render + /// - date: The date to use for the timeline entry + /// - completion: Handler called with the timeline entry (never nil, uses fallback on error) + /// + /// - Note: Always calls completion, even if rendering fails (uses cached values as fallback) + /// - Important: Completion may be called on a background queue + func renderAndProvideEntry( + for complication: CLKComplication, + model: AppWatchComplication, + date: Date, + completion: @escaping (CLKComplicationTimelineEntry) -> Void + ) +} + +// MARK: - Implementation + +/// Default implementation of template rendering for watch complications +/// +/// This class handles the complete lifecycle of rendering Jinja2 templates +/// from Home Assistant and converting them into ClockKit complication templates. +/// +/// ## Architecture +/// The renderer breaks down the rendering process into focused, single-responsibility methods: +/// - `syncNetwork`: Ensures network information is current +/// - `validateServer`: Confirms server and API are available +/// - `renderTemplates`: Calls Home Assistant API with combined template string +/// - `parseResponse`: Extracts rendered values from API response +/// - `updateDatabase`: Persists rendered values for caching +/// - `generateTemplate`: Creates ClockKit template from model +/// +/// ## Timeout Handling +/// A 5-second timeout is enforced to prevent blocking the complication system. +/// If rendering takes too long, the cached values are used instead. +/// +/// ## Error Handling +/// All errors result in graceful fallback to cached values. The complication +/// is never left in a broken state. +/// +/// - SeeAlso: `ComplicationTemplateRendering` for protocol contract +final class ComplicationTemplateRenderer: ComplicationTemplateRendering { + // MARK: - Properties + + /// Timeout duration for template rendering requests + private let timeoutSeconds: TimeInterval = 5 + + /// Fallback template provider for generating templates when data isn't available + private let templateProvider: ComplicationTemplateProvider + + // MARK: - Initialization + + /// Creates a new template renderer + /// + /// - Parameter templateProvider: Provider for generating fallback templates + init(templateProvider: ComplicationTemplateProvider = DefaultComplicationTemplateProvider()) { + self.templateProvider = templateProvider + } + + // MARK: - ComplicationTemplateRendering + + func renderAndProvideEntry( + for complication: CLKComplication, + model: AppWatchComplication, + date: Date, + completion: @escaping (CLKComplicationTimelineEntry) -> Void + ) { + // Validate server identifier exists + guard let serverIdentifier = model.serverIdentifier else { + Current.Log.warning("No server identifier for complication, using cached values") + provideFallbackEntry(for: complication, date: date, completion: completion) + return + } + + Current.Log.info("Starting template rendering for complication \(complication.identifier)") + + // Step 1: Sync network information to ensure accurate URL selection + syncNetworkInformation { [weak self] in + guard let self else { return } + + // Step 2: Validate server and API connection are available + guard let (server, connection) = validateServerAndConnection( + serverIdentifier: serverIdentifier, + complication: complication, + date: date, + completion: completion + ) else { + return // Validation failed, fallback already provided + } + + // Step 3: Render templates via Home Assistant API + renderTemplates( + for: complication, + model: model, + server: server, + connection: connection, + date: date, + completion: completion + ) + } + } + + // MARK: - Private Methods - Orchestration + + /// Syncs network information before proceeding with rendering + /// + /// Network sync is critical for accurate URL selection (internal vs external). + /// It updates SSID information and connectivity state. + /// + /// - Parameter completion: Called when sync completes + private func syncNetworkInformation(completion: @escaping () -> Void) { + Current.Log.info("Syncing network information before rendering templates") + + Current.connectivity.syncNetworkInformation { + Current.Log.info("Network information sync completed") + completion() + } + } + + /// Validates that server and API connection are available + /// + /// This method checks that: + /// 1. Server exists for the given identifier + /// 2. API instance is available for the server + /// 3. API has an active connection + /// + /// If validation fails, it logs an error and provides a fallback entry. + /// + /// - Parameters: + /// - serverIdentifier: The server identifier from the complication model + /// - complication: The complication being rendered + /// - date: Date for the timeline entry + /// - completion: Completion handler to call with fallback if validation fails + /// - Returns: Tuple of (Server, HAConnection) if valid, nil if validation fails + private func validateServerAndConnection( + serverIdentifier: String, + complication: CLKComplication, + date: Date, + completion: @escaping (CLKComplicationTimelineEntry) -> Void + ) -> (Server, HAConnection)? { + guard let server = Current.servers.all.first(where: { $0.identifier.rawValue == serverIdentifier }), + let api = Current.api(for: server) else { + Current.Log.error("No API available for server \(serverIdentifier), using cached values") + provideFallbackEntry(for: complication, date: date, completion: completion) + return nil + } + + return (server, api.connection) + } + + /// Renders templates by calling the Home Assistant API + /// + /// This method: + /// 1. Extracts raw templates from the model + /// 2. Combines them into a single API request + /// 3. Sends the request with timeout handling + /// 4. Processes the response and updates the database + /// 5. Generates the final ClockKit template + /// + /// ## Template Format + /// Templates are combined using special separators: + /// - `:::` separates key from template + /// - `|||` separates different templates + /// + /// Example: `"key1:::{{template1}}|||key2:::{{template2}}"` + /// + /// - Parameters: + /// - complication: The complication being rendered + /// - model: The complication model with templates + /// - server: The Home Assistant server + /// - connection: The HAKit connection to use + /// - date: Date for the timeline entry + /// - completion: Called with the final entry + private func renderTemplates( + for complication: CLKComplication, + model: AppWatchComplication, + server: Server, + connection: HAConnection, + date: Date, + completion: @escaping (CLKComplicationTimelineEntry) -> Void + ) { + let rawTemplates = model.rawRendered() + + // Use a class wrapper to share state between closures + let completionState = CompletionState() + + // Set up timeout fallback + setupTimeout( + for: complication, + date: date, + completionState: completionState, + completion: completion + ) + + Current.Log.info("Rendering templates directly from watch API for complication \(complication.identifier)") + + // Combine templates into single request string + let combinedTemplate = createCombinedTemplateString(from: rawTemplates) + + // Send API request + sendRenderRequest( + combinedTemplate: combinedTemplate, + connection: connection, + onComplete: { [weak self] result in + guard let self else { return } + guard !completionState.hasCompleted else { return } + completionState.hasCompleted = true + + handleRenderResponse( + result: result, + rawTemplates: rawTemplates, + model: model, + complication: complication, + date: date, + completion: completion + ) + } + ) + } + + // MARK: - Private Methods - Template Processing + + /// Creates a combined template string for batch rendering + /// + /// Combines multiple templates into a single string that can be sent + /// in one API request, improving performance. + /// + /// - Parameter templates: Dictionary of template keys to template strings + /// - Returns: Combined template string with separators + /// + /// ## Format + /// ``` + /// key1:::{{template1}}|||key2:::{{template2}}|||key3:::{{template3}} + /// ``` + private func createCombinedTemplateString(from templates: [String: String]) -> String { + templates + .map { key, template in "\(key):::\(template)" } + .joined(separator: "|||") + } + + /// Sends the template render request to Home Assistant + /// + /// - Parameters: + /// - combinedTemplate: The combined template string + /// - connection: HAKit connection to use + /// - onComplete: Handler called with the result + private func sendRenderRequest( + combinedTemplate: String, + connection: HAConnection, + onComplete: @escaping (Result) -> Void + ) { + connection.send(.init( + type: .rest(.post, "template"), + data: ["template": combinedTemplate], + shouldRetry: true + ), completion: onComplete) + } + + /// Sets up a timeout fallback for template rendering + /// + /// If rendering takes longer than `timeoutSeconds`, the completion + /// is called with a fallback template using cached values. + /// + /// - Parameters: + /// - complication: The complication being rendered + /// - date: Date for the timeline entry + /// - completionState: Wrapper object to track completion status + /// - completion: Handler to call on timeout + private func setupTimeout( + for complication: CLKComplication, + date: Date, + completionState: CompletionState, + completion: @escaping (CLKComplicationTimelineEntry) -> Void + ) { + DispatchQueue.main.asyncAfter(deadline: .now() + timeoutSeconds) { [weak self] in + guard let self else { return } + if !completionState.hasCompleted { + completionState.hasCompleted = true + Current.Log.warning("Template rendering timed out after \(timeoutSeconds)s, using cached values") + provideFallbackEntry(for: complication, date: date, completion: completion) + } + } + } + + /// Helper class to track completion state across multiple closures + private final class CompletionState { + var hasCompleted = false + } + + // MARK: - Private Methods - Response Handling + + /// Handles the response from the template render API + /// + /// - Parameters: + /// - result: The result from HAKit + /// - rawTemplates: Original template dictionary for validation + /// - model: Complication model to update + /// - complication: The complication being rendered + /// - date: Date for the timeline entry + /// - completion: Handler to call with final entry + private func handleRenderResponse( + result: Result, + rawTemplates: [String: String], + model: AppWatchComplication, + complication: CLKComplication, + date: Date, + completion: @escaping (CLKComplicationTimelineEntry) -> Void + ) { + switch result { + case let .success(data): + handleSuccessResponse( + data: data, + rawTemplates: rawTemplates, + model: model, + complication: complication, + date: date, + completion: completion + ) + + case let .failure(error): + Current.Log.error("Failed to render templates: \(error), using cached values") + provideFallbackEntry(for: complication, date: date, completion: completion) + } + } + + /// Handles successful API response + /// + /// - Parameters: + /// - data: The HAData from the API + /// - rawTemplates: Original templates for validation + /// - model: Model to update with rendered values + /// - complication: The complication being rendered + /// - date: Date for the timeline entry + /// - completion: Handler to call with final entry + private func handleSuccessResponse( + data: HAData, + rawTemplates: [String: String], + model: AppWatchComplication, + complication: CLKComplication, + date: Date, + completion: @escaping (CLKComplicationTimelineEntry) -> Void + ) { + guard case let .primitive(response) = data, + let renderedString = response as? String else { + Current.Log.error("Template rendering returned non-string response, using cached values") + provideFallbackEntry(for: complication, date: date, completion: completion) + return + } + + let renderedResults = parseRenderedTemplates(from: renderedString) + + guard renderedResults.count == rawTemplates.count else { + Current.Log.error( + "Rendered count mismatch: expected \(rawTemplates.count), got \(renderedResults.count), using cached values" + ) + provideFallbackEntry(for: complication, date: date, completion: completion) + return + } + + Current.Log.info("Successfully rendered \(renderedResults.count) templates") + + updateDatabaseAndProvideEntry( + renderedResults: renderedResults, + model: model, + complication: complication, + date: date, + completion: completion + ) + } + + /// Parses rendered templates from the API response string + /// + /// Splits the combined response back into individual key-value pairs. + /// + /// - Parameter renderedString: The combined response from Home Assistant + /// - Returns: Dictionary of template keys to rendered values + /// + /// ## Expected Format + /// ``` + /// key1:::rendered_value1|||key2:::rendered_value2 + /// ``` + private func parseRenderedTemplates(from renderedString: String) -> [String: Any] { + var renderedResults: [String: Any] = [:] + + let parts = renderedString.components(separatedBy: "|||") + + for part in parts { + let keyValue = part.components(separatedBy: ":::") + if keyValue.count == 2 { + let key = keyValue[0] + let value = keyValue[1] + renderedResults[key] = value + } + } + + return renderedResults + } + + // MARK: - Private Methods - Database & Template Generation + + /// Updates the database with rendered values and provides the entry + /// + /// - Parameters: + /// - renderedResults: Rendered template values + /// - model: Model to update + /// - complication: The complication being rendered + /// - date: Date for the timeline entry + /// - completion: Handler to call with final entry + private func updateDatabaseAndProvideEntry( + renderedResults: [String: Any], + model: AppWatchComplication, + complication: CLKComplication, + date: Date, + completion: @escaping (CLKComplicationTimelineEntry) -> Void + ) { + do { + try Current.database().write { db in + var updatedModel = model + updatedModel.updateRenderedValues(from: renderedResults) + try updatedModel.update(db) + } + + provideEntry(for: complication, date: date, completion: completion) + } catch { + Current.Log.error("Failed to update complication with rendered values: \(error)") + provideFallbackEntry(for: complication, date: date, completion: completion) + } + } + + /// Provides a timeline entry with the current template + /// + /// - Parameters: + /// - complication: The complication to generate a template for + /// - date: Date for the timeline entry + /// - completion: Handler to call with the entry + private func provideEntry( + for complication: CLKComplication, + date: Date, + completion: @escaping (CLKComplicationTimelineEntry) -> Void + ) { + let template = templateProvider.template(for: complication) + let entry = CLKComplicationTimelineEntry(date: date, complicationTemplate: template) + completion(entry) + } + + /// Provides a fallback timeline entry using cached values + /// + /// This is called when rendering fails or times out. + /// + /// - Parameters: + /// - complication: The complication to generate a template for + /// - date: Date for the timeline entry + /// - completion: Handler to call with the entry + private func provideFallbackEntry( + for complication: CLKComplication, + date: Date, + completion: @escaping (CLKComplicationTimelineEntry) -> Void + ) { + let template = templateProvider.template(for: complication) + let entry = CLKComplicationTimelineEntry(date: date, complicationTemplate: template) + completion(entry) + } +} + +// MARK: - Template Provider Protocol + +/// Protocol for generating ClockKit templates from complication models +/// +/// This abstraction allows the renderer to generate templates without +/// needing to know about the ComplicationController's implementation details. +protocol ComplicationTemplateProvider { + /// Generates a ClockKit template for a complication + /// + /// - Parameter complication: The complication to generate a template for + /// - Returns: A valid ClockKit template (never nil) + func template(for complication: CLKComplication) -> CLKComplicationTemplate +} + +// MARK: - Default Template Provider + +/// Default implementation that uses the ComplicationController's template generation +final class DefaultComplicationTemplateProvider: ComplicationTemplateProvider { + func template(for complication: CLKComplication) -> CLKComplicationTemplate { + // Register icons before generating templates + MaterialDesignIcons.register() + + // Try to fetch model and generate template + if let model = fetchComplicationModel(for: complication), + let generated = model.clkComplicationTemplate(family: complication.family) { + return generated + } + + // Check for Assist default complication + if complication.identifier == AssistDefaultComplication.defaultComplicationId { + return AssistDefaultComplication.createAssistTemplate(for: complication.family) + } + + // Fallback to placeholder + Current.Log.info { + "no configured template for \(complication.identifier), providing placeholder" + } + + return ComplicationGroupMember(family: complication.family) + .fallbackTemplate(for: complication.identifier) + } + + /// Fetches the complication model from database + /// + /// - Parameter complication: The complication to fetch + /// - Returns: The model if found, nil otherwise + private func fetchComplicationModel(for complication: CLKComplication) -> AppWatchComplication? { + do { + if complication.identifier != CLKDefaultComplicationIdentifier { + return try Current.database().read { db in + try AppWatchComplication.fetch(identifier: complication.identifier, from: db) + } + } else { + let matchedFamily = ComplicationGroupMember(family: complication.family) + return try Current.database().read { db in + try AppWatchComplication.fetch(identifier: matchedFamily.rawValue, from: db) + } + } + } catch { + Current.Log.error("Failed to fetch complication from GRDB: \(error.localizedDescription)") + return nil + } + } +} diff --git a/Sources/Extensions/Watch/ExtensionDelegate.swift b/Sources/Extensions/Watch/ExtensionDelegate.swift index e71109fa23..9a3410feb4 100644 --- a/Sources/Extensions/Watch/ExtensionDelegate.swift +++ b/Sources/Extensions/Watch/ExtensionDelegate.swift @@ -4,6 +4,7 @@ import PromiseKit import Shared import UserNotifications import WatchKit +import WidgetKit import XCGLogger class ExtensionDelegate: NSObject, WKExtensionDelegate { @@ -72,14 +73,8 @@ class ExtensionDelegate: NSObject, WKExtensionDelegate { case let backgroundTask as WKApplicationRefreshBackgroundTask: // Be sure to complete the background task once you’re done. Current.Log.verbose("WKApplicationRefreshBackgroundTask received") - - firstly { - when(fulfilled: Current.apis.map { $0.updateComplications(passively: true) }) - }.ensureThen { - Current.backgroundRefreshScheduler.schedule() - }.ensure { - backgroundTask.setTaskCompletedWithSnapshot(false) - }.cauterize() + // No need to update complication here anymore since they render templates by themselves now + backgroundTask.setTaskCompletedWithSnapshot(false) case let snapshotTask as WKSnapshotRefreshBackgroundTask: // Snapshot tasks have a unique completion call, make sure to set your expiration date snapshotTask.setTaskCompleted( @@ -111,27 +106,39 @@ class ExtensionDelegate: NSObject, WKExtensionDelegate { // Triggered when a complication is tapped func handleUserActivity(_ userInfo: [AnyHashable: Any]?) { - let complication: WatchComplication? + let complication: AppWatchComplication? if let identifier = userInfo?[CLKLaunchedComplicationIdentifierKey] as? String, identifier != CLKDefaultComplicationIdentifier { - complication = Current.realm().object( - ofType: WatchComplication.self, - forPrimaryKey: identifier - ) + // Fetch from GRDB instead of Realm + do { + complication = try Current.database().read { db in + try AppWatchComplication.fetch(identifier: identifier, from: db) + } + } catch { + Current.Log.error("Failed to fetch complication from GRDB: \(error.localizedDescription)") + complication = nil + } } else if let date = userInfo?[CLKLaunchedTimelineEntryDateKey] as? Date, let clkFamily = date.complicationFamilyFromEncodedDate { let family = ComplicationGroupMember(family: clkFamily) - complication = Current.realm().object( - ofType: WatchComplication.self, - forPrimaryKey: family.rawValue - ) + // Fetch from GRDB using family rawValue as identifier + do { + complication = try Current.database().read { db in + try AppWatchComplication.fetch(identifier: family.rawValue, from: db) + } + } catch { + Current.Log.error("Failed to fetch complication by family from GRDB: \(error.localizedDescription)") + complication = nil + } } else { complication = nil } if let complication { - Current.Log.info("launched for \(complication.identifier) of family \(complication.Family)") + // Parse family from rawFamily string + let familyString = complication.rawFamily.isEmpty ? "unknown" : complication.rawFamily + Current.Log.info("launched for \(complication.identifier) of family \(familyString)") } else if let identifier = userInfo?[CLKLaunchedComplicationIdentifierKey] as? String, identifier == AssistDefaultComplication.defaultComplicationId { NotificationCenter.default.post(name: AssistDefaultComplication.launchNotification, object: nil) @@ -184,7 +191,7 @@ class ExtensionDelegate: NSObject, WKExtensionDelegate { } Context.observations.store[.init(queue: .main)] = { [weak self] context in - Current.Log.verbose("Received context: \(context)") + Current.Log.info("Received context with \(context.content.count) keys: \(Array(context.content.keys))") self?.updateContext(context.content) } @@ -244,40 +251,30 @@ class ExtensionDelegate: NSObject, WKExtensionDelegate { } private func updateContext(_ content: Content) { - let realm = Current.realm() - - if let servers = content["servers"] as? Data { - Current.servers.restoreState(servers) - } - - if let complicationsDictionary = content["complications"] as? [[String: Any]] { - let complications = complicationsDictionary.compactMap { try? WatchComplication(JSON: $0) } + // Enhanced logging to diagnose sync issues + Current.Log.info("Received context update with keys: \(content.keys)") - Current.Log.verbose("Updating complications from context \(complications)") - - realm.reentrantWrite { - realm.delete(realm.objects(WatchComplication.self)) - realm.add(complications, update: .all) - } - } + // Note: Servers are now synced via send/reply pattern + // See WatchHomeViewModel.requestServers() and WatchCommunicatorService.syncServers() updateComplications() } private var isUpdatingComplications = false + private func updateComplications() { - // avoid double-updating due to e.g. complication info update request - guard !isUpdatingComplications else { return } + if #available(watchOS 9.0, *) { + WidgetCenter.shared.reloadAllTimelines() + } - isUpdatingComplications = true + CLKComplicationServer.sharedInstance().reloadComplicationDescriptors() - firstly { - when(fulfilled: Current.apis.map { $0.updateComplications(passively: true) }) - }.ensure { [self] in - isUpdatingComplications = false - }.ensure { [self] in - endWatchConnectivityBackgroundTaskIfNecessary() - }.cauterize() + if let activeComplications = CLKComplicationServer.sharedInstance().activeComplications { + Current.Log.info("Reloading \(activeComplications.count) active complications") + for complication in activeComplications { + CLKComplicationServer.sharedInstance().reloadTimeline(for: complication) + } + } } func didRegisterForRemoteNotifications(withDeviceToken deviceToken: Data) { diff --git a/Sources/Extensions/Watch/Home/AppWatchComplication.swift b/Sources/Extensions/Watch/Home/AppWatchComplication.swift new file mode 100644 index 0000000000..a8cfe4234a --- /dev/null +++ b/Sources/Extensions/Watch/Home/AppWatchComplication.swift @@ -0,0 +1,312 @@ +import Foundation +import GRDB + +/// AppWatchComplication represents a complication stored in the watch's GRDB database +/// It stores the complete JSON data from the iPhone's Realm WatchComplication object +public struct AppWatchComplication: Codable { + public var identifier: String + public var serverIdentifier: String? + public var rawFamily: String + public var rawTemplate: String + public var complicationData: [String: Any] + public var createdAt: Date + public var name: String? + public var isPublic: Bool + + enum CodingKeys: String, CodingKey { + case identifier + case serverIdentifier + case rawFamily + case rawTemplate + case complicationData + case createdAt + case name + case isPublic + } + + public init( + identifier: String, + serverIdentifier: String?, + rawFamily: String, + rawTemplate: String, + complicationData: [String: Any], + createdAt: Date, + name: String?, + isPublic: Bool = true + ) { + self.identifier = identifier + self.serverIdentifier = serverIdentifier + self.rawFamily = rawFamily + self.rawTemplate = rawTemplate + self.complicationData = complicationData + self.createdAt = createdAt + self.name = name + self.isPublic = isPublic + } + + // MARK: - Codable + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.identifier = try container.decode(String.self, forKey: .identifier) + self.serverIdentifier = try container.decodeIfPresent(String.self, forKey: .serverIdentifier) + self.rawFamily = try container.decode(String.self, forKey: .rawFamily) + self.rawTemplate = try container.decode(String.self, forKey: .rawTemplate) + self.createdAt = try container.decode(Date.self, forKey: .createdAt) + self.name = try container.decodeIfPresent(String.self, forKey: .name) + self.isPublic = try container.decodeIfPresent(Bool.self, forKey: .isPublic) ?? true + + // Decode JSON string to dictionary + let jsonString = try container.decode(String.self, forKey: .complicationData) + if let data = jsonString.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] { + self.complicationData = json + } else { + self.complicationData = [:] + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(identifier, forKey: .identifier) + try container.encodeIfPresent(serverIdentifier, forKey: .serverIdentifier) + try container.encode(rawFamily, forKey: .rawFamily) + try container.encode(rawTemplate, forKey: .rawTemplate) + try container.encode(createdAt, forKey: .createdAt) + try container.encodeIfPresent(name, forKey: .name) + try container.encode(isPublic, forKey: .isPublic) + + // Encode dictionary to JSON string for database storage + let data = try JSONSerialization.data(withJSONObject: complicationData, options: []) + if let jsonString = String(data: data, encoding: .utf8) { + try container.encode(jsonString, forKey: .complicationData) + } + } +} + +// MARK: - GRDB Conformance + +extension AppWatchComplication: FetchableRecord, PersistableRecord { + public static var databaseTableName: String { + GRDBDatabaseTable.appWatchComplication.rawValue + } +} + +// MARK: - Convenience Methods + +public extension AppWatchComplication { + /// Creates an AppWatchComplication from JSON data received from iPhone + static func from(jsonData: Data) throws -> AppWatchComplication { + guard let json = try JSONSerialization.jsonObject(with: jsonData, options: []) as? [String: Any] else { + throw NSError( + domain: "AppWatchComplication", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Failed to deserialize JSON data"] + ) + } + + guard let identifier = json["identifier"] as? String else { + throw NSError( + domain: "AppWatchComplication", + code: 2, + userInfo: [NSLocalizedDescriptionKey: "Missing identifier in JSON"] + ) + } + + let serverIdentifier = json["serverIdentifier"] as? String + let rawFamily = json["Family"] as? String ?? "" + let rawTemplate = json["Template"] as? String ?? "" + let name = json["name"] as? String + let complicationData = json["Data"] as? [String: Any] ?? [:] + let isPublic = json["IsPublic"] as? Bool ?? true + + // Parse CreatedAt date + let createdAt: Date + if let timestamp = json["CreatedAt"] as? TimeInterval { + createdAt = Date(timeIntervalSince1970: timestamp) + } else if let dateString = json["CreatedAt"] as? String { + let formatter = ISO8601DateFormatter() + createdAt = formatter.date(from: dateString) ?? Date() + } else { + createdAt = Date() + } + + return AppWatchComplication( + identifier: identifier, + serverIdentifier: serverIdentifier, + rawFamily: rawFamily, + rawTemplate: rawTemplate, + complicationData: complicationData, + createdAt: createdAt, + name: name, + isPublic: isPublic + ) + } + + /// Fetches all complications from the database + static func fetchAll(from database: Database) throws -> [AppWatchComplication] { + try AppWatchComplication.fetchAll(database) + } + + /// Fetches a specific complication by identifier + static func fetch(identifier: String, from database: Database) throws -> AppWatchComplication? { + try AppWatchComplication + .filter(Column(DatabaseTables.AppWatchComplication.identifier.rawValue) == identifier) + .fetchOne(database) + } + + /// Deletes all complications from the database + static func deleteAll(from database: Database) throws { + try AppWatchComplication.deleteAll(database) + } +} + +// MARK: - watchOS Complication Support + +#if os(watchOS) +import ClockKit +import UIKit + +public extension AppWatchComplication { + /// Display name for the complication + var displayName: String { + name ?? template.style + } + + /// The Family enum from rawFamily string + var family: ComplicationGroupMember { + ComplicationGroupMember(rawValue: rawFamily) ?? .modularSmall + } + + /// The Template enum from rawTemplate string + var template: ComplicationTemplate { + ComplicationTemplate(rawValue: rawTemplate) ?? family.templates.first! + } + + // MARK: - Rendered Values Support + + /// Enum representing different types of renderable values in a complication + enum RenderedValueType: Hashable { + case textArea(String) + case gauge + case ring + + init?(stringValue: String) { + let values = stringValue.components(separatedBy: ",") + + guard values.count >= 1 else { + return nil + } + + switch values[0] { + case "textArea" where values.count >= 2: + self = .textArea(values[1]) + case "gauge": + self = .gauge + case "ring": + self = .ring + default: + return nil + } + } + + var stringValue: String { + switch self { + case let .textArea(value): return "textArea,\(value)" + case .gauge: return "gauge" + case .ring: return "ring" + } + } + } + + /// Returns the rendered values dictionary from server template rendering + func renderedValues() -> [RenderedValueType: Any] { + (complicationData["rendered"] as? [String: Any] ?? [:]) + .compactMapKeys(RenderedValueType.init(stringValue:)) + } + + /// Updates the rendered values with response from server + /// - Parameter response: Dictionary of rendered template values from webhook + mutating func updateRenderedValues(from response: [String: Any]) { + complicationData["rendered"] = response + } + + /// Returns the raw unrendered template strings that need server-side rendering + /// Used by webhook system to request template rendering from Home Assistant + func rawRendered() -> [String: String] { + var renders = [RenderedValueType: String]() + + if let textAreas = complicationData["textAreas"] as? [String: [String: Any]], textAreas.isEmpty == false { + let toAdd = textAreas.compactMapValues { $0["text"] as? String } + .filter { $1.containsJinjaTemplate } // Note: Requires String extension from Shared module + .mapKeys { RenderedValueType.textArea($0) } + renders.merge(toAdd, uniquingKeysWith: { a, _ in a }) + } + + if let gaugeDict = complicationData["gauge"] as? [String: String], + let gauge = gaugeDict["gauge"], gauge.containsJinjaTemplate { + renders[.gauge] = gauge + } + + if let ringDict = complicationData["ring"] as? [String: String], + let ringValue = ringDict["ring_value"], ringValue.containsJinjaTemplate { + renders[.ring] = ringValue + } + + return renders.mapKeys { $0.stringValue } + } + + /// Complication descriptor for ClockKit + var complicationDescriptor: CLKComplicationDescriptor { + CLKComplicationDescriptor( + identifier: identifier, + displayName: displayName, + supportedFamilies: [family.family] + ) + } + + /// Generate CLKComplicationTemplate for display + /// This delegates to the WatchComplication implementation temporarily + /// TODO: Port template generation logic directly to AppWatchComplication + func clkComplicationTemplate(family complicationFamily: CLKComplicationFamily) -> CLKComplicationTemplate? { + // For now, convert to WatchComplication to use existing template logic + // This is a temporary solution until we fully port the template generation + guard let watchComplication = try? WatchComplication(JSON: [ + "identifier": identifier, + "serverIdentifier": serverIdentifier as Any, + "Family": rawFamily, + "Template": rawTemplate, + "Data": complicationData, + "CreatedAt": createdAt.timeIntervalSince1970, + "name": name as Any, + "IsPublic": isPublic, + ]) else { + return nil + } + + return watchComplication.CLKComplicationTemplate(family: complicationFamily) + } +} + +// MARK: - Dictionary Helpers + +fileprivate extension Dictionary { + func mapKeys(_ transform: (Key) -> T) -> [T: Value] { + var result = [T: Value]() + for (key, value) in self { + result[transform(key)] = value + } + return result + } + + func compactMapKeys(_ transform: (Key) -> T?) -> [T: Value] { + var result = [T: Value]() + for (key, value) in self { + if let newKey = transform(key) { + result[newKey] = value + } + } + return result + } +} +#endif diff --git a/Sources/Extensions/Watch/Home/AppWatchComplicationTable.swift b/Sources/Extensions/Watch/Home/AppWatchComplicationTable.swift new file mode 100644 index 0000000000..ff3894f4b8 --- /dev/null +++ b/Sources/Extensions/Watch/Home/AppWatchComplicationTable.swift @@ -0,0 +1,35 @@ +import Foundation +import GRDB + +final class AppWatchComplicationTable: DatabaseTableProtocol { + func createIfNeeded(database: DatabaseQueue) throws { + let shouldCreateTable = try database.read { db in + try !db.tableExists(GRDBDatabaseTable.appWatchComplication.rawValue) + } + if shouldCreateTable { + try database.write { db in + try db.create(table: GRDBDatabaseTable.appWatchComplication.rawValue) { t in + t.primaryKey(DatabaseTables.AppWatchComplication.identifier.rawValue, .text).notNull() + // Store the entire complication as JSON data + t.column(DatabaseTables.AppWatchComplication.complicationData.rawValue, .jsonText).notNull() + } + } + } else { + // In case a new column is added to the table, we need to alter the table + try database.write { db in + for column in DatabaseTables.AppWatchComplication.allCases { + let shouldCreateColumn = try !db.columns(in: GRDBDatabaseTable.appWatchComplication.rawValue) + .contains { columnInfo in + columnInfo.name == column.rawValue + } + + if shouldCreateColumn { + try db.alter(table: GRDBDatabaseTable.appWatchComplication.rawValue) { tableAlteration in + tableAlteration.add(column: column.rawValue) + } + } + } + } + } + } +} diff --git a/Sources/Extensions/Watch/Home/COMPLICATION_SYNC_PROTOCOL.md b/Sources/Extensions/Watch/Home/COMPLICATION_SYNC_PROTOCOL.md new file mode 100644 index 0000000000..7c491967fc --- /dev/null +++ b/Sources/Extensions/Watch/Home/COMPLICATION_SYNC_PROTOCOL.md @@ -0,0 +1,412 @@ +# Complication Sync Protocol Documentation + +## Overview + +This document describes the paginated complication sync protocol between the iPhone and Apple Watch. Complications are synced one at a time to avoid WatchConnectivity payload size limits. + +## Architecture + +### Key Components + +**iPhone (iOS App):** +- `WatchCommunicatorService.swift` - Handles sync requests from watch +- Realm database - Stores `WatchComplication` objects + +**Apple Watch (watchOS App):** +- `WatchHomeViewModel.swift` - Initiates sync requests +- `ExtensionDelegate.swift` - Receives and processes individual complications +- `ComplicationController.swift` - Provides complications to watchOS + +## Protocol Flow + +``` +┌─────────┐ ┌─────────┐ +│ Watch │ │ Phone │ +└────┬────┘ └────┬────┘ + │ │ + │ 1. Request complication at index 0 │ + │ Message: "syncComplication" │ + │ Content: {"index": 0} │ + ├──────────────────────────────────────────────>│ + │ │ + │ 2. Fetch complication from Realm + │ 3. Serialize to JSON Data + │ │ + │ 4. Response with complication data │ + │ Message: "syncComplicationResponse" │ + │ Content: { │ + │ "complicationData": Data, │ + │ "hasMore": true, │ + │ "index": 0, │ + │ "total": 5 │ + │ } │ + │<──────────────────────────────────────────────┤ + │ │ +5. Save to Realm │ +6. Check hasMore flag │ + │ │ + │ 7. Request complication at index 1 │ + │ Message: "syncComplication" │ + │ Content: {"index": 1} │ + ├──────────────────────────────────────────────>│ + │ │ + │ ... Repeat for each complication ... + │ │ + │ N. Final complication │ + │ Content: { │ + │ "complicationData": Data, │ + │ "hasMore": false, ← Last one │ + │ "index": 4, │ + │ "total": 5 │ + │ } │ + │<──────────────────────────────────────────────┤ + │ │ +N+1. Complete sync │ +N+2. Reload complication timeline │ + │ │ +``` + +## Message Specifications + +### 1. Sync Request (Watch → Phone) + +**Message Type:** `ImmediateMessage` + +**Identifier:** `"syncComplication"` + +**Content:** +```swift +{ + "index": Int // 0-based index of complication to fetch +} +``` + +**Example:** +```swift +ImmediateMessage( + identifier: "syncComplication", + content: ["index": 0] +) +``` + +### 2. Sync Response (Phone → Watch) + +**Message Type:** `ImmediateMessage` + +**Identifier:** `"syncComplicationResponse"` + +**Content (Success):** +```swift +{ + "complicationData": Data, // JSON-serialized WatchComplication + "hasMore": Bool, // true if more complications are pending + "index": Int, // The index that was requested + "total": Int // Total number of complications +} +``` + +**Content (Error):** +```swift +{ + "error": String, // Error description + "hasMore": false, + "index": -1, + "total": 0 +} +``` + +**Example (Success):** +```swift +ImmediateMessage( + identifier: "syncComplicationResponse", + content: [ + "complicationData": complicationData, // Data object + "hasMore": true, + "index": 2, + "total": 5 + ] +) +``` + +## Implementation Details + +### Phone Side (WatchCommunicatorService.swift) + +#### Key Methods + +**`syncSingleComplication(message:)`** +- Receives sync request with index +- Fetches complication from Realm at that index +- Serializes to JSON string using ObjectMapper's `toJSONString()` +- Converts JSON string to `Data` +- Sends response with `hasMore` flag + +**Protocol:** +```swift +/// Syncs a single complication to the watch by index (paginated approach) +/// This avoids payload size limits by sending complications one at a time. +/// +/// Protocol: +/// 1. Watch sends "syncComplication" with {"index": N} +/// 2. Phone responds with "syncComplicationResponse" containing: +/// - "complicationData": Data (JSON of the complication) +/// - "hasMore": Bool (true if index+1 < total) +/// - "index": Int (the index sent by watch) +/// - "total": Int (total number of complications) +/// 3. Watch saves the complication and requests next if hasMore is true +private func syncSingleComplication(message: ImmediateMessage) +``` + +**Message Observation:** +```swift +ImmediateMessage.observations.store[.init(queue: .main)] = { [weak self] message in + guard let self else { return } + + if message.identifier == "syncComplication" { + self.syncSingleComplication(message: message) + } +} +``` + +### Watch Side + +#### WatchHomeViewModel.swift + +**`requestConfig()`** +- Initiates paginated sync by requesting index 0 +- Called when watch app launches or config is refreshed + +**`requestNextComplication(index:)`** +- Helper method to send sync request for a specific index +- Called recursively as responses are received + +**Protocol:** +```swift +/// Requests a single complication from the phone by index +/// - Parameter index: The index of the complication to request (0-based) +/// +/// This implements a paginated sync protocol: +/// 1. Watch sends request with index +/// 2. Phone responds with complication at that index + "hasMore" flag +/// 3. If hasMore is true, watch requests next index +/// 4. Continues until hasMore is false or error occurs +private func requestNextComplication(index: Int) +``` + +#### ExtensionDelegate.swift + +**`handleSyncComplicationResponse(_:)`** +- Receives individual complication responses +- Deserializes JSON data to `WatchComplication` object +- Saves to Realm database +- Clears existing complications on index 0 (first complication) +- Requests next complication if `hasMore` is true +- Triggers complication reload when complete + +**Key Features:** +- Tracks sync progress (count, timing) +- Comprehensive logging at each step +- Automatic chaining of requests +- Error handling + +**Protocol:** +```swift +/// Handles individual complication sync responses from the phone +/// This is part of the paginated complication sync protocol where complications +/// are sent one at a time to avoid payload size limits. +/// +/// Expected message content: +/// - "complicationData": Data - JSON string of the complication +/// - "hasMore": Bool - true if more complications are pending +/// - "index": Int - index of this complication +/// - "total": Int - total number of complications +/// - "error": String? - optional error message +private func handleSyncComplicationResponse(_ message: ImmediateMessage) +``` + +## Data Flow + +### Complication Serialization (Phone) + +``` +WatchComplication (Realm Object) + ↓ +toJSONString() (ObjectMapper) + ↓ +JSON String + ↓ +.data(using: .utf8) + ↓ +Data (suitable for WatchConnectivity) +``` + +### Complication Deserialization (Watch) + +``` +Data (from WatchConnectivity) + ↓ +JSONSerialization.jsonObject() + ↓ +[String: Any] Dictionary + ↓ +WatchComplication(JSON:) (ObjectMapper) + ↓ +WatchComplication (Realm Object) + ↓ +Save to Realm Database +``` + +## Advantages of Paginated Approach + +1. **No Payload Size Limits** + - Each message only contains one complication + - Avoids "Payload contains unsupported type" errors + - Works with any number of complications + +2. **Better Error Recovery** + - If one complication fails, others can still sync + - Clear indication of which complication caused error + +3. **Progress Tracking** + - Watch knows exact progress (N of M) + - Can display sync progress to user if needed + +4. **Efficient Memory Usage** + - Only one complication in memory at a time + - No large JSON arrays to allocate + +5. **Incremental Sync** + - Could be extended to only sync changed complications + - Could support cancellation mid-sync + +## Logging + +### Watch Logs (Expected Output) + +``` +Requesting complications sync from phone (paginated approach) +Requesting complication at index 0 +Starting complication sync +Received complication 1 of 3 +Deserialized complication: ABC123-UUID +Clearing existing complications from watch database +Saved complication 1 to watch database +More complications pending, requesting index 1 +Received complication 2 of 3 +Deserialized complication: DEF456-UUID +Saved complication 2 to watch database +More complications pending, requesting index 2 +Received complication 3 of 3 +Deserialized complication: GHI789-UUID +Saved complication 3 to watch database +Complication sync complete! Received 3 of 3 complications in 0.45s +Providing complication descriptors: - Configured complications from database: 3 +``` + +### Phone Logs (Expected Output) + +``` +Watch requested complication at index 0 +Sending complication 1 of 3 (hasMore: true) +Watch requested complication at index 1 +Sending complication 2 of 3 (hasMore: true) +Watch requested complication at index 2 +Sending complication 3 of 3 (hasMore: false) +``` + +## Error Handling + +### Invalid Index +``` +Phone: "Invalid complication index 5 (total: 3)" +Watch: "Received error during complication sync: Invalid index 5, total is 3" +``` + +### Serialization Failure +``` +Phone: "Failed to serialize complication at index 2" +Watch: "Received error during complication sync: Failed to serialize complication" +``` + +### Deserialization Failure +``` +Watch: "Failed to create WatchComplication from JSON at index 1" +(Continues with next complication if available) +``` + +## Backward Compatibility + +The legacy `syncComplications` method remains available but is not recommended due to payload size limits. It attempts to send all complications via WatchConnectivity Context in one message. + +**Legacy Method:** `syncComplications(message: InteractiveImmediateMessage)` +- Sends all complications via Context +- May fail with "Payload contains unsupported type" error +- Kept for reference but deprecated + +## Testing + +### Test Scenarios + +1. **Zero Complications** + - Phone has no complications + - Watch should receive total=0 and not request anything + +2. **Single Complication** + - Phone has 1 complication + - Watch receives hasMore=false on first response + +3. **Multiple Complications** + - Phone has N complications + - Watch requests N times, last one has hasMore=false + +4. **Large Complication** + - Test with complex complication data + - Verify individual message stays under size limits + +5. **Network Interruption** + - Start sync, lose connectivity mid-way + - Verify partial sync doesn't corrupt database + +## Performance Considerations + +- **Message Frequency:** Each complication requires one round-trip +- **Typical Timing:** ~0.1s per complication over local connection +- **Database:** Realm write for each complication (fast) +- **UI Impact:** Sync happens in background, doesn't block UI + +## Future Enhancements + +Potential improvements to consider: + +1. **Batch Requests** + - Request multiple indices at once + - Balance between payload size and round-trips + +2. **Delta Sync** + - Only sync changed complications + - Use modification timestamps or checksums + +3. **Compression** + - Compress JSON data before sending + - Significant savings for text-heavy complications + +4. **Cancellation** + - Allow user to cancel sync mid-process + - Add cancellation token to protocol + +5. **Progress UI** + - Show sync progress indicator in watch app + - Display "Syncing complications 3 of 5..." + +## Related Files + +- `WatchCommunicatorService.swift` - Phone sync logic +- `WatchHomeViewModel.swift` - Watch sync initiation +- `ExtensionDelegate.swift` - Watch sync handling +- `ComplicationController.swift` - Provides complications to watchOS +- `WatchComplication.swift` - Data model + +## Version History + +- **v1.0** (Current) - Paginated sync protocol implemented +- **v0.x** (Legacy) - Single-message Context sync (deprecated) diff --git a/Sources/Extensions/Watch/Home/WatchComplicationSyncMessages.swift b/Sources/Extensions/Watch/Home/WatchComplicationSyncMessages.swift new file mode 100644 index 0000000000..b4f958de21 --- /dev/null +++ b/Sources/Extensions/Watch/Home/WatchComplicationSyncMessages.swift @@ -0,0 +1,41 @@ +import Foundation + +/// Constants for complication sync message identifiers and content keys +/// Used for paginated complication sync between iPhone and Apple Watch +enum WatchComplicationSyncMessages { + /// Message identifiers for complication sync protocol + enum Identifier { + /// Request to sync a single complication by index (sent by watch) + static let syncComplication = "syncComplication" + + /// Response containing complication data (sent by phone) + static let syncComplicationResponse = "syncComplicationResponse" + + /// Legacy message to sync all complications at once (deprecated) + static let syncComplications = "syncComplications" + } + + /// Content keys used in complication sync messages + enum ContentKey { + /// The index of the complication to request/send (Int) + static let index = "index" + + /// The serialized complication data (Data) + static let complicationData = "complicationData" + + /// Whether more complications are pending after this one (Bool) + static let hasMore = "hasMore" + + /// The total number of complications being synced (Int) + static let total = "total" + + /// Error message if sync failed (String) + static let error = "error" + + /// Success flag for legacy sync (Bool) + static let success = "success" + + /// Count of complications for legacy sync (Int) + static let count = "count" + } +} diff --git a/Sources/Extensions/Watch/Home/WatchHomeView.swift b/Sources/Extensions/Watch/Home/WatchHomeView.swift index 604c5a15bf..a20f1aca15 100644 --- a/Sources/Extensions/Watch/Home/WatchHomeView.swift +++ b/Sources/Extensions/Watch/Home/WatchHomeView.swift @@ -188,6 +188,8 @@ struct WatchHomeView: View { private var footer: some View { VStack(spacing: .zero) { appVersion + complicationCount + serversCount ssidLabel } .listRowBackground(Color.clear) @@ -204,6 +206,22 @@ struct WatchHomeView: View { .foregroundStyle(.secondary) } + private var complicationCount: some View { + Text(verbatim: "Complications: \(viewModel.complicationCount)") + .font(DesignSystem.Font.caption3) + .frame(maxWidth: .infinity, alignment: .center) + .listRowBackground(Color.clear) + .foregroundStyle(.secondary) + } + + private var serversCount: some View { + Text(verbatim: "Servers: \(viewModel.serversCount)") + .font(DesignSystem.Font.caption3) + .frame(maxWidth: .infinity, alignment: .center) + .listRowBackground(Color.clear) + .foregroundStyle(.secondary) + } + @ViewBuilder private var ssidLabel: some View { if !viewModel.currentSSID.isEmpty { diff --git a/Sources/Extensions/Watch/Home/WatchHomeViewModel.swift b/Sources/Extensions/Watch/Home/WatchHomeViewModel.swift index d99e5afff0..64ada933f3 100644 --- a/Sources/Extensions/Watch/Home/WatchHomeViewModel.swift +++ b/Sources/Extensions/Watch/Home/WatchHomeViewModel.swift @@ -1,8 +1,11 @@ +import ClockKit import Communicator import Foundation +import GRDB import NetworkExtension import PromiseKit import Shared +import WidgetKit enum WatchHomeType { case undefined @@ -26,6 +29,15 @@ final class WatchHomeViewModel: ObservableObject { // are different, the list won't refresh. This is a workaround to force a refresh @Published var refreshListID: UUID = .init() + @Published var complicationCount: Int = 0 + @Published var serversCount: Int = 0 + + private var complicationCountObservation: AnyDatabaseCancellable? + + init() { + setupComplicationObservation() + } + @MainActor func fetchNetworkInfo() async { let networkInformation = await Current.networkInformation @@ -33,10 +45,54 @@ final class WatchHomeViewModel: ObservableObject { currentSSID = networkInformation?.ssid ?? "" } + @MainActor + func fetchComplicationCount() { + do { + let count = try Current.database().read { db in + try AppWatchComplication.fetchCount(db) + } + complicationCount = count + Current.Log.verbose("Fetched complication count: \(count)") + } catch { + Current.Log.error("Failed to fetch complication count from GRDB: \(error.localizedDescription)") + complicationCount = 0 + } + } + + /// Sets up database observation for AppWatchComplication changes + /// Automatically updates complicationCount when complications are added/removed + private func setupComplicationObservation() { + let observation = ValueObservation.tracking { db in + try AppWatchComplication.fetchCount(db) + } + + complicationCountObservation = observation.start( + in: Current.database(), + scheduling: .immediate, + onError: { error in + Current.Log.error("Error observing complication count: \(error.localizedDescription)") + }, + onChange: { [weak self] count in + Task { @MainActor [weak self] in + self?.complicationCount = count + Current.Log.verbose("Complication count updated via observation: \(count)") + } + } + ) + } + @MainActor func initialRoutine() { // First display whatever is in cache loadCache() + // Complication count is now automatically observed via setupComplicationObservation() + + // Set initial servers count from current state + serversCount = Current.servers.all.count + + // Request servers from phone + requestServers() + // Now fetch new data in the background (shows loading indicator only for this fetch) isLoading = true requestConfig() @@ -51,12 +107,70 @@ final class WatchHomeViewModel: ObservableObject { return } isLoading = true + + // Request watch config via interactive message Communicator.shared.send(.init( identifier: InteractiveImmediateMessages.watchConfig.rawValue, reply: { [weak self] message in self?.handleMessageResponse(message) } )) + + // Request complications sync from phone + requestComplicationsSync() + } + + /// Requests server configuration from the iPhone + /// + /// This method implements the send/reply pattern for syncing servers from iPhone to Watch. + /// It replaces the legacy context-based sync to avoid payload size limits. + /// + /// Flow: + /// 1. Watch sends "syncServers" message to iPhone + /// 2. iPhone replies with server data + /// 3. Watch restores servers from data (same as ExtensionDelegate.updateContext) + @MainActor + func requestServers() { + guard Communicator.shared.currentReachability != .notReachable else { + Current.Log.warning("Cannot sync servers - iPhone not reachable") + return + } + + Current.Log.info("Requesting servers from iPhone") + + Communicator.shared.send(.init( + identifier: InteractiveImmediateMessages.syncServers.rawValue, + reply: { [weak self] message in + self?.handleServersResponse(message) + } + ), errorHandler: { error in + Current.Log.error("Failed to request servers from iPhone: \(error)") + }) + } + + /// Handles the server sync response from the iPhone + /// - Parameter message: Reply message containing server data + @MainActor + private func handleServersResponse(_ message: ImmediateMessage) { + guard message.identifier == InteractiveImmediateResponses.syncServersResponse.rawValue else { + Current.Log.error("Received unexpected response identifier for servers: \(message.identifier)") + return + } + + guard let serversData = message.content["servers"] as? Data else { + Current.Log.error("No servers data in syncServers response") + return + } + + Current.Log.info("Received \(serversData.count) bytes of server data from iPhone") + + // Restore servers - same logic as ExtensionDelegate.updateContext + Current.servers.restoreState(serversData) + + // Update servers count + serversCount = Current.servers.all.count + + Current.Log.info("Successfully restored \(serversCount) servers on watch") } func info(for magicItem: MagicItem) -> MagicItem.Info { @@ -235,3 +349,118 @@ final class WatchHomeViewModel: ObservableObject { } } } + +// MARK: - Complication Sync + +extension WatchHomeViewModel { + /// Initiates the complication sync process from the phone + /// This starts a paginated sync where complications are sent one at a time + func requestComplicationsSync() { + Current.Log.info("Requesting complications sync from phone (paginated approach)") + requestNextComplication(index: 0) + } + + /// Requests a single complication from the phone by index + /// - Parameter index: The index of the complication to request (0-based) + /// + /// This implements a paginated sync protocol: + /// 1. Watch sends interactive request with index + /// 2. Phone responds with complication at that index + "hasMore" flag in reply + /// 3. If hasMore is true, watch requests next index + /// 4. Continues until hasMore is false or error occurs + private func requestNextComplication(index: Int) { + Current.Log.info("Requesting complication at index \(index)") + + Communicator.shared.send(.init( + identifier: WatchComplicationSyncMessages.Identifier.syncComplication, + content: [WatchComplicationSyncMessages.ContentKey.index: index], + reply: { [weak self] replyMessage in + self?.handleComplicationResponse(replyMessage, requestedIndex: index) + } + ), errorHandler: { error in + Current.Log.error("Failed to send syncComplication request for index \(index): \(error)") + }) + } + + /// Handles the response for a single complication request + /// - Parameters: + /// - message: The reply message from the phone + /// - requestedIndex: The index that was requested + private func handleComplicationResponse(_ message: ImmediateMessage, requestedIndex: Int) { + // Check for error + if let error = message.content[WatchComplicationSyncMessages.ContentKey.error] as? String { + Current.Log.error("Received error for complication at index \(requestedIndex): \(error)") + return + } + + guard let complicationData = message + .content[WatchComplicationSyncMessages.ContentKey.complicationData] as? Data, + let hasMore = message.content[WatchComplicationSyncMessages.ContentKey.hasMore] as? Bool, + let index = message.content[WatchComplicationSyncMessages.ContentKey.index] as? Int, + let total = message.content[WatchComplicationSyncMessages.ContentKey.total] as? Int else { + Current.Log.error("Invalid syncComplication response format") + return + } + + Current.Log.info("Received complication \(index + 1) of \(total) (hasMore: \(hasMore))") + + // Save the complication + saveComplicationToDatabase(complicationData, index: index, total: total) + + // Request next complication if more are pending + if hasMore { + Current.Log.verbose("More complications pending, requesting index \(index + 1)") + requestNextComplication(index: index + 1) + } else { + Current.Log.info("Complication sync complete! Received \(total) complications") + // Trigger complication reload + reloadComplications() + } + } + + /// Saves a single complication to the watch GRDB database + /// - Parameters: + /// - complicationData: JSON data of the complication + /// - index: The index of this complication + /// - total: Total number of complications being synced + private func saveComplicationToDatabase(_ complicationData: Data, index: Int, total: Int) { + do { + // Convert JSON data to AppWatchComplication + let complication = try AppWatchComplication.from(jsonData: complicationData) + + Current.Log.verbose("Deserialized complication: \(complication.identifier)") + + // Save to GRDB database + try Current.database().write { db in + // On first complication, clear existing ones + if index == 0 { + Current.Log.info("Clearing existing complications from watch GRDB database") + try AppWatchComplication.deleteAll(from: db) + } + + // Insert or replace the complication + try complication.insert(db, onConflict: .replace) + } + + Current.Log.info("Saved complication \(index + 1) of \(total) to watch GRDB database") + } catch { + Current.Log.error("Failed to save complication at index \(index): \(error.localizedDescription)") + } + } + + /// Triggers a reload of all complications on the watch + private func reloadComplications() { + if #available(watchOS 9.0, *) { + WidgetCenter.shared.reloadAllTimelines() + } + + CLKComplicationServer.sharedInstance().reloadComplicationDescriptors() + + if let activeComplications = CLKComplicationServer.sharedInstance().activeComplications { + Current.Log.info("Reloading \(activeComplications.count) active complications") + for complication in activeComplications { + CLKComplicationServer.sharedInstance().reloadTimeline(for: complication) + } + } + } +} diff --git a/Sources/Shared/API/HAAPI.swift b/Sources/Shared/API/HAAPI.swift index 6d5f12efaa..c2fe3917db 100644 --- a/Sources/Shared/API/HAAPI.swift +++ b/Sources/Shared/API/HAAPI.swift @@ -225,7 +225,6 @@ public class HomeAssistantAPI { if !Current.isAppExtension { promises.append(getConfig()) promises.append(Current.modelManager.fetch(apis: [self])) - promises.append(updateComplications(passively: false).asVoid()) } promises.append(UpdateSensors(trigger: reason.updateSensorTrigger).asVoid()) diff --git a/Sources/Shared/API/WatchHelpers.swift b/Sources/Shared/API/WatchHelpers.swift index 23c340d1cb..54f1d08c9b 100644 --- a/Sources/Shared/API/WatchHelpers.swift +++ b/Sources/Shared/API/WatchHelpers.swift @@ -25,8 +25,8 @@ public extension HomeAssistantAPI { var content: Content = Communicator.shared.mostRecentlyReceievedContext.content #if os(iOS) - content[WatchContext.servers.rawValue] = Current.servers.restorableState() - content[WatchContext.complications.rawValue] = Array(Current.realm().objects(WatchComplication.self)).toJSON() + // Note: Servers are now synced via send/reply pattern, not context + // See WatchHomeViewModel.requestServers() and WatchCommunicatorService.syncServers() #if targetEnvironment(simulator) content[WatchContext.ssid.rawValue] = "SimulatorWiFi" @@ -73,38 +73,4 @@ public extension HomeAssistantAPI { return nil } - - func updateComplications(passively: Bool) -> Promise { - #if os(iOS) - guard case .paired = Communicator.shared.currentWatchState else { - Current.Log.verbose("skipping complication updates; no paired watch") - return .value(()) - } - #endif - - let complications = Set( - Current.realm().objects(WatchComplication.self) - .filter("serverIdentifier = %@", server.identifier.rawValue) - ) - - guard let request = WebhookResponseUpdateComplications.request(for: complications) else { - Current.Log.verbose("no complications need templates rendered") - - #if os(iOS) - // in case the user deleted the last complication, sync that fact up to the watch - _ = HomeAssistantAPI.SyncWatchContext() - #else - // in case the user updated just the complication's metadata, force a refresh - WebhookResponseUpdateComplications.updateComplications() - #endif - - return .value(()) - } - - if passively { - return Current.webhooks.sendPassive(identifier: .updateComplications, server: server, request: request) - } else { - return Current.webhooks.send(identifier: .updateComplications, server: server, request: request) - } - } } diff --git a/Sources/Shared/Database/DatabaseTables.swift b/Sources/Shared/Database/DatabaseTables.swift index 7c323ba11d..7f70736338 100644 --- a/Sources/Shared/Database/DatabaseTables.swift +++ b/Sources/Shared/Database/DatabaseTables.swift @@ -9,6 +9,7 @@ public enum GRDBDatabaseTable: String { case appPanel case customWidget case appArea + case appWatchComplication // Dropped since 2025.2, now saved as json file // Context: https://github.com/groue/GRDB.swift/issues/1626#issuecomment-2623927815 @@ -84,4 +85,16 @@ public enum DatabaseTables { case icon case entities } + + // Watch Complications stored in GRDB (watch side only) + public enum AppWatchComplication: String, CaseIterable { + case identifier + case serverIdentifier + case rawFamily + case rawTemplate + case complicationData + case createdAt + case name + case isPublic + } } diff --git a/Sources/Shared/Database/GRDB+Initialization.swift b/Sources/Shared/Database/GRDB+Initialization.swift index 74a6f419a5..c124bafa13 100644 --- a/Sources/Shared/Database/GRDB+Initialization.swift +++ b/Sources/Shared/Database/GRDB+Initialization.swift @@ -52,6 +52,7 @@ public extension DatabaseQueue { AppPanelTable(), CustomWidgetTable(), AppAreaTable(), + AppWatchComplicationTable(), ] } diff --git a/Sources/Shared/Watch/InteractiveImmediateMessages.swift b/Sources/Shared/Watch/InteractiveImmediateMessages.swift index 072f8b7ce4..d1b2fc9a98 100644 --- a/Sources/Shared/Watch/InteractiveImmediateMessages.swift +++ b/Sources/Shared/Watch/InteractiveImmediateMessages.swift @@ -8,6 +8,8 @@ public enum InteractiveImmediateMessages: String, CaseIterable { case assistPipelinesFetch case assistAudioDataChunked case watchConfig + case syncServers + case renderTemplates } public enum InteractiveImmediateResponses: String, CaseIterable { @@ -23,4 +25,6 @@ public enum InteractiveImmediateResponses: String, CaseIterable { case assistError case watchConfigResponse case emptyWatchConfigResponse + case syncServersResponse + case renderTemplatesResponse } diff --git a/Sources/Shared/Watch/WatchComplicationSyncMessages.swift b/Sources/Shared/Watch/WatchComplicationSyncMessages.swift new file mode 100644 index 0000000000..ce459caac8 --- /dev/null +++ b/Sources/Shared/Watch/WatchComplicationSyncMessages.swift @@ -0,0 +1,41 @@ +import Foundation + +/// Constants for complication sync message identifiers and content keys +/// Used for paginated complication sync between iPhone and Apple Watch +public enum WatchComplicationSyncMessages { + /// Message identifiers for complication sync protocol + public enum Identifier { + /// Request to sync a single complication by index (sent by watch) + public static let syncComplication = "syncComplication" + + /// Response containing complication data (sent by phone) + public static let syncComplicationResponse = "syncComplicationResponse" + + /// Legacy message to sync all complications at once (deprecated) + public static let syncComplications = "syncComplications" + } + + /// Content keys used in complication sync messages + public enum ContentKey { + /// The index of the complication to request/send (Int) + public static let index = "index" + + /// The serialized complication data (Data) + public static let complicationData = "complicationData" + + /// Whether more complications are pending after this one (Bool) + public static let hasMore = "hasMore" + + /// The total number of complications being synced (Int) + public static let total = "total" + + /// Error message if sync failed (String) + public static let error = "error" + + /// Success flag for legacy sync (Bool) + public static let success = "success" + + /// Count of complications for legacy sync (Int) + public static let count = "count" + } +} diff --git a/Sources/Watch/WatchCommunicatorService.swift b/Sources/Watch/WatchCommunicatorService.swift index c0c387ea3b..2c455417ea 100644 --- a/Sources/Watch/WatchCommunicatorService.swift +++ b/Sources/Watch/WatchCommunicatorService.swift @@ -54,7 +54,21 @@ final class WatchCommunicatorService { InteractiveImmediateMessage.observations.store[.init(queue: .main)] = { [weak self] message in Current.Log.verbose("Received \(message.identifier) \(message) \(message.content)") - guard let self, let messageId = InteractiveImmediateMessages(rawValue: message.identifier) else { + guard let self else { return } + + // Handle custom syncComplication message (paginated approach) + if message.identifier == WatchComplicationSyncMessages.Identifier.syncComplication { + syncSingleComplication(message: message) + return + } + + // Handle legacy syncComplications message (for backward compatibility) + if message.identifier == WatchComplicationSyncMessages.Identifier.syncComplications { + syncComplications(message: message) + return + } + + guard let messageId = InteractiveImmediateMessages(rawValue: message.identifier) else { Current.Log .error( "Received InteractiveImmediateMessage not mapped in InteractiveImmediateMessages: \(message.identifier)" @@ -67,6 +81,10 @@ final class WatchCommunicatorService { message.reply(.init(identifier: InteractiveImmediateResponses.pong.rawValue)) case .watchConfig: watchConfig(message: message) + case .syncServers: + syncServers(message: message) + case .renderTemplates: + renderTemplates(message: message) case .actionRowPressed: actionRowPressed(message: message) case .pushAction: @@ -153,6 +171,269 @@ final class WatchCommunicatorService { message.reply(.init(identifier: responseIdentifier)) } + /// Syncs server configuration to the watch via send/reply pattern + /// + /// This method handles requests from the watch for server configuration data. + /// It serializes the current server state and sends it back via a reply message. + /// + /// Protocol: + /// 1. Watch sends "syncServers" InteractiveImmediateMessage + /// 2. Phone replies with "syncServersResponse" containing server data + /// 3. Watch restores servers from the data (same as ExtensionDelegate.updateContext does) + /// + /// - Parameter message: The InteractiveImmediateMessage requesting servers + private func syncServers(message: InteractiveImmediateMessage) { + Current.Log.info("Watch requested servers sync") + + let serversData = Current.servers.restorableState() + + Current.Log.info("Sending \(serversData.count) bytes of server data to watch") + + message.reply(.init( + identifier: InteractiveImmediateResponses.syncServersResponse.rawValue, + content: ["servers": serversData] + )) + } + + /// Renders Jinja2 templates on behalf of the watch + /// + /// This method handles template rendering requests from watch complications. + /// The iPhone is better positioned to render templates because: + /// - Better network connectivity to Home Assistant + /// - More processing power + /// - Can handle longer timeouts without blocking the UI + /// + /// Protocol: + /// 1. Watch sends "renderTemplates" message with templates dictionary and server ID + /// 2. iPhone renders templates via Home Assistant API + /// 3. iPhone replies with rendered values or error + /// + /// - Parameter message: The InteractiveImmediateMessage containing: + /// - "templates": [String: String] - Dictionary of template keys to template strings + /// - "serverIdentifier": String - Server identifier to use for rendering + private func renderTemplates(message: InteractiveImmediateMessage) { + guard let templatesDict = message.content["templates"] as? [String: String], + let serverIdentifier = message.content["serverIdentifier"] as? String else { + Current.Log.error("Invalid renderTemplates request - missing templates or serverIdentifier") + message.reply(.init( + identifier: InteractiveImmediateResponses.renderTemplatesResponse.rawValue, + content: ["error": "Missing required parameters"] + )) + return + } + + guard let server = Current.servers.all.first(where: { $0.identifier.rawValue == serverIdentifier }), + let connection = Current.api(for: server)?.connection else { + Current.Log.error("No API available for server \(serverIdentifier)") + message.reply(.init( + identifier: InteractiveImmediateResponses.renderTemplatesResponse.rawValue, + content: ["error": "No API available for server"] + )) + return + } + + Current.Log.info("Rendering \(templatesDict.count) templates for server \(serverIdentifier)") + + // Create a combined template string with keys as markers + // Format: "key1:::{{template1}}|||key2:::{{template2}}|||..." + let combinedTemplate = templatesDict + .map { key, template in "\(key):::\(template)" } + .joined(separator: "|||") + + // Send render request to Home Assistant + connection.send(.init( + type: .rest(.post, "template"), + data: ["template": combinedTemplate], + shouldRetry: true + )) { [weak self] result in + guard let self else { return } + + switch result { + case let .success(data): + // Parse the response + var renderedResults: [String: Any] = [:] + + switch data { + case let .primitive(response): + if let renderedString = response as? String { + // Split the response back into individual results + let parts = renderedString.components(separatedBy: "|||") + + for part in parts { + let keyValue = part.components(separatedBy: ":::") + if keyValue.count == 2 { + let key = keyValue[0] + let value = keyValue[1] + renderedResults[key] = value + } + } + + if renderedResults.count == templatesDict.count { + Current.Log.info("Successfully rendered \(renderedResults.count) templates") + message.reply(.init( + identifier: InteractiveImmediateResponses.renderTemplatesResponse.rawValue, + content: ["rendered": renderedResults] + )) + } else { + Current.Log + .error( + "Rendered count mismatch: expected \(templatesDict.count), got \(renderedResults.count)" + ) + message.reply(.init( + identifier: InteractiveImmediateResponses.renderTemplatesResponse.rawValue, + content: ["error": "Rendered count mismatch"] + )) + } + } else { + Current.Log.error("Template rendering returned non-string response") + message.reply(.init( + identifier: InteractiveImmediateResponses.renderTemplatesResponse.rawValue, + content: ["error": "Invalid response format"] + )) + } + + default: + Current.Log.error("Template rendering returned unexpected data type") + message.reply(.init( + identifier: InteractiveImmediateResponses.renderTemplatesResponse.rawValue, + content: ["error": "Unexpected response data type"] + )) + } + + case let .failure(error): + Current.Log.error("Failed to render templates: \(error)") + message.reply(.init( + identifier: InteractiveImmediateResponses.renderTemplatesResponse.rawValue, + content: ["error": error.localizedDescription] + )) + } + } + } + + /// Syncs a single complication to the watch by index (paginated approach with reply) + /// This avoids payload size limits by sending complications one at a time. + /// + /// Protocol: + /// 1. Watch sends "syncComplication" InteractiveImmediateMessage with {"index": N} + /// 2. Phone replies with complication data containing: + /// - "complicationData": Data (JSON of the complication) + /// - "hasMore": Bool (true if index+1 < total) + /// - "index": Int (the index sent by watch) + /// - "total": Int (total number of complications) + /// 3. Watch saves the complication and requests next if hasMore is true + /// + /// - Parameter message: The InteractiveImmediateMessage containing {"index": Int} + private func syncSingleComplication(message: InteractiveImmediateMessage) { + guard let index = message.content[WatchComplicationSyncMessages.ContentKey.index] as? Int else { + Current.Log.error("syncComplication message missing 'index' parameter") + message.reply(.init( + identifier: WatchComplicationSyncMessages.Identifier.syncComplicationResponse, + content: [ + WatchComplicationSyncMessages.ContentKey.error: "Missing index parameter", + WatchComplicationSyncMessages.ContentKey.hasMore: false, + WatchComplicationSyncMessages.ContentKey.index: -1, + WatchComplicationSyncMessages.ContentKey.total: 0, + ] + )) + return + } + + Current.Log.info("Watch requested complication at index \(index)") + + let realm = Current.realm() + let complications = realm.objects(WatchComplication.self) + let total = complications.count + + // Validate index + guard index >= 0, index < total else { + Current.Log.error("Invalid complication index \(index) (total: \(total))") + message.reply(.init( + identifier: WatchComplicationSyncMessages.Identifier.syncComplicationResponse, + content: [ + WatchComplicationSyncMessages.ContentKey.error: "Invalid index \(index), total is \(total)", + WatchComplicationSyncMessages.ContentKey.hasMore: false, + WatchComplicationSyncMessages.ContentKey.index: -1, + WatchComplicationSyncMessages.ContentKey.total: total, + ] + )) + return + } + + let complication = complications[index] + + // Serialize this single complication + guard let complicationJSONString = complication.toJSONString(), + let complicationData = complicationJSONString.data(using: .utf8) else { + Current.Log.error("Failed to serialize complication at index \(index)") + message.reply(.init( + identifier: WatchComplicationSyncMessages.Identifier.syncComplicationResponse, + content: [ + WatchComplicationSyncMessages.ContentKey.error: "Failed to serialize complication", + WatchComplicationSyncMessages.ContentKey.hasMore: false, + WatchComplicationSyncMessages.ContentKey.index: index, + WatchComplicationSyncMessages.ContentKey.total: total, + ] + )) + return + } + + let hasMore = (index + 1) < total + + Current.Log.info("Sending complication \(index + 1) of \(total) to watch (hasMore: \(hasMore))") + + // Reply with complication data + message.reply(.init( + identifier: WatchComplicationSyncMessages.Identifier.syncComplicationResponse, + content: [ + WatchComplicationSyncMessages.ContentKey.complicationData: complicationData, + WatchComplicationSyncMessages.ContentKey.hasMore: hasMore, + WatchComplicationSyncMessages.ContentKey.index: index, + WatchComplicationSyncMessages.ContentKey.total: total, + ] + )) + } + + /// Legacy method for syncing all complications at once via Context + /// Kept for backward compatibility but may fail with large payloads + private func syncComplications(message: InteractiveImmediateMessage) { + Current.Log.info("Watch requested complications sync - fetching complications from phone database") + + let realm = Current.realm() + let complications = realm.objects(WatchComplication.self) + + Current.Log.info("Found \(complications.count) complications in phone database") + + // Convert complications to JSON data, then send as Data type + // WatchConnectivity supports Data, but not arbitrary nested dictionaries with complex types + let complicationsArray = complications.compactMap { complication -> [String: Any]? in + complication.toJSON() + } + + // Serialize the complications array to Data (JSON) + guard let complicationsData = try? JSONSerialization.data(withJSONObject: complicationsArray, options: []) else { + Current.Log.error("Failed to serialize complications to JSON data") + message.reply(.init( + identifier: "syncComplicationsResponse", + content: ["success": false, "error": "Failed to serialize complications"] + )) + return + } + + Current.Log + .verbose("Serialized \(complications.count) complications to \(complicationsData.count) bytes of JSON data") + + var contextContent: [String: Any] = [:] + // Include complications as Data (will be deserialized on watch side) + contextContent["complicationsData"] = complicationsData + Current.Log.info("Successfully sent \(complications.count) complications to watch via Context") + + // Reply to acknowledge success + message.reply(.init( + identifier: "syncComplicationsResponse", + content: ["success": true, "count": complications.count] + )) + } + private func magicItemPressed(message: InteractiveImmediateMessage) { let responseIdentifier = InteractiveImmediateResponses.magicItemRowPressedResponse.rawValue guard let itemType = message.content["itemType"] as? String,