├── .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 |
--------------------------------------------------------------------------------