31 files changed
+2679
.gitignore
+5
@@ -55,3 +55,8 @@ keystore.properties
55
# keep the real ones untracked. The .example templates ARE committed.
56
/src-tauri/tauri.prod-macos.conf.json
57
/src-tauri/tauri.prod-macos-appstore.conf.json
58
+
59
+# visionOS native app — generated by xcodegen from xcode/project.yml
60
+/xcode/*.xcodeproj/
61
+/xcode/build/
62
+/xcode/.swiftpm/
Makefile
+29
@@ -16,6 +16,14 @@
16
# make asios-fixplist — rewrite beta toolchain stamps (Info.plist + Mach-O) to release values, re-sign
17
# make asios-upload — xcrun altool upload
18
#
19
+# ── visionOS App Store (TestFlight) ────────────────────────────────────────
20
+# make asvision — full pipeline: generate → archive → export → fixplist → upload
21
+# make asvision-generate — xcodegen generate (xcode/SitiVision.xcodeproj)
22
+# make asvision-archive — xcodebuild archive (automatic signing)
23
+# make asvision-export — xcodebuild -exportArchive → .ipa
24
+# make asvision-fixplist — rewrite beta host-OS stamp (Info.plist + Mach-O) to release values, re-sign
25
+# make asvision-upload — xcrun altool upload
26
+#
27
# ── Android Play Store ─────────────────────────────────────────────────────
28
# make psmobile — full pipeline: clean → init → icons → override → keystore → build → open (.aab)
29
# make psmobile-clean — rm -rf src-tauri/gen/android
@@ -36,6 +44,7 @@
44
.PHONY: dev build \
45
asmacos asmacos-build asmacos-sign asmacos-upload \
46
asios asios-clean asios-init asios-icons asios-override asios-build asios-fixplist asios-upload \
47
+ asvision asvision-generate asvision-archive asvision-export asvision-fixplist asvision-upload \
48
psmobile psmobile-clean psmobile-init psmobile-icons psmobile-override psmobile-keystore psmobile-build psmobile-open \
49
web-dev web-build web-deploy \
50
fmt lint clean
@@ -80,6 +89,26 @@ asios-fixplist:
89
asios-upload:
90
./scripts/build-ios.sh upload
91
92
+# ── visionOS App Store (TestFlight) ────────────────────────────────────────
93
+
94
+asvision:
95
+ ./scripts/build-visionos.sh all
96
+
97
+asvision-generate:
98
+ ./scripts/build-visionos.sh generate
99
+
100
+asvision-archive:
101
+ ./scripts/build-visionos.sh archive
102
+
103
+asvision-export:
104
+ ./scripts/build-visionos.sh export
105
+
106
+asvision-fixplist:
107
+ ./scripts/build-visionos.sh fixplist
108
+
109
+asvision-upload:
110
+ ./scripts/build-visionos.sh upload
111
+
112
# ── Android Play Store ─────────────────────────────────────────────────────
113
114
psmobile:
assets/aso/appstore/visionos/01-chat-3840x2160.png
Binary files /dev/null and b/assets/aso/appstore/visionos/01-chat-3840x2160.png differ
assets/aso/appstore/visionos/02-settings-3840x2160.png
Binary files /dev/null and b/assets/aso/appstore/visionos/02-settings-3840x2160.png differ
assets/aso/appstore/visionos/en-US.md
new
+50
@@ -0,0 +1,50 @@
1
+# App Store visionOS — English US
2
+
3
+## Promotional Text (170 characters)
4
+
5
+Meet Siti — your private AI assistant that runs 100% on Vision Pro. Ask anything, fully offline. No cloud, no account, no tracking. Profoundly personal, entirely private.
6
+
7
+## Description (4,000 characters)
8
+
9
+Meet Siti — your private AI assistant for Apple Vision Pro, running entirely on your device.
10
+
11
+Siti is a profoundly capable, profoundly personal assistant that never sends your words to the cloud. Ask questions, draft messages, brainstorm ideas, summarize text, translate languages, or just talk things through — all powered by an on-device AI model that keeps every conversation completely private.
12
+
13
+ASK SITI ANYTHING
14
+• Get clear answers to everyday questions
15
+• Draft emails, messages, and posts
16
+• Brainstorm ideas and make plans
17
+• Summarize long text in seconds
18
+• Translate between languages
19
+• Explain difficult topics simply
20
+• Get help with writing and editing
21
+• Learn something new, one question at a time
22
+
23
+100% ON YOUR DEVICE
24
+Siti's AI runs entirely on Apple Vision Pro. No data is ever sent to a server — your conversations never leave your device. After a one-time model download, Siti works completely offline. No internet connection required.
25
+
26
+PRIVATE BY DESIGN
27
+• No account, no sign-in
28
+• No cloud processing
29
+• No tracking, no ads
30
+• No data collection
31
+Your conversations are yours alone.
32
+
33
+DESIGNED FOR VISION PRO
34
+Siti opens as a floating window you can place anywhere in your space, so your private assistant is always within reach alongside whatever else you're doing.
35
+
36
+WHY SITI?
37
+The name is a playful nod to the assistants we all know — but Siti flips the script: instead of the cloud, everything stays with you. Profoundly personal. Entirely private.
38
+
39
+KEY FEATURES
40
+- Private on-device AI chat
41
+- Works fully offline after model download
42
+- No account or subscription required
43
+- No tracking or data collection
44
+- Clean, distraction-free design
45
+
46
+Download Siti today and meet the assistant that keeps your world to itself.
47
+
48
+## Keywords (100 characters)
49
+
50
+ai assistant,private ai,offline ai,on-device,chatbot,ai chat,local llm,privacy,assistant,offline
assets/aso/appstore/visionos/id.md
new
+50
@@ -0,0 +1,50 @@
1
+# App Store visionOS — Bahasa Indonesia
2
+
3
+## Teks Promosi (170 karakter)
4
+
5
+Kenalkan Siti — asisten AI pribadi yang 100% offline. Tanyakan apa saja. Tanpa cloud, tanpa akun, tanpa pelacakan. Sangat pribadi, sepenuhnya privat.
6
+
7
+## Deskripsi (4.000 karakter)
8
+
9
+Kenalkan Siti — asisten AI pribadi Anda untuk Apple Vision Pro, yang berjalan sepenuhnya di perangkat Anda.
10
+
11
+Siti adalah asisten yang sangat mumpuni dan sangat pribadi, yang tidak pernah mengirim kata-kata Anda ke cloud. Ajukan pertanyaan, tulis draf pesan, brainstorming ide, ringkas teks, terjemahkan bahasa, atau sekadar mengobrol — semuanya didukung oleh model AI on-device yang menjaga setiap percakapan tetap sepenuhnya privat.
12
+
13
+TANYAKAN APA SAJA KE SITI
14
+• Dapatkan jawaban jelas untuk pertanyaan sehari-hari
15
+• Tulis draf email, pesan, dan postingan
16
+• Brainstorming ide dan buat rencana
17
+• Ringkas teks panjang dalam hitungan detik
18
+• Terjemahkan antar bahasa
19
+• Jelaskan topik sulit dengan sederhana
20
+• Dapatkan bantuan menulis dan mengedit
21
+• Pelajari hal baru, satu pertanyaan setiap kali
22
+
23
+100% DI PERANGKAT ANDA
24
+AI Siti berjalan sepenuhnya di Apple Vision Pro. Tidak ada data yang pernah dikirim ke server — percakapan Anda tidak pernah meninggalkan perangkat Anda. Setelah mengunduh model satu kali, Siti bekerja sepenuhnya offline. Tidak perlu koneksi internet.
25
+
26
+PRIVAT SEJAK DIRANCANG
27
+• Tanpa akun, tanpa masuk
28
+• Tanpa pemrosesan cloud
29
+• Tanpa pelacakan, tanpa iklan
30
+• Tanpa pengumpulan data
31
+Percakapan Anda hanya milik Anda.
32
+
33
+DIRANCANG UNTUK VISION PRO
34
+Siti terbuka sebagai jendela mengambang yang dapat Anda tempatkan di mana saja di ruang Anda, sehingga asisten pribadi Anda selalu dalam jangkauan di samping apa pun yang sedang Anda lakukan.
35
+
36
+MENGAPA SITI?
37
+Namanya adalah sindiran main-main pada asisten yang kita semua kenal — tapi Siti membalikkan keadaan: alih-alih cloud, semuanya tetap bersama Anda. Sangat pribadi. Sepenuhnya privat.
38
+
39
+FITUR UTAMA
40
+- Obrolan AI pribadi di perangkat
41
+- Bekerja sepenuhnya offline setelah mengunduh model
42
+- Tidak perlu akun atau langganan
43
+- Tanpa pelacakan atau pengumpulan data
44
+- Desain yang bersih dan bebas gangguan
45
+
46
+Unduh Siti hari ini dan temui asisten yang menjaga dunia Anda untuk diri Anda sendiri.
47
+
48
+## Kata Kunci (100 karakter)
49
+
50
+asisten AI,AI pribadi,AI offline,on-device,chatbot,obrolan AI,LLM lokal,privasi,asisten,offline
assets/aso/appstore/visionos/sv.md
new
+50
@@ -0,0 +1,50 @@
1
+# App Store visionOS — Svenska
2
+
3
+## Marknadsföringstext (170 tecken)
4
+
5
+Möt Siti — din privata AI-assistent som körs 100% offline. Fråga vad som helst. Inget moln, inget konto, ingen spårning. Djupt personlig, helt privat.
6
+
7
+## Beskrivning (4 000 tecken)
8
+
9
+Möt Siti — din privata AI-assistent för Apple Vision Pro, som körs helt på din enhet.
10
+
11
+Siti är en djupt kapabel, djupt personlig assistent som aldrig skickar dina ord till molnet. Ställ frågor, skriv meddelanden, brainstorma idéer, sammanfatta text, översätt språk eller bara resonera högt — allt drivs av en AI-modell som körs lokalt och håller varje konversation helt privat.
12
+
13
+FRÅGA SITI OM VAD SOM HELST
14
+• Få tydliga svar på vardagsfrågor
15
+• Skriv utkast till mejl, meddelanden och inlägg
16
+• Brainstorma idéer och gör planer
17
+• Sammanfatta långa texter på några sekunder
18
+• Översätt mellan språk
19
+• Förklara svåra ämnen enkelt
20
+• Få hjälp med att skriva och redigera
21
+• Lär dig något nytt, en fråga i taget
22
+
23
+100 % PÅ DIN ENHET
24
+Sitis AI körs helt på Apple Vision Pro. Ingen data skickas någonsin till en server — dina konversationer lämnar aldrig din enhet. Efter en engångsnedladdning av modellen fungerar Siti helt offline. Ingen internetuppkoppling krävs.
25
+
26
+PRIVAT PÅ RIKTIGT
27
+• Inget konto, ingen inloggning
28
+• Ingen molnbearbetning
29
+• Ingen spårning, inga annonser
30
+• Ingen datainsamling
31
+Dina konversationer är bara dina.
32
+
33
+GJORD FÖR VISION PRO
34
+Siti öppnas som ett flytande fönster som du kan placera var som helst i ditt utrymme, så att din privata assistent alltid finns nära till hands vid sidan av allt annat du gör.
35
+
36
+VARFÖR SITI?
37
+Namnet är en lekfull nick till assistenterna vi alla känner till — men Siti vänder på steken: istället för molnet stannar allt hos dig. Djupt personlig. Helt privat.
38
+
39
+VIKTIGA FUNKTIONER
40
+- Privat AI-chatt direkt på enheten
41
+- Fungerar helt offline efter nedladdning av modellen
42
+- Inget konto eller abonnemang krävs
43
+- Ingen spårning eller datainsamling
44
+- Rent, distraktionsfritt gränssnitt
45
+
46
+Ladda ner Siti idag och möt assistenten som håller din värld för sig själv.
47
+
48
+## Nyckelord (100 tecken)
49
+
50
+AI-assistent,privat AI,offline AI,på enheten,chatbot,AI-chatt,lokal LLM,integritet,assistent
scripts/appstore-templates/01-chat.html
new
+293
@@ -0,0 +1,293 @@
1
+<!DOCTYPE html>
2
+<html>
3
+<head>
4
+ <meta charset="UTF-8">
5
+ <link rel="preconnect" href="https://fonts.googleapis.com">
6
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
7
+ <style>
8
+ *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
9
+
10
+ html, body {
11
+ width: 3840px;
12
+ height: 2160px;
13
+ overflow: hidden;
14
+ font-family: 'Inter', system-ui, -apple-system, sans-serif;
15
+ }
16
+
17
+ /* ── spatial environment ──────────────────────────── */
18
+ .stage {
19
+ position: relative;
20
+ width: 3840px;
21
+ height: 2160px;
22
+ background: linear-gradient(155deg, #eceae4 0%, #d9d6cd 55%, #cdc9bd 100%);
23
+ display: flex;
24
+ align-items: center;
25
+ overflow: hidden;
26
+ }
27
+
28
+ /* soft ambient light blobs — implies a real room without faking a photo */
29
+ .glow {
30
+ position: absolute;
31
+ border-radius: 50%;
32
+ filter: blur(180px);
33
+ opacity: 0.55;
34
+ }
35
+ .glow-1 { width: 1400px; height: 1400px; top: -420px; left: -320px; background: #ffffff; opacity: 0.5; }
36
+ .glow-2 { width: 1200px; height: 1200px; bottom: -480px; right: -260px; background: #3a7a56; opacity: 0.16; }
37
+
38
+ /* faint floor line to ground the floating window in space */
39
+ .floor {
40
+ position: absolute;
41
+ left: 0; right: 0; bottom: 260px;
42
+ height: 1px;
43
+ background: linear-gradient(90deg, transparent, rgba(26,52,40,0.10) 30%, rgba(26,52,40,0.10) 70%, transparent);
44
+ }
45
+
46
+ /* ── text section ─────────────────────────────── */
47
+ .text-section {
48
+ position: relative;
49
+ z-index: 2;
50
+ width: 1180px;
51
+ padding-left: 160px;
52
+ padding-right: 60px;
53
+ }
54
+
55
+ .label {
56
+ font-size: 34px;
57
+ font-weight: 700;
58
+ letter-spacing: 0.18em;
59
+ text-transform: uppercase;
60
+ color: #3a7a56;
61
+ margin-bottom: 34px;
62
+ }
63
+
64
+ .headline {
65
+ font-size: 96px;
66
+ font-weight: 800;
67
+ line-height: 1.08;
68
+ color: #1a3428;
69
+ margin-bottom: 42px;
70
+ }
71
+
72
+ .headline .accent {
73
+ color: #3a7a56;
74
+ }
75
+
76
+ .subtext {
77
+ font-size: 38px;
78
+ font-weight: 400;
79
+ line-height: 1.5;
80
+ color: #5f6a62;
81
+ max-width: 820px;
82
+ }
83
+
84
+ /* ── floating glass window ────────────────────── */
85
+ .window-wrap {
86
+ position: relative;
87
+ z-index: 2;
88
+ flex: 1;
89
+ display: flex;
90
+ align-items: center;
91
+ justify-content: center;
92
+ padding-right: 140px;
93
+ perspective: 2400px;
94
+ }
95
+
96
+ .window {
97
+ width: 1980px;
98
+ height: 1560px;
99
+ border-radius: 46px;
100
+ background: rgba(255, 255, 255, 0.78);
101
+ border: 1.5px solid rgba(255, 255, 255, 0.9);
102
+ box-shadow:
103
+ 0 6px 0 rgba(255,255,255,0.5) inset,
104
+ 0 90px 160px rgba(20, 30, 24, 0.28),
105
+ 0 30px 60px rgba(20, 30, 24, 0.16);
106
+ overflow: hidden;
107
+ display: flex;
108
+ flex-direction: column;
109
+ transform: rotateY(-4deg) rotateX(1deg);
110
+ }
111
+
112
+ /* window chrome */
113
+ .titlebar {
114
+ flex-shrink: 0;
115
+ display: flex;
116
+ align-items: center;
117
+ gap: 20px;
118
+ padding: 34px 44px 26px;
119
+ }
120
+
121
+ .brain-icon {
122
+ width: 40px;
123
+ height: 40px;
124
+ color: #3a7a56;
125
+ flex-shrink: 0;
126
+ }
127
+
128
+ .titlebar-text {
129
+ flex: 1;
130
+ }
131
+
132
+ .window-title {
133
+ font-size: 30px;
134
+ font-weight: 600;
135
+ color: #14211a;
136
+ }
137
+
138
+ .window-subtitle {
139
+ font-size: 20px;
140
+ color: #8a938c;
141
+ margin-top: 2px;
142
+ }
143
+
144
+ .status-pill {
145
+ font-size: 18px;
146
+ font-weight: 600;
147
+ color: #3a7a56;
148
+ background: rgba(58, 122, 86, 0.12);
149
+ padding: 8px 18px;
150
+ border-radius: 999px;
151
+ }
152
+
153
+ .divider {
154
+ height: 1.5px;
155
+ background: rgba(20, 33, 26, 0.08);
156
+ margin: 0 44px;
157
+ }
158
+
159
+ /* message area */
160
+ .messages {
161
+ flex: 1;
162
+ padding: 44px 64px;
163
+ display: flex;
164
+ flex-direction: column;
165
+ justify-content: center;
166
+ gap: 26px;
167
+ }
168
+
169
+ .row { display: flex; }
170
+ .row.user { justify-content: flex-end; }
171
+
172
+ .bubble {
173
+ max-width: 66%;
174
+ font-size: 24px;
175
+ line-height: 1.5;
176
+ padding: 20px 26px;
177
+ border-radius: 26px;
178
+ }
179
+
180
+ .bubble.assistant {
181
+ background: rgba(20, 33, 26, 0.06);
182
+ color: #1a2620;
183
+ }
184
+
185
+ .bubble.user {
186
+ background: #3a7a56;
187
+ color: #ffffff;
188
+ }
189
+
190
+ .sender {
191
+ font-size: 16px;
192
+ color: #9aa39c;
193
+ margin-bottom: 8px;
194
+ padding: 0 4px;
195
+ }
196
+ .row.user .sender { text-align: right; }
197
+
198
+ /* input bar */
199
+ .input-bar {
200
+ flex-shrink: 0;
201
+ padding: 30px 44px 44px;
202
+ }
203
+
204
+ .input-row {
205
+ background: rgba(20, 33, 26, 0.06);
206
+ border-radius: 32px;
207
+ padding: 22px 22px 22px 32px;
208
+ display: flex;
209
+ align-items: center;
210
+ justify-content: space-between;
211
+ gap: 16px;
212
+ }
213
+
214
+ .input-placeholder {
215
+ font-size: 24px;
216
+ color: #9aa39c;
217
+ }
218
+
219
+ .send-btn {
220
+ width: 52px;
221
+ height: 52px;
222
+ border-radius: 50%;
223
+ background: #3a7a56;
224
+ display: flex;
225
+ align-items: center;
226
+ justify-content: center;
227
+ flex-shrink: 0;
228
+ }
229
+ </style>
230
+</head>
231
+<body>
232
+ <div class="stage">
233
+ <div class="glow glow-1"></div>
234
+ <div class="glow glow-2"></div>
235
+ <div class="floor"></div>
236
+
237
+ <div class="text-section">
238
+ <div class="label">Spatial Assistant</div>
239
+ <div class="headline">
240
+ Your assistant, <span class="accent">right in the room.</span>
241
+ </div>
242
+ <div class="subtext">Ask Siti anything from a floating glass window — every word stays entirely on your Vision Pro.</div>
243
+ </div>
244
+
245
+ <div class="window-wrap">
246
+ <div class="window">
247
+ <div class="titlebar">
248
+ <svg class="brain-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
249
+ <path d="M9.5 2a3.5 3.5 0 0 0-3.5 3.5v.55A3.5 3.5 0 0 0 3 9.5a3.5 3.5 0 0 0 1.06 2.52A3.5 3.5 0 0 0 3.5 14a3.5 3.5 0 0 0 2.53 3.37A3.5 3.5 0 0 0 9.5 21a3.5 3.5 0 0 0 3.5-3.5V5.5A3.5 3.5 0 0 0 9.5 2Z"/>
250
+ <path d="M14.5 2a3.5 3.5 0 0 1 3.5 3.5v.55A3.5 3.5 0 0 1 21 9.5a3.5 3.5 0 0 1-1.06 2.52A3.5 3.5 0 0 1 20.5 14a3.5 3.5 0 0 1-2.53 3.37A3.5 3.5 0 0 1 14.5 21a3.5 3.5 0 0 1-3.5-3.5V5.5A3.5 3.5 0 0 1 14.5 2Z"/>
251
+ </svg>
252
+ <div class="titlebar-text">
253
+ <div class="window-title">Siti AI</div>
254
+ <div class="window-subtitle">on-device · private</div>
255
+ </div>
256
+ <div class="status-pill">Ready</div>
257
+ </div>
258
+
259
+ <div class="divider"></div>
260
+
261
+ <div class="messages">
262
+ <div class="row user">
263
+ <div>
264
+ <div class="sender">You</div>
265
+ <div class="bubble user">Explain the difference between a window and a volume in visionOS, in plain terms.</div>
266
+ </div>
267
+ </div>
268
+ <div class="row">
269
+ <div>
270
+ <div class="sender">Siti</div>
271
+ <div class="bubble assistant">A window is flat, like a page floating in space — good for reading and typing. A volume has real depth, so 3D content can sit inside it and you can walk around it.</div>
272
+ </div>
273
+ </div>
274
+ </div>
275
+
276
+ <div class="divider"></div>
277
+
278
+ <div class="input-bar">
279
+ <div class="input-row">
280
+ <span class="input-placeholder">Message Siti…</span>
281
+ <div class="send-btn">
282
+ <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
283
+ <line x1="12" y1="19" x2="12" y2="5"/>
284
+ <polyline points="5 12 12 5 19 12"/>
285
+ </svg>
286
+ </div>
287
+ </div>
288
+ </div>
289
+ </div>
290
+ </div>
291
+ </div>
292
+</body>
293
+</html>
scripts/appstore-templates/02-settings.html
new
+271
@@ -0,0 +1,271 @@
1
+<!DOCTYPE html>
2
+<html>
3
+<head>
4
+ <meta charset="UTF-8">
5
+ <link rel="preconnect" href="https://fonts.googleapis.com">
6
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
7
+ <style>
8
+ *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
9
+
10
+ html, body {
11
+ width: 3840px;
12
+ height: 2160px;
13
+ overflow: hidden;
14
+ font-family: 'Inter', system-ui, -apple-system, sans-serif;
15
+ }
16
+
17
+ .stage {
18
+ position: relative;
19
+ width: 3840px;
20
+ height: 2160px;
21
+ background: linear-gradient(155deg, #eceae4 0%, #d9d6cd 55%, #cdc9bd 100%);
22
+ display: flex;
23
+ align-items: center;
24
+ overflow: hidden;
25
+ }
26
+
27
+ .glow {
28
+ position: absolute;
29
+ border-radius: 50%;
30
+ filter: blur(180px);
31
+ opacity: 0.55;
32
+ }
33
+ .glow-1 { width: 1400px; height: 1400px; top: -420px; left: -320px; background: #ffffff; opacity: 0.5; }
34
+ .glow-2 { width: 1200px; height: 1200px; bottom: -480px; right: -260px; background: #3a7a56; opacity: 0.16; }
35
+
36
+ .floor {
37
+ position: absolute;
38
+ left: 0; right: 0; bottom: 260px;
39
+ height: 1px;
40
+ background: linear-gradient(90deg, transparent, rgba(26,52,40,0.10) 30%, rgba(26,52,40,0.10) 70%, transparent);
41
+ }
42
+
43
+ .text-section {
44
+ position: relative;
45
+ z-index: 2;
46
+ width: 1180px;
47
+ padding-left: 160px;
48
+ padding-right: 60px;
49
+ }
50
+
51
+ .label {
52
+ font-size: 34px;
53
+ font-weight: 700;
54
+ letter-spacing: 0.18em;
55
+ text-transform: uppercase;
56
+ color: #3a7a56;
57
+ margin-bottom: 34px;
58
+ }
59
+
60
+ .headline {
61
+ font-size: 96px;
62
+ font-weight: 800;
63
+ line-height: 1.08;
64
+ color: #1a3428;
65
+ margin-bottom: 42px;
66
+ }
67
+
68
+ .headline .accent {
69
+ color: #3a7a56;
70
+ }
71
+
72
+ .subtext {
73
+ font-size: 38px;
74
+ font-weight: 400;
75
+ line-height: 1.5;
76
+ color: #5f6a62;
77
+ max-width: 820px;
78
+ }
79
+
80
+ .window-wrap {
81
+ position: relative;
82
+ z-index: 2;
83
+ flex: 1;
84
+ display: flex;
85
+ align-items: center;
86
+ justify-content: center;
87
+ padding-right: 140px;
88
+ perspective: 2400px;
89
+ }
90
+
91
+ .window {
92
+ width: 1760px;
93
+ height: 1560px;
94
+ border-radius: 46px;
95
+ background: rgba(255, 255, 255, 0.82);
96
+ border: 1.5px solid rgba(255, 255, 255, 0.9);
97
+ box-shadow:
98
+ 0 6px 0 rgba(255,255,255,0.5) inset,
99
+ 0 90px 160px rgba(20, 30, 24, 0.28),
100
+ 0 30px 60px rgba(20, 30, 24, 0.16);
101
+ overflow: hidden;
102
+ display: flex;
103
+ flex-direction: column;
104
+ transform: rotateY(-4deg) rotateX(1deg);
105
+ }
106
+
107
+ .sheet-header {
108
+ flex-shrink: 0;
109
+ display: flex;
110
+ align-items: center;
111
+ justify-content: space-between;
112
+ padding: 38px 48px 30px;
113
+ }
114
+
115
+ .sheet-title {
116
+ font-size: 34px;
117
+ font-weight: 700;
118
+ color: #14211a;
119
+ }
120
+
121
+ .sheet-action {
122
+ font-size: 24px;
123
+ font-weight: 600;
124
+ color: #3a7a56;
125
+ }
126
+ .sheet-action.muted { color: #9aa39c; }
127
+
128
+ .divider {
129
+ height: 1.5px;
130
+ background: rgba(20, 33, 26, 0.08);
131
+ margin: 0 48px;
132
+ }
133
+
134
+ .section {
135
+ padding: 30px 48px 6px;
136
+ }
137
+
138
+ .section-label {
139
+ font-size: 19px;
140
+ font-weight: 600;
141
+ letter-spacing: 0.08em;
142
+ text-transform: uppercase;
143
+ color: #9aa39c;
144
+ margin-bottom: 16px;
145
+ }
146
+
147
+ .prompt-box {
148
+ background: rgba(20, 33, 26, 0.05);
149
+ border-radius: 20px;
150
+ padding: 24px 26px;
151
+ font-size: 21px;
152
+ line-height: 1.55;
153
+ color: #3f4a43;
154
+ min-height: 168px;
155
+ }
156
+
157
+ .footnote {
158
+ font-size: 17px;
159
+ color: #9aa39c;
160
+ margin-top: 14px;
161
+ line-height: 1.5;
162
+ }
163
+
164
+ .preset-list {
165
+ display: flex;
166
+ flex-direction: column;
167
+ gap: 14px;
168
+ }
169
+
170
+ .preset-row {
171
+ display: flex;
172
+ align-items: flex-start;
173
+ gap: 18px;
174
+ background: rgba(20, 33, 26, 0.05);
175
+ border-radius: 18px;
176
+ padding: 20px 24px;
177
+ }
178
+
179
+ .preset-row.active {
180
+ background: rgba(58, 122, 86, 0.12);
181
+ border: 1.5px solid rgba(58, 122, 86, 0.35);
182
+ }
183
+
184
+ .radio {
185
+ width: 22px;
186
+ height: 22px;
187
+ border-radius: 50%;
188
+ border: 2px solid #c3cac4;
189
+ margin-top: 4px;
190
+ flex-shrink: 0;
191
+ }
192
+
193
+ .radio.checked {
194
+ border-color: #3a7a56;
195
+ background: #3a7a56;
196
+ box-shadow: inset 0 0 0 4px #fff;
197
+ }
198
+
199
+ .preset-title {
200
+ font-size: 22px;
201
+ font-weight: 600;
202
+ color: #14211a;
203
+ }
204
+
205
+ .preset-sub {
206
+ font-size: 18px;
207
+ color: #8a938c;
208
+ margin-top: 3px;
209
+ }
210
+ </style>
211
+</head>
212
+<body>
213
+ <div class="stage">
214
+ <div class="glow glow-1"></div>
215
+ <div class="glow glow-2"></div>
216
+ <div class="floor"></div>
217
+
218
+ <div class="text-section">
219
+ <div class="label">Model Settings</div>
220
+ <div class="headline">
221
+ Set it once. <span class="accent">Keep it private.</span>
222
+ </div>
223
+ <div class="subtext">Tune the system prompt and response style — every setting stays on-device, just like every conversation.</div>
224
+ </div>
225
+
226
+ <div class="window-wrap">
227
+ <div class="window">
228
+ <div class="sheet-header">
229
+ <span class="sheet-action muted">Cancel</span>
230
+ <span class="sheet-title">Model Settings</span>
231
+ <span class="sheet-action">Save</span>
232
+ </div>
233
+
234
+ <div class="divider"></div>
235
+
236
+ <div class="section">
237
+ <div class="section-label">System Prompt</div>
238
+ <div class="prompt-box">You are Siti, a friendly and capable personal assistant that runs entirely on the user's device. Nothing the user says ever leaves the device.</div>
239
+ </div>
240
+
241
+ <div class="section">
242
+ <div class="section-label" style="margin-top: 10px;">Response Style</div>
243
+ <div class="preset-list">
244
+ <div class="preset-row active">
245
+ <div class="radio checked"></div>
246
+ <div>
247
+ <div class="preset-title">Balanced</div>
248
+ <div class="preset-sub">General-purpose responses with a bit of creativity.</div>
249
+ </div>
250
+ </div>
251
+ <div class="preset-row">
252
+ <div class="radio"></div>
253
+ <div>
254
+ <div class="preset-title">Deterministic</div>
255
+ <div class="preset-sub">Reproducible, focused answers.</div>
256
+ </div>
257
+ </div>
258
+ <div class="preset-row">
259
+ <div class="radio"></div>
260
+ <div>
261
+ <div class="preset-title">Fast</div>
262
+ <div class="preset-sub">Shorter responses with lower latency.</div>
263
+ </div>
264
+ </div>
265
+ </div>
266
+ </div>
267
+ </div>
268
+ </div>
269
+ </div>
270
+</body>
271
+</html>
scripts/build-visionos.sh
new
+393
@@ -0,0 +1,393 @@
1
+#!/usr/bin/env bash
2
+# build-visionos.sh
3
+#
4
+# Full App Store pipeline for the native SitiVision visionOS app (xcode/):
5
+# 1. Generate — xcodegen generate (SitiVision.xcodeproj is not checked in)
6
+# 2. Archive — xcodebuild archive (automatic signing, Splitfire AB team)
7
+# 3. Export — xcodebuild -exportArchive (method app-store-connect → .ipa)
8
+# 4. Fixplist — rewrite the beta host-OS stamp (BuildMachineOSBuild) + Mach-O
9
+# LC_BUILD_VERSION, then re-sign — see do_fixplist for why
10
+# 5. Upload — xcrun altool (TestFlight / App Store Connect submission)
11
+#
12
+# Unlike build-ios.sh (Tauri + cargo-tauri), SitiVision is a plain XcodeGen
13
+# project, so this pipeline is xcodebuild/xcodegen only — no cargo/pnpm steps.
14
+# The fixplist step follows the same workaround as the sibling splitfire repo's
15
+# Scripts/build_karokowe_appstore.sh (native tvOS/visionOS Xcode projects) and
16
+# this repo's own build-ios.sh (Tauri iOS) — see do_fixplist for the mechanism.
17
+#
18
+# Every configurable value has a hardcoded default that can be overridden by
19
+# setting the corresponding environment variable before invoking the script.
20
+#
21
+# Usage:
22
+# ./scripts/build-visionos.sh # full pipeline
23
+# ./scripts/build-visionos.sh generate # xcodegen generate only
24
+# ./scripts/build-visionos.sh archive # archive only (assumes project generated)
25
+# ./scripts/build-visionos.sh export # export .ipa only (assumes archive already built)
26
+# ./scripts/build-visionos.sh fixplist # patch beta host-OS stamp + re-sign
27
+# ./scripts/build-visionos.sh upload # upload only (assumes .ipa already exported)
28
+#
29
+# make asvision # full pipeline via Makefile
30
+# make asvision-generate
31
+# make asvision-archive
32
+# make asvision-export
33
+# make asvision-fixplist
34
+# make asvision-upload
35
+
36
+set -euo pipefail
37
+
38
+# ── Paths ─────────────────────────────────────────────────────────────────────
39
+
40
+REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
41
+XCODE_DIR="${REPO_ROOT}/xcode"
42
+PROJECT="${XCODE_DIR}/SitiVision.xcodeproj"
43
+BUILD_DIR="${XCODE_DIR}/build"
44
+ARCHIVE_PATH="${BUILD_DIR}/SitiVision.xcarchive"
45
+EXPORT_DIR="${BUILD_DIR}/export"
46
+EXPORT_OPTIONS="${BUILD_DIR}/ExportOptions.plist"
47
+IPA_PATH="${EXPORT_DIR}/SitiVision.ipa"
48
+
49
+# Auto-load the untracked .env.appstore (signing identities + ASC creds) so
50
+# `make asvision` works without the caller exporting them first. See
51
+# .env.appstore.example for the expected keys.
52
+if [[ -f "${REPO_ROOT}/.env.appstore" ]]; then
53
+ set -a
54
+ # shellcheck disable=SC1091
55
+ . "${REPO_ROOT}/.env.appstore"
56
+ set +a
57
+fi
58
+
59
+# Xcode toolchain. App Store Connect rejects binaries built with a beta Xcode or
60
+# beta SDK, so pin a release Xcode here regardless of the global `xcode-select`.
61
+# Override: DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer ./scripts/build-visionos.sh
62
+pick_release_xcode() {
63
+ local active; active="$(xcode-select -p 2>/dev/null)"
64
+ if [[ -n "$active" && "$active" != *[Bb]eta* ]]; then
65
+ printf '%s\n' "$active"; return
66
+ fi
67
+ local app
68
+ for app in /Applications/Xcode*.app; do
69
+ case "$app" in *[Bb]eta*) continue ;; esac
70
+ [[ -d "$app/Contents/Developer" ]] && { printf '%s\n' "$app/Contents/Developer"; return; }
71
+ done
72
+}
73
+DEVELOPER_DIR="${DEVELOPER_DIR:-$(pick_release_xcode)}"
74
+export DEVELOPER_DIR
75
+
76
+# ── Overridable config ─────────────────────────────────────────────────────────
77
+
78
+# Apple Developer team (Splitfire AB — same team as the Tauri app's iOS/macOS
79
+# builds; see project.yml's DEVELOPMENT_TEAM and ../src-tauri/tauri.conf.json).
80
+TEAM_ID="${TEAM_ID:-2TQF86ZACD}"
81
+
82
+# App Store Connect API credentials. Used both for -allowProvisioningUpdates
83
+# (so archive/export can fetch/create the distribution profile headlessly,
84
+# without a signed-in Xcode account) and for the altool upload.
85
+# ASC_API_KEY — Key ID shown in App Store Connect → Users & Access → Keys.
86
+# ASC_ISSUER_ID — Issuer UUID on the same page.
87
+# The private key (.p8) must be present at:
88
+# ~/.appstoreconnect/private_keys/AuthKey_<ASC_API_KEY>.p8 (Xcode's default), or
89
+# ~/private_keys/AuthKey_<ASC_API_KEY>.p8 (ASC_PRIVATE_KEY_PATH override)
90
+ASC_API_KEY="${ASC_API_KEY:-L84N624YQH}"
91
+ASC_ISSUER_ID="${ASC_ISSUER_ID:-b4e8d369-8b7d-4538-8435-643b73237575}"
92
+ASC_PRIVATE_KEY_PATH="${ASC_PRIVATE_KEY_PATH:-${HOME}/private_keys/AuthKey_${ASC_API_KEY}.p8}"
93
+
94
+# Re-sign identity used by the fixplist step (distribution cert for the same
95
+# team as TEAM_ID). Not secret — safe to commit once known.
96
+SIGN_IDENTITY="${SIGN_IDENTITY:-Apple Distribution: Splitfire AB (2TQF86ZACD)}"
97
+
98
+# A shipping macOS build number to stamp for BuildMachineOSBuild during
99
+# fixplist (matches the constant already used by build-ios.sh and the
100
+# sibling splitfire repo's Scripts/build_karokowe_appstore.sh).
101
+RELEASE_OS_BUILD="${RELEASE_OS_BUILD:-25A354}"
102
+
103
+# ── Helpers ───────────────────────────────────────────────────────────────────
104
+
105
+step() { echo ""; echo "── $* ──────────────────────────────────────────────"; }
106
+ok() { echo "✅ $*"; }
107
+fail() { printf "❌ %b\n" "$*" >&2; exit 1; }
108
+
109
+# True when an Apple build number is a pre-release (beta) seed. Apple stamps
110
+# seeds with a trailing lowercase letter (e.g. 26A5353q); shipping builds
111
+# never carry one (e.g. 25A354).
112
+is_beta_build() {
113
+ [[ "${1:-}" =~ [a-z]$ ]]
114
+}
115
+
116
+require_release_xcode() {
117
+ [[ -n "${DEVELOPER_DIR}" && -d "${DEVELOPER_DIR}" ]] \
118
+ || fail "No release Xcode found.\n Install a release Xcode, or set DEVELOPER_DIR to its Contents/Developer dir."
119
+ case "${DEVELOPER_DIR}" in
120
+ *[Bb]eta*)
121
+ fail "DEVELOPER_DIR points at a beta Xcode:\n ${DEVELOPER_DIR}\n App Store Connect rejects beta-built binaries. Point DEVELOPER_DIR at a release Xcode." ;;
122
+ esac
123
+ echo "🛠 Xcode → $(xcodebuild -version 2>/dev/null | head -1) [${DEVELOPER_DIR}]"
124
+}
125
+
126
+require_asc_key() {
127
+ [[ -f "${ASC_PRIVATE_KEY_PATH}" ]] \
128
+ || fail "ASC API private key not found: ${ASC_PRIVATE_KEY_PATH}\n Download it from App Store Connect → Users & Access → Integrations → App Store Connect API (once — Apple won't let you re-download), or set ASC_PRIVATE_KEY_PATH."
129
+}
130
+
131
+# Release toolchain stamps used by fixplist to rewrite a bundle's Info.plist
132
+# so App Store Connect reads it as a release-Xcode/SDK build. Derived from the
133
+# pinned release Xcode (DEVELOPER_DIR); every value is overridable via env.
134
+derive_release_toolchain() {
135
+ local xv sv xver xbuild sdkver sdkbuild d
136
+ xv="$(xcodebuild -version 2>/dev/null)"
137
+ sv="$(xcodebuild -version -sdk xros 2>/dev/null)"
138
+ xver="$(printf '%s\n' "$xv" | awk '/^Xcode/{print $2}')"
139
+ xbuild="$(printf '%s\n' "$xv" | awk '/Build version/{print $3}')"
140
+ sdkver="$(printf '%s\n' "$sv" | awk -F': ' '/SDKVersion/{print $2; exit}')"
141
+ sdkbuild="$(printf '%s\n' "$sv" | awk -F': ' '/ProductBuildVersion/{print $2; exit}')"
142
+ d="${xver//./}"; while [[ -n "$d" && ${#d} -lt 4 ]]; do d="${d}0"; done
143
+ REL_DTXCODE="${RELEASE_DTXCODE:-$d}"
144
+ REL_XCODE_BUILD="${RELEASE_XCODE_BUILD:-$xbuild}"
145
+ REL_SDK_VERSION="${RELEASE_SDK_VERSION:-$sdkver}"
146
+ REL_SDK_NAME="${RELEASE_SDK_NAME:-xros${sdkver}}"
147
+ REL_SDK_BUILD="${RELEASE_SDK_BUILD:-$sdkbuild}"
148
+ [[ -n "$REL_DTXCODE" && -n "$REL_XCODE_BUILD" && -n "$REL_SDK_BUILD" ]] \
149
+ || fail "Could not determine release toolchain stamps from ${DEVELOPER_DIR}."
150
+}
151
+
152
+patch_bundle_plist() {
153
+ local plist="$1"
154
+ pl() { /usr/libexec/PlistBuddy -c "Print :$1" "$plist" 2>/dev/null || echo ""; }
155
+ setpl() { /usr/libexec/PlistBuddy -c "Set :$1 $2" "$plist" 2>/dev/null \
156
+ || /usr/libexec/PlistBuddy -c "Add :$1 string $2" "$plist"; }
157
+ setpl BuildMachineOSBuild "${RELEASE_OS_BUILD}"
158
+ setpl DTXcode "${REL_DTXCODE}"; setpl DTXcodeBuild "${REL_XCODE_BUILD}"
159
+ setpl DTSDKName "${REL_SDK_NAME}"; setpl DTSDKBuild "${REL_SDK_BUILD}"
160
+ setpl DTPlatformBuild "${REL_SDK_BUILD}"; setpl DTPlatformVersion "${REL_SDK_VERSION}"
161
+}
162
+
163
+# ── Steps ─────────────────────────────────────────────────────────────────────
164
+
165
+do_generate() {
166
+ step "Generate"
167
+
168
+ command -v xcodegen >/dev/null || fail "xcodegen not found.\n Install with: brew install xcodegen"
169
+
170
+ ( cd "${XCODE_DIR}" && xcodegen generate )
171
+ [[ -d "$PROJECT" ]] || fail "xcodegen ran but ${PROJECT} was not created"
172
+ ok "Xcode project generated → ${PROJECT}"
173
+}
174
+
175
+do_archive() {
176
+ step "Archive"
177
+
178
+ require_release_xcode
179
+ require_asc_key
180
+ [[ -d "$PROJECT" ]] || fail "Xcode project not found at ${PROJECT}\n Run 'generate' step first."
181
+
182
+ # CURRENT_PROJECT_VERSION: unix-seconds build number.
183
+ # Strictly increasing across builds → satisfies App Store Connect's
184
+ # "each build must have a higher build number than the last" requirement.
185
+ BUILD_NUMBER="$(date -u +"%s")"
186
+
187
+ mkdir -p "${BUILD_DIR}"
188
+ rm -rf "${ARCHIVE_PATH}"
189
+
190
+ echo "🏷 buildNumber → ${BUILD_NUMBER}"
191
+ echo "👥 team → ${TEAM_ID}"
192
+ echo ""
193
+
194
+ xcodebuild archive \
195
+ -project "${PROJECT}" \
196
+ -scheme SitiVision \
197
+ -destination "generic/platform=visionOS" \
198
+ -archivePath "${ARCHIVE_PATH}" \
199
+ -configuration Release \
200
+ -allowProvisioningUpdates \
201
+ -authenticationKeyPath "${ASC_PRIVATE_KEY_PATH}" \
202
+ -authenticationKeyID "${ASC_API_KEY}" \
203
+ -authenticationKeyIssuerID "${ASC_ISSUER_ID}" \
204
+ CURRENT_PROJECT_VERSION="${BUILD_NUMBER}" \
205
+ DEVELOPMENT_TEAM="${TEAM_ID}"
206
+
207
+ [[ -d "$ARCHIVE_PATH" ]] || fail "Archive succeeded but not found at: $ARCHIVE_PATH"
208
+ ok "Archive complete → ${ARCHIVE_PATH}"
209
+}
210
+
211
+do_export() {
212
+ step "Export"
213
+
214
+ require_release_xcode
215
+ require_asc_key
216
+ [[ -d "$ARCHIVE_PATH" ]] || fail "Archive not found: ${ARCHIVE_PATH}\n Run 'archive' step first."
217
+
218
+ # exportOptionsPlist: automatic signing re-resolves to a *distribution*
219
+ # certificate + App Store profile for method app-store-connect, even though
220
+ # the archive step itself signs with a development identity (expected —
221
+ # Xcode always archives with the dev cert; export re-signs per method).
222
+ cat > "${EXPORT_OPTIONS}" <<PLIST
223
+<?xml version="1.0" encoding="UTF-8"?>
224
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
225
+<plist version="1.0">
226
+<dict>
227
+ <key>method</key>
228
+ <string>app-store-connect</string>
229
+ <key>teamID</key>
230
+ <string>${TEAM_ID}</string>
231
+ <key>signingStyle</key>
232
+ <string>automatic</string>
233
+ <key>destination</key>
234
+ <string>export</string>
235
+</dict>
236
+</plist>
237
+PLIST
238
+
239
+ rm -rf "${EXPORT_DIR}"
240
+
241
+ xcodebuild -exportArchive \
242
+ -archivePath "${ARCHIVE_PATH}" \
243
+ -exportPath "${EXPORT_DIR}" \
244
+ -exportOptionsPlist "${EXPORT_OPTIONS}" \
245
+ -allowProvisioningUpdates \
246
+ -authenticationKeyPath "${ASC_PRIVATE_KEY_PATH}" \
247
+ -authenticationKeyID "${ASC_API_KEY}" \
248
+ -authenticationKeyIssuerID "${ASC_ISSUER_ID}"
249
+
250
+ [[ -f "$IPA_PATH" ]] || fail "Export succeeded but .ipa not found at: $IPA_PATH"
251
+ ok "Export complete → ${IPA_PATH}"
252
+}
253
+
254
+# Why this is needed even with a release Xcode pinned via DEVELOPER_DIR:
255
+# xcodebuild fully respects DEVELOPER_DIR, so DTXcode/DTXcodeBuild/DTSDKBuild
256
+# and every Mach-O's embedded LC_BUILD_VERSION sdk field all come out
257
+# clean/release automatically. But `BuildMachineOSBuild` reflects the literal
258
+# host macOS build — this Mac itself, regardless of which Xcode.app performs
259
+# the build. When the host OS is a beta seed (a build number with a trailing
260
+# lowercase letter, e.g. `26A5353q`), that field alone is enough to make App
261
+# Store Connect's REVIEW-SUBMISSION validation — a separate, stricter pass
262
+# than TestFlight build processing — reject the binary as "Invalid Binary"
263
+# with no further detail, even though TestFlight processing itself reports
264
+# the build as valid. Mirrors build-ios.sh's fixplist step and the sibling
265
+# splitfire repo's Scripts/build_karokowe_appstore.sh, adapted for a plain
266
+# xcodebuild archive/export instead of `cargo tauri ios build`.
267
+do_fixplist() {
268
+ step "Fixplist (beta host OS → release)"
269
+
270
+ [[ -f "$IPA_PATH" ]] || fail ".ipa not found: ${IPA_PATH}\n Run 'export' step first."
271
+ command -v /usr/libexec/PlistBuddy >/dev/null || fail "PlistBuddy not found"
272
+
273
+ local work app_dir os_cur
274
+ work="$(mktemp -d)"
275
+ trap 'rm -rf "$work"' RETURN
276
+
277
+ unzip -q "$IPA_PATH" -d "$work"
278
+ app_dir="$(find "$work/Payload" -maxdepth 1 -name '*.app' -print -quit)"
279
+ [[ -n "$app_dir" && -d "$app_dir" ]] || fail "No .app found inside ${IPA_PATH}"
280
+
281
+ os_cur="$(/usr/libexec/PlistBuddy -c 'Print :BuildMachineOSBuild' "$app_dir/Info.plist" 2>/dev/null || echo "")"
282
+ if [[ -z "${FORCE_FIXPLIST:-}" ]] && ! is_beta_build "$os_cur"; then
283
+ ok "BuildMachineOSBuild (${os_cur}) is already a release seed — no patch needed."
284
+ return 0
285
+ fi
286
+
287
+ require_release_xcode
288
+ derive_release_toolchain
289
+ echo "🛠 xcode → ${REL_DTXCODE}/${REL_XCODE_BUILD} sdk → ${REL_SDK_NAME}/${REL_SDK_BUILD}"
290
+ echo "🔁 BuildMachineOSBuild ${os_cur:-<unset>} → ${RELEASE_OS_BUILD}"
291
+ echo "🔏 identity → ${SIGN_IDENTITY}"
292
+ echo ""
293
+
294
+ # Patch every bundle's Info.plist: the main .app and any nested extensions.
295
+ local bundles=("$app_dir")
296
+ while IFS= read -r appex; do bundles+=("$appex"); done \
297
+ < <(find "$app_dir" -name '*.appex' -type d)
298
+ local bundle
299
+ for bundle in "${bundles[@]}"; do
300
+ [[ -f "$bundle/Info.plist" ]] && patch_bundle_plist "$bundle/Info.plist"
301
+ done
302
+
303
+ # Critical: App Store Connect's review-stage validation reads the `sdk`
304
+ # baked into each executable's Mach-O LC_BUILD_VERSION, not just the
305
+ # Info.plist DTSDK* keys. Patch every Mach-O in the bundle; minos is
306
+ # preserved, only the sdk is (re)set to the release SDK.
307
+ local macho minos_cur
308
+ while IFS= read -r macho; do
309
+ [[ -z "$macho" ]] && continue
310
+ minos_cur="$(xcrun vtool -show-build "$macho" 2>/dev/null | awk '/minos/{print $2; exit}')"
311
+ minos_cur="${minos_cur:-2.0}"
312
+ echo "🔁 Mach-O sdk → ${REL_SDK_VERSION} (minos ${minos_cur}): ${macho#"$app_dir"/}"
313
+ xcrun vtool -set-build-version visionos "${minos_cur}" "${REL_SDK_VERSION}" \
314
+ -replace -output "${macho}.vt" "$macho" >/dev/null 2>&1 \
315
+ && mv "${macho}.vt" "$macho" \
316
+ || fail "vtool failed to patch LC_BUILD_VERSION on ${macho#"$app_dir"/}"
317
+ done < <(find "$app_dir" -type f -exec sh -c 'file "$1" | grep -q Mach-O' _ {} \; -print)
318
+
319
+ # Re-sign nested code deepest-first, then the .app last — editing the
320
+ # plist/Mach-O invalidates the original signature.
321
+ local nested ent
322
+ while IFS= read -r nested; do
323
+ [[ -z "$nested" ]] && continue
324
+ ent="${work}/$(basename "$nested" | tr '/' '_').entitlements"
325
+ echo "✍️ sign nested → ${nested#"$app_dir"/}"
326
+ if codesign -d --entitlements :"$ent" "$nested" 2>/dev/null && [[ -s "$ent" ]]; then
327
+ codesign --force --timestamp=none --sign "$SIGN_IDENTITY" --entitlements "$ent" "$nested"
328
+ else
329
+ codesign --force --timestamp=none --sign "$SIGN_IDENTITY" "$nested"
330
+ fi
331
+ done < <(find "$app_dir" \( -name '*.framework' -o -name '*.dylib' -o -name '*.appex' \) -print \
332
+ | awk '{ print length, $0 }' | sort -rn | cut -d' ' -f2-)
333
+
334
+ ent="${work}/app.entitlements"
335
+ if codesign -d --entitlements :"$ent" "$app_dir" 2>/dev/null && [[ -s "$ent" ]]; then
336
+ codesign --force --timestamp=none --sign "$SIGN_IDENTITY" --entitlements "$ent" "$app_dir"
337
+ else
338
+ codesign --force --timestamp=none --sign "$SIGN_IDENTITY" "$app_dir"
339
+ fi
340
+ codesign --verify --deep --strict "$app_dir" || fail "Re-signed app failed signature verification"
341
+ echo "🔁 now: DTXcodeBuild=$(/usr/libexec/PlistBuddy -c 'Print :DTXcodeBuild' "$app_dir/Info.plist" 2>/dev/null) BuildMachineOSBuild=$(/usr/libexec/PlistBuddy -c 'Print :BuildMachineOSBuild' "$app_dir/Info.plist" 2>/dev/null)"
342
+
343
+ rm -f "$IPA_PATH"
344
+ ( cd "$work" && zip -qry "$IPA_PATH" Payload )
345
+ [[ -f "$IPA_PATH" ]] || fail "Repack failed: $IPA_PATH"
346
+
347
+ ok "Patched + re-signed → ${IPA_PATH}"
348
+}
349
+
350
+do_upload() {
351
+ step "Upload"
352
+
353
+ [[ -f "$IPA_PATH" ]] || fail ".ipa not found: ${IPA_PATH}\n Run 'export' step first."
354
+
355
+ echo "📤 file → ${IPA_PATH}"
356
+ echo "🔑 apiKey → ${ASC_API_KEY}"
357
+ echo "🏢 issuer → ${ASC_ISSUER_ID}"
358
+ echo ""
359
+
360
+ xcrun altool \
361
+ --upload-app \
362
+ --type visionos \
363
+ --file "${IPA_PATH}" \
364
+ --apiKey "${ASC_API_KEY}" \
365
+ --apiIssuer "${ASC_ISSUER_ID}"
366
+
367
+ ok "Uploaded to App Store Connect"
368
+}
369
+
370
+# ── Entry point ───────────────────────────────────────────────────────────────
371
+
372
+STEP="${1:-all}"
373
+
374
+case "$STEP" in
375
+ generate) do_generate ;;
376
+ archive) do_archive ;;
377
+ export) do_export ;;
378
+ fixplist) do_fixplist ;;
379
+ upload) do_upload ;;
380
+ all)
381
+ do_generate
382
+ do_archive
383
+ do_export
384
+ do_fixplist
385
+ do_upload
386
+ echo ""
387
+ echo "🎉 Pipeline complete: built and uploaded SitiVision to App Store Connect."
388
+ ;;
389
+ *)
390
+ echo "Usage: $0 [generate|archive|export|fixplist|upload|all]" >&2
391
+ exit 1
392
+ ;;
393
+esac
scripts/generate-visionos-graphics.mjs
new
+59
@@ -0,0 +1,59 @@
1
+#!/usr/bin/env node
2
+/**
3
+ * Renders App Store visionOS screenshots from HTML templates using Puppeteer.
4
+ * Apple requires exactly 3840x2160 for Apple Vision Pro screenshots (verify
5
+ * against the App Store Connect upload panel at build time — see
6
+ * assets/aso/appstore/shared.md and the app-store-optimization skill).
7
+ *
8
+ * Output: assets/aso/appstore/visionos/{01-chat,02-settings}-3840x2160.png
9
+ *
10
+ * Usage: node scripts/generate-visionos-graphics.mjs
11
+ */
12
+
13
+import puppeteer from 'puppeteer';
14
+import { fileURLToPath } from 'url';
15
+import { dirname, join, resolve } from 'path';
16
+import { mkdirSync } from 'fs';
17
+
18
+const __dirname = dirname(fileURLToPath(import.meta.url));
19
+const repoRoot = resolve(__dirname, '..');
20
+const templateDir = join(__dirname, 'appstore-templates');
21
+const outDir = join(repoRoot, 'assets', 'aso', 'appstore', 'visionos');
22
+
23
+mkdirSync(outDir, { recursive: true });
24
+
25
+const WIDTH = 3840;
26
+const HEIGHT = 2160;
27
+
28
+const targets = [
29
+ { template: '01-chat.html', output: '01-chat-3840x2160.png' },
30
+ { template: '02-settings.html', output: '02-settings-3840x2160.png' },
31
+];
32
+
33
+console.log('Launching browser…');
34
+const browser = await puppeteer.launch({
35
+ headless: true,
36
+ args: ['--no-sandbox', '--disable-setuid-sandbox'],
37
+});
38
+
39
+for (const { template, output } of targets) {
40
+ const page = await browser.newPage();
41
+ await page.setViewport({ width: WIDTH, height: HEIGHT, deviceScaleFactor: 1 });
42
+
43
+ const url = `file://${join(templateDir, template)}`;
44
+ await page.goto(url, { waitUntil: 'networkidle0', timeout: 30_000 });
45
+
46
+ // Wait a tick for any final layout reflow after font load
47
+ await new Promise(r => setTimeout(r, 500));
48
+
49
+ const outputPath = join(outDir, output);
50
+ // App Store Connect rejects screenshots with an alpha channel — omitOmitBackground
51
+ // (default) plus a fully opaque .page background keeps the PNG opaque.
52
+ await page.screenshot({ path: outputPath, fullPage: false, type: 'png' });
53
+
54
+ console.log(`✓ ${output}`);
55
+ await page.close();
56
+}
57
+
58
+await browser.close();
59
+console.log(`\nDone — files in assets/aso/appstore/visionos/`);
xcode/README.md
new
+67
@@ -0,0 +1,67 @@
1
+# Siti AI — visionOS app
2
+
3
+A native SwiftUI chat app for visionOS, built directly on the [`onde-swift`](https://github.com/ondeinference/onde-swift) SDK — the same on-device inference engine that powers the Tauri app at the repo root. This is a separate native client, not a Tauri build target; Tauri does not support visionOS.
4
+
5
+Project layout and build-target conventions (window sizing, `xros`/`xrsimulator` platform settings, App Group entitlements, solid-image-stack app icon) follow the `SplitFireAIVision` target in the sibling `splitfire` repo (`../splitfire/frontend/xcode/splitfire/SplitFireAI.xcodeproj`). The chat engine wiring (streaming bridge, model lifecycle, conversation persistence) follows `onde-swift`'s own `Examples/OndeExample` app.
6
+
7
+## Setup
8
+
9
+Install [XcodeGen](https://github.com/yonaskolb/XcodeGen) if you don't have it:
10
+
11
+```
12
+brew install xcodegen
13
+```
14
+
15
+Generate the Xcode project and open it:
16
+
17
+```
18
+cd xcode
19
+xcodegen generate
20
+open SitiVision.xcodeproj
21
+```
22
+
23
+Set your development team under Signing & Capabilities (defaults to `2TQF86ZACD`, the same team as the Tauri app's iOS build — see `../src-tauri/tauri.conf.json`), pick a Vision Pro simulator or device, hit Run.
24
+
25
+### Developing against a local `onde-swift` checkout
26
+
27
+By default `project.yml` pulls `Onde` from the published `onde-swift` releases (`from: 1.1.0`), the same way `src-tauri/Cargo.toml` pins `onde = "1.1"` from crates.io. To iterate against a sibling `onde-swift` checkout instead — e.g. while adding a new SDK method — point the package at a local path the same way the Rust side's `[patch.crates-io]` block does:
28
+
29
+```yaml
30
+packages:
31
+ Onde:
32
+ path: ../../onde-swift
33
+```
34
+
35
+Then, from that `onde-swift` checkout, build the visionOS slice before regenerating:
36
+
37
+```
38
+make visionos
39
+```
40
+
41
+`make visionos` needs the sibling `onde` Rust crate checked out next to `onde-swift` (`ONDE_REPO` defaults to `../onde`) and a nightly Rust toolchain with `-Z build-std`, since visionOS is a tier-3 target.
42
+
43
+## What it does
44
+
45
+On first launch, it downloads and loads the platform-default model (Qwen 2.5 3B on visionOS, same tier as macOS) and streams replies token-by-token. Conversations persist locally between launches. The model cache lives in the shared `group.com.ondeinference.apps` App Group, so a model already downloaded by the Tauri macOS/iOS build of Siti is reused here instead of being fetched twice.
46
+
47
+Current MVP feature set (mirrors `onde-swift`'s example app, reskinned for Siti):
48
+
49
+- token-by-token streaming with a stop button
50
+- local conversation persistence between launches
51
+- model settings sheet for system prompt + sampling preset
52
+- toolbar actions to reload the model or clear the conversation
53
+- prompt suggestions when the conversation is empty
54
+
55
+Not yet done: a real visionOS-native layout (ornaments, volumes, spatial windows) — this MVP is a single glass `WindowGroup`, functionally equivalent to the iOS/macOS chat view. Immersive-space exploration is a good next step once the 2D chat flow is verified end-to-end.
56
+
57
+## Files
58
+
59
+| File | What's in it |
60
+|------|---------------|
61
+| `SitiVisionApp.swift` | Entry point. Calls `setupInferenceEnvironment()` at launch, sizes the default window. |
62
+| `ContentView.swift` | The chat UI: header, empty state, message list, input bar, reload/clear actions, settings sheet. |
63
+| `ChatViewModel.swift` | Bridges UniFFI streaming into Swift concurrency, manages model lifecycle, restores history, persists conversation. |
64
+| `SitiDefaults.swift` | Siti's system prompt (mirrors `../src-tauri/src/chat/mod.rs`'s `CHAT_SYSTEM_PROMPT`), sampling presets, prompt suggestions. |
65
+| `InferenceEnvironment.swift` | Points `HF_HOME`/`TMPDIR` at the shared App Group container so downloads land somewhere visionOS allows and are shared with the Tauri build. |
66
+| `SitiVision.entitlements` | Generated by `xcodegen generate` from `project.yml`'s `entitlements.properties` — do not hand-edit, it's overwritten on every generate. |
67
+| `project.yml` | XcodeGen spec. `SitiVision.xcodeproj` is generated from this, not checked in. |
xcode/SitiVision/Assets.xcassets/AccentColor.colorset/Contents.json
new
+47
@@ -0,0 +1,47 @@
1
+{
2
+ "colors" : [
3
+ {
4
+ "idiom" : "universal"
5
+ },
6
+ {
7
+ "idiom" : "universal",
8
+ "appearances" : [
9
+ {
10
+ "appearance" : "luminosity",
11
+ "value" : "dark"
12
+ }
13
+ ],
14
+ "color" : {
15
+ "color-space" : "srgb",
16
+ "components" : {
17
+ "alpha" : "1.000",
18
+ "blue" : "0.878",
19
+ "green" : "0.878",
20
+ "red" : "0.878"
21
+ }
22
+ }
23
+ },
24
+ {
25
+ "color" : {
26
+ "color-space" : "srgb",
27
+ "components" : {
28
+ "alpha" : "1.000",
29
+ "blue" : "0.176",
30
+ "green" : "0.176",
31
+ "red" : "0.176"
32
+ }
33
+ },
34
+ "idiom" : "universal",
35
+ "appearances" : [
36
+ {
37
+ "appearance" : "luminosity",
38
+ "value" : "light"
39
+ }
40
+ ]
41
+ }
42
+ ],
43
+ "info" : {
44
+ "author" : "xcode",
45
+ "version" : 1
46
+ }
47
+}
xcode/SitiVision/Assets.xcassets/AppIcon.solidimagestack/Back.solidimagestacklayer/Content.imageset/Back.png
Binary files /dev/null and b/xcode/SitiVision/Assets.xcassets/AppIcon.solidimagestack/Back.solidimagestacklayer/Content.imageset/Back.png differ
xcode/SitiVision/Assets.xcassets/AppIcon.solidimagestack/Back.solidimagestacklayer/Content.imageset/Contents.json
new
+13
@@ -0,0 +1,13 @@
1
+{
2
+ "images" : [
3
+ {
4
+ "filename" : "Back.png",
5
+ "idiom" : "vision",
6
+ "scale" : "2x"
7
+ }
8
+ ],
9
+ "info" : {
10
+ "author" : "xcode",
11
+ "version" : 1
12
+ }
13
+}
xcode/SitiVision/Assets.xcassets/AppIcon.solidimagestack/Back.solidimagestacklayer/Contents.json
new
+6
@@ -0,0 +1,6 @@
1
+{
2
+ "info" : {
3
+ "author" : "xcode",
4
+ "version" : 1
5
+ }
6
+}
xcode/SitiVision/Assets.xcassets/AppIcon.solidimagestack/Contents.json
new
+23
@@ -0,0 +1,23 @@
1
+{
2
+ "info" : {
3
+ "author" : "xcode",
4
+ "version" : 1
5
+ },
6
+ "layers" : [
7
+ {
8
+ "filename" : "Front.solidimagestacklayer"
9
+ },
10
+ {
11
+ "filename" : "Middle.solidimagestacklayer"
12
+ },
13
+ {
14
+ "filename" : "Back.solidimagestacklayer"
15
+ }
16
+ ],
17
+ "properties" : {
18
+ "canvasSize" : {
19
+ "height" : 512,
20
+ "width" : 512
21
+ }
22
+ }
23
+}
xcode/SitiVision/Assets.xcassets/AppIcon.solidimagestack/Front.solidimagestacklayer/Content.imageset/Contents.json
new
+13
@@ -0,0 +1,13 @@
1
+{
2
+ "images" : [
3
+ {
4
+ "filename" : "Front.png",
5
+ "idiom" : "vision",
6
+ "scale" : "2x"
7
+ }
8
+ ],
9
+ "info" : {
10
+ "author" : "xcode",
11
+ "version" : 1
12
+ }
13
+}
xcode/SitiVision/Assets.xcassets/AppIcon.solidimagestack/Front.solidimagestacklayer/Content.imageset/Front.png
Binary files /dev/null and b/xcode/SitiVision/Assets.xcassets/AppIcon.solidimagestack/Front.solidimagestacklayer/Content.imageset/Front.png differ
xcode/SitiVision/Assets.xcassets/AppIcon.solidimagestack/Front.solidimagestacklayer/Contents.json
new
+6
@@ -0,0 +1,6 @@
1
+{
2
+ "info" : {
3
+ "author" : "xcode",
4
+ "version" : 1
5
+ }
6
+}
xcode/SitiVision/Assets.xcassets/AppIcon.solidimagestack/Middle.solidimagestacklayer/Content.imageset/Contents.json
new
+13
@@ -0,0 +1,13 @@
1
+{
2
+ "images" : [
3
+ {
4
+ "filename" : "Middle.png",
5
+ "idiom" : "vision",
6
+ "scale" : "2x"
7
+ }
8
+ ],
9
+ "info" : {
10
+ "author" : "xcode",
11
+ "version" : 1
12
+ }
13
+}
xcode/SitiVision/Assets.xcassets/AppIcon.solidimagestack/Middle.solidimagestacklayer/Content.imageset/Middle.png
Binary files /dev/null and b/xcode/SitiVision/Assets.xcassets/AppIcon.solidimagestack/Middle.solidimagestacklayer/Content.imageset/Middle.png differ
xcode/SitiVision/Assets.xcassets/AppIcon.solidimagestack/Middle.solidimagestacklayer/Contents.json
new
+6
@@ -0,0 +1,6 @@
1
+{
2
+ "info" : {
3
+ "author" : "xcode",
4
+ "version" : 1
5
+ }
6
+}
xcode/SitiVision/Assets.xcassets/Contents.json
new
+6
@@ -0,0 +1,6 @@
1
+{
2
+ "info" : {
3
+ "author" : "xcode",
4
+ "version" : 1
5
+ }
6
+}
xcode/SitiVision/ChatViewModel.swift
new
+472
@@ -0,0 +1,472 @@
1
+//
2
+// ChatViewModel.swift
3
+// Siti AI visionOS
4
+//
5
+
6
+import Foundation
7
+import Onde
8
+
9
+// ─────────────────────────────────────────────────────────────────────────────
10
+// MARK: - Message model
11
+// ─────────────────────────────────────────────────────────────────────────────
12
+
13
+struct Message: Identifiable, Codable, Hashable {
14
+ enum Role: String, Codable {
15
+ case user
16
+ case assistant
17
+ }
18
+
19
+ let id: UUID
20
+ let role: Role
21
+ var text: String
22
+ /// True while the assistant is still streaming tokens for this message.
23
+ var isStreaming: Bool
24
+
25
+ init(
26
+ id: UUID = UUID(),
27
+ role: Role,
28
+ text: String,
29
+ isStreaming: Bool = false
30
+ ) {
31
+ self.id = id
32
+ self.role = role
33
+ self.text = text
34
+ self.isStreaming = isStreaming
35
+ }
36
+}
37
+
38
+private extension Message {
39
+ var asChatMessage: ChatMessage {
40
+ switch role {
41
+ case .user:
42
+ return userMessage(content: text)
43
+ case .assistant:
44
+ return assistantMessage(content: text)
45
+ }
46
+ }
47
+}
48
+
49
+// ─────────────────────────────────────────────────────────────────────────────
50
+// MARK: - Stream bridge
51
+// ─────────────────────────────────────────────────────────────────────────────
52
+
53
+/// Thread-safe bridge between the Rust callback thread and Swift concurrency.
54
+private final class StreamBridge: @unchecked Sendable {
55
+ private let lock = NSLock()
56
+ private var continuation: AsyncStream<String>.Continuation?
57
+ private var cancelled = false
58
+
59
+ func attach(_ continuation: AsyncStream<String>.Continuation) {
60
+ lock.lock()
61
+ self.continuation = continuation
62
+ let shouldFinish = cancelled
63
+ lock.unlock()
64
+
65
+ if shouldFinish {
66
+ continuation.finish()
67
+ }
68
+ }
69
+
70
+ func yield(_ delta: String) {
71
+ lock.lock()
72
+ guard !cancelled else {
73
+ lock.unlock()
74
+ return
75
+ }
76
+ let continuation = continuation
77
+ lock.unlock()
78
+ continuation?.yield(delta)
79
+ }
80
+
81
+ func finish() {
82
+ lock.lock()
83
+ let continuation = continuation
84
+ self.continuation = nil
85
+ lock.unlock()
86
+ continuation?.finish()
87
+ }
88
+
89
+ func cancel() {
90
+ lock.lock()
91
+ cancelled = true
92
+ let continuation = continuation
93
+ self.continuation = nil
94
+ lock.unlock()
95
+ continuation?.finish()
96
+ }
97
+
98
+ var isCancelled: Bool {
99
+ lock.lock()
100
+ let cancelled = cancelled
101
+ lock.unlock()
102
+ return cancelled
103
+ }
104
+}
105
+
106
+/// Bridges the UniFFI callback interface to an `AsyncStream<String>`.
107
+private final class ChunkCollector: StreamChunkListener {
108
+ private let bridge: StreamBridge
109
+
110
+ init(bridge: StreamBridge) {
111
+ self.bridge = bridge
112
+ }
113
+
114
+ /// Called by the Rust runtime for each streamed token.
115
+ /// - Returns: `true` to keep streaming, `false` to cancel.
116
+ func onChunk(chunk: StreamChunk) -> Bool {
117
+ if bridge.isCancelled {
118
+ return false
119
+ }
120
+
121
+ if chunk.done {
122
+ bridge.finish()
123
+ return false
124
+ }
125
+
126
+ bridge.yield(chunk.delta)
127
+ return !bridge.isCancelled
128
+ }
129
+}
130
+
131
+// ─────────────────────────────────────────────────────────────────────────────
132
+// MARK: - ViewModel
133
+// ─────────────────────────────────────────────────────────────────────────────
134
+
135
+@MainActor
136
+final class ChatViewModel: ObservableObject {
137
+
138
+ // ── Published state ───────────────────────────────────────────────────────
139
+
140
+ @Published private(set) var messages: [Message]
141
+ @Published private(set) var isModelLoading: Bool = false
142
+ @Published private(set) var isModelReady: Bool = false
143
+ @Published private(set) var isSending: Bool = false
144
+ @Published private(set) var loadingProgress: String = "Preparing model…"
145
+ @Published private(set) var engineInfo: EngineInfo = EngineInfo(
146
+ status: .unloaded,
147
+ modelName: nil,
148
+ approxMemory: nil,
149
+ historyLength: 0
150
+ )
151
+
152
+ @Published var alertError: IdentifiableError? = nil
153
+
154
+ // ── Private state ─────────────────────────────────────────────────────────
155
+
156
+ // Lazy so the Rust/UniFFI runtime isn't initialized during
157
+ // @StateObject construction on the UIKit event-fetch thread.
158
+ private var engine: OndeChatEngine?
159
+ private var streamingTask: Task<Void, Never>?
160
+ private var streamBridge: StreamBridge?
161
+ private var didHydrateEngineHistory = false
162
+
163
+ private static let persistedMessagesKey = "ai.siti.vision.messages"
164
+
165
+ init() {
166
+ self.messages = Self.loadPersistedMessages()
167
+ }
168
+
169
+ private func getOrCreateEngine() -> OndeChatEngine {
170
+ if let existing = engine { return existing }
171
+ let new = OndeChatEngine()
172
+ engine = new
173
+ return new
174
+ }
175
+
176
+ var hasMessages: Bool {
177
+ !messages.isEmpty
178
+ }
179
+
180
+ var isBusy: Bool {
181
+ isModelLoading || isSending
182
+ }
183
+
184
+ // ─────────────────────────────────────────────────────────────────────────
185
+ // MARK: Model lifecycle
186
+ // ─────────────────────────────────────────────────────────────────────────
187
+
188
+ func loadModelIfNeeded(systemPrompt: String, samplingPreset: SamplingPreset) async {
189
+ if isModelReady {
190
+ await applySettings(systemPrompt: systemPrompt, samplingPreset: samplingPreset)
191
+ return
192
+ }
193
+
194
+ await loadModel(
195
+ systemPrompt: systemPrompt,
196
+ samplingPreset: samplingPreset,
197
+ forceReload: false
198
+ )
199
+ }
200
+
201
+ func retryLoadingModel(systemPrompt: String, samplingPreset: SamplingPreset) async {
202
+ await loadModel(
203
+ systemPrompt: systemPrompt,
204
+ samplingPreset: samplingPreset,
205
+ forceReload: false
206
+ )
207
+ }
208
+
209
+ func reloadModel(systemPrompt: String, samplingPreset: SamplingPreset) async {
210
+ await loadModel(
211
+ systemPrompt: systemPrompt,
212
+ samplingPreset: samplingPreset,
213
+ forceReload: true
214
+ )
215
+ }
216
+
217
+ func applySettings(systemPrompt: String, samplingPreset: SamplingPreset) async {
218
+ guard isModelReady else { return }
219
+
220
+ let engine = getOrCreateEngine()
221
+ let normalizedPrompt = normalizedSystemPrompt(systemPrompt)
222
+
223
+ if let normalizedPrompt {
224
+ await engine.setSystemPrompt(prompt: normalizedPrompt)
225
+ } else {
226
+ await engine.clearSystemPrompt()
227
+ }
228
+
229
+ await engine.setSampling(sampling: samplingPreset.samplingConfig)
230
+ await refreshEngineInfo()
231
+ }
232
+
233
+ func clearConversation() async {
234
+ cancelStreaming()
235
+ messages.removeAll()
236
+ persistMessages()
237
+
238
+ if let engine {
239
+ _ = await engine.clearHistory()
240
+ await refreshEngineInfo()
241
+ } else {
242
+ engineInfo = EngineInfo(
243
+ status: .unloaded,
244
+ modelName: nil,
245
+ approxMemory: nil,
246
+ historyLength: 0
247
+ )
248
+ }
249
+ }
250
+
251
+ // ─────────────────────────────────────────────────────────────────────────
252
+ // MARK: Sending messages
253
+ // ─────────────────────────────────────────────────────────────────────────
254
+
255
+ func send(text: String) {
256
+ let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
257
+ guard !trimmed.isEmpty, !isSending, isModelReady else { return }
258
+
259
+ alertError = nil
260
+ messages.append(Message(role: .user, text: trimmed))
261
+ persistMessages()
262
+
263
+ streamingTask = Task {
264
+ await streamResponse(for: trimmed)
265
+ }
266
+ }
267
+
268
+ func sendSuggestion(_ suggestion: PromptSuggestion) {
269
+ send(text: suggestion.prompt)
270
+ }
271
+
272
+ func cancelStreaming() {
273
+ streamBridge?.cancel()
274
+ streamingTask?.cancel()
275
+ streamingTask = nil
276
+ streamBridge = nil
277
+
278
+ if let idx = messages.indices.last, messages[idx].isStreaming {
279
+ if messages[idx].text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
280
+ messages.remove(at: idx)
281
+ } else {
282
+ messages[idx].isStreaming = false
283
+ }
284
+ persistMessages()
285
+ }
286
+
287
+ isSending = false
288
+ if engineInfo.status == .generating {
289
+ engineInfo = EngineInfo(
290
+ status: .ready,
291
+ modelName: engineInfo.modelName,
292
+ approxMemory: engineInfo.approxMemory,
293
+ historyLength: engineInfo.historyLength
294
+ )
295
+ }
296
+ }
297
+
298
+ // ─────────────────────────────────────────────────────────────────────────
299
+ // MARK: Private helpers
300
+ // ─────────────────────────────────────────────────────────────────────────
301
+
302
+ private func loadModel(
303
+ systemPrompt: String,
304
+ samplingPreset: SamplingPreset,
305
+ forceReload: Bool
306
+ ) async {
307
+ guard !isModelLoading else { return }
308
+
309
+ if forceReload {
310
+ cancelStreaming()
311
+ didHydrateEngineHistory = false
312
+ }
313
+
314
+ isModelLoading = true
315
+ isModelReady = false
316
+ alertError = nil
317
+ loadingProgress = forceReload ? "Reloading model…" : "Loading model…"
318
+
319
+ defer {
320
+ isModelLoading = false
321
+ }
322
+
323
+ do {
324
+ let elapsed = try await getOrCreateEngine().loadDefaultModel(
325
+ systemPrompt: normalizedSystemPrompt(systemPrompt),
326
+ sampling: samplingPreset.samplingConfig
327
+ )
328
+
329
+ loadingProgress = String(format: "Model ready in %.1f s", elapsed)
330
+ isModelReady = true
331
+ didHydrateEngineHistory = false
332
+ await restorePersistedConversationIfNeeded()
333
+ await refreshEngineInfo()
334
+ } catch {
335
+ isModelReady = false
336
+ await refreshEngineInfo()
337
+ alertError = IdentifiableError(error)
338
+ loadingProgress = "Unable to load model"
339
+ }
340
+ }
341
+
342
+ private func streamResponse(for userText: String) async {
343
+ isSending = true
344
+ engineInfo = EngineInfo(
345
+ status: .generating,
346
+ modelName: engineInfo.modelName,
347
+ approxMemory: engineInfo.approxMemory,
348
+ historyLength: engineInfo.historyLength
349
+ )
350
+
351
+ let assistantIndex = messages.count
352
+ messages.append(Message(role: .assistant, text: "", isStreaming: true))
353
+
354
+ let bridge = StreamBridge()
355
+ streamBridge = bridge
356
+ let listener = ChunkCollector(bridge: bridge)
357
+
358
+ let stream = AsyncStream<String> { continuation in
359
+ bridge.attach(continuation)
360
+ }
361
+
362
+ let capturedEngine = getOrCreateEngine()
363
+ let producer = Task.detached(priority: .userInitiated) {
364
+ do {
365
+ try await streamChatMessage(
366
+ engine: capturedEngine,
367
+ message: userText,
368
+ listener: listener
369
+ )
370
+ bridge.finish()
371
+ } catch {
372
+ bridge.finish()
373
+ await MainActor.run {
374
+ if !bridge.isCancelled {
375
+ self.alertError = IdentifiableError(error)
376
+ }
377
+ }
378
+ }
379
+ }
380
+
381
+ for await delta in stream {
382
+ guard !Task.isCancelled else { break }
383
+ if assistantIndex < messages.count {
384
+ messages[assistantIndex].text += delta
385
+ }
386
+ }
387
+
388
+ _ = await producer.result
389
+
390
+ let assistantHasText = assistantIndex < messages.count &&
391
+ !messages[assistantIndex].text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
392
+
393
+ if assistantIndex < messages.count {
394
+ if assistantHasText {
395
+ messages[assistantIndex].isStreaming = false
396
+ } else {
397
+ messages.remove(at: assistantIndex)
398
+ }
399
+ }
400
+
401
+ persistMessages()
402
+ streamBridge = nil
403
+ streamingTask = nil
404
+ isSending = false
405
+ await refreshEngineInfo()
406
+ }
407
+
408
+ private func restorePersistedConversationIfNeeded() async {
409
+ guard !didHydrateEngineHistory else { return }
410
+ guard !messages.isEmpty else {
411
+ didHydrateEngineHistory = true
412
+ return
413
+ }
414
+
415
+ loadingProgress = "Restoring conversation…"
416
+ let engine = getOrCreateEngine()
417
+
418
+ for message in messages {
419
+ await engine.pushHistory(message: message.asChatMessage)
420
+ }
421
+
422
+ didHydrateEngineHistory = true
423
+ }
424
+
425
+ private func refreshEngineInfo() async {
426
+ guard let engine else {
427
+ engineInfo = EngineInfo(
428
+ status: .unloaded,
429
+ modelName: nil,
430
+ approxMemory: nil,
431
+ historyLength: 0
432
+ )
433
+ return
434
+ }
435
+
436
+ let info = await engine.info()
437
+ engineInfo = info
438
+ isModelReady = info.status == .ready || info.status == .generating
439
+ }
440
+
441
+ private func normalizedSystemPrompt(_ prompt: String) -> String? {
442
+ let trimmed = prompt.trimmingCharacters(in: .whitespacesAndNewlines)
443
+ return trimmed.isEmpty ? nil : trimmed
444
+ }
445
+
446
+ private func persistMessages() {
447
+ do {
448
+ let data = try JSONEncoder().encode(messages)
449
+ UserDefaults.standard.set(data, forKey: Self.persistedMessagesKey)
450
+ } catch {
451
+ logPersistenceFailure(error)
452
+ }
453
+ }
454
+
455
+ private static func loadPersistedMessages() -> [Message] {
456
+ guard let data = UserDefaults.standard.data(forKey: persistedMessagesKey) else {
457
+ return []
458
+ }
459
+
460
+ do {
461
+ return try JSONDecoder().decode([Message].self, from: data).map {
462
+ Message(id: $0.id, role: $0.role, text: $0.text, isStreaming: false)
463
+ }
464
+ } catch {
465
+ return []
466
+ }
467
+ }
468
+
469
+ private func logPersistenceFailure(_ error: Error) {
470
+ NSLog("SitiVision: failed to persist messages: %@", String(describing: error))
471
+ }
472
+}
xcode/SitiVision/ContentView.swift
new
+555
@@ -0,0 +1,555 @@
1
+//
2
+// ContentView.swift
3
+// Siti AI visionOS
4
+//
5
+
6
+import SwiftUI
7
+import Onde
8
+
9
+// ─────────────────────────────────────────────────────────────────────────────
10
+// MARK: - Root view
11
+// ─────────────────────────────────────────────────────────────────────────────
12
+
13
+struct ContentView: View {
14
+
15
+ @StateObject private var viewModel = ChatViewModel()
16
+ @State private var showingSettings = false
17
+
18
+ @AppStorage("ai.siti.vision.systemPrompt")
19
+ private var systemPrompt: String = SitiDefaults.systemPrompt
20
+
21
+ @AppStorage("ai.siti.vision.samplingPreset")
22
+ private var samplingPresetRawValue: String = SamplingPreset.balanced.rawValue
23
+
24
+ private var samplingPreset: SamplingPreset {
25
+ SamplingPreset(rawValue: samplingPresetRawValue) ?? .balanced
26
+ }
27
+
28
+ var body: some View {
29
+ NavigationStack {
30
+ VStack(spacing: 0) {
31
+ HeaderView(
32
+ info: viewModel.engineInfo,
33
+ isLoading: viewModel.isModelLoading,
34
+ isSending: viewModel.isSending
35
+ )
36
+
37
+ Divider()
38
+
39
+ ZStack {
40
+ if viewModel.messages.isEmpty {
41
+ EmptyStateView(
42
+ isModelReady: viewModel.isModelReady,
43
+ isModelLoading: viewModel.isModelLoading,
44
+ loadingProgress: viewModel.loadingProgress,
45
+ promptSuggestions: SitiDefaults.promptSuggestions,
46
+ onPromptTap: { suggestion in
47
+ viewModel.sendSuggestion(suggestion)
48
+ },
49
+ onRetry: {
50
+ Task {
51
+ await viewModel.retryLoadingModel(
52
+ systemPrompt: systemPrompt,
53
+ samplingPreset: samplingPreset
54
+ )
55
+ }
56
+ }
57
+ )
58
+ } else {
59
+ MessageListView(messages: viewModel.messages)
60
+ }
61
+
62
+ if viewModel.isModelLoading {
63
+ LoadingOverlayView(progress: viewModel.loadingProgress)
64
+ }
65
+ }
66
+
67
+ Divider()
68
+
69
+ InputBarView(
70
+ isSending: viewModel.isSending,
71
+ isModelReady: viewModel.isModelReady,
72
+ placeholder: viewModel.isModelReady
73
+ ? "Message Siti…"
74
+ : "Waiting for model to load…",
75
+ onSend: { text in viewModel.send(text: text) },
76
+ onCancel: { viewModel.cancelStreaming() }
77
+ )
78
+ }
79
+ .navigationTitle("Siti AI")
80
+ .toolbar {
81
+ ToolbarItemGroup(placement: .cancellationAction) {
82
+ if viewModel.hasMessages {
83
+ Button(role: .destructive) {
84
+ Task {
85
+ await viewModel.clearConversation()
86
+ }
87
+ } label: {
88
+ Label("Clear", systemImage: "trash")
89
+ }
90
+ .disabled(viewModel.isBusy)
91
+ }
92
+ }
93
+
94
+ ToolbarItemGroup(placement: .confirmationAction) {
95
+ Button {
96
+ Task {
97
+ await viewModel.reloadModel(
98
+ systemPrompt: systemPrompt,
99
+ samplingPreset: samplingPreset
100
+ )
101
+ }
102
+ } label: {
103
+ Label("Reload model", systemImage: "arrow.clockwise")
104
+ }
105
+ .disabled(viewModel.isBusy)
106
+
107
+ Button {
108
+ showingSettings = true
109
+ } label: {
110
+ Label("Model settings", systemImage: "slider.horizontal.3")
111
+ }
112
+ .disabled(viewModel.isBusy)
113
+ }
114
+ }
115
+ }
116
+ .frame(minWidth: 640, minHeight: 480)
117
+ .task {
118
+ await viewModel.loadModelIfNeeded(
119
+ systemPrompt: systemPrompt,
120
+ samplingPreset: samplingPreset
121
+ )
122
+ }
123
+ .sheet(isPresented: $showingSettings) {
124
+ ModelSettingsSheet(
125
+ systemPrompt: systemPrompt,
126
+ samplingPreset: samplingPreset
127
+ ) { updatedPrompt, updatedPreset in
128
+ systemPrompt = updatedPrompt
129
+ samplingPresetRawValue = updatedPreset.rawValue
130
+ Task {
131
+ await viewModel.applySettings(
132
+ systemPrompt: updatedPrompt,
133
+ samplingPreset: updatedPreset
134
+ )
135
+ }
136
+ }
137
+ }
138
+ .alert(
139
+ "Error",
140
+ isPresented: Binding(
141
+ get: { viewModel.alertError != nil },
142
+ set: { if !$0 { viewModel.alertError = nil } }
143
+ ),
144
+ presenting: viewModel.alertError
145
+ ) { _ in
146
+ Button("OK", role: .cancel) { viewModel.alertError = nil }
147
+ } message: { error in
148
+ Text(error.message)
149
+ }
150
+ }
151
+}
152
+
153
+// ─────────────────────────────────────────────────────────────────────────────
154
+// MARK: - Header
155
+// ─────────────────────────────────────────────────────────────────────────────
156
+
157
+private struct HeaderView: View {
158
+ let info: EngineInfo
159
+ let isLoading: Bool
160
+ let isSending: Bool
161
+
162
+ private var statusText: String {
163
+ if isLoading { return "Loading" }
164
+ if isSending { return "Generating" }
165
+
166
+ switch info.status {
167
+ case .ready: return "Ready"
168
+ case .generating: return "Generating"
169
+ case .loading: return "Loading"
170
+ case .error: return "Error"
171
+ case .unloaded: return "Unloaded"
172
+ }
173
+ }
174
+
175
+ private var statusColor: Color {
176
+ if isLoading { return .orange }
177
+ if isSending { return .blue }
178
+
179
+ switch info.status {
180
+ case .ready: return .green
181
+ case .generating: return .blue
182
+ case .loading: return .orange
183
+ case .error: return .red
184
+ case .unloaded: return .secondary
185
+ }
186
+ }
187
+
188
+ var body: some View {
189
+ HStack(spacing: 12) {
190
+ Image(systemName: "brain.head.profile")
191
+ .font(.title2)
192
+ .foregroundStyle(statusColor)
193
+
194
+ VStack(alignment: .leading, spacing: 2) {
195
+ Text("Siti AI")
196
+ .font(.headline)
197
+
198
+ Text(info.modelName ?? "on-device · private")
199
+ .font(.caption)
200
+ .foregroundStyle(.secondary)
201
+ }
202
+
203
+ Spacer()
204
+
205
+ Label(info.approxMemory ?? "—", systemImage: "memorychip")
206
+ Label("\(info.historyLength) turns", systemImage: "text.bubble")
207
+
208
+ Text(statusText)
209
+ .font(.caption.weight(.semibold))
210
+ .padding(.horizontal, 10)
211
+ .padding(.vertical, 6)
212
+ .background(statusColor.opacity(0.14), in: Capsule())
213
+ .foregroundStyle(statusColor)
214
+ }
215
+ .font(.caption)
216
+ .foregroundStyle(.secondary)
217
+ .padding(.horizontal, 20)
218
+ .padding(.vertical, 14)
219
+ }
220
+}
221
+
222
+// ─────────────────────────────────────────────────────────────────────────────
223
+// MARK: - Loading overlay
224
+// ─────────────────────────────────────────────────────────────────────────────
225
+
226
+private struct LoadingOverlayView: View {
227
+ let progress: String
228
+
229
+ var body: some View {
230
+ VStack(spacing: 16) {
231
+ ProgressView()
232
+ .scaleEffect(1.4)
233
+
234
+ Text(progress)
235
+ .font(.subheadline)
236
+ .foregroundStyle(.secondary)
237
+ .multilineTextAlignment(.center)
238
+ }
239
+ .padding(.horizontal, 28)
240
+ .padding(.vertical, 24)
241
+ .glassBackgroundEffect(in: RoundedRectangle(cornerRadius: 20, style: .continuous))
242
+ .padding(.horizontal, 24)
243
+ }
244
+}
245
+
246
+// ─────────────────────────────────────────────────────────────────────────────
247
+// MARK: - Empty state
248
+// ─────────────────────────────────────────────────────────────────────────────
249
+
250
+private struct EmptyStateView: View {
251
+ let isModelReady: Bool
252
+ let isModelLoading: Bool
253
+ let loadingProgress: String
254
+ let promptSuggestions: [PromptSuggestion]
255
+ let onPromptTap: (PromptSuggestion) -> Void
256
+ let onRetry: () -> Void
257
+
258
+ var body: some View {
259
+ ScrollView {
260
+ VStack(alignment: .leading, spacing: 20) {
261
+ VStack(alignment: .leading, spacing: 8) {
262
+ Text(isModelReady ? "How can I help?" : "Preparing the on-device model")
263
+ .font(.title2.weight(.semibold))
264
+
265
+ Text(subtitle)
266
+ .font(.subheadline)
267
+ .foregroundStyle(.secondary)
268
+ }
269
+
270
+ if isModelReady {
271
+ VStack(alignment: .leading, spacing: 12) {
272
+ Text("Try one of these prompts")
273
+ .font(.headline)
274
+
275
+ ForEach(promptSuggestions) { suggestion in
276
+ Button {
277
+ onPromptTap(suggestion)
278
+ } label: {
279
+ VStack(alignment: .leading, spacing: 6) {
280
+ Text(suggestion.title)
281
+ .font(.subheadline.weight(.semibold))
282
+ .foregroundStyle(.primary)
283
+ Text(suggestion.prompt)
284
+ .font(.subheadline)
285
+ .foregroundStyle(.secondary)
286
+ .multilineTextAlignment(.leading)
287
+ }
288
+ .frame(maxWidth: .infinity, alignment: .leading)
289
+ .padding(16)
290
+ .background(
291
+ RoundedRectangle(cornerRadius: 18, style: .continuous)
292
+ .fill(Color.secondary.opacity(0.10))
293
+ )
294
+ }
295
+ .buttonStyle(.plain)
296
+ .hoverEffect(.highlight)
297
+ }
298
+ }
299
+ } else {
300
+ VStack(alignment: .leading, spacing: 12) {
301
+ Label(loadingProgress, systemImage: isModelLoading ? "arrow.down.circle" : "exclamationmark.triangle")
302
+ .font(.subheadline)
303
+ .foregroundStyle(.secondary)
304
+
305
+ if !isModelLoading {
306
+ Button(action: onRetry) {
307
+ Label("Retry loading model", systemImage: "arrow.clockwise")
308
+ }
309
+ .buttonStyle(.borderedProminent)
310
+ }
311
+ }
312
+ }
313
+ }
314
+ .frame(maxWidth: 640, alignment: .leading)
315
+ .frame(maxWidth: .infinity, alignment: .center)
316
+ .padding(28)
317
+ }
318
+ }
319
+
320
+ private var subtitle: String {
321
+ if isModelReady {
322
+ return "Your personal assistant, running entirely on this device. Nothing you say ever leaves it."
323
+ }
324
+
325
+ return "The first launch may take a while because the model has to be downloaded and loaded into memory before the chat becomes interactive."
326
+ }
327
+}
328
+
329
+// ─────────────────────────────────────────────────────────────────────────────
330
+// MARK: - Message list
331
+// ─────────────────────────────────────────────────────────────────────────────
332
+
333
+private struct MessageListView: View {
334
+ let messages: [Message]
335
+
336
+ var body: some View {
337
+ ScrollViewReader { proxy in
338
+ ScrollView {
339
+ LazyVStack(alignment: .leading, spacing: 12) {
340
+ ForEach(messages) { message in
341
+ MessageBubble(message: message)
342
+ .id(message.id)
343
+ }
344
+ }
345
+ .frame(maxWidth: 760)
346
+ .frame(maxWidth: .infinity, alignment: .center)
347
+ .padding(20)
348
+ }
349
+ .onChange(of: messages.count) { _, _ in
350
+ if let last = messages.last {
351
+ withAnimation(.easeOut(duration: 0.2)) {
352
+ proxy.scrollTo(last.id, anchor: .bottom)
353
+ }
354
+ }
355
+ }
356
+ .onChange(of: messages.last?.text) { _, _ in
357
+ if let last = messages.last {
358
+ proxy.scrollTo(last.id, anchor: .bottom)
359
+ }
360
+ }
361
+ }
362
+ }
363
+}
364
+
365
+// ─────────────────────────────────────────────────────────────────────────────
366
+// MARK: - Message bubble
367
+// ─────────────────────────────────────────────────────────────────────────────
368
+
369
+private struct MessageBubble: View {
370
+ let message: Message
371
+
372
+ private var isUser: Bool { message.role == .user }
373
+
374
+ var body: some View {
375
+ HStack {
376
+ if isUser { Spacer(minLength: 48) }
377
+
378
+ VStack(alignment: isUser ? .trailing : .leading, spacing: 4) {
379
+ Text(isUser ? "You" : "Siti")
380
+ .font(.caption2)
381
+ .foregroundStyle(.secondary)
382
+ .padding(.horizontal, 4)
383
+
384
+ HStack(alignment: .bottom, spacing: 6) {
385
+ Text(message.text.isEmpty ? " " : message.text)
386
+ .font(.body)
387
+ .foregroundStyle(isUser ? .white : .primary)
388
+ .padding(.horizontal, 14)
389
+ .padding(.vertical, 10)
390
+ .background(
391
+ RoundedRectangle(cornerRadius: 18, style: .continuous)
392
+ .fill(isUser ? Color.accentColor : Color.secondary.opacity(0.15))
393
+ )
394
+
395
+ if message.isStreaming {
396
+ ProgressView()
397
+ .scaleEffect(0.7)
398
+ .padding(.bottom, 8)
399
+ }
400
+ }
401
+ }
402
+
403
+ if !isUser { Spacer(minLength: 48) }
404
+ }
405
+ }
406
+}
407
+
408
+// ─────────────────────────────────────────────────────────────────────────────
409
+// MARK: - Input bar
410
+// ─────────────────────────────────────────────────────────────────────────────
411
+
412
+private struct InputBarView: View {
413
+ let isSending: Bool
414
+ let isModelReady: Bool
415
+ let placeholder: String
416
+ let onSend: (String) -> Void
417
+ let onCancel: () -> Void
418
+
419
+ @State private var draftText: String = ""
420
+ @FocusState private var fieldFocused: Bool
421
+
422
+ private var canSend: Bool {
423
+ isModelReady && !isSending && !draftText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
424
+ }
425
+
426
+ var body: some View {
427
+ HStack(spacing: 10) {
428
+ TextField(placeholder, text: $draftText, axis: .vertical)
429
+ .lineLimit(1...5)
430
+ .padding(.horizontal, 14)
431
+ .padding(.vertical, 10)
432
+ .background(
433
+ RoundedRectangle(cornerRadius: 20, style: .continuous)
434
+ .fill(Color.secondary.opacity(0.15))
435
+ )
436
+ .focused($fieldFocused)
437
+ .disabled(!isModelReady || isSending)
438
+ .onSubmit {
439
+ submitIfReady()
440
+ }
441
+
442
+ Group {
443
+ if isSending {
444
+ Button(action: onCancel) {
445
+ Image(systemName: "stop.circle.fill")
446
+ .font(.system(size: 32))
447
+ .foregroundStyle(.red)
448
+ }
449
+ .accessibilityLabel("Stop generating")
450
+ } else {
451
+ Button(action: submitIfReady) {
452
+ Image(systemName: "arrow.up.circle.fill")
453
+ .font(.system(size: 32))
454
+ .foregroundStyle(canSend ? Color.accentColor : .secondary)
455
+ }
456
+ .disabled(!canSend)
457
+ .accessibilityLabel("Send message")
458
+ }
459
+ }
460
+ .buttonStyle(.plain)
461
+ .animation(.easeInOut(duration: 0.15), value: isSending)
462
+ }
463
+ .frame(maxWidth: 760)
464
+ .frame(maxWidth: .infinity, alignment: .center)
465
+ .padding(.horizontal, 20)
466
+ .padding(.vertical, 14)
467
+ }
468
+
469
+ private func submitIfReady() {
470
+ guard canSend else { return }
471
+ let text = draftText
472
+ draftText = ""
473
+ onSend(text)
474
+ }
475
+}
476
+
477
+// ─────────────────────────────────────────────────────────────────────────────
478
+// MARK: - Model settings sheet
479
+// ─────────────────────────────────────────────────────────────────────────────
480
+
481
+private struct ModelSettingsSheet: View {
482
+ let initialSystemPrompt: String
483
+ let initialSamplingPreset: SamplingPreset
484
+ let onSave: (String, SamplingPreset) -> Void
485
+
486
+ @Environment(\.dismiss) private var dismiss
487
+ @State private var systemPrompt: String
488
+ @State private var samplingPreset: SamplingPreset
489
+
490
+ init(
491
+ systemPrompt: String,
492
+ samplingPreset: SamplingPreset,
493
+ onSave: @escaping (String, SamplingPreset) -> Void
494
+ ) {
495
+ self.initialSystemPrompt = systemPrompt
496
+ self.initialSamplingPreset = samplingPreset
497
+ self.onSave = onSave
498
+ _systemPrompt = State(initialValue: systemPrompt)
499
+ _samplingPreset = State(initialValue: samplingPreset)
500
+ }
501
+
502
+ var body: some View {
503
+ NavigationStack {
504
+ Form {
505
+ Section {
506
+ TextEditor(text: $systemPrompt)
507
+ .frame(minHeight: 140)
508
+ } header: {
509
+ Text("System Prompt")
510
+ } footer: {
511
+ Text("This prompt applies to future generations. Leave it blank to clear the runtime system prompt.")
512
+ }
513
+
514
+ Section("Response Style") {
515
+ Picker("Sampling Preset", selection: $samplingPreset) {
516
+ ForEach(SamplingPreset.allCases) { preset in
517
+ VStack(alignment: .leading, spacing: 2) {
518
+ Text(preset.title)
519
+ Text(preset.subtitle)
520
+ .font(.caption)
521
+ .foregroundStyle(.secondary)
522
+ }
523
+ .tag(preset)
524
+ }
525
+ }
526
+ .pickerStyle(.inline)
527
+ }
528
+ }
529
+ .navigationTitle("Model Settings")
530
+ .toolbar {
531
+ ToolbarItem(placement: .cancellationAction) {
532
+ Button("Cancel") {
533
+ dismiss()
534
+ }
535
+ }
536
+
537
+ ToolbarItem(placement: .confirmationAction) {
538
+ Button("Save") {
539
+ onSave(systemPrompt, samplingPreset)
540
+ dismiss()
541
+ }
542
+ }
543
+ }
544
+ }
545
+ .frame(minWidth: 480, minHeight: 420)
546
+ }
547
+}
548
+
549
+// ─────────────────────────────────────────────────────────────────────────────
550
+// MARK: - Preview
551
+// ─────────────────────────────────────────────────────────────────────────────
552
+
553
+#Preview {
554
+ ContentView()
555
+}
xcode/SitiVision/InferenceEnvironment.swift
new
+34
@@ -0,0 +1,34 @@
1
+//
2
+// InferenceEnvironment.swift
3
+// Siti AI visionOS
4
+//
5
+
6
+import Foundation
7
+
8
+/// Points `HF_HOME` and `HF_HUB_CACHE` at a writable location inside the app
9
+/// sandbox. Uses the same `group.com.ondeinference.apps` App Group as the
10
+/// Tauri build of Siti (see `src-tauri/src/setup/setup_application_filesystem.rs`)
11
+/// so a model downloaded by one Siti app on this device is reused by the
12
+/// other instead of being fetched twice. Falls back to the app's own
13
+/// Application Support directory if the group is unavailable.
14
+///
15
+/// Call this once at launch, before creating an `OndeChatEngine`.
16
+func setupInferenceEnvironment() {
17
+ let fm = FileManager.default
18
+
19
+ let base: URL = fm.containerURL(
20
+ forSecurityApplicationGroupIdentifier: "group.com.ondeinference.apps"
21
+ ) ?? fm.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
22
+
23
+ let hfHome = base.appendingPathComponent("models", isDirectory: true)
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
+ // Make sure TMPDIR points inside the sandbox for the Rust side.
31
+ let tmp = base.appendingPathComponent("tmp", isDirectory: true)
32
+ try? fm.createDirectory(at: tmp, withIntermediateDirectories: true)
33
+ setenv("TMPDIR", tmp.path, 1)
34
+}
xcode/SitiVision/SitiDefaults.swift
new
+103
@@ -0,0 +1,103 @@
1
+//
2
+// SitiDefaults.swift
3
+// Siti AI visionOS
4
+//
5
+
6
+import SwiftUI
7
+import Onde
8
+
9
+/// Mirrors `CHAT_SYSTEM_PROMPT` in `src-tauri/src/chat/mod.rs` so the native
10
+/// visionOS client and the Tauri desktop/mobile client present the same
11
+/// assistant persona.
12
+enum SitiDefaults {
13
+ static let systemPrompt = """
14
+ You are Siti, a friendly and capable personal assistant that runs entirely on the user's device. Nothing the user says ever leaves the device.
15
+
16
+ You help with everyday things: answering questions, brainstorming, drafting and editing text, summarising, explaining ideas in plain terms, and planning.
17
+
18
+ Keep replies clear and concise, and prefer plain language. If a request is ambiguous, ask one short question instead of guessing. Use a short list only when it genuinely helps, and go easy on emoji.
19
+
20
+ You run locally, so you may not know about very recent events. When you are not sure, say so instead of inventing an answer.
21
+ """
22
+
23
+ static let promptSuggestions: [PromptSuggestion] = [
24
+ PromptSuggestion(
25
+ title: "How can I help?",
26
+ prompt: "What can you help me with on visionOS?"
27
+ ),
28
+ PromptSuggestion(
29
+ title: "Brainstorm",
30
+ prompt: "Give me three ideas for staying focused while working in a shared space."
31
+ ),
32
+ PromptSuggestion(
33
+ title: "Summarize",
34
+ prompt: "Explain the difference between a window and a volume in visionOS, in plain terms."
35
+ ),
36
+ PromptSuggestion(
37
+ title: "Draft something",
38
+ prompt: "Draft a short, friendly message asking a teammate to review my PR."
39
+ )
40
+ ]
41
+}
42
+
43
+struct PromptSuggestion: Identifiable, Hashable {
44
+ let title: String
45
+ let prompt: String
46
+
47
+ var id: String { title }
48
+}
49
+
50
+enum SamplingPreset: String, CaseIterable, Identifiable {
51
+ case balanced
52
+ case deterministic
53
+ case mobile
54
+
55
+ var id: String { rawValue }
56
+
57
+ var title: String {
58
+ switch self {
59
+ case .balanced:
60
+ return "Balanced"
61
+ case .deterministic:
62
+ return "Deterministic"
63
+ case .mobile:
64
+ return "Fast"
65
+ }
66
+ }
67
+
68
+ var subtitle: String {
69
+ switch self {
70
+ case .balanced:
71
+ return "General-purpose responses with a bit of creativity."
72
+ case .deterministic:
73
+ return "Reproducible, focused answers."
74
+ case .mobile:
75
+ return "Shorter responses with lower latency."
76
+ }
77
+ }
78
+
79
+ var samplingConfig: SamplingConfig {
80
+ switch self {
81
+ case .balanced:
82
+ return defaultSamplingConfig()
83
+ case .deterministic:
84
+ return deterministicSamplingConfig()
85
+ case .mobile:
86
+ return mobileSamplingConfig()
87
+ }
88
+ }
89
+}
90
+
91
+/// Makes any `Error` `Identifiable` so it can drive a SwiftUI `.alert`.
92
+struct IdentifiableError: Identifiable {
93
+ let id = UUID()
94
+ let underlying: Error
95
+
96
+ init(_ error: Error) {
97
+ self.underlying = error
98
+ }
99
+
100
+ var message: String {
101
+ underlying.localizedDescription
102
+ }
103
+}
xcode/SitiVision/SitiVision.entitlements
new
+12
@@ -0,0 +1,12 @@
1
+<?xml version="1.0" encoding="UTF-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>
10
+ </array>
11
+</dict>
12
+</plist>
xcode/SitiVision/SitiVisionApp.swift
new
+23
@@ -0,0 +1,23 @@
1
+//
2
+// SitiVisionApp.swift
3
+// Siti AI visionOS
4
+//
5
+
6
+import SwiftUI
7
+
8
+@main
9
+struct SitiVisionApp: App {
10
+
11
+ init() {
12
+ // Must be called before any OndeChatEngine interaction so the Rust
13
+ // core can find its cache directories inside the app sandbox.
14
+ setupInferenceEnvironment()
15
+ }
16
+
17
+ var body: some Scene {
18
+ WindowGroup {
19
+ ContentView()
20
+ }
21
+ .defaultSize(width: 900, height: 700)
22
+ }
23
+}
xcode/project.yml
new
+80
@@ -0,0 +1,80 @@
1
+name: SitiVision
2
+options:
3
+ bundleIdPrefix: ai.siti
4
+ deploymentTarget:
5
+ visionOS: "2.0"
6
+ xcodeVersion: "16.0"
7
+ generateEmptyDirectories: false
8
+ createIntermediateGroups: false
9
+
10
+# Onde is normally consumed from its published releases, same as the Rust
11
+# side pins `onde = "1.1"` from crates.io in src-tauri/Cargo.toml. To develop
12
+# against a local sibling `onde-swift` checkout instead, point this at a
13
+# `path:` the same way src-tauri's `[patch.crates-io]` block does.
14
+packages:
15
+ Onde:
16
+ url: https://github.com/ondeinference/onde-swift
17
+ from: 1.1.0
18
+
19
+schemes:
20
+ SitiVision:
21
+ build:
22
+ targets:
23
+ SitiVision: all
24
+ run:
25
+ config: Debug
26
+ analyze:
27
+ config: Debug
28
+ archive:
29
+ config: Release
30
+ profile:
31
+ config: Release
32
+
33
+targets:
34
+ SitiVision:
35
+ type: application
36
+ platform: visionOS
37
+ deploymentTarget: "2.0"
38
+ sources:
39
+ - path: SitiVision
40
+ excludes:
41
+ - build
42
+ - project.yml
43
+ - "*.xcodeproj"
44
+ entitlements:
45
+ path: SitiVision/SitiVision.entitlements
46
+ properties:
47
+ com.apple.developer.kernel.increased-memory-limit: true
48
+ com.apple.security.application-groups:
49
+ - group.com.ondeinference.apps
50
+ dependencies:
51
+ - package: Onde
52
+ product: Onde
53
+ settings:
54
+ base:
55
+ PRODUCT_BUNDLE_IDENTIFIER: ai.siti.Siti
56
+ DEVELOPMENT_TEAM: 2TQF86ZACD
57
+ MARKETING_VERSION: "1.0.0"
58
+ CURRENT_PROJECT_VERSION: 1
59
+ ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
60
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor
61
+ GENERATE_INFOPLIST_FILE: YES
62
+ INFOPLIST_KEY_CFBundleDisplayName: "Siti AI"
63
+ INFOPLIST_KEY_UIApplicationSceneManifest_Generation: YES
64
+ INFOPLIST_KEY_LSApplicationCategoryType: "public.app-category.productivity"
65
+ INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO
66
+ SWIFT_VERSION: "6.0"
67
+ ENABLE_USER_SCRIPT_SANDBOXING: YES
68
+ CODE_SIGN_STYLE: Automatic
69
+ REGISTER_APP_GROUPS: YES
70
+ "EXCLUDED_ARCHS[sdk=xrsimulator*]": x86_64
71
+ configs:
72
+ # No CODE_SIGN_IDENTITY override here: with CODE_SIGN_STYLE Automatic,
73
+ # explicitly pinning an identity conflicts with Xcode's own automatic
74
+ # dev/distribution resolution and fails archive with "conflicting
75
+ # provisioning settings" even though the identity matches what Xcode
76
+ # would have picked anyway. Let Automatic signing choose it per config.
77
+ Debug:
78
+ SWIFT_OPTIMIZATION_LEVEL: -Onone
79
+ Release:
80
+ SWIFT_OPTIMIZATION_LEVEL: -O