Add Makefile for local Apple builds and example app improvements

- Add Makefile to build OndeFramework.xcframework for all Apple platforms - Regenerate Swift bindings from local Rust crate during build - Update .gitignore for Xcode build output - Add ExampleSupport.swift with prompt suggestions and settings UI - Improve ChatViewModel streaming and message model - Update InferenceEnvironment for better App Group fallback - Add scheme to XcodeGen project config - Update entitlements for increased memory limit - Improve README with local development instructions

Seto Elkahfi committed May 14, 2026 at 11:49 UTC fe0f3e4f92d14f304cdee2c11d3f7fdb99dcfd7b
11 files changed +978 -97
.gitignore
+1
@@ -7,5 +7,6 @@ OndeFramework.xcframework.zip
7
8 # Generated by xcodegen from project.yml
9 Examples/OndeExample/OndeExample.xcodeproj/
10 +Examples/OndeExample/build/
11
12 .zed
Examples/OndeExample/ChatViewModel.swift
+342 -46
@@ -5,42 +5,121 @@ import Onde
5 // MARK: - Message model
6 // ─────────────────────────────────────────────────────────────────────────────
7
8 -struct Message: Identifiable {
9 - enum Role { case user, assistant }
8 +struct Message: Identifiable, Codable, Hashable {
9 + enum Role: String, Codable {
10 + case user
11 + case assistant
12 + }
13
11 - let id = UUID()
14 + let id: UUID
15 let role: Role
16 var text: String
17 /// True while the assistant is still streaming tokens for this message.
15 - var isStreaming: Bool = false
18 + var isStreaming: Bool
19 +
20 + init(
21 + id: UUID = UUID(),
22 + role: Role,
23 + text: String,
24 + isStreaming: Bool = false
25 + ) {
26 + self.id = id
27 + self.role = role
28 + self.text = text
29 + self.isStreaming = isStreaming
30 + }
31 +}
32 +
33 +private extension Message {
34 + var asChatMessage: ChatMessage {
35 + switch role {
36 + case .user:
37 + return userMessage(content: text)
38 + case .assistant:
39 + return assistantMessage(content: text)
40 + }
41 + }
42 }
43
44 // ─────────────────────────────────────────────────────────────────────────────
19 -// MARK: - StreamChunkListener bridge
45 +// MARK: - Stream bridge
46 // ─────────────────────────────────────────────────────────────────────────────
47
48 +/// Thread-safe bridge between the Rust callback thread and Swift concurrency.
49 +private final class StreamBridge: @unchecked Sendable {
50 + private let lock = NSLock()
51 + private var continuation: AsyncStream<String>.Continuation?
52 + private var cancelled = false
53 +
54 + func attach(_ continuation: AsyncStream<String>.Continuation) {
55 + lock.lock()
56 + self.continuation = continuation
57 + let shouldFinish = cancelled
58 + lock.unlock()
59 +
60 + if shouldFinish {
61 + continuation.finish()
62 + }
63 + }
64 +
65 + func yield(_ delta: String) {
66 + lock.lock()
67 + guard !cancelled else {
68 + lock.unlock()
69 + return
70 + }
71 + let continuation = continuation
72 + lock.unlock()
73 + continuation?.yield(delta)
74 + }
75 +
76 + func finish() {
77 + lock.lock()
78 + let continuation = continuation
79 + self.continuation = nil
80 + lock.unlock()
81 + continuation?.finish()
82 + }
83 +
84 + func cancel() {
85 + lock.lock()
86 + cancelled = true
87 + let continuation = continuation
88 + self.continuation = nil
89 + lock.unlock()
90 + continuation?.finish()
91 + }
92 +
93 + var isCancelled: Bool {
94 + lock.lock()
95 + let cancelled = cancelled
96 + lock.unlock()
97 + return cancelled
98 + }
99 +}
100 +
101 /// 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.
102 private final class ChunkCollector: StreamChunkListener {
103 + private let bridge: StreamBridge
104
29 - private let continuation: AsyncStream<String>.Continuation
30 -
31 - init(continuation: AsyncStream<String>.Continuation) {
32 - self.continuation = continuation
105 + init(bridge: StreamBridge) {
106 + self.bridge = bridge
107 }
108
109 /// Called by the Rust runtime for each streamed token.
110 /// - Returns: `true` to keep streaming, `false` to cancel.
111 func onChunk(chunk: StreamChunk) -> Bool {
112 + if bridge.isCancelled {
113 + return false
114 + }
115 +
116 if chunk.done {
39 - continuation.finish()
117 + bridge.finish()
118 return false
119 }
42 - continuation.yield(chunk.delta)
43 - return true
120 +
121 + bridge.yield(chunk.delta)
122 + return !bridge.isCancelled
123 }
124 }
125
@@ -53,11 +132,17 @@ final class ChatViewModel: ObservableObject {
132
133 // ── Published state ───────────────────────────────────────────────────────
134
56 - @Published private(set) var messages: [Message] = []
135 + @Published private(set) var messages: [Message]
136 @Published private(set) var isModelLoading: Bool = false
137 @Published private(set) var isModelReady: Bool = false
138 @Published private(set) var isSending: Bool = false
139 @Published private(set) var loadingProgress: String = "Preparing model…"
140 + @Published private(set) var engineInfo: EngineInfo = EngineInfo(
141 + status: .unloaded,
142 + modelName: nil,
143 + approxMemory: nil,
144 + historyLength: 0
145 + )
146
147 @Published var alertError: IdentifiableError? = nil
148
@@ -66,7 +151,15 @@ final class ChatViewModel: ObservableObject {
151 // Lazy so the Rust/UniFFI runtime isn't initialized during
152 // @StateObject construction on the UIKit event-fetch thread.
153 private var engine: OndeChatEngine?
69 - private var streamingTask: Task<Void, Never>? = nil
154 + private var streamingTask: Task<Void, Never>?
155 + private var streamBridge: StreamBridge?
156 + private var didHydrateEngineHistory = false
157 +
158 + private static let persistedMessagesKey = "onde.example.messages"
159 +
160 + init() {
161 + self.messages = Self.loadPersistedMessages()
162 + }
163
164 private func getOrCreateEngine() -> OndeChatEngine {
165 if let existing = engine { return existing }
@@ -75,28 +168,79 @@ final class ChatViewModel: ObservableObject {
168 return new
169 }
170
171 + var hasMessages: Bool {
172 + !messages.isEmpty
173 + }
174 +
175 + var isBusy: Bool {
176 + isModelLoading || isSending
177 + }
178 +
179 // ─────────────────────────────────────────────────────────────────────────
180 // MARK: Model lifecycle
181 // ─────────────────────────────────────────────────────────────────────────
182
82 - func loadModel() async {
83 - guard !isModelReady, !isModelLoading else { return }
183 + func loadModelIfNeeded(systemPrompt: String, samplingPreset: SamplingPreset) async {
184 + if isModelReady {
185 + await applySettings(systemPrompt: systemPrompt, samplingPreset: samplingPreset)
186 + return
187 + }
188
85 - isModelLoading = true
86 - loadingProgress = "Downloading / loading model…"
189 + await loadModel(
190 + systemPrompt: systemPrompt,
191 + samplingPreset: samplingPreset,
192 + forceReload: false
193 + )
194 + }
195
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)
196 + func retryLoadingModel(systemPrompt: String, samplingPreset: SamplingPreset) async {
197 + await loadModel(
198 + systemPrompt: systemPrompt,
199 + samplingPreset: samplingPreset,
200 + forceReload: false
201 + )
202 + }
203 +
204 + func reloadModel(systemPrompt: String, samplingPreset: SamplingPreset) async {
205 + await loadModel(
206 + systemPrompt: systemPrompt,
207 + samplingPreset: samplingPreset,
208 + forceReload: true
209 + )
210 + }
211 +
212 + func applySettings(systemPrompt: String, samplingPreset: SamplingPreset) async {
213 + guard isModelReady else { return }
214 +
215 + let engine = getOrCreateEngine()
216 + let normalizedPrompt = normalizedSystemPrompt(systemPrompt)
217 +
218 + if let normalizedPrompt {
219 + await engine.setSystemPrompt(prompt: normalizedPrompt)
220 + } else {
221 + await engine.clearSystemPrompt()
222 }
223
99 - isModelLoading = false
224 + await engine.setSampling(sampling: samplingPreset.samplingConfig)
225 + await refreshEngineInfo()
226 + }
227 +
228 + func clearConversation() async {
229 + cancelStreaming()
230 + messages.removeAll()
231 + persistMessages()
232 +
233 + if let engine {
234 + _ = await engine.clearHistory()
235 + await refreshEngineInfo()
236 + } else {
237 + engineInfo = EngineInfo(
238 + status: .unloaded,
239 + modelName: nil,
240 + approxMemory: nil,
241 + historyLength: 0
242 + )
243 + }
244 }
245
246 // ─────────────────────────────────────────────────────────────────────────
@@ -105,56 +249,129 @@ final class ChatViewModel: ObservableObject {
249
250 func send(text: String) {
251 let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
108 - guard !trimmed.isEmpty, !isSending else { return }
252 + guard !trimmed.isEmpty, !isSending, isModelReady else { return }
253
254 + alertError = nil
255 messages.append(Message(role: .user, text: trimmed))
256 + persistMessages()
257
258 streamingTask = Task {
259 await streamResponse(for: trimmed)
260 }
261 }
262
263 + func sendSuggestion(_ suggestion: PromptSuggestion) {
264 + send(text: suggestion.prompt)
265 + }
266 +
267 func cancelStreaming() {
268 + streamBridge?.cancel()
269 streamingTask?.cancel()
270 streamingTask = nil
271 + streamBridge = nil
272 +
273 if let idx = messages.indices.last, messages[idx].isStreaming {
121 - messages[idx].isStreaming = false
274 + if messages[idx].text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
275 + messages.remove(at: idx)
276 + } else {
277 + messages[idx].isStreaming = false
278 + }
279 + persistMessages()
280 }
281 +
282 isSending = false
283 + if engineInfo.status == .generating {
284 + engineInfo = EngineInfo(
285 + status: .ready,
286 + modelName: engineInfo.modelName,
287 + approxMemory: engineInfo.approxMemory,
288 + historyLength: engineInfo.historyLength
289 + )
290 + }
291 }
292
293 // ─────────────────────────────────────────────────────────────────────────
294 // MARK: Private helpers
295 // ─────────────────────────────────────────────────────────────────────────
296
297 + private func loadModel(
298 + systemPrompt: String,
299 + samplingPreset: SamplingPreset,
300 + forceReload: Bool
301 + ) async {
302 + guard !isModelLoading else { return }
303 +
304 + if forceReload {
305 + cancelStreaming()
306 + didHydrateEngineHistory = false
307 + }
308 +
309 + isModelLoading = true
310 + isModelReady = false
311 + alertError = nil
312 + loadingProgress = forceReload ? "Reloading model…" : "Loading model…"
313 +
314 + defer {
315 + isModelLoading = false
316 + }
317 +
318 + do {
319 + let elapsed = try await getOrCreateEngine().loadDefaultModel(
320 + systemPrompt: normalizedSystemPrompt(systemPrompt),
321 + sampling: samplingPreset.samplingConfig
322 + )
323 +
324 + loadingProgress = String(format: "Model ready in %.1f s", elapsed)
325 + isModelReady = true
326 + didHydrateEngineHistory = false
327 + await restorePersistedConversationIfNeeded()
328 + await refreshEngineInfo()
329 + } catch {
330 + isModelReady = false
331 + await refreshEngineInfo()
332 + alertError = IdentifiableError(error)
333 + loadingProgress = "Unable to load model"
334 + }
335 + }
336 +
337 private func streamResponse(for userText: String) async {
338 isSending = true
339 + engineInfo = EngineInfo(
340 + status: .generating,
341 + modelName: engineInfo.modelName,
342 + approxMemory: engineInfo.approxMemory,
343 + historyLength: engineInfo.historyLength
344 + )
345
346 let assistantIndex = messages.count
347 messages.append(Message(role: .assistant, text: "", isStreaming: true))
348
136 - // Hold onto the continuation so we can finish it from the error path.
137 - var storedContinuation: AsyncStream<String>.Continuation?
349 + let bridge = StreamBridge()
350 + streamBridge = bridge
351 + let listener = ChunkCollector(bridge: bridge)
352 +
353 let stream = AsyncStream<String> { continuation in
139 - storedContinuation = continuation
354 + bridge.attach(continuation)
355 }
141 - guard let cont = storedContinuation else { return }
142 - let listener = ChunkCollector(continuation: cont)
356
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) {
357 + let capturedEngine = getOrCreateEngine()
358 + let producer = Task.detached(priority: .userInitiated) {
359 do {
360 try await streamChatMessage(
361 engine: capturedEngine,
362 message: userText,
363 listener: listener
364 )
365 + bridge.finish()
366 } catch {
155 - cont.finish()
367 + bridge.finish()
368 + await MainActor.run {
369 + if !bridge.isCancelled {
370 + self.alertError = IdentifiableError(error)
371 + }
372 + }
373 }
157 - }.value
374 + }
375
376 for await delta in stream {
377 guard !Task.isCancelled else { break }
@@ -163,10 +380,89 @@ final class ChatViewModel: ObservableObject {
380 }
381 }
382
383 + _ = await producer.result
384 +
385 + let assistantHasText = assistantIndex < messages.count &&
386 + !messages[assistantIndex].text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
387 +
388 if assistantIndex < messages.count {
167 - messages[assistantIndex].isStreaming = false
389 + if assistantHasText {
390 + messages[assistantIndex].isStreaming = false
391 + } else {
392 + messages.remove(at: assistantIndex)
393 + }
394 }
395 +
396 + persistMessages()
397 + streamBridge = nil
398 + streamingTask = nil
399 isSending = false
400 + await refreshEngineInfo()
401 + }
402 +
403 + private func restorePersistedConversationIfNeeded() async {
404 + guard !didHydrateEngineHistory else { return }
405 + guard !messages.isEmpty else {
406 + didHydrateEngineHistory = true
407 + return
408 + }
409 +
410 + loadingProgress = "Restoring conversation…"
411 + let engine = getOrCreateEngine()
412 +
413 + for message in messages {
414 + await engine.pushHistory(message: message.asChatMessage)
415 + }
416 +
417 + didHydrateEngineHistory = true
418 + }
419 +
420 + private func refreshEngineInfo() async {
421 + guard let engine else {
422 + engineInfo = EngineInfo(
423 + status: .unloaded,
424 + modelName: nil,
425 + approxMemory: nil,
426 + historyLength: 0
427 + )
428 + return
429 + }
430 +
431 + let info = await engine.info()
432 + engineInfo = info
433 + isModelReady = info.status == .ready || info.status == .generating
434 + }
435 +
436 + private func normalizedSystemPrompt(_ prompt: String) -> String? {
437 + let trimmed = prompt.trimmingCharacters(in: .whitespacesAndNewlines)
438 + return trimmed.isEmpty ? nil : trimmed
439 + }
440 +
441 + private func persistMessages() {
442 + do {
443 + let data = try JSONEncoder().encode(messages)
444 + UserDefaults.standard.set(data, forKey: Self.persistedMessagesKey)
445 + } catch {
446 + logPersistenceFailure(error)
447 + }
448 + }
449 +
450 + private static func loadPersistedMessages() -> [Message] {
451 + guard let data = UserDefaults.standard.data(forKey: persistedMessagesKey) else {
452 + return []
453 + }
454 +
455 + do {
456 + return try JSONDecoder().decode([Message].self, from: data).map {
457 + Message(id: $0.id, role: $0.role, text: $0.text, isStreaming: false)
458 + }
459 + } catch {
460 + return []
461 + }
462 + }
463 +
464 + private func logPersistenceFailure(_ error: Error) {
465 + NSLog("OndeExample: failed to persist messages: %@", String(describing: error))
466 }
467 }
468
Examples/OndeExample/ContentView.swift
+275 -33
@@ -1,4 +1,5 @@
1 import SwiftUI
2 +import Onde
3
4 // ─────────────────────────────────────────────────────────────────────────────
5 // MARK: - Root view
@@ -7,32 +8,127 @@ import SwiftUI
8 struct ContentView: View {
9
10 @StateObject private var viewModel = ChatViewModel()
11 + @State private var showingSettings = false
12
11 - var body: some View {
12 - VStack(spacing: 0) {
13 - HeaderView(isReady: viewModel.isModelReady)
13 + @AppStorage("onde.example.systemPrompt")
14 + private var systemPrompt: String = ExampleDefaults.systemPrompt
15 +
16 + @AppStorage("onde.example.samplingPreset")
17 + private var samplingPresetRawValue: String = SamplingPreset.balanced.rawValue
18 +
19 + private var samplingPreset: SamplingPreset {
20 + get { SamplingPreset(rawValue: samplingPresetRawValue) ?? .balanced }
21 + set { samplingPresetRawValue = newValue.rawValue }
22 + }
23
15 - Divider()
24 + var body: some View {
25 + NavigationStack {
26 + VStack(spacing: 0) {
27 + HeaderView(
28 + info: viewModel.engineInfo,
29 + isLoading: viewModel.isModelLoading,
30 + isSending: viewModel.isSending
31 + )
32
17 - ZStack {
18 - MessageListView(messages: viewModel.messages)
33 + Divider()
34 +
35 + ZStack {
36 + if viewModel.messages.isEmpty {
37 + EmptyStateView(
38 + isModelReady: viewModel.isModelReady,
39 + isModelLoading: viewModel.isModelLoading,
40 + loadingProgress: viewModel.loadingProgress,
41 + promptSuggestions: ExampleDefaults.promptSuggestions,
42 + onPromptTap: { suggestion in
43 + viewModel.sendSuggestion(suggestion)
44 + },
45 + onRetry: {
46 + Task {
47 + await viewModel.retryLoadingModel(
48 + systemPrompt: systemPrompt,
49 + samplingPreset: samplingPreset
50 + )
51 + }
52 + }
53 + )
54 + } else {
55 + MessageListView(messages: viewModel.messages)
56 + }
57
20 - if viewModel.isModelLoading {
21 - LoadingOverlayView(progress: viewModel.loadingProgress)
58 + if viewModel.isModelLoading {
59 + LoadingOverlayView(progress: viewModel.loadingProgress)
60 + }
61 }
62 +
63 + Divider()
64 +
65 + InputBarView(
66 + isSending: viewModel.isSending,
67 + isModelReady: viewModel.isModelReady,
68 + placeholder: viewModel.isModelReady
69 + ? "Message the on-device model…"
70 + : "Waiting for model to load…",
71 + onSend: { text in viewModel.send(text: text) },
72 + onCancel: { viewModel.cancelStreaming() }
73 + )
74 }
75 + .navigationBarTitleDisplayMode(.inline)
76 + .toolbar {
77 + ToolbarItemGroup(placement: .topBarLeading) {
78 + if viewModel.hasMessages {
79 + Button(role: .destructive) {
80 + Task {
81 + await viewModel.clearConversation()
82 + }
83 + } label: {
84 + Label("Clear", systemImage: "trash")
85 + }
86 + .disabled(viewModel.isBusy)
87 + }
88 + }
89
25 - Divider()
90 + ToolbarItemGroup(placement: .topBarTrailing) {
91 + Button {
92 + Task {
93 + await viewModel.reloadModel(
94 + systemPrompt: systemPrompt,
95 + samplingPreset: samplingPreset
96 + )
97 + }
98 + } label: {
99 + Label("Reload model", systemImage: "arrow.clockwise")
100 + }
101 + .disabled(viewModel.isBusy)
102
27 - InputBarView(
28 - isSending: viewModel.isSending,
29 - isModelReady: viewModel.isModelReady,
30 - onSend: { text in viewModel.send(text: text) },
31 - onCancel: { viewModel.cancelStreaming() }
32 - )
103 + Button {
104 + showingSettings = true
105 + } label: {
106 + Label("Model settings", systemImage: "slider.horizontal.3")
107 + }
108 + .disabled(viewModel.isBusy)
109 + }
110 + }
111 }
112 .task {
35 - await viewModel.loadModel()
113 + await viewModel.loadModelIfNeeded(
114 + systemPrompt: systemPrompt,
115 + samplingPreset: samplingPreset
116 + )
117 + }
118 + .sheet(isPresented: $showingSettings) {
119 + ModelSettingsSheet(
120 + systemPrompt: systemPrompt,
121 + samplingPreset: samplingPreset
122 + ) { updatedPrompt, updatedPreset in
123 + systemPrompt = updatedPrompt
124 + samplingPresetRawValue = updatedPreset.rawValue
125 + Task {
126 + await viewModel.applySettings(
127 + systemPrompt: updatedPrompt,
128 + samplingPreset: updatedPreset
129 + )
130 + }
131 + }
132 }
133 .alert(
134 "Error",
@@ -54,24 +150,86 @@ struct ContentView: View {
150 // ─────────────────────────────────────────────────────────────────────────────
151
152 private struct HeaderView: View {
57 - let isReady: Bool
153 + let info: EngineInfo
154 + let isLoading: Bool
155 + let isSending: Bool
156 +
157 + private var statusText: String {
158 + if isLoading {
159 + return "Loading"
160 + }
161 + if isSending {
162 + return "Generating"
163 + }
164 +
165 + switch info.status {
166 + case .ready:
167 + return "Ready"
168 + case .generating:
169 + return "Generating"
170 + case .loading:
171 + return "Loading"
172 + case .error:
173 + return "Error"
174 + case .unloaded:
175 + return "Unloaded"
176 + }
177 + }
178 +
179 + private var statusColor: Color {
180 + if isLoading {
181 + return .orange
182 + }
183 + if isSending {
184 + return .blue
185 + }
186 +
187 + switch info.status {
188 + case .ready:
189 + return .green
190 + case .generating:
191 + return .blue
192 + case .loading:
193 + return .orange
194 + case .error:
195 + return .red
196 + case .unloaded:
197 + return .secondary
198 + }
199 + }
200
201 var body: some View {
60 - HStack(spacing: 8) {
61 - Image(systemName: "brain.head.profile")
62 - .font(.title2)
63 - .foregroundStyle(isReady ? .green : .secondary)
202 + VStack(alignment: .leading, spacing: 10) {
203 + HStack(spacing: 8) {
204 + Image(systemName: "brain.head.profile")
205 + .font(.title2)
206 + .foregroundStyle(statusColor)
207 +
208 + VStack(alignment: .leading, spacing: 2) {
209 + Text("Onde Chat")
210 + .font(.headline)
211 +
212 + Text(info.modelName ?? "Local development example")
213 + .font(.caption)
214 + .foregroundStyle(.secondary)
215 + }
216
65 - Text("Onde Chat")
66 - .font(.headline)
217 + Spacer()
218
68 - Spacer()
219 + Text(statusText)
220 + .font(.caption.weight(.semibold))
221 + .padding(.horizontal, 10)
222 + .padding(.vertical, 6)
223 + .background(statusColor.opacity(0.14), in: Capsule())
224 + .foregroundStyle(statusColor)
225 + }
226
70 - if isReady {
71 - Label("On-device", systemImage: "iphone.and.arrow.forward")
72 - .font(.caption)
73 - .foregroundStyle(.secondary)
227 + HStack(spacing: 12) {
228 + Label(info.approxMemory ?? "—", systemImage: "memorychip")
229 + Label("\(info.historyLength) turns", systemImage: "text.bubble")
230 }
231 + .font(.caption)
232 + .foregroundStyle(.secondary)
233 }
234 .padding(.horizontal)
235 .padding(.vertical, 12)
@@ -88,11 +246,10 @@ private struct LoadingOverlayView: View {
246
247 var body: some View {
248 ZStack {
91 - Color(white: 1.0)
92 - .colorInvert()
249 + Color.black.opacity(0.08)
250 .ignoresSafeArea()
251
95 - VStack(spacing: 20) {
252 + VStack(spacing: 16) {
253 ProgressView()
254 .scaleEffect(1.4)
255
@@ -100,10 +257,94 @@ private struct LoadingOverlayView: View {
257 .font(.subheadline)
258 .foregroundStyle(.secondary)
259 .multilineTextAlignment(.center)
103 - .padding(.horizontal, 32)
260 }
261 + .padding(.horizontal, 28)
262 + .padding(.vertical, 24)
263 + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 20, style: .continuous))
264 + .padding(.horizontal, 24)
265 + }
266 + }
267 +}
268 +
269 +// ─────────────────────────────────────────────────────────────────────────────
270 +// MARK: - Empty state
271 +// ─────────────────────────────────────────────────────────────────────────────
272 +
273 +private struct EmptyStateView: View {
274 + let isModelReady: Bool
275 + let isModelLoading: Bool
276 + let loadingProgress: String
277 + let promptSuggestions: [PromptSuggestion]
278 + let onPromptTap: (PromptSuggestion) -> Void
279 + let onRetry: () -> Void
280 +
281 + var body: some View {
282 + ScrollView {
283 + VStack(alignment: .leading, spacing: 20) {
284 + VStack(alignment: .leading, spacing: 8) {
285 + Text(isModelReady ? "Start chatting" : "Preparing the on-device model")
286 + .font(.title3.weight(.semibold))
287 +
288 + Text(subtitle)
289 + .font(.subheadline)
290 + .foregroundStyle(.secondary)
291 + }
292 +
293 + if isModelReady {
294 + VStack(alignment: .leading, spacing: 12) {
295 + Text("Try one of these prompts")
296 + .font(.headline)
297 +
298 + ForEach(promptSuggestions) { suggestion in
299 + Button {
300 + onPromptTap(suggestion)
301 + } label: {
302 + VStack(alignment: .leading, spacing: 6) {
303 + Text(suggestion.title)
304 + .font(.subheadline.weight(.semibold))
305 + .foregroundStyle(.primary)
306 + Text(suggestion.prompt)
307 + .font(.subheadline)
308 + .foregroundStyle(.secondary)
309 + .multilineTextAlignment(.leading)
310 + }
311 + .frame(maxWidth: .infinity, alignment: .leading)
312 + .padding(16)
313 + .background(
314 + RoundedRectangle(cornerRadius: 18, style: .continuous)
315 + .fill(Color.secondary.opacity(0.10))
316 + )
317 + }
318 + .buttonStyle(.plain)
319 + }
320 + }
321 + } else {
322 + VStack(alignment: .leading, spacing: 12) {
323 + Label(loadingProgress, systemImage: isModelLoading ? "arrow.down.circle" : "exclamationmark.triangle")
324 + .font(.subheadline)
325 + .foregroundStyle(.secondary)
326 +
327 + if !isModelLoading {
328 + Button(action: onRetry) {
329 + Label("Retry loading model", systemImage: "arrow.clockwise")
330 + }
331 + .buttonStyle(.borderedProminent)
332 + }
333 + }
334 + }
335 + }
336 + .frame(maxWidth: .infinity, alignment: .leading)
337 + .padding(20)
338 }
339 }
340 +
341 + private var subtitle: String {
342 + if isModelReady {
343 + return "Your model runs locally on this device. Conversations are restored inside the example app so you can keep iterating while developing the SDK."
344 + }
345 +
346 + return "The first launch may take a while because the model has to be downloaded and loaded into memory before the chat UI becomes interactive."
347 + }
348 }
349
350 // ─────────────────────────────────────────────────────────────────────────────
@@ -194,6 +435,7 @@ private struct MessageBubble: View {
435 private struct InputBarView: View {
436 let isSending: Bool
437 let isModelReady: Bool
438 + let placeholder: String
439 let onSend: (String) -> Void
440 let onCancel: () -> Void
441
@@ -210,7 +452,7 @@ private struct InputBarView: View {
452
453 var body: some View {
454 HStack(spacing: 10) {
213 - TextField("Message…", text: $draftText, axis: .vertical)
455 + TextField(placeholder, text: $draftText, axis: .vertical)
456 .lineLimit(1...5)
457 .padding(.horizontal, 14)
458 .padding(.vertical, 10)
Examples/OndeExample/ExampleSupport.swift new
+154
@@ -0,0 +1,154 @@
1 +import SwiftUI
2 +import Onde
3 +
4 +enum ExampleDefaults {
5 + static let systemPrompt = "You are a helpful on-device assistant. Be concise, practical, and truthful."
6 +
7 + static let promptSuggestions: [PromptSuggestion] = [
8 + PromptSuggestion(
9 + title: "Explain Onde",
10 + prompt: "In simple terms, what is Onde Inference and when would I use it in an iOS app?"
11 + ),
12 + PromptSuggestion(
13 + title: "SwiftUI idea",
14 + prompt: "Give me three ideas for an on-device AI feature in a SwiftUI app."
15 + ),
16 + PromptSuggestion(
17 + title: "Debugging help",
18 + prompt: "What are the first things to check when a local iOS ML model fails to load?"
19 + ),
20 + PromptSuggestion(
21 + title: "Prompt writing",
22 + prompt: "Write a strong system prompt for an iOS coding assistant focused on SwiftUI."
23 + )
24 + ]
25 +}
26 +
27 +struct PromptSuggestion: Identifiable, Hashable {
28 + let title: String
29 + let prompt: String
30 +
31 + var id: String { title }
32 +}
33 +
34 +enum SamplingPreset: String, CaseIterable, Identifiable {
35 + case balanced
36 + case deterministic
37 + case mobile
38 +
39 + var id: String { rawValue }
40 +
41 + var title: String {
42 + switch self {
43 + case .balanced:
44 + return "Balanced"
45 + case .deterministic:
46 + return "Deterministic"
47 + case .mobile:
48 + return "Fast / Mobile"
49 + }
50 + }
51 +
52 + var subtitle: String {
53 + switch self {
54 + case .balanced:
55 + return "General-purpose responses with a bit of creativity."
56 + case .deterministic:
57 + return "Best for reproducible, focused coding and analysis tasks."
58 + case .mobile:
59 + return "Shorter responses with lower latency on constrained devices."
60 + }
61 + }
62 +
63 + var samplingConfig: SamplingConfig {
64 + switch self {
65 + case .balanced:
66 + return defaultSamplingConfig()
67 + case .deterministic:
68 + return deterministicSamplingConfig()
69 + case .mobile:
70 + return mobileSamplingConfig()
71 + }
72 + }
73 +}
74 +
75 +struct ModelSettingsSheet: View {
76 + let initialSystemPrompt: String
77 + let initialSamplingPreset: SamplingPreset
78 + let onSave: (String, SamplingPreset) -> Void
79 +
80 + @Environment(\.dismiss) private var dismiss
81 + @State private var systemPrompt: String
82 + @State private var samplingPreset: SamplingPreset
83 +
84 + init(
85 + systemPrompt: String,
86 + samplingPreset: SamplingPreset,
87 + onSave: @escaping (String, SamplingPreset) -> Void
88 + ) {
89 + self.initialSystemPrompt = systemPrompt
90 + self.initialSamplingPreset = samplingPreset
91 + self.onSave = onSave
92 + _systemPrompt = State(initialValue: systemPrompt)
93 + _samplingPreset = State(initialValue: samplingPreset)
94 + }
95 +
96 + var body: some View {
97 + NavigationStack {
98 + Form {
99 + Section {
100 + TextEditor(text: $systemPrompt)
101 + .frame(minHeight: 140)
102 + } header: {
103 + Text("System Prompt")
104 + } footer: {
105 + Text("This prompt applies to future generations. Leave it blank to clear the runtime system prompt.")
106 + }
107 +
108 + Section("Response Style") {
109 + Picker("Sampling Preset", selection: $samplingPreset) {
110 + ForEach(SamplingPreset.allCases) { preset in
111 + VStack(alignment: .leading, spacing: 2) {
112 + Text(preset.title)
113 + Text(preset.subtitle)
114 + .font(.caption)
115 + .foregroundStyle(.secondary)
116 + }
117 + .tag(preset)
118 + }
119 + }
120 + .pickerStyle(.inline)
121 + }
122 +
123 + Section("Current Configuration") {
124 + LabeledContent("System prompt") {
125 + Text(initialSystemPrompt.isEmpty ? "None" : "Custom")
126 + .foregroundStyle(.secondary)
127 + }
128 +
129 + LabeledContent("Sampling preset") {
130 + Text(initialSamplingPreset.title)
131 + .foregroundStyle(.secondary)
132 + }
133 + }
134 + }
135 + .navigationTitle("Model Settings")
136 + .navigationBarTitleDisplayMode(.inline)
137 + .toolbar {
138 + ToolbarItem(placement: .cancellationAction) {
139 + Button("Cancel") {
140 + dismiss()
141 + }
142 + }
143 +
144 + ToolbarItem(placement: .confirmationAction) {
145 + Button("Save") {
146 + onSave(systemPrompt, samplingPreset)
147 + dismiss()
148 + }
149 + }
150 + }
151 + }
152 + .presentationDetents([.medium, .large])
153 + }
154 +}
Examples/OndeExample/InferenceEnvironment.swift
+8 -5
@@ -1,15 +1,18 @@
1 import Foundation
2
3 -/// Points `HF_HOME` and `HF_HUB_CACHE` at the Onde shared App Group container
4 -/// so downloaded models are available to every Onde-powered app on the device.
3 +/// Points `HF_HOME` and `HF_HUB_CACHE` at a writable location inside the app
4 +/// sandbox. SDK examples prefer the shared Onde App Group so downloaded models
5 +/// can be reused across Swift, Flutter, and React Native example apps on the
6 +/// same device. If the group is unavailable, fall back to the app's own
7 +/// Application Support directory.
8 ///
9 /// Call this once at launch, before creating an `OndeChatEngine`.
10 func setupInferenceEnvironment() {
11 let fm = FileManager.default
12
10 - // All Onde apps use this App Group to share the HuggingFace model cache.
11 - // If the group isn't available (e.g. entitlement missing during development),
12 - // fall back to the app's own Application Support directory.
13 + // The Apple SDK examples share one App Group so downloaded models can be
14 + // reused across Swift, Flutter, and React Native sample apps. Try the
15 + // shared group first, then fall back to the app's private sandbox.
16 let base: URL = fm.containerURL(
17 forSecurityApplicationGroupIdentifier: "group.com.ondeinference.apps"
18 ) ?? fm.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
Examples/OndeExample/OndeExample.entitlements
+2
@@ -2,6 +2,8 @@
2 <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3 <plist version="1.0">
4 <dict>
5 + <key>com.apple.developer.kernel.increased-memory-limit</key>
6 + <true/>
7 <key>com.apple.security.application-groups</key>
8 <array>
9 <string>group.com.ondeinference.apps</string>
Examples/OndeExample/README.md
+22 -5
@@ -10,7 +10,15 @@ Install [XcodeGen](https://github.com/yonaskolb/XcodeGen) if you don't have it:
10 brew install xcodegen
11 ```
12
13 -Generate the Xcode project and open it:
13 +From the `onde-swift` repository root, build the local Onde XCFramework for iOS:
14 +
15 +```
16 +make ios
17 +```
18 +
19 +This regenerates `Sources/Onde/onde.swift` from your local Rust build in `../onde` and writes `OndeFramework.xcframework` into the package root so SwiftPM can link it.
20 +
21 +Then generate the Xcode project and open it:
22
23 ```
24 cd Examples/OndeExample
@@ -18,18 +26,27 @@ xcodegen generate
26 open OndeExample.xcodeproj
27 ```
28
21 -Set your development team under Signing & Capabilities, pick your iPhone, hit Run.
29 +Set your development team under Signing & Capabilities, pick your iPhone or simulator, hit Run.
30
31 ## What it does
32
25 -On first launch, it downloads and loads the default model for your device (Qwen 2.5 Coder 1.5B on iPhone, 3B on Mac). After that you can chat with it. Responses stream in token by token, and there's a stop button if you want to cut it short.
33 +On first launch, it downloads and loads the default model for your device (Qwen 2.5 Coder 1.5B on iPhone, 3B on Mac). After that you can chat with it.
34 +
35 +Current example app features:
36 +
37 +- token-by-token streaming with a stop button
38 +- local conversation persistence between launches
39 +- model settings sheet for system prompt + sampling preset
40 +- toolbar actions to reload the model or clear the conversation
41 +- prompt suggestions when the conversation is empty
42
43 ## Files
44
45 | File | What's in it |
46 |------|-------------|
47 | `App.swift` | Entry point. Calls `setupInferenceEnvironment()` at launch. |
32 -| `ContentView.swift` | The chat UI: message bubbles, text input, loading spinner. |
33 -| `ChatViewModel.swift` | Bridges UniFFI's sync `StreamChunkListener` callback into an `AsyncStream` so SwiftUI can consume tokens on the main actor. |
48 +| `ContentView.swift` | The chat UI: header, empty state, message list, input bar, reload/clear actions. |
49 +| `ChatViewModel.swift` | Bridges UniFFI streaming into Swift concurrency, manages model lifecycle, restores history, persists conversation. |
50 +| `ExampleSupport.swift` | Prompt suggestions, sampling presets, and the settings sheet. |
51 | `InferenceEnvironment.swift` | Sets `HF_HOME` inside the app sandbox so HuggingFace downloads land somewhere iOS allows. |
52 | `project.yml` | XcodeGen spec. The `.xcodeproj` is generated from this, not checked in. |
Examples/OndeExample/project.yml
+18 -1
@@ -11,6 +11,20 @@ packages:
11 Onde:
12 path: ../..
13
14 +schemes:
15 + OndeExample:
16 + build:
17 + targets:
18 + OndeExample: all
19 + run:
20 + config: Debug
21 + analyze:
22 + config: Debug
23 + archive:
24 + config: Release
25 + profile:
26 + config: Release
27 +
28 targets:
29 OndeExample:
30 type: application
@@ -18,11 +32,13 @@ targets:
32 sources:
33 - path: .
34 excludes:
35 + - build
36 - project.yml
37 - "*.xcodeproj"
38 entitlements:
39 path: OndeExample.entitlements
40 properties:
41 + com.apple.developer.kernel.increased-memory-limit: true
42 com.apple.security.application-groups:
43 - group.com.ondeinference.apps
44 dependencies:
@@ -30,7 +46,7 @@ targets:
46 product: Onde
47 settings:
48 base:
33 - PRODUCT_BUNDLE_IDENTIFIER: com.ondeinference.OndeExample
49 + PRODUCT_BUNDLE_IDENTIFIER: com.ondeinference.OndeExampleSDKSwift
50 MARKETING_VERSION: "1.0.0"
51 CURRENT_PROJECT_VERSION: 1
52 GENERATE_INFOPLIST_FILE: YES
@@ -38,6 +54,7 @@ targets:
54 INFOPLIST_KEY_UILaunchScreen_Generation: YES
55 SWIFT_VERSION: "6.0"
56 ENABLE_USER_SCRIPT_SANDBOXING: YES
57 + "EXCLUDED_ARCHS[sdk=iphonesimulator*]": x86_64
58 configs:
59 Debug:
60 SWIFT_OPTIMIZATION_LEVEL: -Onone
Makefile new
+123
@@ -0,0 +1,123 @@
1 +SHELL := /bin/sh
2 +
3 +SWIFT_REPO := $(CURDIR)
4 +ONDE_REPO ?= $(abspath ../onde)
5 +CARGO_MANIFEST := $(ONDE_REPO)/Cargo.toml
6 +BINDGEN_MANIFEST := $(ONDE_REPO)/uniffi-bindgen/Cargo.toml
7 +BINDGEN := $(ONDE_REPO)/uniffi-bindgen/target/release/uniffi-bindgen
8 +
9 +RUST_STABLE ?= +1.92.0
10 +RUST_NIGHTLY ?= +nightly
11 +
12 +IOS_DEPLOYMENT_TARGET ?= 16.0
13 +MACOS_DEPLOYMENT_TARGET ?= 14.0
14 +TVOS_DEPLOYMENT_TARGET ?= 16.0
15 +VISIONOS_DEPLOYMENT_TARGET ?= 1.0
16 +WATCHOS_DEPLOYMENT_TARGET ?= 9.0
17 +
18 +LOCAL_DIR := $(SWIFT_REPO)/.local
19 +GENERATED_DIR := $(LOCAL_DIR)/generated/Onde
20 +HEADERS_DIR := $(LOCAL_DIR)/Headers
21 +FRAMEWORK_DIR := $(SWIFT_REPO)/OndeFramework.xcframework
22 +SWIFT_GLUE := $(SWIFT_REPO)/Sources/Onde/onde.swift
23 +
24 +SUPPORTED_PLATFORMS := ios macos tvos visionos watchos
25 +
26 +.PHONY: help platform ios macos tvos visionos watchos validate build-bindgen prepare generate-swift clean
27 +
28 +help:
29 + @printf '%s\n' \
30 + 'Onde Swift local Apple-platform builds' \
31 + '' \
32 + 'Usage:' \
33 + ' make ios Build OndeFramework.xcframework for iOS device + simulator' \
34 + ' make macos Build OndeFramework.xcframework for macOS (Apple silicon)' \
35 + ' make tvos Build OndeFramework.xcframework for tvOS device + simulator' \
36 + ' make visionos Build OndeFramework.xcframework for visionOS device + simulator' \
37 + ' make watchos Build OndeFramework.xcframework for watchOS device + simulator' \
38 + ' make platform PLATFORM=ios' \
39 + '' \
40 + 'Artifacts:' \
41 + ' OndeFramework.xcframework' \
42 + ' Sources/Onde/onde.swift (regenerated from the local Rust build)' \
43 + '' \
44 + 'Optional overrides:' \
45 + ' ONDE_REPO=/absolute/path/to/onde'
46 +
47 +platform:
48 + @test -n "$(PLATFORM)" || { echo "Set PLATFORM=$(SUPPORTED_PLATFORMS)"; exit 1; }
49 + @case "$(PLATFORM)" in \
50 + ios|macos|tvos|visionos|watchos) $(MAKE) "$(PLATFORM)" ;; \
51 + *) echo "Unsupported PLATFORM='$(PLATFORM)'. Expected one of: $(SUPPORTED_PLATFORMS)"; exit 1 ;; \
52 + esac
53 +
54 +validate:
55 + @test -f "$(CARGO_MANIFEST)" || { echo "Could not find onde Cargo.toml at $(CARGO_MANIFEST)"; exit 1; }
56 + @test -d "$(SWIFT_REPO)/Sources/Onde" || { echo "Run make from the onde-swift repository root."; exit 1; }
57 +
58 +build-bindgen: validate
59 + cargo $(RUST_STABLE) build --manifest-path "$(BINDGEN_MANIFEST)" --release
60 +
61 +prepare:
62 + @rm -rf "$(FRAMEWORK_DIR)" "$(GENERATED_DIR)" "$(HEADERS_DIR)"
63 + @mkdir -p "$(GENERATED_DIR)" "$(HEADERS_DIR)"
64 +
65 +generate-swift:
66 + @test -n "$(BINDGEN_INPUT)" || { echo "BINDGEN_INPUT is required"; exit 1; }
67 + cd "$(ONDE_REPO)" && "$(BINDGEN)" generate "$(BINDGEN_INPUT)" --crate onde --language swift --out-dir "$(GENERATED_DIR)"
68 + cp "$(GENERATED_DIR)/onde.swift" "$(SWIFT_GLUE)"
69 + cp "$(GENERATED_DIR)/ondeFFI.h" "$(HEADERS_DIR)/ondeFFI.h"
70 + cp "$(GENERATED_DIR)/ondeFFI.modulemap" "$(HEADERS_DIR)/module.modulemap"
71 + @echo "Updated $(SWIFT_GLUE)"
72 +
73 +ios: build-bindgen prepare
74 + IPHONEOS_DEPLOYMENT_TARGET=$(IOS_DEPLOYMENT_TARGET) cargo $(RUST_STABLE) rustc --manifest-path "$(CARGO_MANIFEST)" --target aarch64-apple-ios --release --lib --crate-type staticlib
75 + IPHONEOS_DEPLOYMENT_TARGET=$(IOS_DEPLOYMENT_TARGET) cargo $(RUST_STABLE) rustc --manifest-path "$(CARGO_MANIFEST)" --target aarch64-apple-ios-sim --release --lib --crate-type staticlib
76 + $(MAKE) generate-swift BINDGEN_INPUT="$(ONDE_REPO)/target/aarch64-apple-ios/release/libonde.a"
77 + xcodebuild -create-xcframework \
78 + -library "$(ONDE_REPO)/target/aarch64-apple-ios/release/libonde.a" -headers "$(HEADERS_DIR)" \
79 + -library "$(ONDE_REPO)/target/aarch64-apple-ios-sim/release/libonde.a" -headers "$(HEADERS_DIR)" \
80 + -output "$(FRAMEWORK_DIR)"
81 + @echo "Built $(FRAMEWORK_DIR) for iOS"
82 +
83 +macos: build-bindgen prepare
84 + MACOSX_DEPLOYMENT_TARGET=$(MACOS_DEPLOYMENT_TARGET) cargo $(RUST_STABLE) rustc --manifest-path "$(CARGO_MANIFEST)" --target aarch64-apple-darwin --release --lib --crate-type staticlib
85 + $(MAKE) generate-swift BINDGEN_INPUT="$(ONDE_REPO)/target/aarch64-apple-darwin/release/libonde.a"
86 + xcodebuild -create-xcframework \
87 + -library "$(ONDE_REPO)/target/aarch64-apple-darwin/release/libonde.a" -headers "$(HEADERS_DIR)" \
88 + -output "$(FRAMEWORK_DIR)"
89 + @echo "Built $(FRAMEWORK_DIR) for macOS"
90 +
91 +tvos: build-bindgen prepare
92 + TVOS_DEPLOYMENT_TARGET=$(TVOS_DEPLOYMENT_TARGET) cargo $(RUST_NIGHTLY) rustc -Z build-std --manifest-path "$(CARGO_MANIFEST)" --target aarch64-apple-tvos --release --lib --crate-type staticlib
93 + TVOS_DEPLOYMENT_TARGET=$(TVOS_DEPLOYMENT_TARGET) cargo $(RUST_NIGHTLY) rustc -Z build-std --manifest-path "$(CARGO_MANIFEST)" --target aarch64-apple-tvos-sim --release --lib --crate-type staticlib
94 + $(MAKE) generate-swift BINDGEN_INPUT="$(ONDE_REPO)/target/aarch64-apple-tvos/release/libonde.a"
95 + xcodebuild -create-xcframework \
96 + -library "$(ONDE_REPO)/target/aarch64-apple-tvos/release/libonde.a" -headers "$(HEADERS_DIR)" \
97 + -library "$(ONDE_REPO)/target/aarch64-apple-tvos-sim/release/libonde.a" -headers "$(HEADERS_DIR)" \
98 + -output "$(FRAMEWORK_DIR)"
99 + @echo "Built $(FRAMEWORK_DIR) for tvOS"
100 +
101 +visionos: build-bindgen prepare
102 + XROS_DEPLOYMENT_TARGET=$(VISIONOS_DEPLOYMENT_TARGET) cargo $(RUST_NIGHTLY) rustc -Z build-std --manifest-path "$(CARGO_MANIFEST)" --target aarch64-apple-visionos --release --lib --crate-type staticlib
103 + XROS_DEPLOYMENT_TARGET=$(VISIONOS_DEPLOYMENT_TARGET) cargo $(RUST_NIGHTLY) rustc -Z build-std --manifest-path "$(CARGO_MANIFEST)" --target aarch64-apple-visionos-sim --release --lib --crate-type staticlib
104 + $(MAKE) generate-swift BINDGEN_INPUT="$(ONDE_REPO)/target/aarch64-apple-visionos/release/libonde.a"
105 + xcodebuild -create-xcframework \
106 + -library "$(ONDE_REPO)/target/aarch64-apple-visionos/release/libonde.a" -headers "$(HEADERS_DIR)" \
107 + -library "$(ONDE_REPO)/target/aarch64-apple-visionos-sim/release/libonde.a" -headers "$(HEADERS_DIR)" \
108 + -output "$(FRAMEWORK_DIR)"
109 + @echo "Built $(FRAMEWORK_DIR) for visionOS"
110 +
111 +watchos: build-bindgen prepare
112 + WATCHOS_DEPLOYMENT_TARGET=$(WATCHOS_DEPLOYMENT_TARGET) cargo $(RUST_NIGHTLY) rustc -Z build-std --manifest-path "$(CARGO_MANIFEST)" --target aarch64-apple-watchos --release --lib --crate-type staticlib
113 + WATCHOS_DEPLOYMENT_TARGET=$(WATCHOS_DEPLOYMENT_TARGET) cargo $(RUST_NIGHTLY) rustc -Z build-std --manifest-path "$(CARGO_MANIFEST)" --target aarch64-apple-watchos-sim --release --lib --crate-type staticlib
114 + $(MAKE) generate-swift BINDGEN_INPUT="$(ONDE_REPO)/target/aarch64-apple-watchos/release/libonde.a"
115 + xcodebuild -create-xcframework \
116 + -library "$(ONDE_REPO)/target/aarch64-apple-watchos/release/libonde.a" -headers "$(HEADERS_DIR)" \
117 + -library "$(ONDE_REPO)/target/aarch64-apple-watchos-sim/release/libonde.a" -headers "$(HEADERS_DIR)" \
118 + -output "$(FRAMEWORK_DIR)"
119 + @echo "Built $(FRAMEWORK_DIR) for watchOS"
120 +
121 +clean:
122 + rm -rf "$(FRAMEWORK_DIR)" "$(LOCAL_DIR)"
123 + @echo "Removed local Onde Swift build artifacts"
Package.swift
+7 -7
@@ -4,14 +4,14 @@ import PackageDescription
4 // ─────────────────────────────────────────────────────────────────────────────
5 // Local development
6 //
7 -// When you are working on the Onde Rust crate locally, swap the URL-based
8 -// binary target below for a path-based one that points at your local XCFramework:
7 +// When you are working on the Onde Rust crate locally, build a path-based
8 +// XCFramework into this repository before opening the example app:
9 //
10 -// name: "OndeFramework"
11 -// path: "./../onde/sdk/Onde/OndeFramework.xcframework"
10 +// make ios
11 +// # or: make macos / make tvos / make visionos / make watchos
12 //
13 -// Build the framework first:
14 -// cd onde && .github/scripts/build-swift-xcframework.sh
13 +// The generated framework lives at:
14 +// ./OndeFramework.xcframework
15 //
16 // Distribution
17 //
@@ -35,7 +35,7 @@ let package = Package(
35 targets: [
36 .binaryTarget(
37 name: "OndeFramework",
38 - path: "./../onde/sdk/Onde/OndeFramework.xcframework"
38 + path: "./OndeFramework.xcframework"
39 ),
40 .target(
41 name: "Onde",
README.md
+26
@@ -137,6 +137,32 @@ private func setupHfCache(at base: URL) {
137 }
138 ```
139
140 +## Local Development
141 +
142 +For local SDK work, keep `onde-swift` next to the Rust repo:
143 +
144 +```text
145 +Repositories/
146 +├── onde/
147 +└── onde-swift/
148 +```
149 +
150 +Then from the `onde-swift` root:
151 +
152 +```bash
153 +make ios
154 +```
155 +
156 +That builds the local Rust core from `../onde`, regenerates `Sources/Onde/onde.swift`, and writes `OndeFramework.xcframework` into the package root so Xcode and SwiftPM can link it.
157 +
158 +To work on the example iOS app:
159 +
160 +```bash
161 +cd Examples/OndeExample
162 +xcodegen generate
163 +open OndeExample.xcodeproj
164 +```
165 +
166 ## Documentation
167
168 - [on-device AI inference for iOS and macOS](https://ondeinference.com/sdk/swift)