Files
iOS/Sources/App/Scenes/SceneManager.swift
Copilot 0c0c2533e5 Add scene support for AssistView on macOS (#3965)
## Summary
Enables AssistView to open in a dedicated window on macOS while
maintaining modal presentation on iOS/iPad.

**Implementation:**
- Added `.assist` case to `SceneActivity` enum with `ha.assist`
identifier
- Created `AssistSceneDelegate` extending `BasicSceneDelegate` to manage
Assist windows
- Updated `Info.plist` with Assist scene configuration
- Added `SceneManager.activateAnyScene(for:with:)` to pass parameters
via `NSUserActivity.userInfo`
- Modified `WebViewExternalMessageHandler.showAssist()` to branch on
`Current.isCatalyst`:
  - macOS: `SceneManager.activateAnyScene(for: .assist, with: userInfo)`
  - iOS/iPad: Existing modal presentation (unchanged)
- Window centering: Assist window opens centered on screen (600x600)
using `UIWindowScene.MacGeometryPreferences` (iOS 17+)
- Close button: Removed toolbar close button in scene mode since window
has native close button; modal presentation on iOS/iPad retains close
button

**Parameters passed via userInfo:**
- `server`: Server identifier string
- `pipelineId`: Pipeline ID string
- `autoStartRecording`: Boolean

## Screenshots

N/A - Scene system change, requires macOS build to screenshot

## Link to pull request in Documentation repository
Documentation: home-assistant/companion.home-assistant#

## Any other notes
Follows existing scene patterns from `SettingsSceneDelegate` and
`AboutSceneDelegate`. No breaking changes to iOS/iPad behavior. The
`showCloseButton` parameter in `AssistView` allows flexible control of
close button visibility - `false` for scene mode (window has native
controls), `true` for modal presentation.

<!-- START COPILOT CODING AGENT SUFFIX -->



<details>

<summary>Original prompt</summary>

> Turn AssistView into a scene, so it can be initiated in a new window
on macOS


</details>



<!-- START COPILOT CODING AGENT TIPS -->
---

💡 You can make Copilot smarter by setting up custom instructions,
customizing its development environment and configuring Model Context
Protocol (MCP) servers. Learn more [Copilot coding agent
tips](https://gh.io/copilot-coding-agent-tips) in the docs.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: bgoncal <5808343+bgoncal@users.noreply.github.com>
2025-11-12 17:16:50 +00:00

234 lines
8.4 KiB
Swift

import Foundation
import MBProgressHUD
import PromiseKit
import Shared
import UIKit
// TODO: can i combine this with the enum?
struct SceneQuery<DelegateType: UIWindowSceneDelegate> {
let activity: SceneActivity
}
extension UIWindowSceneDelegate {
func informManager(from connectionOptions: UIScene.ConnectionOptions) {
let pendingResolver: (Self) -> Void = Current.sceneManager
.pendingResolver(from: connectionOptions.userActivities)
pendingResolver(self)
}
}
final class SceneManager {
// types too hard here
fileprivate static let activityUserInfoKeyResolver = "resolver"
private struct PendingResolver {
private var handleBlock: (Any) -> Void
init<T>(resolver: @escaping (T) -> Void) {
self.handleBlock = { value in
if let value = value as? T {
resolver(value)
}
}
}
func resolve(with possible: some Any) {
handleBlock(possible)
}
}
private var pendingResolvers: [String: PendingResolver] = [:]
var webViewWindowControllerPromise: Guarantee<WebViewWindowController> {
firstly { () -> Guarantee<WebViewSceneDelegate> in
scene(for: .init(activity: .webView))
}.map { delegate in
delegate.windowController!
}
}
init() {
// swiftlint:disable prohibit_environment_assignment
Current.realmFatalPresentation = { [weak self] viewController in
guard let self else { return }
let under = UIViewController()
under.view.backgroundColor = .black
under.modalPresentationStyle = .fullScreen
webViewWindowControllerPromise.done { parent in
parent.present(under, animated: false, completion: {
under.present(viewController, animated: true, completion: nil)
})
}
}
// swiftlint:enable prohibit_environment_assignment
}
fileprivate func pendingResolver<T>(from activities: Set<NSUserActivity>) -> (T) -> Void {
let (promise, outerResolver) = Guarantee<T>.pending()
if supportsMultipleScenes {
activities.compactMap { activity in
activity.userInfo?[Self.activityUserInfoKeyResolver] as? String
}.compactMap { token in
pendingResolvers[token]
}.forEach { resolver in
promise.done { resolver.resolve(with: $0) }
}
} else {
pendingResolvers
.values
.forEach { resolver in promise.done { resolver.resolve(with: $0) } }
}
return outerResolver
}
private func existingScenes(for activity: SceneActivity) -> [UIScene] {
UIApplication.shared.connectedScenes.filter { scene in
// Filter out scenes that are in the background or unattached state
// as they may be in the process of being destroyed
guard scene.activationState != .unattached else {
return false
}
return scene.session.configuration.name.flatMap(SceneActivity.init(configurationName:)) == activity
}.sorted { a, b in
switch (a.activationState, b.activationState) {
case (.unattached, .unattached): return true
case (.unattached, _): return false
case (_, .unattached): return true
case (.foregroundActive, _): return true
case (_, .foregroundActive): return false
case (.foregroundInactive, _): return true
case (_, .foregroundInactive): return false
case (_, _): return true
}
}
}
public var supportsMultipleScenes: Bool {
UIApplication.shared.supportsMultipleScenes
}
public func activateAnyScene(for activity: SceneActivity) {
UIApplication.shared.requestSceneSessionActivation(
existingScenes(for: activity).first?.session,
userActivity: activity.activity,
options: nil
) { error in
Current.Log.error(error)
}
}
public func activateAnyScene(for activity: SceneActivity, with userInfo: [AnyHashable: Any]) {
UIApplication.shared.requestSceneSessionActivation(
existingScenes(for: activity).first?.session,
userActivity: activity.activity(with: userInfo),
options: nil
) { error in
Current.Log.error(error)
}
}
public func scene<DelegateType: UIWindowSceneDelegate>(
for query: SceneQuery<DelegateType>
) -> Guarantee<DelegateType> {
if let active = existingScenes(for: query.activity).first,
let delegate = active.delegate as? DelegateType {
Current.Log.verbose("Ready to activate scene \(active.session.persistentIdentifier)")
let options = UIScene.ActivationRequestOptions()
options.requestingScene = active
// Only activate scene if not activated already
guard active.activationState != .foregroundActive else {
Current.Log
.verbose("Did not activate scene \(active.session.persistentIdentifier), it was already active")
return .value(delegate)
}
// Only activate scene if the app is already in foreground or transitioning to foreground
// This prevents widgets, notifications, or background tasks from unexpectedly bringing the app to
// foreground
let shouldActivate = UIApplication.shared.applicationState == .active ||
active.activationState == .foregroundInactive
if shouldActivate {
Current.Log.verbose("Activating scene \(active.session.persistentIdentifier)")
// Guarantee it runs on main thread when coming from widgets
DispatchQueue.main.async {
if #available(iOS 17.0, *) {
UIApplication.shared.activateSceneSession(for: .init(session: active.session, options: options))
} else {
UIApplication.shared.requestSceneSessionActivation(
active.session,
userActivity: nil,
options: options,
errorHandler: nil
)
}
}
} else {
Current.Log
.verbose(
"Skipping scene activation for \(active.session.persistentIdentifier) - app is in background"
)
}
return .value(delegate)
}
assert(
supportsMultipleScenes || query.activity == .webView,
"if we don't support multiple scenes, how are we running without one besides at immediate startup?"
)
let (promise, resolver) = Guarantee<DelegateType>.pending()
let token = UUID().uuidString
pendingResolvers[token] = PendingResolver(resolver: resolver)
if supportsMultipleScenes {
Current.Log.verbose("Ready to request new scene activation for \(query.activity)")
let activity = query.activity.activity
activity.userInfo = [
Self.activityUserInfoKeyResolver: token,
]
UIApplication.shared.requestSceneSessionActivation(
nil,
userActivity: activity,
options: nil,
errorHandler: { error in
// error is called in most cases, even when no error occurs, so we silently swallow it
// TODO: does this actually happen in normal circumstances?
Current.Log.error("scene activation error: \(error)")
}
)
}
return promise
}
public func showFullScreenConfirm(
icon: MaterialDesignIcons,
text: String,
onto window: Promise<UIWindow>
) {
window.done { window in
let hud = MBProgressHUD.showAdded(to: window, animated: true)
hud.mode = .customView
hud.backgroundView.style = .blur
hud.customView = with(IconImageView(frame: .init(x: 0, y: 0, width: 64, height: 64))) {
$0.iconDrawable = icon
}
hud.label.text = text
hud.hide(animated: true, afterDelay: 3)
}.cauterize()
}
}