Skip to content

Commit

Permalink
Add VisionOS and macOS support (#13)
Browse files Browse the repository at this point in the history
# Add VisionOS and macOS support

## ♻️ Current situation & Problem
Currently, SpeziChat only supports iOS platforms.

watchOS and tvOS both don't have the `Speech` framework (important for
SpeziSpeech), sadly preventing us from adding platform support.


## ⚙️ Release Notes 
- Add VisionOS and macOS support


## 📚 Documentation
--


## ✅ Testing
CI steps to ensure building & launching on the respective platforms.


## 📝 Code of Conduct & Contributing Guidelines 

By submitting creating this pull request, you agree to follow our [Code
of
Conduct](https://github.com/StanfordSpezi/.github/blob/main/CODE_OF_CONDUCT.md)
and [Contributing
Guidelines](https://github.com/StanfordSpezi/.github/blob/main/CONTRIBUTING.md):
- [x] I agree to follow the [Code of
Conduct](https://github.com/StanfordSpezi/.github/blob/main/CODE_OF_CONDUCT.md)
and [Contributing
Guidelines](https://github.com/StanfordSpezi/.github/blob/main/CONTRIBUTING.md).
  • Loading branch information
philippzagar authored Feb 28, 2024
1 parent eae5c15 commit 2334583
Show file tree
Hide file tree
Showing 10 changed files with 341 additions and 78 deletions.
114 changes: 102 additions & 12 deletions .github/workflows/build-and-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,34 +16,124 @@ on:
workflow_dispatch:

jobs:
packageios:
buildandtest_ios:
name: Build and Test Swift Package iOS
uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2
strategy:
matrix:
include:
- buildConfig: Debug
artifactname: SpeziChat-iOS.xcresult
resultBundle: SpeziChat-iOS.xcresult
- buildConfig: Release
artifactname: SpeziChat-iOS-Release.xcresult
resultBundle: SpeziChat-iOS-Release.xcresult
with:
runsonlabels: '["macOS", "self-hosted"]'
scheme: SpeziChat
artifactname: SpeziChat.xcresult
ios:
name: Build and Test iOS
buildConfig: ${{ matrix.buildConfig }}
resultBundle: ${{ matrix.resultBundle }}
artifactname: ${{ matrix.artifactname }}
buildandtest_visionos:
name: Build and Test Swift Package visionOS
uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2
strategy:
matrix:
include:
- buildConfig: Debug
artifactname: SpeziChat-visionOS.xcresult
resultBundle: SpeziChat-visionOS.xcresult
- buildConfig: Release
artifactname: SpeziChat-visionOS-Release.xcresult
resultBundle: SpeziChat-visionOS-Release.xcresult
with:
runsonlabels: '["macOS", "self-hosted"]'
scheme: SpeziChat
destination: 'platform=visionOS Simulator,name=Apple Vision Pro'
buildConfig: ${{ matrix.buildConfig }}
resultBundle: ${{ matrix.resultBundle }}
artifactname: ${{ matrix.artifactname }}
buildandtest_macos:
name: Build and Test Swift Package macOS
uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2
strategy:
matrix:
include:
- buildConfig: Debug
artifactname: SpeziChat-macOS.xcresult
resultBundle: SpeziChat-macOS.xcresult
- buildConfig: Release
artifactname: SpeziChat-macOS-Release.xcresult
resultBundle: SpeziChat-macOS-Release.xcresult
with:
runsonlabels: '["macOS", "self-hosted"]'
scheme: SpeziChat
destination: 'platform=macOS,arch=arm64'
buildConfig: ${{ matrix.buildConfig }}
resultBundle: ${{ matrix.resultBundle }}
artifactname: ${{ matrix.artifactname }}
buildandtestuitests_ios:
name: Build and Test UI Tests iOS
uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2
strategy:
matrix:
include:
- buildConfig: Debug
resultBundle: TestApp-iOS.xcresult
artifactname: TestApp-iOS.xcresult
- buildConfig: Release
resultBundle: TestApp-iOS-Release.xcresult
artifactname: TestApp-iOS-Release.xcresult
with:
runsonlabels: '["macOS", "self-hosted"]'
path: 'Tests/UITests'
scheme: TestApp
buildConfig: ${{ matrix.buildConfig }}
resultBundle: ${{ matrix.resultBundle }}
artifactname: ${{ matrix.artifactname }}
buildandtestuitests_ipad:
name: Build and Test UI Tests iPadOS
uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2
strategy:
matrix:
include:
- buildConfig: Debug
resultBundle: TestApp-iPad.xcresult
artifactname: TestApp-iPad.xcresult
- buildConfig: Release
resultBundle: TestApp-iPad-Release.xcresult
artifactname: TestApp-iPad-Release.xcresult
with:
runsonlabels: '["macOS", "self-hosted"]'
path: 'Tests/UITests'
scheme: TestApp
artifactname: TestApp.xcresult
ipados:
name: Build and Test iPadOS
destination: 'platform=iOS Simulator,name=iPad Air (5th generation)'
buildConfig: ${{ matrix.buildConfig }}
resultBundle: ${{ matrix.resultBundle }}
artifactname: ${{ matrix.artifactname }}
buildandtestuitests_visionos:
name: Build and Test UI Tests visionOS
uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2
strategy:
matrix:
include:
- buildConfig: Debug
resultBundle: TestApp-visionOS.xcresult
artifactname: TestApp-visionOS.xcresult
- buildConfig: Release
resultBundle: TestApp-visionOS-Release.xcresult
artifactname: TestApp-visionOS-Release.xcresult
with:
runsonlabels: '["macOS", "self-hosted"]'
path: 'Tests/UITests'
scheme: TestApp
resultBundle: TestAppiPadOS.xcresult
destination: 'platform=iOS Simulator,name=iPad mini (6th generation)'
artifactname: TestAppiPadOS.xcresult
destination: 'platform=visionOS Simulator,name=Apple Vision Pro'
buildConfig: ${{ matrix.buildConfig }}
resultBundle: ${{ matrix.resultBundle }}
artifactname: ${{ matrix.artifactname }}
uploadcoveragereport:
name: Upload Coverage Report
needs: [packageios, ios, ipados]
needs: [buildandtest_ios, buildandtest_visionos, buildandtest_macos, buildandtestuitests_ios, buildandtestuitests_ipad, buildandtestuitests_visionos]
uses: StanfordSpezi/.github/.github/workflows/create-and-upload-coverage-report.yml@v2
with:
coveragereports: SpeziChat.xcresult TestApp.xcresult TestAppiPadOS.xcresult
coveragereports: 'SpeziChat-iOS.xcresult SpeziChat-visionOS.xcresult SpeziChat-macOS.xcresult TestApp-iOS.xcresult TestApp-iPad.xcresult TestApp-visionOS.xcresult'
12 changes: 9 additions & 3 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,26 @@ let package = Package(
name: "SpeziChat",
defaultLocalization: "en",
platforms: [
.iOS(.v17)
.iOS(.v17),
.visionOS(.v1),
.macOS(.v14)
],
products: [
.library(name: "SpeziChat", targets: ["SpeziChat"])
],
dependencies: [
.package(url: "https://github.com/StanfordSpezi/SpeziSpeech", from: "1.0.0")
.package(url: "https://github.com/StanfordSpezi/SpeziFoundation", from: "1.0.3"),
.package(url: "https://github.com/StanfordSpezi/SpeziSpeech", from: "1.0.1"),
.package(url: "https://github.com/StanfordSpezi/SpeziViews", from: "1.3.1")
],
targets: [
.target(
name: "SpeziChat",
dependencies: [
.product(name: "SpeziFoundation", package: "SpeziFoundation"),
.product(name: "SpeziSpeechRecognizer", package: "SpeziSpeech"),
.product(name: "SpeziSpeechSynthesizer", package: "SpeziSpeech")
.product(name: "SpeziSpeechSynthesizer", package: "SpeziSpeech"),
.product(name: "SpeziViews", package: "SpeziViews")
]
),
.testTarget(
Expand Down
24 changes: 21 additions & 3 deletions Sources/SpeziChat/ChatView+Export.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,15 @@ extension ChatView {
Spacer(minLength: 32)
}
VStack(alignment: chatEntity.alignment == .leading ? .leading : .trailing) {
Text(chatEntity.content)
Text(chatEntity.attributedContent)
.fixedSize(horizontal: false, vertical: true)
#if !os(visionOS)
.chatMessageStyle(alignment: chatEntity.alignment)
#else
// Workaround setting the user background color to blue
// for visionOS as .accentColor isn't properly set during export with the `ImageRenderer`
.chatMessageStyle(alignment: chatEntity.alignment, backgroundColorUserChat: .blue)
#endif

Text("\(chatEntity.role.rawValue.capitalized): \(chatEntity.date.formatted())")
.font(.caption)
Expand Down Expand Up @@ -109,7 +115,13 @@ extension ChatView {
@MainActor private var pdfChatData: Data? {
let renderer = ImageRenderer(content: ChatExportPDFView(chat: chat))

guard let proposedHeight = renderer.uiImage?.size.height else {
#if !os(macOS)
var proposedHeightOptional = renderer.uiImage?.size.height
#else
var proposedHeightOptional = renderer.nsImage?.size.height
#endif

guard let proposedHeight = proposedHeightOptional else {
Self.logger.error("""
The to be exported chat couldn't be rendered as a PDF as the height of the rendered page couldn't be determined!
""")
Expand All @@ -125,8 +137,14 @@ extension ChatView {

renderer.proposedSize = .init(size)

#if !os(macOS)
proposedHeightOptional = renderer.uiImage?.size.height
#else
proposedHeightOptional = renderer.nsImage?.size.height
#endif

// Need to fetch page height again as it is adjusted after setting the `proposedSize` on the `ImageRenderer`
guard let proposedHeight = renderer.uiImage?.size.height else {
guard let proposedHeight = proposedHeightOptional else {
Self.logger.error("""
The to be exported chat couldn't be rendered as a PDF as the height of the rendered page couldn't be determined!
""")
Expand Down
52 changes: 43 additions & 9 deletions Sources/SpeziChat/ChatView+ShareSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,26 +8,23 @@

import Foundation
import SwiftUI
#if os(macOS)
import AppKit
#endif


extension ChatView {
/// Provides an iOS-typical Share Sheet (also called Activity View: https://developer.apple.com/design/human-interface-guidelines/activity-views) SwiftUI wrapper
/// Provides an iOS-typical Share Sheet (also called Activity View: https://developer.apple.com/design/human-interface-guidelines/activity-views) SwiftUI wrapper
/// for exporting the ``Chat`` content of the ``ChatView`` without the downsides of the SwiftUI `ShareLink` such as unnecessary reevaluations of the to-be shared content.
#if !os(macOS)
struct ShareSheet: UIViewControllerRepresentable {
let sharedItem: Data
let sharedItemType: ChatExportFormat


func makeUIViewController(context: Context) -> UIActivityViewController {
// Note: Need to write down the data to storage as in-memory shared content is not recognized properly (e.g., PDFs)
var temporaryPath = FileManager.default.temporaryDirectory.appendingPathComponent("Exported Chat")

switch sharedItemType {
case .json: temporaryPath = temporaryPath.appendingPathExtension("json")
case .text: temporaryPath = temporaryPath.appendingPathExtension("txt")
case .pdf: temporaryPath = temporaryPath.appendingPathExtension("pdf")
}

let temporaryPath = temporaryExportFilePath(sharedItemType: sharedItemType)
try? sharedItem.write(to: temporaryPath)

let controller = UIActivityViewController(
Expand All @@ -43,4 +40,41 @@ extension ChatView {

func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
}
#else
struct ShareSheet {
let sharedItem: Data
let sharedItemType: ChatExportFormat


func show() {
// Note: Need to write down the data to storage as in-memory shared content is not recognized properly (e.g., PDFs)
let temporaryPath = temporaryExportFilePath(sharedItemType: sharedItemType)
try? sharedItem.write(to: temporaryPath)

let sharingServicePicker = NSSharingServicePicker(items: [temporaryPath])

// Present the sharing service picker
if let keyWindow = NSApp.keyWindow, let contentView = keyWindow.contentView {
sharingServicePicker.show(relativeTo: contentView.bounds, of: contentView, preferredEdge: .minY)
}
}
}
#endif


/// Constructs the temporary file path for the exported chat file.
///
/// - Parameters:
/// - sharedItemType: The shared item type, therefore defining the file extension.
static func temporaryExportFilePath(sharedItemType: ChatExportFormat) -> URL {
var temporaryPath = FileManager.default.temporaryDirectory.appendingPathComponent("Exported Chat")

switch sharedItemType {
case .json: temporaryPath = temporaryPath.appendingPathExtension("json")
case .text: temporaryPath = temporaryPath.appendingPathExtension("txt")
case .pdf: temporaryPath = temporaryPath.appendingPathExtension("pdf")
}

return temporaryPath
}
}
28 changes: 24 additions & 4 deletions Sources/SpeziChat/ChatView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,9 @@ import SwiftUI
///
/// var body: some View {
/// ChatView($chat)
/// // Output new completed `assistant` content within the `Chat` via speech
/// .speak(chat, muted: muted)
/// .speechToolbarButton(muted: $muted)
/// .task {
/// // Add new completed `assistant` content to the `Chat` that is outputted via speech.
/// // ...
/// }
/// }
/// }
/// ```
Expand Down Expand Up @@ -99,6 +96,7 @@ public struct ChatView: View {
ZStack {
VStack {
MessagesView($chat, typingIndicator: messagePendingAnimation, bottomPadding: $messageInputHeight)
#if !os(macOS)
.gesture(
TapGesture().onEnded {
UIApplication.shared.sendAction(
Expand All @@ -109,6 +107,7 @@ public struct ChatView: View {
)
}
)
#endif
}
VStack {
Spacer()
Expand All @@ -133,14 +132,35 @@ public struct ChatView: View {
}
.sheet(isPresented: $showShareSheet) {
if let exportedChatData, let exportFormat {
#if !os(macOS)
ShareSheet(sharedItem: exportedChatData, sharedItemType: exportFormat)
.presentationDetents([.medium])
#endif
} else {
ProgressView()
.padding()
.presentationDetents([.medium])
}
}
#if os(macOS)
.onChange(of: showShareSheet) { _, isPresented in
if isPresented, let exportedChatData, let exportFormat {
let shareSheet = ShareSheet(sharedItem: exportedChatData, sharedItemType: exportFormat)
shareSheet.show()

showShareSheet = false
}
}
// `NSSharingServicePicker` doesn't provide a completion handler as `UIActivityViewController` does,
// therefore necessitating the deletion of the temporary file on disappearing.
.onDisappear {
if let exportFormat {
try? FileManager.default.removeItem(
at: Self.temporaryExportFilePath(sharedItemType: exportFormat)
)
}
}
#endif
}

private var exportEnabled: Bool {
Expand Down
Loading

0 comments on commit 2334583

Please sign in to comment.