├── .editorconfig ├── .github ├── FUNDING.yml └── workflows │ └── ci.yml ├── .gitignore ├── .spi.yml ├── CODE_OF_CONDUCT.md ├── LICENSE ├── Package.swift ├── README.md ├── Sources ├── Extendable │ ├── AppExtension+Init.swift │ ├── AppExtensionSceneGroup.swift │ ├── ConnectableExtension.swift │ ├── ConnectableSceneExtension.swift │ ├── ConnectingAppExtensionScene.swift │ └── ConnectionAccepter.swift └── ExtendableHost │ ├── AppExtensionBrowserView.swift │ ├── AppExtensionProcess+Utilities.swift │ └── ExtensionHostingView.swift └── Tests └── ExtendableTests └── ConnectionAccepterTests.swift /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | indent_size = 4 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [mattmassicotte] 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths-ignore: 8 | - 'README.md' 9 | - 'CODE_OF_CONDUCT.md' 10 | - '.editorconfig' 11 | - '.spi.yml' 12 | pull_request: 13 | branches: 14 | - main 15 | 16 | jobs: 17 | test: 18 | name: Test 19 | runs-on: macOS-14 20 | env: 21 | DEVELOPER_DIR: /Applications/Xcode_15.4.app/Contents/Developer 22 | strategy: 23 | matrix: 24 | destination: 25 | - "platform=macOS" 26 | - "platform=iOS Simulator,name=iPhone 14" 27 | steps: 28 | - uses: actions/checkout@v4 29 | - name: Test platform ${{ matrix.destination }} 30 | run: set -o pipefail && xcodebuild -scheme Extendable-Package -destination "${{ matrix.destination }}" test | xcbeautify 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Carthage 4 | /Packages 5 | /.swiftpm 6 | .docc-build/ -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - documentation_targets: [Extendable, ExtendableHost] 5 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | 2 | # Contributor Covenant Code of Conduct 3 | 4 | ## Our Pledge 5 | 6 | We as members, contributors, and leaders pledge to make participation in our 7 | community a harassment-free experience for everyone, regardless of age, body 8 | size, visible or invisible disability, ethnicity, sex characteristics, gender 9 | identity and expression, level of experience, education, socio-economic status, 10 | nationality, personal appearance, race, caste, color, religion, or sexual 11 | identity and orientation. 12 | 13 | We pledge to act and interact in ways that contribute to an open, welcoming, 14 | diverse, inclusive, and healthy community. 15 | 16 | ## Our Standards 17 | 18 | Examples of behavior that contributes to a positive environment for our 19 | community include: 20 | 21 | * Demonstrating empathy and kindness toward other people 22 | * Being respectful of differing opinions, viewpoints, and experiences 23 | * Giving and gracefully accepting constructive feedback 24 | * Accepting responsibility and apologizing to those affected by our mistakes, 25 | and learning from the experience 26 | * Focusing on what is best not just for us as individuals, but for the overall 27 | community 28 | 29 | Examples of unacceptable behavior include: 30 | 31 | * The use of sexualized language or imagery, and sexual attention or advances of 32 | any kind 33 | * Trolling, insulting or derogatory comments, and personal or political attacks 34 | * Public or private harassment 35 | * Publishing others' private information, such as a physical or email address, 36 | without their explicit permission 37 | * Other conduct which could reasonably be considered inappropriate in a 38 | professional setting 39 | 40 | ## Enforcement Responsibilities 41 | 42 | Community leaders are responsible for clarifying and enforcing our standards of 43 | acceptable behavior and will take appropriate and fair corrective action in 44 | response to any behavior that they deem inappropriate, threatening, offensive, 45 | or harmful. 46 | 47 | Community leaders have the right and responsibility to remove, edit, or reject 48 | comments, commits, code, wiki edits, issues, and other contributions that are 49 | not aligned to this Code of Conduct, and will communicate reasons for moderation 50 | decisions when appropriate. 51 | 52 | ## Scope 53 | 54 | This Code of Conduct applies within all community spaces, and also applies when 55 | an individual is officially representing the community in public spaces. 56 | Examples of representing our community include using an official e-mail address, 57 | posting via an official social media account, or acting as an appointed 58 | representative at an online or offline event. 59 | 60 | ## Enforcement 61 | 62 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 63 | reported to the community leaders responsible for enforcement at 64 | support@chimehq.com. 65 | All complaints will be reviewed and investigated promptly and fairly. 66 | 67 | All community leaders are obligated to respect the privacy and security of the 68 | reporter of any incident. 69 | 70 | ## Enforcement Guidelines 71 | 72 | Community leaders will follow these Community Impact Guidelines in determining 73 | the consequences for any action they deem in violation of this Code of Conduct: 74 | 75 | ### 1. Correction 76 | 77 | **Community Impact**: Use of inappropriate language or other behavior deemed 78 | unprofessional or unwelcome in the community. 79 | 80 | **Consequence**: A private, written warning from community leaders, providing 81 | clarity around the nature of the violation and an explanation of why the 82 | behavior was inappropriate. A public apology may be requested. 83 | 84 | ### 2. Warning 85 | 86 | **Community Impact**: A violation through a single incident or series of 87 | actions. 88 | 89 | **Consequence**: A warning with consequences for continued behavior. No 90 | interaction with the people involved, including unsolicited interaction with 91 | those enforcing the Code of Conduct, for a specified period of time. This 92 | includes avoiding interactions in community spaces as well as external channels 93 | like social media. Violating these terms may lead to a temporary or permanent 94 | ban. 95 | 96 | ### 3. Temporary Ban 97 | 98 | **Community Impact**: A serious violation of community standards, including 99 | sustained inappropriate behavior. 100 | 101 | **Consequence**: A temporary ban from any sort of interaction or public 102 | communication with the community for a specified period of time. No public or 103 | private interaction with the people involved, including unsolicited interaction 104 | with those enforcing the Code of Conduct, is allowed during this period. 105 | Violating these terms may lead to a permanent ban. 106 | 107 | ### 4. Permanent Ban 108 | 109 | **Community Impact**: Demonstrating a pattern of violation of community 110 | standards, including sustained inappropriate behavior, harassment of an 111 | individual, or aggression toward or disparagement of classes of individuals. 112 | 113 | **Consequence**: A permanent ban from any sort of public interaction within the 114 | community. 115 | 116 | ## Attribution 117 | 118 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 119 | version 2.1, available at 120 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 121 | 122 | Community Impact Guidelines were inspired by 123 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 124 | 125 | For answers to common questions about this code of conduct, see the FAQ at 126 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 127 | [https://www.contributor-covenant.org/translations][translations]. 128 | 129 | [homepage]: https://www.contributor-covenant.org 130 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 131 | [Mozilla CoC]: https://github.com/mozilla/diversity 132 | [FAQ]: https://www.contributor-covenant.org/faq 133 | [translations]: https://www.contributor-covenant.org/translations 134 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2022, Chime 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.8 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "Extendable", 7 | platforms: [ 8 | .macOS(.v11), 9 | .macCatalyst(.v14), 10 | .iOS(.v14), 11 | .watchOS(.v7), 12 | .tvOS(.v14) 13 | ], 14 | products: [ 15 | .library(name: "Extendable", targets: ["Extendable"]), 16 | .library(name: "ExtendableHost", targets: ["ExtendableHost"]), 17 | ], 18 | dependencies: [], 19 | targets: [ 20 | .target(name: "Extendable"), 21 | .target(name: "ExtendableHost"), 22 | .testTarget(name: "ExtendableTests", dependencies: ["Extendable"]), 23 | ] 24 | ) 25 | 26 | let swiftSettings: [SwiftSetting] = [ 27 | .enableExperimentalFeature("StrictConcurrency"), 28 | ] 29 | 30 | for target in package.targets { 31 | var settings = target.swiftSettings ?? [] 32 | settings.append(contentsOf: swiftSettings) 33 | target.swiftSettings = settings 34 | } 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | [![Build Status][build status badge]][build status] 4 | [![Platforms][platforms badge]][platforms] 5 | [![Documentation][documentation badge]][documentation] 6 | [![Matrix][matrix badge]][matrix] 7 | 8 |
9 | 10 | # Extendable 11 | A set of utilities for more pleasant work with ExtensionKit 12 | 13 | ## Installation 14 | 15 | ```swift 16 | dependencies: [ 17 | .package(url: "https://github.com/ChimeHQ/Extendable", from: "0.1.0") 18 | ], 19 | targets: [ 20 | .target( 21 | name: "ExtensionSide", 22 | dependencies: ["Extendable"] 23 | ), 24 | .target( 25 | name: "HostSide", 26 | dependencies: [.product(name: "ExtendableHost", package: "Extendable")] 27 | ), 28 | ] 29 | ``` 30 | 31 | ## Global Connection 32 | 33 | Setting up an ExtensionKit extension can be confusing, and requires a fair amount of boilerplate. `ConnectableExtension` makes it easier to manage the global host connection. 34 | 35 | ```swift 36 | @main 37 | final class ExampleExtension: ConnectableExtension { 38 | init() { 39 | } 40 | 41 | func acceptConnection(_ connection: NSXPCConnection) throws { 42 | // configure your global connection and possibly 43 | // store references to host interface objects 44 | } 45 | } 46 | ``` 47 | 48 | ## Scenes 49 | 50 | Dealing with View-based extensions is even more complex. And, there isn't a clear way to get access to the host connection in your views. Extendable comes with a few components that make it easier to build scenes and manage view connections. 51 | 52 | ### ConnectingAppExtensionScene 53 | 54 | This is a `AppExtensionScene` that makes it easier to get access to the scene's connection within your `View`. 55 | 56 | ```swift 57 | ConnectingAppExtensionScene(sceneID: "one") { (sceneId, connection) in 58 | try ConnectionView(sceneId: sceneId, connection: connection) 59 | } 60 | ``` 61 | 62 | ### AppExtensionSceneGroup 63 | 64 | I expect this type won't be needed once Ventura ships. And, maybe it's just me, but I've been unable to figure out how to use `AppExtensionSceneBuilder` without a wrapper type. So here it is. 65 | 66 | ### Example View 67 | 68 | You can use `ConnectingAppExtensionScene` and `AppExtensionSceneGroup` independently, or as part of a more standard extension structure. But, if you want, you can also make use of the `ConnectableSceneExtension` protocol to really streamline your view class. Here's a full example: 69 | 70 | ```swift 71 | @main 72 | final class ViewExtension: ConnectableSceneExtension { 73 | init() { 74 | } 75 | 76 | func acceptConnection(_ connection: NSXPCConnection) throws { 77 | // handle global connection 78 | } 79 | 80 | var scene: some AppExtensionScene { 81 | AppExtensionSceneGroup { 82 | ConnectingAppExtensionScene(sceneID: "one") { (sceneId, connection) in 83 | try ConnectionView(sceneId: sceneId, connection: connection) 84 | } 85 | ConnectingAppExtensionScene(sceneID: "two") { (sceneId, connection) in 86 | try ConnectionView(sceneId: sceneId, connection: connection) 87 | } 88 | } 89 | } 90 | } 91 | 92 | struct ConnectionView: View { 93 | let sceneName: String 94 | let connection: NSXPCConnection? 95 | 96 | init(sceneId: String, connection: NSXPCConnection?) throws { 97 | self.sceneName = sceneId 98 | self.connection = connection 99 | } 100 | 101 | var value: String { 102 | return String(describing: connection) 103 | } 104 | 105 | var body: some View { 106 | VStack { 107 | Rectangle().frame(width: nil, height: 4).foregroundColor(.green) 108 | Spacer() 109 | Text("\(sceneName): \(value)") 110 | Spacer() 111 | Rectangle().frame(width: nil, height: 4).foregroundColor(.red) 112 | } 113 | } 114 | } 115 | ``` 116 | 117 | ## ExtendableHost 118 | 119 | Extendable also includes a second library called `ExtendableHost`. 120 | 121 | You can its `AppExtensionBrowserView` and `ExtensionHostingView` to integrate the ExtensionKit view system with SwiftUI in your host application. 122 | 123 | ```swift 124 | // very simple init extension to help with actor-isolation compatibility 125 | let process = try await AppExtensionProcess(appExtensionIdentity: identity) 126 | ``` 127 | 128 | ## Isolation and AppExtension 129 | 130 | Currently, the `init` in the `AppExtention` protocol lacks any isolation. This makes it difficult to initialize instance variables if you are relying on the true-but-unexpressed `@MainActor` isolation of extensions. I've included a workaround that can help. SE-0414 will make this unecessary, as will ExtensionFoundation adding annotations. In the mean time though, it's nice to have no warnings. 131 | 132 | ```swift 133 | @main 134 | final class MyExtension: AppExtension { 135 | @InitializerTransferred private var value: MainActorType 136 | 137 | nonisolated init() { 138 | self._value = InitializerTransferred(mainActorProvider: { 139 | MainActorType() 140 | }) 141 | } 142 | } 143 | ``` 144 | 145 | ## Contributing and Collaboration 146 | 147 | I would love to hear from you! Issues or pull requests work great. A [Matrix space][matrix] is also available for live help, but I have a strong bias towards answering in the form of documentation. 148 | 149 | I prefer collaboration, and would love to find ways to work together if you have a similar project. 150 | 151 | I prefer indentation with tabs for improved accessibility. But, I'd rather you use the system you want and make a PR than hesitate because of whitespace. 152 | 153 | By participating in this project you agree to abide by the [Contributor Code of Conduct](CODE_OF_CONDUCT.md). 154 | 155 | [build status]: https://github.com/ChimeHQ/Extendable/actions 156 | [build status badge]: https://github.com/ChimeHQ/Extendable/workflows/CI/badge.svg 157 | [platforms]: https://swiftpackageindex.com/ChimeHQ/Extendable 158 | [platforms badge]: https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FChimeHQ%2FExtendable%2Fbadge%3Ftype%3Dplatforms 159 | [documentation]: https://swiftpackageindex.com/ChimeHQ/Extendable/main/documentation 160 | [documentation badge]: https://img.shields.io/badge/Documentation-DocC-blue 161 | [matrix]: https://matrix.to/#/%23chimehq%3Amatrix.org 162 | [matrix badge]: https://img.shields.io/matrix/chimehq%3Amatrix.org?label=Matrix 163 | -------------------------------------------------------------------------------- /Sources/Extendable/AppExtension+Init.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // from https://forums.swift.org/t/complete-checking-with-an-incorrectly-annotated-init-conformance/69955/7 4 | 5 | /// Used to initialize properties for non-isolated init conformance. 6 | /// 7 | /// Will be obseleted by SE-0414 8 | @propertyWrapper 9 | public struct InitializerTransferred: @unchecked Sendable { 10 | public let wrappedValue: Value 11 | 12 | public init(_ wrappedValue: Value) { 13 | self.wrappedValue = wrappedValue 14 | } 15 | 16 | public init(mainActorProvider: @MainActor () -> Value) { 17 | self.wrappedValue = MainActor.assumeIsolated { 18 | mainActorProvider() 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/Extendable/AppExtensionSceneGroup.swift: -------------------------------------------------------------------------------- 1 | import ExtensionKit 2 | 3 | /// Can be used to group multiple `AppExtensionScene` views. 4 | @available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) 5 | public struct AppExtensionSceneGroup: AppExtensionScene { 6 | private let content: Content 7 | 8 | public init(@AppExtensionSceneBuilder content: () throws -> Content) rethrows { 9 | self.content = try content() 10 | } 11 | 12 | public var body: some AppExtensionScene { 13 | return content 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/Extendable/ConnectableExtension.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import ExtensionKit 3 | import SwiftUI 4 | 5 | @available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) 6 | public struct ConnectingAppExtensionConfiguration: AppExtensionConfiguration { 7 | let accepter: ConnectionAccepter 8 | 9 | public init(_ handler: @escaping ConnectionHandler) { 10 | self.accepter = ConnectionAccepter(handler) 11 | } 12 | 13 | public func accept(connection: NSXPCConnection) -> Bool { 14 | return accepter.accept(connection: connection) 15 | } 16 | } 17 | 18 | @available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) 19 | public protocol ConnectableExtension: AppExtension { 20 | func acceptConnection(_ connection: NSXPCConnection) throws 21 | } 22 | 23 | @available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) 24 | public extension ConnectableExtension { 25 | /// The global, per-exension configuration 26 | /// 27 | /// This configuration applies to the extension process, and 28 | /// its connection corresponds to `AppExtensionProcess`. This 29 | /// will be used for the `configuration` property by default. 30 | var globalConfiguration: ConnectingAppExtensionConfiguration { 31 | return ConnectingAppExtensionConfiguration { connection in 32 | try self.acceptConnection(connection) 33 | } 34 | } 35 | 36 | var configuration: ConnectingAppExtensionConfiguration { 37 | return globalConfiguration 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/Extendable/ConnectableSceneExtension.swift: -------------------------------------------------------------------------------- 1 | import ExtensionKit 2 | import SwiftUI 3 | 4 | /// Defines an interface between a host and view-based extension. 5 | /// 6 | /// This type provides more structure to a view-based extension. You can also 7 | /// optionally use the `acceptConnection(_:, for:)` method to return a new `Body` 8 | /// that uses the connection as input. Handy for putting interface-related objects 9 | /// in the enviroment. 10 | @available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) 11 | public protocol ConnectableSceneExtension: ConnectableExtension { 12 | associatedtype Content : AppExtensionScene 13 | 14 | var scene: Content { get } 15 | } 16 | 17 | @available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) 18 | public extension ConnectableSceneExtension { 19 | var configuration: AppExtensionSceneConfiguration { 20 | return AppExtensionSceneConfiguration(scene, configuration: globalConfiguration) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/Extendable/ConnectingAppExtensionScene.swift: -------------------------------------------------------------------------------- 1 | import ExtensionKit 2 | import SwiftUI 3 | 4 | @MainActor 5 | private final class ConnectingSceneModel: ObservableObject { 6 | @Published var view: Content 7 | 8 | init(initial: Content) { 9 | self.view = initial 10 | } 11 | } 12 | 13 | @MainActor 14 | private struct ConnectingView: View { 15 | @ObservedObject var model: ConnectingSceneModel 16 | 17 | init(model: ConnectingSceneModel) { 18 | self.model = model 19 | } 20 | 21 | var body: some View { 22 | model.view 23 | } 24 | } 25 | 26 | /// An `AppExtensionScene` that generates content as function of the host connection. 27 | @available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) 28 | @MainActor 29 | public struct ConnectingAppExtensionScene: AppExtensionScene { 30 | public let sceneID: String 31 | let accepter: ConnectionAccepter 32 | private let model: ConnectingSceneModel 33 | private let connectingView: ConnectingView 34 | 35 | public init(sceneID: String, @ViewBuilder content: @escaping (String, NSXPCConnection?) throws -> Content) { 36 | self.sceneID = sceneID 37 | 38 | // This is lame, but it is a programming error to throw when the connection 39 | // is nil. That must be supported. 40 | let sceneModel = ConnectingSceneModel(initial: try! content(sceneID, nil)) 41 | 42 | self.model = sceneModel 43 | self.connectingView = ConnectingView(model: sceneModel) 44 | 45 | self.accepter = ConnectionAccepter({ conn in 46 | sceneModel.view = try content(sceneID, conn) 47 | }) 48 | } 49 | 50 | 51 | public nonisolated var body: some AppExtensionScene { 52 | MainActor.assumeIsolated { 53 | PrimitiveAppExtensionScene(id: sceneID) { 54 | connectingView 55 | } onConnection: { connection in 56 | return accepter.accept(connection: connection) 57 | } 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Sources/Extendable/ConnectionAccepter.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import os.log 3 | 4 | public typealias ConnectionHandler = (NSXPCConnection) throws -> Void 5 | 6 | struct ConnectionAccepter { 7 | private let logger = Logger(subsystem: "com.chimehq.Extendable", category: "ConnectionAccepter") 8 | 9 | let handler: ConnectionHandler 10 | 11 | init(_ handler: @escaping ConnectionHandler) { 12 | self.handler = handler 13 | } 14 | 15 | func accept(connection: NSXPCConnection) -> Bool { 16 | logger.debug("accepting connection") 17 | 18 | do { 19 | try handler(connection) 20 | 21 | logger.debug("accepted connection") 22 | 23 | connection.activate() 24 | 25 | return true 26 | } catch { 27 | logger.debug("accepting connection failed: \(String(describing: error), privacy: .public)") 28 | 29 | return false 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sources/ExtendableHost/AppExtensionBrowserView.swift: -------------------------------------------------------------------------------- 1 | #if os(macOS) 2 | import ExtensionKit 3 | import SwiftUI 4 | 5 | @available(macOS 13.0, *) 6 | public struct AppExtensionBrowserView: NSViewControllerRepresentable { 7 | public init() { 8 | } 9 | 10 | public func makeNSViewController(context: Context) -> EXAppExtensionBrowserViewController { 11 | return EXAppExtensionBrowserViewController() 12 | } 13 | 14 | public func updateNSViewController(_ viewController: EXAppExtensionBrowserViewController, context: Context) { 15 | } 16 | } 17 | 18 | #endif 19 | -------------------------------------------------------------------------------- /Sources/ExtendableHost/AppExtensionProcess+Utilities.swift: -------------------------------------------------------------------------------- 1 | #if canImport(ExtensionKit) 2 | import ExtensionKit 3 | 4 | @available(macOS 13.0, *) 5 | @available(iOS, unavailable) 6 | @available(tvOS, unavailable) 7 | @available(watchOS, unavailable) 8 | extension AppExtensionProcess { 9 | /// Async init that is compatible with an actor-isolated context. 10 | public init(appExtensionIdentity identity: AppExtensionIdentity, onInterruption handler: @escaping @Sendable () -> Void = {}) async throws { 11 | let config = AppExtensionProcess.Configuration(appExtensionIdentity: identity, onInterruption: handler) 12 | 13 | try await self.init(configuration: config) 14 | } 15 | } 16 | #endif 17 | -------------------------------------------------------------------------------- /Sources/ExtendableHost/ExtensionHostingView.swift: -------------------------------------------------------------------------------- 1 | #if os(macOS) 2 | import ExtensionKit 3 | import SwiftUI 4 | 5 | @available(macOS 13.0, *) 6 | public struct ExtensionHostingView: NSViewControllerRepresentable { 7 | public typealias ConnectionHandler = (NSXPCConnection) -> Void 8 | 9 | public var connectionHandler: ConnectionHandler? 10 | 11 | public let configuration: EXHostViewController.Configuration? 12 | 13 | public init(configuration: EXHostViewController.Configuration?, connectionHandler: ConnectionHandler? = nil) { 14 | self.configuration = configuration 15 | self.connectionHandler = connectionHandler 16 | } 17 | 18 | public init(identity: AppExtensionIdentity?, sceneID: String = "default", connectionHandler: ConnectionHandler? = nil) { 19 | self.configuration = identity.map { .init(appExtension: $0, sceneID: sceneID) } 20 | self.connectionHandler = connectionHandler 21 | } 22 | 23 | public func makeNSViewController(context: Context) -> EXHostViewController { 24 | let controller = EXHostViewController() 25 | 26 | controller.delegate = context.coordinator 27 | 28 | return controller 29 | } 30 | 31 | public func updateNSViewController(_ viewController: EXHostViewController, context: Context) { 32 | viewController.configuration = configuration 33 | context.coordinator.connectionHandler = connectionHandler 34 | } 35 | 36 | public func makeCoordinator() -> Coordinator { 37 | return ExtensionHostingView.Coordinator() 38 | } 39 | } 40 | 41 | @available(macOS 13.0, *) 42 | extension ExtensionHostingView { 43 | @MainActor 44 | public class Coordinator: NSObject, EXHostViewControllerDelegate { 45 | public var connectionHandler: ConnectionHandler? 46 | 47 | public func shouldAccept(_ connection: NSXPCConnection) -> Bool { 48 | return true 49 | } 50 | 51 | public nonisolated func hostViewControllerDidActivate(_ viewController: EXHostViewController) { 52 | MainActor.assumeIsolated { 53 | guard let handler = connectionHandler else { return } 54 | 55 | do { 56 | let connection = try viewController.makeXPCConnection() 57 | 58 | handler(connection) 59 | 60 | connection.activate() 61 | } catch { 62 | print("Unable to create connection: \(String(describing: error))") 63 | } 64 | } 65 | } 66 | } 67 | } 68 | #endif 69 | -------------------------------------------------------------------------------- /Tests/ExtendableTests/ConnectionAccepterTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Extendable 3 | 4 | enum PhonyError: Error { 5 | case boom 6 | } 7 | 8 | final class ConnectionAccepterTests: XCTestCase { 9 | func testThrowsDuringAccept() throws { 10 | let connection = NSXPCConnection() 11 | 12 | let accepter = ConnectionAccepter({ _ in 13 | throw PhonyError.boom 14 | }) 15 | 16 | XCTAssertFalse(accepter.accept(connection: connection)) 17 | } 18 | } 19 | --------------------------------------------------------------------------------