From c51ddccd9e1d34013c59481e2d8cace947324b1b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 22:49:32 +0000 Subject: [PATCH 1/6] Update ResponsesExample for BonjourPico v2 API BonjourPico's v2 rewrite replaced its discovery surface with an @Observable facade. Migrate SelectServerView to the new API: - bonjourPico.servers -> bonjourPico.endpoints ([BonjourEndpoint]) - server.name -> endpoint.displayName - server.ipAddress -> endpoint.ipAddresses.first (falls back to hostName) - startStop() -> async startScanning() / stopScanning(), toggled in a Task; the initial scan is kicked off via .task Repoint the example app's BonjourPico dependency from the 0.0.1 tag (old API) to the main branch, where v2 is merged, and refresh Package.resolved. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XhgHkRGhNMDrJ525CAeiga --- .../project.pbxproj | 4 ++-- .../xcshareddata/swiftpm/Package.resolved | 4 ++-- .../ResponsesExample/SelectServerView.swift | 24 ++++++++++++------- 3 files changed, 20 insertions(+), 12 deletions(-) diff --git a/ResponsesExample/ResponsesExample.xcodeproj/project.pbxproj b/ResponsesExample/ResponsesExample.xcodeproj/project.pbxproj index 6c7ce1c..a2df9a2 100644 --- a/ResponsesExample/ResponsesExample.xcodeproj/project.pbxproj +++ b/ResponsesExample/ResponsesExample.xcodeproj/project.pbxproj @@ -408,8 +408,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/PicoMLX/BonjourPico"; requirement = { - kind = upToNextMajorVersion; - minimumVersion = 0.0.1; + branch = main; + kind = branch; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/ResponsesExample/ResponsesExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ResponsesExample/ResponsesExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 8d04352..ddafcb2 100644 --- a/ResponsesExample/ResponsesExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ResponsesExample/ResponsesExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/PicoMLX/BonjourPico", "state" : { - "revision" : "e8b8c906d4c0e5da34d6e81a82d4cdd62a7c0d5b", - "version" : "0.0.1" + "branch" : "main", + "revision" : "c8dece524057da6f275d7aed9af8cbaa3c057a65" } }, { diff --git a/ResponsesExample/ResponsesExample/SelectServerView.swift b/ResponsesExample/ResponsesExample/SelectServerView.swift index a831a85..28e9f59 100644 --- a/ResponsesExample/ResponsesExample/SelectServerView.swift +++ b/ResponsesExample/ResponsesExample/SelectServerView.swift @@ -18,7 +18,7 @@ struct SelectServerView: View { VStack { List { Section("Local Pico AI Servers") { - if bonjourPico.servers.isEmpty { + if bonjourPico.endpoints.isEmpty { VStack(alignment: .leading) { Text("No Pico AI servers found on this network") Group { @@ -29,10 +29,12 @@ struct SelectServerView: View { .foregroundStyle(.secondary) } } - ForEach(bonjourPico.servers, id: \.self) { server in - Button("\(server.name)") { - guard let baseURL = URL(string: "http://\(server.ipAddress):\(server.port)") else { - print("Invalid url: http://\(server.ipAddress):\(server.port)") + ForEach(bonjourPico.endpoints) { endpoint in + Button("\(endpoint.displayName)") { + // Prefer the advertised IP address; fall back to the Bonjour host name. + guard let host = endpoint.ipAddresses.first ?? endpoint.hostName, + let baseURL = URL(string: "http://\(host):\(endpoint.port)") else { + print("Invalid url for \(endpoint.displayName)") return } let apiRoot = baseURL.appendingPathComponent("v1") @@ -66,12 +68,18 @@ struct SelectServerView: View { .buttonStyle(.plain) Button(bonjourPico.isScanning ? "Stop scanning for Pico AI Servers" : "Scan for Pico AI Servers") { - bonjourPico.startStop() + Task { + if bonjourPico.isScanning { + await bonjourPico.stopScanning() + } else { + try? await bonjourPico.startScanning() + } + } } } .padding() - .onAppear { - bonjourPico.startStop() + .task { + try? await bonjourPico.startScanning() } } } From 25d3515e7139df1cc107671b257c5d2f33d07184 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 22:55:14 +0000 Subject: [PATCH 2/6] Add CI workflow: build & test the Swift package on macOS The package has no SwiftUI/UIKit/AppKit or platform-conditional code, so a single macOS SwiftPM run compiles and exercises both library targets (OpenResponses, OpenResponsesSwiftUI) and both Swift Testing suites. Runs on pushes to main and on pull requests. Uses the latest stable Xcode to provide the Swift 6.2 toolchain the package's tools-version requires. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XhgHkRGhNMDrJ525CAeiga --- .github/workflows/ci.yml | 42 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e18cdf0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +# Cancel in-progress runs for the same ref when new commits are pushed. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + test: + name: Build & Test (SwiftPM · macOS) + runs-on: macos-15 + steps: + - name: Checkout + uses: actions/checkout@v4 + + # The package declares swift-tools-version 6.2, so it needs a Swift 6.2+ + # toolchain (Xcode 26+). latest-stable tracks that going forward. + - name: Select latest stable Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + + - name: Show Swift version + run: swift --version + + - name: Resolve dependencies + run: swift package resolve + + - name: Build + run: swift build + + - name: Run tests + run: swift test From 887f45072d057a23d5860f8449d7d2e5ccf9f02c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 23:42:22 +0000 Subject: [PATCH 3/6] Fix IPv6 host handling when selecting a discovered server A discovered endpoint may advertise an IPv6 address as its first IP. Interpolating it raw into "http://:" produces an invalid URL (URL(string:) returns nil), so the server can't be selected. Prefer the Bonjour host name (which BonjourPico documents as the way to connect) and fall back to an advertised IP, bracketing IPv6 literals so the URL host is valid. Addresses the IPv6 review feedback on PR #1. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XhgHkRGhNMDrJ525CAeiga --- .../ResponsesExample/SelectServerView.swift | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/ResponsesExample/ResponsesExample/SelectServerView.swift b/ResponsesExample/ResponsesExample/SelectServerView.swift index 28e9f59..59e96d1 100644 --- a/ResponsesExample/ResponsesExample/SelectServerView.swift +++ b/ResponsesExample/ResponsesExample/SelectServerView.swift @@ -31,9 +31,14 @@ struct SelectServerView: View { } ForEach(bonjourPico.endpoints) { endpoint in Button("\(endpoint.displayName)") { - // Prefer the advertised IP address; fall back to the Bonjour host name. - guard let host = endpoint.ipAddresses.first ?? endpoint.hostName, - let baseURL = URL(string: "http://\(host):\(endpoint.port)") else { + // Prefer the Bonjour host name (recommended for connecting); fall back + // to an advertised IP. Bracket IPv6 literals so the URL host stays valid. + guard let rawHost = endpoint.hostName ?? endpoint.ipAddresses.first else { + print("Invalid url for \(endpoint.displayName)") + return + } + let host = rawHost.contains(":") ? "[\(rawHost)]" : rawHost + guard let baseURL = URL(string: "http://\(host):\(endpoint.port)") else { print("Invalid url for \(endpoint.displayName)") return } From 1183db8b3b2aa27e89ae857536fb3d131e609556 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 23:59:31 +0000 Subject: [PATCH 4/6] Keep discovered servers listed after stopping a scan With the new BonjourPico API, stopScanning() clears `endpoints`, which made the "Stop scanning" button wipe every discovered server from the list. Restore the previous behavior by rendering from a local @State snapshot that mirrors bonjourPico.endpoints while scanning and is preserved when scanning stops, so the list stays visible and selectable. Addresses the review feedback on PR #1. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XhgHkRGhNMDrJ525CAeiga --- .../ResponsesExample/SelectServerView.swift | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/ResponsesExample/ResponsesExample/SelectServerView.swift b/ResponsesExample/ResponsesExample/SelectServerView.swift index 59e96d1..04286fd 100644 --- a/ResponsesExample/ResponsesExample/SelectServerView.swift +++ b/ResponsesExample/ResponsesExample/SelectServerView.swift @@ -13,12 +13,16 @@ struct SelectServerView: View { @State var bonjourPico = BonjourPico() @Binding var server: (URL, String?, [String])? + + // Local snapshot of discovered servers. Mirrors `bonjourPico.endpoints` while scanning, + // but is kept (not cleared) when the user stops scanning so the list stays selectable. + @State private var discoveredServers: [BonjourEndpoint] = [] var body: some View { VStack { List { Section("Local Pico AI Servers") { - if bonjourPico.endpoints.isEmpty { + if discoveredServers.isEmpty { VStack(alignment: .leading) { Text("No Pico AI servers found on this network") Group { @@ -29,7 +33,7 @@ struct SelectServerView: View { .foregroundStyle(.secondary) } } - ForEach(bonjourPico.endpoints) { endpoint in + ForEach(discoveredServers) { endpoint in Button("\(endpoint.displayName)") { // Prefer the Bonjour host name (recommended for connecting); fall back // to an advertised IP. Bracket IPv6 literals so the URL host stays valid. @@ -86,6 +90,14 @@ struct SelectServerView: View { .task { try? await bonjourPico.startScanning() } + .onChange(of: bonjourPico.endpoints) { _, endpoints in + // Mirror live results while scanning. stopScanning() sets isScanning = false + // before clearing endpoints, so the cleared snapshot is ignored here and the + // last discovered list stays visible (and selectable) after the scan stops. + if bonjourPico.isScanning { + discoveredServers = endpoints + } + } } } From b2b8532b3b94d2e4b50935ced7bd7a8c934278ce Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 00:12:08 +0000 Subject: [PATCH 5/6] Clear kept server snapshot when starting a new scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The local discoveredServers snapshot is intentionally preserved when a scan stops, so the list stays selectable. But the onChange guard only copies endpoints while isScanning is true, so a *new* scan that finds nothing never overwrote the old snapshot — leaving stale servers from the previous scan visible and selectable. Clear discoveredServers at the start of each scan (via a startScan() helper used by both .task and the Scan button) so a fresh scan begins empty and only shows servers it actually rediscovers. Stopping a scan still keeps the list. Addresses Codex review feedback on PR #1. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XhgHkRGhNMDrJ525CAeiga --- .../ResponsesExample/SelectServerView.swift | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/ResponsesExample/ResponsesExample/SelectServerView.swift b/ResponsesExample/ResponsesExample/SelectServerView.swift index 04286fd..b7f20b7 100644 --- a/ResponsesExample/ResponsesExample/SelectServerView.swift +++ b/ResponsesExample/ResponsesExample/SelectServerView.swift @@ -81,14 +81,14 @@ struct SelectServerView: View { if bonjourPico.isScanning { await bonjourPico.stopScanning() } else { - try? await bonjourPico.startScanning() + await startScan() } } } } .padding() .task { - try? await bonjourPico.startScanning() + await startScan() } .onChange(of: bonjourPico.endpoints) { _, endpoints in // Mirror live results while scanning. stopScanning() sets isScanning = false @@ -99,6 +99,13 @@ struct SelectServerView: View { } } } + + /// Starts a fresh scan. Clears the kept snapshot first so a new scan that finds nothing + /// doesn't leave stale servers from a previous (stopped) scan visible and selectable. + private func startScan() async { + discoveredServers = [] + try? await bonjourPico.startScanning() + } } private struct ModelListResponse: Decodable { From d6d065f5e5d5e774e67b3cb152abe8de24d1fbbd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 00:42:23 +0000 Subject: [PATCH 6/6] Build the example app in CI and fix its package wiring Codex flagged that CI only built the SwiftPM package, so the example app (the subject of this PR) and its Xcode package resolution were never compiled. Wiring it up for CI surfaced stale, pre-existing dependencies: - The project linked products PicoResponsesCore / PicoResponsesSwiftUI from a local sibling package ../../PicoResponses (the repo's pre-rename name), while the sources import the renamed OpenResponses / OpenResponsesSwiftUI modules. Repoint that dependency to the in-repo package (..) and link the OpenResponses / OpenResponsesSwiftUI products. - PicoMarkdownView was a local sibling (../../PicoMarkdownView) absent from this repo, so CI could not resolve it. Convert it to a remote package reference (github.com/PicoMLX/PicoMarkdownView, main) and pin it. Add a shared ResponsesExample scheme so xcodebuild can build it by name, and add a build-example CI job that resolves packages and builds the app for macOS with code signing disabled. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XhgHkRGhNMDrJ525CAeiga --- .github/workflows/ci.yml | 38 +++++++++ .../project.pbxproj | 41 +++++----- .../xcshareddata/swiftpm/Package.resolved | 9 +++ .../xcschemes/ResponsesExample.xcscheme | 78 +++++++++++++++++++ 4 files changed, 148 insertions(+), 18 deletions(-) create mode 100644 ResponsesExample/ResponsesExample.xcodeproj/xcshareddata/xcschemes/ResponsesExample.xcscheme diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e18cdf0..dcd01d0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,3 +40,41 @@ jobs: - name: Run tests run: swift test + + build-example: + name: Build Example App (Xcode · macOS) + runs-on: macos-15 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Select latest stable Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + + - name: Show versions + run: | + swift --version + xcodebuild -version + + # Resolve as a distinct step so dependency-resolution failures (the + # BonjourPico / PicoMarkdownView remote packages, or the in-repo + # OpenResponses package) are easy to spot separately from compilation. + - name: Resolve package dependencies + run: > + xcodebuild -resolvePackageDependencies + -project ResponsesExample/ResponsesExample.xcodeproj + -scheme ResponsesExample + -clonedSourcePackagesDirPath .spm + + - name: Build for macOS + run: | + xcodebuild build \ + -project ResponsesExample/ResponsesExample.xcodeproj \ + -scheme ResponsesExample \ + -destination 'platform=macOS' \ + -clonedSourcePackagesDirPath .spm \ + -skipMacroValidation \ + -skipPackagePluginValidation \ + CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" diff --git a/ResponsesExample/ResponsesExample.xcodeproj/project.pbxproj b/ResponsesExample/ResponsesExample.xcodeproj/project.pbxproj index a2df9a2..53ccf5e 100644 --- a/ResponsesExample/ResponsesExample.xcodeproj/project.pbxproj +++ b/ResponsesExample/ResponsesExample.xcodeproj/project.pbxproj @@ -8,8 +8,8 @@ /* Begin PBXBuildFile section */ F8B183562E9ECE55007F778C /* PicoMarkdownView in Frameworks */ = {isa = PBXBuildFile; productRef = F8B183552E9ECE55007F778C /* PicoMarkdownView */; }; - F8E2A9E02E92E3FC00EFF610 /* PicoResponsesCore in Frameworks */ = {isa = PBXBuildFile; productRef = F8E2A9DF2E92E3FC00EFF610 /* PicoResponsesCore */; }; - F8E2A9E22E92E3FC00EFF610 /* PicoResponsesSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = F8E2A9E12E92E3FC00EFF610 /* PicoResponsesSwiftUI */; }; + F8E2A9E02E92E3FC00EFF610 /* OpenResponses in Frameworks */ = {isa = PBXBuildFile; productRef = F8E2A9DF2E92E3FC00EFF610 /* OpenResponses */; }; + F8E2A9E22E92E3FC00EFF610 /* OpenResponsesSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = F8E2A9E12E92E3FC00EFF610 /* OpenResponsesSwiftUI */; }; F8E2A9F32E94376500EFF610 /* BonjourPico in Frameworks */ = {isa = PBXBuildFile; productRef = F8E2A9F22E94376500EFF610 /* BonjourPico */; }; /* End PBXBuildFile section */ @@ -32,8 +32,8 @@ files = ( F8E2A9F32E94376500EFF610 /* BonjourPico in Frameworks */, F8B183562E9ECE55007F778C /* PicoMarkdownView in Frameworks */, - F8E2A9E22E92E3FC00EFF610 /* PicoResponsesSwiftUI in Frameworks */, - F8E2A9E02E92E3FC00EFF610 /* PicoResponsesCore in Frameworks */, + F8E2A9E22E92E3FC00EFF610 /* OpenResponsesSwiftUI in Frameworks */, + F8E2A9E02E92E3FC00EFF610 /* OpenResponses in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -76,8 +76,8 @@ ); name = ResponsesExample; packageProductDependencies = ( - F8E2A9DF2E92E3FC00EFF610 /* PicoResponsesCore */, - F8E2A9E12E92E3FC00EFF610 /* PicoResponsesSwiftUI */, + F8E2A9DF2E92E3FC00EFF610 /* OpenResponses */, + F8E2A9E12E92E3FC00EFF610 /* OpenResponsesSwiftUI */, F8E2A9F22E94376500EFF610 /* BonjourPico */, F8B183552E9ECE55007F778C /* PicoMarkdownView */, ); @@ -110,9 +110,9 @@ mainGroup = F8E2A99F2E92D91600EFF610; minimizedProjectReferenceProxies = 1; packageReferences = ( - F8E2A9DE2E92E3FC00EFF610 /* XCLocalSwiftPackageReference "../../PicoResponses" */, + F8E2A9DE2E92E3FC00EFF610 /* XCLocalSwiftPackageReference ".." */, F8E2A9F12E94376500EFF610 /* XCRemoteSwiftPackageReference "BonjourPico" */, - F8B183542E9ECE55007F778C /* XCLocalSwiftPackageReference "../../PicoMarkdownView" */, + F8B183542E9ECE55007F778C /* XCRemoteSwiftPackageReference "PicoMarkdownView" */, ); preferredProjectObjectVersion = 77; productRefGroup = F8E2A9A92E92D91600EFF610 /* Products */; @@ -393,17 +393,21 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - F8B183542E9ECE55007F778C /* XCLocalSwiftPackageReference "../../PicoMarkdownView" */ = { + F8E2A9DE2E92E3FC00EFF610 /* XCLocalSwiftPackageReference ".." */ = { isa = XCLocalSwiftPackageReference; - relativePath = ../../PicoMarkdownView; - }; - F8E2A9DE2E92E3FC00EFF610 /* XCLocalSwiftPackageReference "../../PicoResponses" */ = { - isa = XCLocalSwiftPackageReference; - relativePath = ../../PicoResponses; + relativePath = ..; }; /* End XCLocalSwiftPackageReference section */ /* Begin XCRemoteSwiftPackageReference section */ + F8B183542E9ECE55007F778C /* XCRemoteSwiftPackageReference "PicoMarkdownView" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/PicoMLX/PicoMarkdownView"; + requirement = { + branch = main; + kind = branch; + }; + }; F8E2A9F12E94376500EFF610 /* XCRemoteSwiftPackageReference "BonjourPico" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/PicoMLX/BonjourPico"; @@ -417,15 +421,16 @@ /* Begin XCSwiftPackageProductDependency section */ F8B183552E9ECE55007F778C /* PicoMarkdownView */ = { isa = XCSwiftPackageProductDependency; + package = F8B183542E9ECE55007F778C /* XCRemoteSwiftPackageReference "PicoMarkdownView" */; productName = PicoMarkdownView; }; - F8E2A9DF2E92E3FC00EFF610 /* PicoResponsesCore */ = { + F8E2A9DF2E92E3FC00EFF610 /* OpenResponses */ = { isa = XCSwiftPackageProductDependency; - productName = PicoResponsesCore; + productName = OpenResponses; }; - F8E2A9E12E92E3FC00EFF610 /* PicoResponsesSwiftUI */ = { + F8E2A9E12E92E3FC00EFF610 /* OpenResponsesSwiftUI */ = { isa = XCSwiftPackageProductDependency; - productName = PicoResponsesSwiftUI; + productName = OpenResponsesSwiftUI; }; F8E2A9F22E94376500EFF610 /* BonjourPico */ = { isa = XCSwiftPackageProductDependency; diff --git a/ResponsesExample/ResponsesExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ResponsesExample/ResponsesExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index ddafcb2..852beaf 100644 --- a/ResponsesExample/ResponsesExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ResponsesExample/ResponsesExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -19,6 +19,15 @@ "version" : "1.3.0" } }, + { + "identity" : "picomarkdownview", + "kind" : "remoteSourceControl", + "location" : "https://github.com/PicoMLX/PicoMarkdownView", + "state" : { + "branch" : "main", + "revision" : "afc6e54749ba5960a124fc7918c16b866df35696" + } + }, { "identity" : "swiftmath", "kind" : "remoteSourceControl", diff --git a/ResponsesExample/ResponsesExample.xcodeproj/xcshareddata/xcschemes/ResponsesExample.xcscheme b/ResponsesExample/ResponsesExample.xcodeproj/xcshareddata/xcschemes/ResponsesExample.xcscheme new file mode 100644 index 0000000..b7948fc --- /dev/null +++ b/ResponsesExample/ResponsesExample.xcodeproj/xcshareddata/xcschemes/ResponsesExample.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +