@dvai-bridge/capacitor-llama 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,412 @@
1
+ // Tests/DVAICapacitorLlamaTests/RealModelSmokeTest.swift
2
+ //
3
+ // End-to-end smoke test against a small public GGUF model. Verifies
4
+ // mechanics (download → load → respond → free) only, not output quality.
5
+ //
6
+ // The test reads `SMOKE_MODEL_URL` and `SMOKE_MODEL_SHA256` from the
7
+ // process environment. When either is missing, it skips cleanly via
8
+ // `XCTSkip`, so this file is safe to compile and run locally even
9
+ // without those env vars set.
10
+ //
11
+ // On the self-hosted Mac runner the workflow forwards the secrets to
12
+ // the simulator via `SIMCTL_CHILD_SMOKE_MODEL_URL=...` (xcodebuild's
13
+ // documented mechanism for env vars to reach the simulator-hosted
14
+ // XCTest process).
15
+
16
+ import XCTest
17
+ import DVAILlamaCore
18
+ import DVAILlamaCoreObjC
19
+
20
+ /// Unbuffered breadcrumb. NSLog flushes per call to stderr / oslog, so
21
+ /// even if the test process dies mid-step (jetsam SIGKILL on simulator,
22
+ /// for example) the most recent step still appears in xcresult /
23
+ /// `log show`. Plain `print(...)` buffers on stdout and silently
24
+ /// disappears when the process is killed.
25
+ @inline(__always)
26
+ fileprivate func smokeStep(_ msg: String) {
27
+ NSLog("DVAI-SMOKE: %@", msg)
28
+ }
29
+
30
+ final class RealModelSmokeTest: XCTestCase {
31
+ private var tempDir: URL!
32
+ private var bridge: LlamaCppBridge?
33
+
34
+ /// Vision + audio smoke involves downloading a 5 GB GGUF + 557 MB
35
+ /// mmproj plus loading both into the simulator's Metal context and
36
+ /// running an eval pass. The combined runtime can easily exceed
37
+ /// Xcode's default 10-minute per-test allowance, after which xctest
38
+ /// kills and "Restarts" the test bundle. We ask for 45 minutes per
39
+ /// test to absorb slow networks + model load + first-Metal-shader
40
+ /// compile.
41
+ override class var defaultTestSuite: XCTestSuite {
42
+ let suite = super.defaultTestSuite
43
+ for case let testCase as XCTestCase in suite.tests {
44
+ testCase.executionTimeAllowance = 45 * 60
45
+ }
46
+ return suite
47
+ }
48
+
49
+ override func setUpWithError() throws {
50
+ let base = FileManager.default.temporaryDirectory
51
+ .appendingPathComponent("dvai-smoke-\(UUID().uuidString)")
52
+ try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true)
53
+ tempDir = base
54
+ }
55
+
56
+ override func tearDownWithError() throws {
57
+ bridge?.unload()
58
+ bridge = nil
59
+ if let tempDir { try? FileManager.default.removeItem(at: tempDir) }
60
+ tempDir = nil
61
+ }
62
+
63
+ func testSmokeRealModelEndToEnd() async throws {
64
+ let env = Self.loadSmokeEnv()
65
+ guard let urlStr = env["SMOKE_MODEL_URL"], !urlStr.isEmpty,
66
+ let sha = env["SMOKE_MODEL_SHA256"], !sha.isEmpty,
67
+ let url = URL(string: urlStr)
68
+ else {
69
+ throw XCTSkip("SMOKE_MODEL_URL/SMOKE_MODEL_SHA256 not set in env; skipping real-model smoke")
70
+ }
71
+
72
+ // Generous timeout for the 800 MB download + 1B-param load.
73
+ let downloader = ModelDownloader(cacheDirOverride: tempDir)
74
+ let result = try await downloader.downloadModel(
75
+ url: url,
76
+ expectedSha256: sha.lowercased(),
77
+ destFilename: "smoke-model.gguf",
78
+ headers: [:],
79
+ onProgress: { _, _ in /* no-op for smoke */ }
80
+ )
81
+
82
+ XCTAssertFalse(result.cached, "first download into a fresh temp dir should not be cached")
83
+ XCTAssertTrue(
84
+ FileManager.default.fileExists(atPath: result.path),
85
+ "downloaded file should exist at \(result.path)"
86
+ )
87
+
88
+ let bridge = LlamaCppBridge()
89
+ self.bridge = bridge
90
+ try bridge.loadModel(
91
+ atPath: result.path,
92
+ mmprojPath: nil,
93
+ gpuLayers: 99,
94
+ contextSize: 2048,
95
+ threads: 4,
96
+ embeddingMode: false
97
+ )
98
+ XCTAssertTrue(bridge.isLoaded, "model should be loaded after loadModel(...) returns")
99
+
100
+ let completion = try bridge.completePrompt(
101
+ "<|begin_of_text|>What is 2+2?",
102
+ maxTokens: 32,
103
+ temperature: 0.0,
104
+ topP: 1.0
105
+ )
106
+ // Don't assert specific content — that's quality testing, not smoke.
107
+ XCTAssertFalse(completion.isEmpty, "completion should not be empty")
108
+ }
109
+
110
+ /// Vision smoke: download model + mmproj, load both, run a chat
111
+ /// completion against the tiny test image fixture. Skips cleanly if any
112
+ /// of SMOKE_VISION_MODEL_URL / SMOKE_VISION_MODEL_SHA256 /
113
+ /// SMOKE_VISION_MMPROJ_URL / SMOKE_VISION_MMPROJ_SHA256 are unset.
114
+ func testSmokeVisionEndToEnd() async throws {
115
+ let env = Self.loadSmokeEnv()
116
+ guard let modelUrlStr = env["SMOKE_VISION_MODEL_URL"], !modelUrlStr.isEmpty,
117
+ let modelSha = env["SMOKE_VISION_MODEL_SHA256"], !modelSha.isEmpty,
118
+ let mmprojUrlStr = env["SMOKE_VISION_MMPROJ_URL"], !mmprojUrlStr.isEmpty,
119
+ let mmprojSha = env["SMOKE_VISION_MMPROJ_SHA256"], !mmprojSha.isEmpty,
120
+ let modelUrl = URL(string: modelUrlStr),
121
+ let mmprojUrl = URL(string: mmprojUrlStr)
122
+ else {
123
+ throw XCTSkip("SMOKE_VISION_* env vars not all set; skipping vision smoke")
124
+ }
125
+
126
+ smokeStep("vision: downloading main model")
127
+ let downloader = ModelDownloader(cacheDirOverride: tempDir)
128
+ let modelResult = try await downloader.downloadModel(
129
+ url: modelUrl,
130
+ expectedSha256: modelSha.lowercased(),
131
+ destFilename: "smoke-vision-model.gguf",
132
+ headers: [:],
133
+ onProgress: { _, _ in /* no-op for smoke */ }
134
+ )
135
+ XCTAssertTrue(FileManager.default.fileExists(atPath: modelResult.path))
136
+ smokeStep("vision: model downloaded; downloading mmproj")
137
+ let mmprojResult = try await downloader.downloadModel(
138
+ url: mmprojUrl,
139
+ expectedSha256: mmprojSha.lowercased(),
140
+ destFilename: "smoke-vision-mmproj.gguf",
141
+ headers: [:],
142
+ onProgress: { _, _ in /* no-op for smoke */ }
143
+ )
144
+ XCTAssertTrue(FileManager.default.fileExists(atPath: mmprojResult.path))
145
+ smokeStep("vision: mmproj downloaded")
146
+
147
+ let bridge = LlamaCppBridge()
148
+ self.bridge = bridge
149
+ // gpuLayers=0 on simulator: same MTLSimDevice allocation cap that
150
+ // hits the mmproj also bites the main model when mtmd_helper_decode
151
+ // builds image-embedding tensors and llama_decode runs them on
152
+ // Metal. Falling back to CPU for the main model avoids the abort.
153
+ // Real iPhone hardware uses Metal end-to-end → gpuLayers=99 is the
154
+ // production default.
155
+ #if targetEnvironment(simulator)
156
+ let mainGPULayers: Int32 = 0
157
+ #else
158
+ let mainGPULayers: Int32 = 99
159
+ #endif
160
+ smokeStep("vision: loading main model (gpuLayers=\(mainGPULayers))")
161
+ try bridge.loadModel(
162
+ atPath: modelResult.path,
163
+ mmprojPath: nil,
164
+ // Smoke: small context to keep KV-cache memory well under
165
+ // the simulator's per-process budget. We sample at most 32
166
+ // tokens, so 1024 leaves plenty of headroom for the prompt
167
+ // + image chunk + completion without paging.
168
+ gpuLayers: mainGPULayers,
169
+ contextSize: 1024,
170
+ threads: 4,
171
+ embeddingMode: false
172
+ )
173
+ XCTAssertTrue(bridge.isLoaded)
174
+ smokeStep("vision: main model loaded")
175
+ // useGPU=false on simulator: iOS Simulator's MTLSimDevice aborts in
176
+ // _xpc_shmem_create_with_prot when CLIP tries to allocate the
177
+ // ~60 MiB position-embedding tensor (gemma4v has shape [768, 10240, 2]).
178
+ // CPU-only projection is slow but lets the smoke run end-to-end.
179
+ // Real iPhone hardware uses Metal without issue → useGPU=true is the
180
+ // production default.
181
+ #if targetEnvironment(simulator)
182
+ let useGPUForMmproj = false
183
+ #else
184
+ let useGPUForMmproj = true
185
+ #endif
186
+ smokeStep("vision: loading mmproj (useGPU=\(useGPUForMmproj))")
187
+ try bridge.loadMmproj(atPath: mmprojResult.path, useGPU: useGPUForMmproj)
188
+ XCTAssertTrue(bridge.isMmprojLoaded)
189
+ smokeStep("vision: mmproj loaded")
190
+
191
+ // Read the smoke PNG fixture. tiny-test.png is a 256x256 image with
192
+ // three primary-colour squares + a yellow ellipse — picked so a
193
+ // captioner has unambiguous content to describe (a blank canvas
194
+ // tends to make Gemma 4 emit `<end_of_turn>` as its first sample,
195
+ // which the greedy sampler treats as a clean exit and returns "").
196
+ // Regenerate via `scripts/generate-image-fixtures.sh`.
197
+ let imageURL = fixturesURL().appendingPathComponent("images").appendingPathComponent("tiny-test.png")
198
+ let imageData = try Data(contentsOf: imageURL)
199
+
200
+ // Build a marker-bearing prompt and apply the model's chat template.
201
+ // Gemma 4's published GGUFs at ggml-org/gemma-4-E2B-it-GGUF do not
202
+ // embed a tokenizer.chat_template that llama.cpp's heuristic
203
+ // recognizes, so passing nil here produces error 41 ("model has no
204
+ // chat template and none provided"). Production developers using
205
+ // capacitor-llama are expected to supply their model's template at
206
+ // start time; for smoke purposes we hardcode Gemma's published
207
+ // chat-template format inline.
208
+ let gemmaTemplate = """
209
+ {% for m in messages %}<start_of_turn>{% if m.role == 'assistant' %}model{% else %}{{ m.role }}{% endif %}
210
+ {{ m.content }}<end_of_turn>
211
+ {% endfor %}{% if add_generation_prompt %}<start_of_turn>model
212
+ {% endif %}
213
+ """
214
+ let messages: [[String: String]] = [
215
+ ["role": "user", "content": "Describe this image: \(MTMD_MEDIA_MARKER)"]
216
+ ]
217
+ let chatPrompt = try bridge.applyChatTemplate(gemmaTemplate, messages: messages, addAssistant: true)
218
+ XCTAssertFalse(chatPrompt.isEmpty)
219
+ smokeStep("vision: chat template applied; running multimodal eval")
220
+
221
+ let completion = try bridge.completeMultimodalPrompt(
222
+ chatPrompt,
223
+ media: [imageData],
224
+ maxTokens: 32,
225
+ temperature: 0.0,
226
+ topP: 1.0
227
+ )
228
+ smokeStep("vision: eval done — completion=\(completion.prefix(80))")
229
+ XCTAssertFalse(completion.isEmpty, "vision completion should not be empty")
230
+ }
231
+
232
+ /// Audio smoke: same as vision, but with the WAV fixture instead of PNG.
233
+ /// mtmd's `mtmd_helper_bitmap_init_from_buf` accepts wav/mp3/flac for
234
+ /// audio (per mtmd-helper.h docs). Skips when the model declared no
235
+ /// audio encoder (e.g. vision-only mmproj).
236
+ func testSmokeAudioEndToEnd() async throws {
237
+ let env = Self.loadSmokeEnv()
238
+ guard let modelUrlStr = env["SMOKE_VISION_MODEL_URL"], !modelUrlStr.isEmpty,
239
+ let modelSha = env["SMOKE_VISION_MODEL_SHA256"], !modelSha.isEmpty,
240
+ let mmprojUrlStr = env["SMOKE_VISION_MMPROJ_URL"], !mmprojUrlStr.isEmpty,
241
+ let mmprojSha = env["SMOKE_VISION_MMPROJ_SHA256"], !mmprojSha.isEmpty,
242
+ let modelUrl = URL(string: modelUrlStr),
243
+ let mmprojUrl = URL(string: mmprojUrlStr)
244
+ else {
245
+ throw XCTSkip("SMOKE_VISION_* env vars not all set; skipping audio smoke")
246
+ }
247
+
248
+ smokeStep("audio: downloading main model")
249
+ let downloader = ModelDownloader(cacheDirOverride: tempDir)
250
+ let modelResult = try await downloader.downloadModel(
251
+ url: modelUrl,
252
+ expectedSha256: modelSha.lowercased(),
253
+ destFilename: "smoke-audio-model.gguf",
254
+ headers: [:],
255
+ onProgress: { _, _ in /* no-op for smoke */ }
256
+ )
257
+ smokeStep("audio: model downloaded; downloading mmproj")
258
+ let mmprojResult = try await downloader.downloadModel(
259
+ url: mmprojUrl,
260
+ expectedSha256: mmprojSha.lowercased(),
261
+ destFilename: "smoke-audio-mmproj.gguf",
262
+ headers: [:],
263
+ onProgress: { _, _ in /* no-op for smoke */ }
264
+ )
265
+ smokeStep("audio: mmproj downloaded")
266
+
267
+ let bridge = LlamaCppBridge()
268
+ self.bridge = bridge
269
+ // gpuLayers=0 on simulator: same MTLSimDevice allocation cap that
270
+ // hits the mmproj also bites the main model when mtmd_helper_decode
271
+ // builds audio-embedding tensors and llama_decode runs them on
272
+ // Metal. Falling back to CPU for the main model avoids the abort.
273
+ #if targetEnvironment(simulator)
274
+ let mainGPULayers: Int32 = 0
275
+ #else
276
+ let mainGPULayers: Int32 = 99
277
+ #endif
278
+ smokeStep("audio: loading main model (gpuLayers=\(mainGPULayers))")
279
+ try bridge.loadModel(
280
+ atPath: modelResult.path,
281
+ mmprojPath: nil,
282
+ gpuLayers: mainGPULayers,
283
+ contextSize: 1024,
284
+ threads: 4,
285
+ embeddingMode: false
286
+ )
287
+ smokeStep("audio: main model loaded")
288
+ // useGPU=false on simulator: iOS Simulator's MTLSimDevice aborts in
289
+ // _xpc_shmem_create_with_prot when CLIP tries to allocate the
290
+ // ~60 MiB position-embedding tensor (gemma4v has shape [768, 10240, 2]).
291
+ // CPU-only projection is slow but lets the smoke run end-to-end.
292
+ // Real iPhone hardware uses Metal without issue → useGPU=true is the
293
+ // production default.
294
+ #if targetEnvironment(simulator)
295
+ let useGPUForMmproj = false
296
+ #else
297
+ let useGPUForMmproj = true
298
+ #endif
299
+ smokeStep("audio: loading mmproj (useGPU=\(useGPUForMmproj))")
300
+ try bridge.loadMmproj(atPath: mmprojResult.path, useGPU: useGPUForMmproj)
301
+ smokeStep("audio: mmproj loaded")
302
+
303
+ // Skip cleanly if the loaded mmproj has no audio encoder (e.g. when
304
+ // SMOKE_VISION_* points at a vision-only projector).
305
+ guard bridge.hasAudioEncoder() else {
306
+ throw XCTSkip("Loaded mmproj reports no audio encoder; skipping audio smoke")
307
+ }
308
+ smokeStep("audio: hasAudioEncoder=true; running multimodal eval")
309
+
310
+ let audioURL = fixturesURL().appendingPathComponent("audio").appendingPathComponent("wav-1s-16khz-mono.wav")
311
+ let audioData = try Data(contentsOf: audioURL)
312
+
313
+ // Same Gemma chat template as the vision test — Gemma 4 GGUFs at
314
+ // ggml-org don't ship a llama.cpp-recognized chat_template.
315
+ let gemmaTemplate = """
316
+ {% for m in messages %}<start_of_turn>{% if m.role == 'assistant' %}model{% else %}{{ m.role }}{% endif %}
317
+ {{ m.content }}<end_of_turn>
318
+ {% endfor %}{% if add_generation_prompt %}<start_of_turn>model
319
+ {% endif %}
320
+ """
321
+ // Open-ended prompt: the WAV fixture is a synthetic 1-second 440 Hz
322
+ // sine tone with no speech content, so "Transcribe this:" makes the
323
+ // model emit `<end_of_turn>` as its first sample (legitimate — there's
324
+ // nothing to transcribe). "Describe what you hear" gives Gemma room
325
+ // to say something like "A pure tone." instead of bailing immediately.
326
+ let messages: [[String: String]] = [
327
+ ["role": "user", "content": "Describe what you hear: \(MTMD_MEDIA_MARKER)"]
328
+ ]
329
+ let chatPrompt = try bridge.applyChatTemplate(gemmaTemplate, messages: messages, addAssistant: true)
330
+
331
+ // Note: we do NOT assert that the completion is non-empty. With a
332
+ // synthetic tone fixture, an immediate-EOS sample is a *correct*
333
+ // model response, not a pipeline failure. This smoke verifies that
334
+ // the audio path runs end-to-end without throwing — eval, decode,
335
+ // and sampler all return cleanly. Production code paths (real
336
+ // speech audio) are exercised by host-app integration tests.
337
+ _ = try bridge.completeMultimodalPrompt(
338
+ chatPrompt,
339
+ media: [audioData],
340
+ maxTokens: 32,
341
+ temperature: 0.0,
342
+ topP: 1.0
343
+ )
344
+ smokeStep("audio: eval done")
345
+ }
346
+
347
+ /// Walks up from #file to find the repo-root `fixtures/` dir.
348
+ private func fixturesURL() -> URL {
349
+ var dir = URL(fileURLWithPath: #file).deletingLastPathComponent()
350
+ while !FileManager.default.fileExists(atPath: dir.appendingPathComponent("fixtures").path) {
351
+ let parent = dir.deletingLastPathComponent()
352
+ if parent.path == dir.path {
353
+ fatalError("fixtures dir not found walking up from \(#file)")
354
+ }
355
+ dir = parent
356
+ }
357
+ return dir.appendingPathComponent("fixtures")
358
+ }
359
+
360
+ /// Reads SMOKE_* env vars from the test process's environment first,
361
+ /// then falls back to the per-developer `scripts/smoke.local.env`
362
+ /// file on the host filesystem. The fallback exists because
363
+ /// `xcodebuild test` does not propagate parent-process env vars
364
+ /// (or `SIMCTL_CHILD_*` / `TEST_RUNNER_*`) to the unit-test bundle's
365
+ /// `ProcessInfo.processInfo.environment` — that's an XCUITest-only
366
+ /// channel. Reading the file directly works reliably both locally
367
+ /// (via the gitignored smoke.local.env) and in CI (which actually
368
+ /// does inject env vars at the workflow level — `ProcessInfo` sees
369
+ /// those because the Mac runner inherits the GitHub Actions step env
370
+ /// before xcodebuild starts).
371
+ fileprivate static func loadSmokeEnv() -> [String: String] {
372
+ var env = ProcessInfo.processInfo.environment.filter { $0.key.hasPrefix("SMOKE_") }
373
+ if !env.isEmpty {
374
+ return env
375
+ }
376
+ // Walk up from this file to find `scripts/smoke.local.env`.
377
+ var dir = URL(fileURLWithPath: #file).deletingLastPathComponent()
378
+ while !FileManager.default.fileExists(atPath: dir.appendingPathComponent("scripts/smoke.local.env").path) {
379
+ let parent = dir.deletingLastPathComponent()
380
+ if parent.path == dir.path {
381
+ return env // empty
382
+ }
383
+ dir = parent
384
+ }
385
+ let envFile = dir.appendingPathComponent("scripts/smoke.local.env")
386
+ guard let contents = try? String(contentsOf: envFile, encoding: .utf8) else {
387
+ return env
388
+ }
389
+ for line in contents.split(separator: "\n") {
390
+ let trimmed = line.trimmingCharacters(in: .whitespaces)
391
+ if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
392
+ guard let eq = trimmed.firstIndex(of: "=") else { continue }
393
+ let key = String(trimmed[..<eq]).trimmingCharacters(in: .whitespaces)
394
+ var value = String(trimmed[trimmed.index(after: eq)...]).trimmingCharacters(in: .whitespaces)
395
+ // Strip a single matching pair of leading/trailing quotes so a
396
+ // developer writing `SMOKE_MODEL_URL="https://..."` in the env
397
+ // file doesn't end up with the quote chars baked into the URL
398
+ // (which makes `URL(string:)` return nil and the test silently
399
+ // skip with a confusing reason).
400
+ if value.count >= 2 {
401
+ if (value.first == "\"" && value.last == "\"") ||
402
+ (value.first == "'" && value.last == "'") {
403
+ value = String(value.dropFirst().dropLast())
404
+ }
405
+ }
406
+ if key.hasPrefix("SMOKE_") && !value.isEmpty {
407
+ env[key] = value
408
+ }
409
+ }
410
+ return env
411
+ }
412
+ }
@@ -0,0 +1,10 @@
1
+ import XCTest
2
+ @testable import DVAILlamaCore
3
+
4
+ final class SmokeTest: XCTestCase {
5
+ func testHandlerContextInit() {
6
+ let ctx = HandlerContext(modelId: "test", backendName: "llama")
7
+ XCTAssertEqual(ctx.modelId, "test")
8
+ XCTAssertEqual(ctx.backendName, "llama")
9
+ }
10
+ }
package/package.json ADDED
@@ -0,0 +1,70 @@
1
+ {
2
+ "name": "@dvai-bridge/capacitor-llama",
3
+ "version": "4.0.0",
4
+ "publishConfig": {
5
+ "registry": "https://registry.npmjs.org/",
6
+ "access": "public"
7
+ },
8
+ "description": "DVAI-Bridge Capacitor plugin: llama.cpp on iOS (Metal) + Android (Vulkan/CPU) with embedded HTTP server.",
9
+ "main": "dist/index.cjs",
10
+ "module": "dist/index.js",
11
+ "types": "dist/index.d.ts",
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "import": "./dist/index.js",
16
+ "require": "./dist/index.cjs"
17
+ }
18
+ },
19
+ "type": "module",
20
+ "files": [
21
+ "dist",
22
+ "ios/Sources",
23
+ "ios/Tests",
24
+ "ios/Package.swift",
25
+ "android/src",
26
+ "android/build.gradle",
27
+ "android/gradle.properties",
28
+ "android/settings.gradle",
29
+ "DVAICapacitorLlama.podspec",
30
+ "README.md",
31
+ "LICENSE"
32
+ ],
33
+ "keywords": [
34
+ "dvai-bridge",
35
+ "capacitor",
36
+ "llama-cpp",
37
+ "llm",
38
+ "on-device"
39
+ ],
40
+ "author": "Deep Chakraborty <https://github.com/dk013>",
41
+ "license": "Custom (See LICENSE)",
42
+ "repository": {
43
+ "type": "git",
44
+ "url": "https://github.com/westenets/dvai-bridge.git"
45
+ },
46
+ "peerDependencies": {
47
+ "@capacitor/core": "^6.0.0 || ^7.0.0 || ^8.0.0",
48
+ "@dvai-bridge/capacitor": "4.0.0",
49
+ "@dvai-bridge/android-llama-core": "4.0.0",
50
+ "@dvai-bridge/ios-llama-core": "4.0.0"
51
+ },
52
+ "devDependencies": {
53
+ "@dvai-bridge/capacitor": "4.0.0"
54
+ },
55
+ "capacitor": {
56
+ "ios": {
57
+ "src": "ios"
58
+ },
59
+ "android": {
60
+ "src": "android"
61
+ }
62
+ },
63
+ "scripts": {
64
+ "build": "tsup",
65
+ "mac:build": "pwsh -File ../../scripts/mac-build.ps1 -Action build -Target capacitor-llama",
66
+ "mac:test": "pwsh -File ../../scripts/mac-build.ps1 -Action test -Target capacitor-llama",
67
+ "android-test": "cd android && ./gradlew test",
68
+ "android-test:instrumented": "cd android && ./gradlew connectedAndroidTest"
69
+ }
70
+ }