Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 5 additions & 6 deletions .github/workflows/push_ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,13 @@ on:
paths:
- 'Sources/PushServer/**'

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
steps:
- uses: fwal/setup-swift@364295d9c23900ce04d4e5cc708387921b4e50f9 # v3
with:
swift-version: "5.8"
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Run Tests
run: swift test
working-directory: Sources/PushServer
run: docker run --rm -v "$PWD:/workspace" -w /workspace/Sources/PushServer swift:5.8-jammy swift test
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
8 changes: 8 additions & 0 deletions HomeAssistant.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,8 @@
11ADB13E24C29E6900FF5EB2 /* ZoneManagerRegionFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11ADB13D24C29E6900FF5EB2 /* ZoneManagerRegionFilter.swift */; };
11ADF940267D34B10040A7E3 /* NotificationsCommandManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11ADF93E267D34AD0040A7E3 /* NotificationsCommandManager.swift */; };
11ADF941267D34B20040A7E3 /* NotificationsCommandManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11ADF93E267D34AD0040A7E3 /* NotificationsCommandManager.swift */; };
11ADF942267D34B20040A7E3 /* HandlerShowCamera.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11ADF93F267D34AD0040A7E3 /* HandlerShowCamera.swift */; };
11ADF943267D34B20040A7E3 /* HandlerHideCamera.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11ADF944267D34AD0040A7E3 /* HandlerHideCamera.swift */; };
11AF4D13249C7E08006C74C0 /* ActivitySensor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11AF4D10249C7DFD006C74C0 /* ActivitySensor.swift */; };
11AF4D14249C7E09006C74C0 /* ActivitySensor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11AF4D10249C7DFD006C74C0 /* ActivitySensor.swift */; };
11AF4D16249C8083006C74C0 /* With.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11AF4D15249C8082006C74C0 /* With.swift */; };
Expand Down Expand Up @@ -3145,6 +3147,8 @@
A95FDD092F6B89C6008EF72F /* HALiveActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HALiveActivityAttributes.swift; sourceTree = "<group>"; };
A95FDD0A2F6B89C6008EF72F /* LiveActivityRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityRegistry.swift; sourceTree = "<group>"; };
A95FDD0E2F6B8A19008EF72F /* HandlerLiveActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandlerLiveActivity.swift; sourceTree = "<group>"; };
11ADF93F267D34AD0040A7E3 /* HandlerShowCamera.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandlerShowCamera.swift; sourceTree = "<group>"; };
11ADF944267D34AD0040A7E3 /* HandlerHideCamera.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandlerHideCamera.swift; sourceTree = "<group>"; };
A95FDD102F6B8A3E008EF72F /* HADynamicIslandView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HADynamicIslandView.swift; sourceTree = "<group>"; };
A95FDD112F6B8A3E008EF72F /* HALiveActivityConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HALiveActivityConfiguration.swift; sourceTree = "<group>"; };
A95FDD122F6B8A3E008EF72F /* HALockScreenView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HALockScreenView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -4211,6 +4215,8 @@
isa = PBXGroup;
children = (
A95FDD0E2F6B8A19008EF72F /* HandlerLiveActivity.swift */,
11ADF93F267D34AD0040A7E3 /* HandlerShowCamera.swift */,
11ADF944267D34AD0040A7E3 /* HandlerHideCamera.swift */,
11ADF93E267D34AD0040A7E3 /* NotificationsCommandManager.swift */,
);
path = NotificationCommands;
Expand Down Expand Up @@ -10288,6 +10294,8 @@
B6B74CBD228399AB00D58A68 /* Action.swift in Sources */,
428DC00A2F0CAAE7003B08D5 /* EntityProvider+Details.swift in Sources */,
A95FDD0F2F6B8A19008EF72F /* HandlerLiveActivity.swift in Sources */,
11ADF942267D34B20040A7E3 /* HandlerShowCamera.swift in Sources */,
11ADF943267D34B20040A7E3 /* HandlerHideCamera.swift in Sources */,
11CB98CA249E62E700B05222 /* Version+HA.swift in Sources */,
420F53EA2C4E9D54003C8415 /* WidgetsKind.swift in Sources */,
11EE9B4924C5116F00404AF8 /* LegacyModelManager.swift in Sources */,
Expand Down
168 changes: 168 additions & 0 deletions Sources/App/Notifications/NotificationManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ class NotificationManager: NSObject, LocalPushManagerDelegate {
}()

var commandManager = NotificationCommandManager()
private weak var cameraOverlayController: UIViewController?

override init() {
super.init()
Expand All @@ -31,6 +32,18 @@ class NotificationManager: NSObject, LocalPushManagerDelegate {
name: UIApplication.didBecomeActiveNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(showCameraFromNotification(_:)),
name: NotificationCommandManager.didReceiveShowCameraNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(hideCameraFromNotification),
name: NotificationCommandManager.didReceiveHideCameraNotification,
object: nil
)
}

func setupNotifications() {
Expand All @@ -44,6 +57,137 @@ class NotificationManager: NSObject, LocalPushManagerDelegate {
}
}

@objc private func showCameraFromNotification(_ notification: Notification) {
guard UIApplication.shared.applicationState == .active else {
scheduleShowCameraFallbackNotification(userInfo: notification.userInfo)
return
}

openCamera(from: notification.userInfo)
}

private func openCamera(from userInfo: [AnyHashable: Any]?) {
guard #available(iOS 16.0, *) else {
Current.Log.info("Ignoring show_camera push command because camera player requires iOS 16")
return
}

guard let entityId = cameraEntityId(from: userInfo) else {
Current.Log.error("Received show_camera push command without a valid camera entity_id")
return
}

Current.sceneManager.webViewWindowControllerPromise.then(\.webViewControllerPromise)
.done { webViewController in
let view = CameraPlayerView(
server: self.cameraServer(from: userInfo, fallback: webViewController.server),
cameraEntityId: entityId
).embeddedInHostingController()
Comment thread
bgoncal marked this conversation as resolved.
self.cameraOverlayController = view
view.modalPresentationStyle = .overFullScreen
webViewController.presentOverlayController(controller: view, animated: true)
}.catch { error in
Current.Log.error("Failed to show camera from push command: \(error)")
}
}

private func scheduleShowCameraFallbackNotification(userInfo: [AnyHashable: Any]?) {
guard let entityId = cameraEntityId(from: userInfo) else {
Current.Log.error("Received show_camera push command without a valid camera entity_id")
return
}

let content = UNMutableNotificationContent()
content.body = L10n.CameraPlayer.Notification.body
content.sound = .default
Comment thread
bgoncal marked this conversation as resolved.
var fallbackUserInfo: [AnyHashable: Any] = [
"homeassistant": [
"command": "show_camera",
"entity_id": entityId,
],
]
if let webhookId = webhookId(from: userInfo) {
fallbackUserInfo["webhook_id"] = webhookId
}
content.userInfo = fallbackUserInfo

Current.userNotificationCenter.add(UNNotificationRequest(
identifier: "show_camera.\(entityId)",
content: content,
trigger: nil
)) { error in
if let error {
Current.Log.error("Failed to schedule show_camera fallback notification: \(error)")
}
}
}

private func cameraEntityId(from userInfo: [AnyHashable: Any]?) -> String? {
guard let userInfo else { return nil }

if let entityId = userInfo["entity_id"] as? String, entityId.hasPrefix("camera.") {
return entityId
}

if let homeassistant = userInfo["homeassistant"] as? [String: Any],
let entityId = homeassistant["entity_id"] as? String,
entityId.hasPrefix("camera.") {
return entityId
}

if let homeassistant = userInfo["homeassistant"] as? [AnyHashable: Any],
let entityId = homeassistant["entity_id"] as? String,
entityId.hasPrefix("camera.") {
return entityId
}

return nil
}

private func webhookId(from userInfo: [AnyHashable: Any]?) -> String? {
guard let userInfo else { return nil }

if let webhookId = userInfo["webhook_id"] as? String {
return webhookId
}

if let homeassistant = userInfo["homeassistant"] as? [String: Any] {
return homeassistant["webhook_id"] as? String
}

if let homeassistant = userInfo["homeassistant"] as? [AnyHashable: Any] {
return homeassistant["webhook_id"] as? String
}

return nil
}

private func cameraServer(from userInfo: [AnyHashable: Any]?, fallback: Server) -> Server {
guard let webhookId = webhookId(from: userInfo),
let server = Current.servers.server(forWebhookID: webhookId) else {
return fallback
}

return server
}

@objc private func hideCameraFromNotification() {
Current.sceneManager.webViewWindowControllerPromise.then(\.webViewControllerPromise)
.done { webViewController in
guard let cameraOverlayController = self.cameraOverlayController,
webViewController.overlayedController === cameraOverlayController else {
Current.Log.info("Ignoring hide_camera push command because no camera is on display")
return
}

webViewController.dismissOverlayController(animated: true) { [weak self] in
self?.cameraOverlayController = nil
}
}.catch { error in
Current.Log.error("Failed to hide camera from push command: \(error)")
}
}

func resetPushID() -> Promise<String> {
firstly {
Promise<Void> { seal in
Expand Down Expand Up @@ -238,6 +382,12 @@ extension NotificationManager: UNUserNotificationCenterDelegate {

Current.Log.verbose("User info in incoming notification \(userInfo) with response \(response)")

if isShowCameraCommand(userInfo: userInfo), cameraEntityId(from: userInfo) != nil {
openCamera(from: userInfo)
completionHandler()
return
}

guard let server = Current.servers.server(for: response.notification.request.content) else {
Current.Log.info("ignoring push when unable to find server")
completionHandler()
Expand Down Expand Up @@ -278,6 +428,24 @@ extension NotificationManager: UNUserNotificationCenterDelegate {
}
}

private func isShowCameraCommand(userInfo: [AnyHashable: Any]) -> Bool {
if userInfo["command"] as? String == "show_camera" {
return true
}

if let homeassistant = userInfo["homeassistant"] as? [String: Any],
homeassistant["command"] as? String == "show_camera" {
return true
}

if let homeassistant = userInfo["homeassistant"] as? [AnyHashable: Any],
homeassistant["command"] as? String == "show_camera" {
return true
}

return false
}

public func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
Expand Down
3 changes: 2 additions & 1 deletion Sources/App/Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@
"camera_player.errors.no_stream_available" = "No stream available";
"camera_player.errors.unable_to_connect_to_server" = "Unable to connect to Home Assistant";
"camera_player.errors.unknown" = "Unknown error";
"camera_player.notification.body" = "Tap to open camera";
"cameras.drag_to_reorder" = "Drag and drop to reorder";
"cameras.no_server_found" = "No server found for camera: %@";
"cancel_label" = "Cancel";
Expand Down Expand Up @@ -1743,4 +1744,4 @@ Home Assistant is open source, advocates for privacy and runs locally in your ho
"widgets.todo_list.refresh_title" = "Refresh To-do List";
"widgets.todo_list.select_list" = "Edit widget to select list.";
"widgets.todo_list.title" = "To-do List";
"yes_label" = "Yes";
"yes_label" = "Yes";
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,16 @@ public struct LegacyNotificationParserImpl: LegacyNotificationParser {
return .init(LegacyNotificationCommandType.updateComplications.rawValue)
case .updateWidgets:
return .init(LegacyNotificationCommandType.updateWidgets.rawValue)
case .showCamera:
var homeassistant = [String: Any]()

if let entityId = data["entity_id"] {
homeassistant["entity_id"] = entityId
}

return .init(LegacyNotificationCommandType.showCamera.rawValue, homeassistant: homeassistant)
Comment thread
bgoncal marked this conversation as resolved.
case .hideCamera:
return .init(LegacyNotificationCommandType.hideCamera.rawValue)
default: return nil
}
}()
Expand Down Expand Up @@ -255,6 +265,8 @@ enum LegacyNotificationCommandType: String {
case clearNotification = "clear_notification"
case updateComplications = "update_complications"
case updateWidgets = "update_widgets"
case showCamera = "show_camera"
case hideCamera = "hide_camera"
}

private extension Dictionary where Value == Any {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"input": {
"title": "extra excluded title",
"message": "hide_camera",
"registration_info": {
"app_id": "io.robbie.HomeAssistant.dev",
"os_version": "10.15",
"app_version": "2026.1"
}
},
"rate_limit": false,
"headers": {
"apns-push-type": "background"
},
"payload": {
"aps": {
"contentAvailable": true
},
"homeassistant": {
"command": "hide_camera"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"input": {
"title": "extra excluded title",
"message": "show_camera",
"data": {
"entity_id": "camera.front_door"
},
"registration_info": {
"app_id": "io.robbie.HomeAssistant.dev",
"os_version": "10.15",
"app_version": "2026.1"
}
},
"rate_limit": false,
"headers": {
"apns-push-type": "background"
},
"payload": {
"aps": {
"contentAvailable": true
},
"homeassistant": {
"command": "show_camera",
"entity_id": "camera.front_door"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
#if os(iOS)
import Foundation
import PromiseKit

struct HandlerHideCamera: NotificationCommandHandler {
func handle(_ payload: [String: Any]) -> Promise<Void> {
Promise<Void> { seal in
Comment thread
bgoncal marked this conversation as resolved.
let postNotification = {
NotificationCenter.default.post(
name: NotificationCommandManager.didReceiveHideCameraNotification,
object: nil
)
seal.fulfill(())
}

if Thread.isMainThread {
postNotification()
} else {
DispatchQueue.main.async(execute: postNotification)
}
}
}
}
#endif
Loading
Loading