Add OndeExample SwiftUI demo app

Includes: - App.swift entry point and environment setup for Onde/Rust - ChatViewModel with streaming, error handling, and message state - ContentView with message list, input bar, and loading overlay - InferenceEnvironment helper for iOS/tvOS sandbox - Updates to Package.swift for platform versions

keypair34 committed Apr 28, 2026 at 19:16 UTC a79d5bf64c885603f21de0d8723a6266313cae4f
5 files changed +523 -13
Examples/OndeExample/App.swift new
+17
@@ -0,0 +1,17 @@
1 +import SwiftUI
2 +
3 +@main
4 +struct OndeExampleApp: App {
5 +
6 + init() {
7 + // Must be called before any OndeChatEngine interaction so the Rust
8 + // core can find its cache directories inside the app sandbox.
9 + setupInferenceEnvironment()
10 + }
11 +
12 + var body: some Scene {
13 + WindowGroup {
14 + ContentView()
15 + }
16 + }
17 +}
Examples/OndeExample/ChatViewModel.swift new
+189
@@ -0,0 +1,189 @@
1 +import Foundation
2 +import Onde
3 +
4 +// ─────────────────────────────────────────────────────────────────────────────
5 +// MARK: - Message model
6 +// ─────────────────────────────────────────────────────────────────────────────
7 +
8 +struct Message: Identifiable {
9 + enum Role { case user, assistant }
10 +
11 + let id = UUID()
12 + let role: Role
13 + var text: String
14 + /// True while the assistant is still streaming tokens for this message.
15 + var isStreaming: Bool = false
16 +}
17 +
18 +// ─────────────────────────────────────────────────────────────────────────────
19 +// MARK: - StreamChunkListener bridge
20 +// ─────────────────────────────────────────────────────────────────────────────
21 +
22 +/// Bridges the UniFFI callback interface to an `AsyncStream<String>`.
23 +///
24 +/// `StreamChunkListener.onChunk` is called from the Rust thread pool, so we
25 +/// funnel every delta through a continuation that re-publishes on the caller's
26 +/// structured-concurrency task.
27 +private final class ChunkCollector: StreamChunkListener {
28 +
29 + private let continuation: AsyncStream<String>.Continuation
30 +
31 + init(continuation: AsyncStream<String>.Continuation) {
32 + self.continuation = continuation
33 + }
34 +
35 + /// Called by the Rust runtime for each streamed token.
36 + /// - Returns: `true` to keep streaming, `false` to cancel.
37 + func onChunk(chunk: StreamChunk) -> Bool {
38 + if chunk.done {
39 + continuation.finish()
40 + return false
41 + }
42 + continuation.yield(chunk.delta)
43 + return true
44 + }
45 +}
46 +
47 +// ─────────────────────────────────────────────────────────────────────────────
48 +// MARK: - ViewModel
49 +// ─────────────────────────────────────────────────────────────────────────────
50 +
51 +@MainActor
52 +final class ChatViewModel: ObservableObject {
53 +
54 + // ── Published state ───────────────────────────────────────────────────────
55 +
56 + @Published private(set) var messages: [Message] = []
57 + @Published private(set) var isModelLoading: Bool = false
58 + @Published private(set) var isModelReady: Bool = false
59 + @Published private(set) var isSending: Bool = false
60 + @Published private(set) var loadingProgress: String = "Preparing model…"
61 +
62 + @Published var alertError: IdentifiableError? = nil
63 +
64 + // ── Private state ─────────────────────────────────────────────────────────
65 +
66 + // Lazy so the Rust/UniFFI runtime isn't initialized during
67 + // @StateObject construction on the UIKit event-fetch thread.
68 + private var engine: OndeChatEngine?
69 + private var streamingTask: Task<Void, Never>? = nil
70 +
71 + private func getOrCreateEngine() -> OndeChatEngine {
72 + if let existing = engine { return existing }
73 + let new = OndeChatEngine()
74 + engine = new
75 + return new
76 + }
77 +
78 + // ─────────────────────────────────────────────────────────────────────────
79 + // MARK: Model lifecycle
80 + // ─────────────────────────────────────────────────────────────────────────
81 +
82 + func loadModel() async {
83 + guard !isModelReady, !isModelLoading else { return }
84 +
85 + isModelLoading = true
86 + loadingProgress = "Downloading / loading model…"
87 +
88 + do {
89 + let elapsed = try await getOrCreateEngine().loadDefaultModel(
90 + systemPrompt: "You are a helpful assistant. Be concise.",
91 + sampling: nil
92 + )
93 + loadingProgress = String(format: "Model ready in %.1f s", elapsed)
94 + isModelReady = true
95 + } catch {
96 + alertError = IdentifiableError(error)
97 + }
98 +
99 + isModelLoading = false
100 + }
101 +
102 + // ─────────────────────────────────────────────────────────────────────────
103 + // MARK: Sending messages
104 + // ─────────────────────────────────────────────────────────────────────────
105 +
106 + func send(text: String) {
107 + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
108 + guard !trimmed.isEmpty, !isSending else { return }
109 +
110 + messages.append(Message(role: .user, text: trimmed))
111 +
112 + streamingTask = Task {
113 + await streamResponse(for: trimmed)
114 + }
115 + }
116 +
117 + func cancelStreaming() {
118 + streamingTask?.cancel()
119 + streamingTask = nil
120 + if let idx = messages.indices.last, messages[idx].isStreaming {
121 + messages[idx].isStreaming = false
122 + }
123 + isSending = false
124 + }
125 +
126 + // ─────────────────────────────────────────────────────────────────────────
127 + // MARK: Private helpers
128 + // ─────────────────────────────────────────────────────────────────────────
129 +
130 + private func streamResponse(for userText: String) async {
131 + isSending = true
132 +
133 + let assistantIndex = messages.count
134 + messages.append(Message(role: .assistant, text: "", isStreaming: true))
135 +
136 + // Hold onto the continuation so we can finish it from the error path.
137 + var storedContinuation: AsyncStream<String>.Continuation?
138 + let stream = AsyncStream<String> { continuation in
139 + storedContinuation = continuation
140 + }
141 + guard let cont = storedContinuation else { return }
142 + let listener = ChunkCollector(continuation: cont)
143 +
144 + // Run streamChatMessage off the main actor so the Rust callback thread
145 + // can call onChunk without deadlocking against the actor.
146 + let capturedEngine = self.getOrCreateEngine()
147 + async let _ = Task.detached(priority: .userInitiated) {
148 + do {
149 + try await streamChatMessage(
150 + engine: capturedEngine,
151 + message: userText,
152 + listener: listener
153 + )
154 + } catch {
155 + cont.finish()
156 + }
157 + }.value
158 +
159 + for await delta in stream {
160 + guard !Task.isCancelled else { break }
161 + if assistantIndex < messages.count {
162 + messages[assistantIndex].text += delta
163 + }
164 + }
165 +
166 + if assistantIndex < messages.count {
167 + messages[assistantIndex].isStreaming = false
168 + }
169 + isSending = false
170 + }
171 +}
172 +
173 +// ─────────────────────────────────────────────────────────────────────────────
174 +// MARK: - Error wrapper
175 +// ─────────────────────────────────────────────────────────────────────────────
176 +
177 +/// Makes any `Error` `Identifiable` so it can drive a SwiftUI `.alert`.
178 +struct IdentifiableError: Identifiable {
179 + let id = UUID()
180 + let underlying: Error
181 +
182 + init(_ error: Error) {
183 + self.underlying = error
184 + }
185 +
186 + var message: String {
187 + underlying.localizedDescription
188 + }
189 +}
Examples/OndeExample/ContentView.swift new
+266
@@ -0,0 +1,266 @@
1 +import SwiftUI
2 +
3 +// ─────────────────────────────────────────────────────────────────────────────
4 +// MARK: - Root view
5 +// ─────────────────────────────────────────────────────────────────────────────
6 +
7 +struct ContentView: View {
8 +
9 + @StateObject private var viewModel = ChatViewModel()
10 +
11 + var body: some View {
12 + VStack(spacing: 0) {
13 + HeaderView(isReady: viewModel.isModelReady)
14 +
15 + Divider()
16 +
17 + ZStack {
18 + MessageListView(messages: viewModel.messages)
19 +
20 + if viewModel.isModelLoading {
21 + LoadingOverlayView(progress: viewModel.loadingProgress)
22 + }
23 + }
24 +
25 + Divider()
26 +
27 + InputBarView(
28 + isSending: viewModel.isSending,
29 + isModelReady: viewModel.isModelReady,
30 + onSend: { text in viewModel.send(text: text) },
31 + onCancel: { viewModel.cancelStreaming() }
32 + )
33 + }
34 + .task {
35 + await viewModel.loadModel()
36 + }
37 + .alert(
38 + "Error",
39 + isPresented: Binding(
40 + get: { viewModel.alertError != nil },
41 + set: { if !$0 { viewModel.alertError = nil } }
42 + ),
43 + presenting: viewModel.alertError
44 + ) { _ in
45 + Button("OK", role: .cancel) { viewModel.alertError = nil }
46 + } message: { error in
47 + Text(error.message)
48 + }
49 + }
50 +}
51 +
52 +// ─────────────────────────────────────────────────────────────────────────────
53 +// MARK: - Header
54 +// ─────────────────────────────────────────────────────────────────────────────
55 +
56 +private struct HeaderView: View {
57 + let isReady: Bool
58 +
59 + var body: some View {
60 + HStack(spacing: 8) {
61 + Image(systemName: "brain.head.profile")
62 + .font(.title2)
63 + .foregroundStyle(isReady ? .green : .secondary)
64 +
65 + Text("Onde Chat")
66 + .font(.headline)
67 +
68 + Spacer()
69 +
70 + if isReady {
71 + Label("On-device", systemImage: "iphone.and.arrow.forward")
72 + .font(.caption)
73 + .foregroundStyle(.secondary)
74 + }
75 + }
76 + .padding(.horizontal)
77 + .padding(.vertical, 12)
78 + .background(.bar)
79 + }
80 +}
81 +
82 +// ─────────────────────────────────────────────────────────────────────────────
83 +// MARK: - Loading overlay
84 +// ─────────────────────────────────────────────────────────────────────────────
85 +
86 +private struct LoadingOverlayView: View {
87 + let progress: String
88 +
89 + var body: some View {
90 + ZStack {
91 + Color(white: 1.0)
92 + .colorInvert()
93 + .ignoresSafeArea()
94 +
95 + VStack(spacing: 20) {
96 + ProgressView()
97 + .scaleEffect(1.4)
98 +
99 + Text(progress)
100 + .font(.subheadline)
101 + .foregroundStyle(.secondary)
102 + .multilineTextAlignment(.center)
103 + .padding(.horizontal, 32)
104 + }
105 + }
106 + }
107 +}
108 +
109 +// ─────────────────────────────────────────────────────────────────────────────
110 +// MARK: - Message list
111 +// ─────────────────────────────────────────────────────────────────────────────
112 +
113 +private struct MessageListView: View {
114 + let messages: [Message]
115 +
116 + var body: some View {
117 + ScrollViewReader { proxy in
118 + ScrollView {
119 + LazyVStack(alignment: .leading, spacing: 12) {
120 + ForEach(messages) { message in
121 + MessageBubble(message: message)
122 + .id(message.id)
123 + }
124 + }
125 + .padding()
126 + }
127 + .onChange(of: messages.count) { _ in
128 + if let last = messages.last {
129 + withAnimation(.easeOut(duration: 0.2)) {
130 + proxy.scrollTo(last.id, anchor: .bottom)
131 + }
132 + }
133 + }
134 + .onChange(of: messages.last?.text) { _ in
135 + if let last = messages.last {
136 + proxy.scrollTo(last.id, anchor: .bottom)
137 + }
138 + }
139 + }
140 + }
141 +}
142 +
143 +// ─────────────────────────────────────────────────────────────────────────────
144 +// MARK: - Message bubble
145 +// ─────────────────────────────────────────────────────────────────────────────
146 +
147 +private struct MessageBubble: View {
148 + let message: Message
149 +
150 + private var isUser: Bool { message.role == .user }
151 +
152 + private var bubbleBackground: Color {
153 + Color.secondary.opacity(0.15)
154 + }
155 +
156 + var body: some View {
157 + HStack {
158 + if isUser { Spacer(minLength: 48) }
159 +
160 + VStack(alignment: isUser ? .trailing : .leading, spacing: 4) {
161 + Text(isUser ? "You" : "Onde")
162 + .font(.caption2)
163 + .foregroundStyle(.secondary)
164 + .padding(.horizontal, 4)
165 +
166 + HStack(alignment: .bottom, spacing: 6) {
167 + Text(message.text.isEmpty ? " " : message.text)
168 + .font(.body)
169 + .foregroundStyle(isUser ? .white : .primary)
170 + .padding(.horizontal, 14)
171 + .padding(.vertical, 10)
172 + .background(
173 + RoundedRectangle(cornerRadius: 18, style: .continuous)
174 + .fill(isUser ? Color.accentColor : bubbleBackground)
175 + )
176 +
177 + if message.isStreaming {
178 + ProgressView()
179 + .scaleEffect(0.7)
180 + .padding(.bottom, 8)
181 + }
182 + }
183 + }
184 +
185 + if !isUser { Spacer(minLength: 48) }
186 + }
187 + }
188 +}
189 +
190 +// ─────────────────────────────────────────────────────────────────────────────
191 +// MARK: - Input bar
192 +// ─────────────────────────────────────────────────────────────────────────────
193 +
194 +private struct InputBarView: View {
195 + let isSending: Bool
196 + let isModelReady: Bool
197 + let onSend: (String) -> Void
198 + let onCancel: () -> Void
199 +
200 + @State private var draftText: String = ""
201 + @FocusState private var fieldFocused: Bool
202 +
203 + private var inputBackground: Color {
204 + Color.secondary.opacity(0.15)
205 + }
206 +
207 + private var canSend: Bool {
208 + isModelReady && !isSending && !draftText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
209 + }
210 +
211 + var body: some View {
212 + HStack(spacing: 10) {
213 + TextField("Message…", text: $draftText, axis: .vertical)
214 + .lineLimit(1...5)
215 + .padding(.horizontal, 14)
216 + .padding(.vertical, 10)
217 + .background(
218 + RoundedRectangle(cornerRadius: 20, style: .continuous)
219 + .fill(inputBackground)
220 + )
221 + .focused($fieldFocused)
222 + .disabled(!isModelReady || isSending)
223 + .onSubmit {
224 + submitIfReady()
225 + }
226 +
227 + Group {
228 + if isSending {
229 + Button(action: onCancel) {
230 + Image(systemName: "stop.circle.fill")
231 + .font(.system(size: 32))
232 + .foregroundStyle(.red)
233 + }
234 + .accessibilityLabel("Stop generating")
235 + } else {
236 + Button(action: submitIfReady) {
237 + Image(systemName: "arrow.up.circle.fill")
238 + .font(.system(size: 32))
239 + .foregroundStyle(canSend ? Color.accentColor : .secondary)
240 + }
241 + .disabled(!canSend)
242 + .accessibilityLabel("Send message")
243 + }
244 + }
245 + .animation(.easeInOut(duration: 0.15), value: isSending)
246 + }
247 + .padding(.horizontal, 14)
248 + .padding(.vertical, 10)
249 + .background(.bar)
250 + }
251 +
252 + private func submitIfReady() {
253 + guard canSend else { return }
254 + let text = draftText
255 + draftText = ""
256 + onSend(text)
257 + }
258 +}
259 +
260 +// ─────────────────────────────────────────────────────────────────────────────
261 +// MARK: - Preview
262 +// ─────────────────────────────────────────────────────────────────────────────
263 +
264 +#Preview {
265 + ContentView()
266 +}
Examples/OndeExample/InferenceEnvironment.swift new
+34
@@ -0,0 +1,34 @@
1 +import Foundation
2 +
3 +/// Configures the environment variables that the Onde Rust core requires when
4 +/// running inside an iOS/tvOS app sandbox.
5 +///
6 +/// Call this **before** any `OndeChatEngine` method — ideally in your `@main`
7 +/// App struct's `init()`.
8 +///
9 +/// What each variable does:
10 +/// - `HF_HOME` – root of the HuggingFace Hub cache inside the app container
11 +/// - `HF_HUB_CACHE` – models subdirectory (avoids the default `~/.cache` path
12 +/// which is unreachable in the sandbox)
13 +/// - `TMPDIR` – the system-provided temporary directory for the process
14 +func setupInferenceEnvironment() {
15 + let fm = FileManager.default
16 +
17 + // Base directory for all Onde / HuggingFace data.
18 + // Application Support is backed up by iCloud by default; use Caches if
19 + // you prefer a purgeable location and are OK re-downloading models.
20 + let appSupport = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
21 + let hfHome = appSupport.appendingPathComponent("huggingface", isDirectory: true)
22 +
23 + // Create the directory tree up-front so hf-hub never has to.
24 + let hfHubCache = hfHome.appendingPathComponent("hub", isDirectory: true)
25 + try? fm.createDirectory(at: hfHubCache, withIntermediateDirectories: true)
26 +
27 + setenv("HF_HOME", hfHome.path, 1)
28 + setenv("HF_HUB_CACHE", hfHubCache.path, 1)
29 +
30 + // The Rust std-lib respects TMPDIR; make sure it points inside our sandbox.
31 + if let tmp = ProcessInfo.processInfo.environment["TMPDIR"] {
32 + setenv("TMPDIR", tmp, 1) // already set by iOS — just make it explicit
33 + }
34 +}
Package.swift
+17 -13
@@ -2,32 +2,31 @@
2 import PackageDescription
3
4 // ─────────────────────────────────────────────────────────────────────────────
5 -// LOCAL DEVELOPMENT
5 +// Local development
6 //
7 -// When working on the Onde Rust crate locally, swap the url-based binary
8 -// target below to a path-based one pointing at your local XCFramework:
7 +// Working on the Onde Rust crate locally? Swap the url-based binary target
8 +// below for a path-based one pointing at your local XCFramework:
9 //
10 // name: "OndeFramework"
11 // path: "./../onde/sdk/Onde/OndeFramework.xcframework"
12 //
13 -// Run the build script first to produce the framework:
13 +// Build the framework first:
14 // cd onde && .github/scripts/build-swift-xcframework.sh
15 //
16 -// DISTRIBUTION
16 +// Distribution
17 //
18 -// The url + checksum below are updated automatically by CI on every release.
19 -// Do not edit them by hand — they are rewritten by build-swift-xcframework.yml
20 -// when a new onde GitHub Release is published.
18 +// The url and checksum below are rewritten by CI (build-swift-xcframework.yml)
19 +// each time a new onde GitHub Release goes out. Don't edit them by hand.
20 // ─────────────────────────────────────────────────────────────────────────────
21
22 let package = Package(
23 name: "Onde",
24 platforms: [
26 - .iOS(.v15),
27 - .macOS(.v11),
28 - .tvOS(.v15),
29 - .visionOS(.v26),
30 - .watchOS(.v10),
25 + .iOS(.v16),
26 + .macOS(.v14),
27 + .tvOS(.v16),
28 + .visionOS(.v1),
29 + .watchOS(.v9),
30 ],
31 products: [
32 .library(name: "Onde", targets: ["Onde"])
@@ -43,5 +42,10 @@ let package = Package(
42 dependencies: [.target(name: "OndeFramework")],
43 path: "Sources/Onde"
44 ),
45 + .executableTarget(
46 + name: "OndeExample",
47 + dependencies: [.target(name: "Onde")],
48 + path: "Examples/OndeExample"
49 + ),
50 ]
51 )