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
)