BlueSky & more on desktop lazurite.stormlightlabs.org/
tauri rust typescript bluesky appview atproto solid
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add settings module with commands

* settings wireframe/design

+1966 -34
+642
docs/designs/settings.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 + <title>Settings - Lazurite</title> 7 + <script src="https://cdn.tailwindcss.com"></script> 8 + <link href="https://fonts.googleapis.com/css2?family=Google+Sans:wght@400;500;700&display=swap" rel="stylesheet"> 9 + <style> 10 + :root { 11 + --surface-container-lowest: #000000; 12 + --surface: #0e0e0e; 13 + --surface-container: #191919; 14 + --surface-container-high: #1f1f1f; 15 + --surface-container-highest: rgba(36, 36, 36, 0.7); 16 + --surface-bright: rgba(255, 255, 255, 0.05); 17 + --primary: #7dafff; 18 + --primary-dim: #0073de; 19 + --on-primary-fixed: #05080f; 20 + --on-surface: #f4f6fb; 21 + --on-surface-variant: #ababab; 22 + --on-secondary-container: #c9d1dd; 23 + --error: #ff6b6b; 24 + } 25 + 26 + * { box-sizing: border-box; } 27 + 28 + body { 29 + margin: 0; 30 + min-height: 100vh; 31 + font-family: "Google Sans", "Segoe UI", sans-serif; 32 + background: 33 + radial-gradient(circle at 14% 12%, rgba(125, 175, 255, 0.22), transparent 32%), 34 + radial-gradient(circle at 88% 22%, rgba(0, 115, 222, 0.18), transparent 28%), 35 + radial-gradient(circle at 72% 88%, rgba(125, 175, 255, 0.12), transparent 30%), 36 + var(--surface-container-lowest); 37 + color: var(--on-surface); 38 + } 39 + 40 + .panel { 41 + background: rgba(25, 25, 25, 0.6); 42 + border: 1px solid rgba(255, 255, 255, 0.05); 43 + } 44 + 45 + .gradient-btn { 46 + background: linear-gradient(135deg, #7dafff 0%, #0073de 100%); 47 + color: #05080f; 48 + } 49 + 50 + .app-rail { 51 + width: 64px; 52 + background: var(--surface-container-lowest); 53 + } 54 + 55 + .rail-icon { 56 + width: 40px; 57 + height: 40px; 58 + display: flex; 59 + align-items: center; 60 + justify-content: center; 61 + border-radius: 12px; 62 + transition: all 0.15s ease; 63 + } 64 + 65 + .rail-icon.active { 66 + color: var(--primary); 67 + background: rgba(125, 175, 255, 0.1); 68 + } 69 + 70 + .rail-icon:not(.active) { 71 + color: var(--on-surface-variant); 72 + } 73 + 74 + .rail-icon:not(.active):hover { 75 + color: var(--on-surface); 76 + background: rgba(255, 255, 255, 0.05); 77 + } 78 + 79 + .settings-card { 80 + background: var(--surface-container); 81 + border-radius: 1.5rem; 82 + padding: 1.5rem; 83 + } 84 + 85 + .segmented-control { 86 + display: flex; 87 + background: rgba(0, 0, 0, 0.4); 88 + border-radius: 0.75rem; 89 + padding: 0.25rem; 90 + } 91 + 92 + .segmented-control button { 93 + flex: 1; 94 + padding: 0.5rem 1rem; 95 + border-radius: 0.5rem; 96 + font-size: 0.875rem; 97 + transition: all 0.15s ease; 98 + background: transparent; 99 + color: var(--on-surface-variant); 100 + border: none; 101 + cursor: pointer; 102 + } 103 + 104 + .segmented-control button.active { 105 + background: rgba(125, 175, 255, 0.2); 106 + color: var(--primary); 107 + } 108 + 109 + .toggle-switch { 110 + width: 48px; 111 + height: 24px; 112 + background: rgba(255, 255, 255, 0.2); 113 + border-radius: 12px; 114 + position: relative; 115 + cursor: pointer; 116 + transition: background 0.15s ease; 117 + } 118 + 119 + .toggle-switch.enabled { 120 + background: var(--primary); 121 + } 122 + 123 + .toggle-switch::after { 124 + content: ''; 125 + position: absolute; 126 + width: 20px; 127 + height: 20px; 128 + background: white; 129 + border-radius: 50%; 130 + top: 2px; 131 + left: 2px; 132 + transition: transform 0.15s ease; 133 + } 134 + 135 + .toggle-switch.enabled::after { 136 + transform: translateX(24px); 137 + } 138 + 139 + .input-field { 140 + background: rgba(0, 0, 0, 0.4); 141 + border: 1px solid rgba(255, 255, 255, 0.1); 142 + border-radius: 0.5rem; 143 + padding: 0.75rem 1rem; 144 + color: var(--on-surface); 145 + font-size: 0.875rem; 146 + width: 100%; 147 + outline: none; 148 + transition: border-color 0.15s ease; 149 + } 150 + 151 + .input-field:focus { 152 + border-color: rgba(125, 175, 255, 0.5); 153 + box-shadow: 0 0 0 3px rgba(125, 175, 255, 0.1); 154 + } 155 + 156 + .input-field::placeholder { 157 + color: rgba(255, 255, 255, 0.3); 158 + } 159 + 160 + .glass-overlay { 161 + background: rgba(36, 36, 36, 0.7); 162 + backdrop-filter: blur(20px); 163 + } 164 + 165 + .account-item { 166 + background: rgba(255, 255, 255, 0.03); 167 + border-radius: 0.75rem; 168 + padding: 1rem; 169 + transition: background 0.15s ease; 170 + } 171 + 172 + .account-item:hover { 173 + background: rgba(255, 255, 255, 0.05); 174 + } 175 + 176 + .danger-btn { 177 + background: rgba(255, 107, 107, 0.1); 178 + color: var(--error); 179 + border: 1px solid rgba(255, 107, 107, 0.3); 180 + } 181 + 182 + .danger-btn:hover { 183 + background: rgba(255, 107, 107, 0.2); 184 + } 185 + </style> 186 + </head> 187 + <body class="flex"> 188 + <!-- App Rail --> 189 + <aside class="app-rail fixed left-0 top-0 h-full flex flex-col items-center py-4 z-50"> 190 + <div class="mb-6"> 191 + <svg width="40" height="40" viewBox="0 0 512 512" style="color: #7dafff;"> 192 + <path fill="currentColor" d="M128 16v99.3l119 118.9V120.1zm256 0L265 120.1v114.1l119-119zM16 128l104 119h114.2L115.3 128zm380.8 0l-119 119h114.1l104-119zM120 265L16 384h99.2l119-119zm157.8 0l119 119h99.1l-104-119zM247 277.8l-119 119V496l119-104.1zm18 0v114.1L384 496v-99.2z"/> 193 + </svg> 194 + </div> 195 + 196 + <nav class="flex flex-col gap-1 flex-1"> 197 + <button class="rail-icon" title="Timeline"> 198 + <svg width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 199 + <rect x="3" y="3" width="7" height="7" rx="1"/> 200 + <rect x="14" y="3" width="7" height="7" rx="1"/> 201 + <rect x="14" y="14" width="7" height="7" rx="1"/> 202 + <rect x="3" y="14" width="7" height="7" rx="1"/> 203 + </svg> 204 + </button> 205 + 206 + <button class="rail-icon" title="Search"> 207 + <svg width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 208 + <circle cx="11" cy="11" r="8"/> 209 + <path d="m21 21-4.35-4.35"/> 210 + </svg> 211 + </button> 212 + 213 + <button class="rail-icon" title="Notifications"> 214 + <svg width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 215 + <path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/> 216 + <path d="M13.73 21a2 2 0 0 1-3.46 0"/> 217 + </svg> 218 + </button> 219 + 220 + <button class="rail-icon active" title="Settings"> 221 + <svg width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 222 + <circle cx="12" cy="12" r="3"/> 223 + <path d="M12 1v6m0 6v6m4.22-10.22l4.24-4.24M6.34 6.34L2.1 2.1m17.9 10h-6m-6 0H2.1m16.12 4.24l4.24 4.24M6.34 17.66l-4.24 4.24"/> 224 + </svg> 225 + </button> 226 + </nav> 227 + 228 + <div class="flex flex-col gap-2"> 229 + <button class="w-10 h-10 rounded-full overflow-hidden border-2 border-transparent hover:border-white/20 transition-colors"> 230 + <img src="https://placehold.co/40x40/7dafff/05080f?text=U" alt="Current account" class="w-full h-full object-cover"> 231 + </button> 232 + </div> 233 + </aside> 234 + 235 + <!-- Main Content --> 236 + <main class="flex-1 ml-16"> 237 + <!-- Header --> 238 + <header class="sticky top-0 z-40 panel backdrop-blur-xl border-b border-white/5"> 239 + <div class="flex items-center justify-between px-6 py-4"> 240 + <h1 class="text-xl font-medium" style="letter-spacing: -0.02em;">Settings</h1> 241 + <button class="p-2 rounded-lg hover:bg-white/5 transition-colors" style="color: var(--on-surface-variant);"> 242 + <svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 243 + <path d="M18 6L6 18M6 6l12 12"/> 244 + </svg> 245 + </button> 246 + </div> 247 + </header> 248 + 249 + <!-- Settings Content --> 250 + <div class="px-6 py-8"> 251 + <div class="max-w-xl mx-auto space-y-8"> 252 + 253 + <!-- Section 1: Appearance --> 254 + <section class="settings-card"> 255 + <div class="flex items-center justify-between mb-4"> 256 + <div class="flex items-center gap-3"> 257 + <svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" style="color: var(--primary);"> 258 + <circle cx="12" cy="12" r="5"/> 259 + <path d="M12 1v2m0 18v2M4.22 4.22l1.42 1.42m12.72 12.72l1.42 1.42M1 12h2m18 0h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/> 260 + </svg> 261 + <h2 class="font-medium">Appearance</h2> 262 + </div> 263 + </div> 264 + <div class="space-y-4"> 265 + <div class="flex items-center justify-between"> 266 + <div> 267 + <p class="text-sm font-medium">Theme</p> 268 + <p class="text-xs" style="color: var(--on-surface-variant);">Choose your preferred color scheme</p> 269 + </div> 270 + <div class="segmented-control"> 271 + <button>Light</button> 272 + <button class="active">Dark</button> 273 + <button>Auto</button> 274 + </div> 275 + </div> 276 + </div> 277 + </section> 278 + 279 + <!-- Section 2: Timeline --> 280 + <section class="settings-card"> 281 + <div class="flex items-center gap-3 mb-4"> 282 + <svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" style="color: var(--primary);"> 283 + <rect x="3" y="3" width="7" height="7" rx="1"/> 284 + <rect x="14" y="3" width="7" height="7" rx="1"/> 285 + <rect x="14" y="14" width="7" height="7" rx="1"/> 286 + <rect x="3" y="14" width="7" height="7" rx="1"/> 287 + </svg> 288 + <h2 class="font-medium">Timeline</h2> 289 + </div> 290 + <div class="space-y-4"> 291 + <div class="flex items-center justify-between"> 292 + <div> 293 + <p class="text-sm font-medium">Auto-refresh interval</p> 294 + <p class="text-xs" style="color: var(--on-surface-variant);">How often to check for new posts</p> 295 + </div> 296 + <div class="segmented-control" style="width: 280px;"> 297 + <button>30s</button> 298 + <button class="active">1m</button> 299 + <button>2m</button> 300 + <button>5m</button> 301 + <button>Manual</button> 302 + </div> 303 + </div> 304 + </div> 305 + </section> 306 + 307 + <!-- Section 3: Notifications --> 308 + <section class="settings-card"> 309 + <div class="flex items-center gap-3 mb-4"> 310 + <svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" style="color: var(--primary);"> 311 + <path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/> 312 + <path d="M13.73 21a2 2 0 0 1-3.46 0"/> 313 + </svg> 314 + <h2 class="font-medium">Notifications</h2> 315 + </div> 316 + <div class="space-y-4"> 317 + <div class="flex items-center justify-between"> 318 + <div> 319 + <p class="text-sm font-medium">Desktop notifications</p> 320 + <p class="text-xs" style="color: var(--on-surface-variant);">Show OS-level notification popups</p> 321 + </div> 322 + <div class="toggle-switch enabled"></div> 323 + </div> 324 + <div class="flex items-center justify-between"> 325 + <div> 326 + <p class="text-sm font-medium">Badge count</p> 327 + <p class="text-xs" style="color: var(--on-surface-variant);">Show unread count on dock icon</p> 328 + </div> 329 + <div class="toggle-switch enabled"></div> 330 + </div> 331 + <div class="flex items-center justify-between"> 332 + <div> 333 + <p class="text-sm font-medium">Sound</p> 334 + <p class="text-xs" style="color: var(--on-surface-variant);">Play sound for new notifications</p> 335 + </div> 336 + <div class="toggle-switch"></div> 337 + </div> 338 + </div> 339 + </section> 340 + 341 + <!-- Section 4: Accounts --> 342 + <section class="settings-card"> 343 + <div class="flex items-center justify-between mb-4"> 344 + <div class="flex items-center gap-3"> 345 + <svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" style="color: var(--primary);"> 346 + <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/> 347 + <circle cx="12" cy="7" r="4"/> 348 + </svg> 349 + <h2 class="font-medium">Accounts</h2> 350 + </div> 351 + <button class="gradient-btn px-4 py-2 rounded-full text-sm font-medium">Add account</button> 352 + </div> 353 + <div class="space-y-3"> 354 + <!-- Active Account --> 355 + <div class="account-item flex items-center justify-between"> 356 + <div class="flex items-center gap-3"> 357 + <div class="relative"> 358 + <div class="w-10 h-10 rounded-full overflow-hidden"> 359 + <img src="https://placehold.co/40x40/7dafff/05080f?text=U" alt="Account" class="w-full h-full object-cover"> 360 + </div> 361 + <div class="absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full bg-green-500 border-2 border-black"></div> 362 + </div> 363 + <div> 364 + <p class="text-sm font-medium">@user.bsky.social</p> 365 + <p class="text-xs" style="color: var(--on-surface-variant);">did:plc:abc123</p> 366 + </div> 367 + </div> 368 + <div class="flex items-center gap-2"> 369 + <span class="px-2 py-1 rounded-full text-xs" style="background: rgba(125, 175, 255, 0.2); color: var(--primary);">Active</span> 370 + <button class="p-2 rounded-lg hover:bg-white/5" style="color: var(--on-surface-variant);"> 371 + <svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 372 + <circle cx="12" cy="12" r="1"/> 373 + <circle cx="19" cy="12" r="1"/> 374 + <circle cx="5" cy="12" r="1"/> 375 + </svg> 376 + </button> 377 + </div> 378 + </div> 379 + <!-- Secondary Account --> 380 + <div class="account-item flex items-center justify-between"> 381 + <div class="flex items-center gap-3"> 382 + <div class="w-10 h-10 rounded-full overflow-hidden"> 383 + <img src="https://placehold.co/40x40/333/fff?text=A" alt="Account" class="w-full h-full object-cover"> 384 + </div> 385 + <div> 386 + <p class="text-sm font-medium">@alt.bsky.social</p> 387 + <p class="text-xs" style="color: var(--on-surface-variant);">did:plc:xyz789</p> 388 + </div> 389 + </div> 390 + <div class="flex items-center gap-2"> 391 + <button class="px-3 py-1.5 rounded-lg text-xs border border-white/20 hover:bg-white/5">Switch</button> 392 + <button class="p-2 rounded-lg hover:bg-white/5" style="color: var(--on-surface-variant);"> 393 + <svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 394 + <circle cx="12" cy="12" r="1"/> 395 + <circle cx="19" cy="12" r="1"/> 396 + <circle cx="5" cy="12" r="1"/> 397 + </svg> 398 + </button> 399 + </div> 400 + </div> 401 + </div> 402 + </section> 403 + 404 + <!-- Section 5: Search & Embeddings --> 405 + <section class="settings-card"> 406 + <div class="flex items-center gap-3 mb-4"> 407 + <svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" style="color: var(--primary);"> 408 + <circle cx="11" cy="11" r="8"/> 409 + <path d="m21 21-4.35-4.35"/> 410 + </svg> 411 + <h2 class="font-medium">Search & Embeddings</h2> 412 + </div> 413 + <div class="space-y-4"> 414 + <div class="flex items-center justify-between"> 415 + <div> 416 + <p class="text-sm font-medium">Enable semantic search</p> 417 + <p class="text-xs" style="color: var(--on-surface-variant);">Use AI embeddings for better results</p> 418 + </div> 419 + <div class="toggle-switch enabled"></div> 420 + </div> 421 + <div class="p-4 rounded-xl" style="background: rgba(0, 0, 0, 0.3);"> 422 + <div class="flex items-center justify-between mb-2"> 423 + <p class="text-sm font-medium">Model: nomic-embed-text-v1.5</p> 424 + <span class="px-2 py-1 rounded-lg text-xs" style="background: rgba(125, 175, 255, 0.2); color: var(--primary);">Downloaded</span> 425 + </div> 426 + <div class="flex items-center gap-2 text-xs" style="color: var(--on-surface-variant);"> 427 + <svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 428 + <rect x="3" y="4" width="18" height="18" rx="2"/> 429 + <line x1="16" y1="2" x2="16" y2="6"/> 430 + <line x1="8" y1="2" x2="8" y2="6"/> 431 + <line x1="3" y1="10" x2="21" y2="10"/> 432 + </svg> 433 + <span>274 MB on disk</span> 434 + </div> 435 + <div class="flex gap-2 mt-3"> 436 + <button class="px-3 py-1.5 rounded-lg text-xs border border-white/20 hover:bg-white/5">Reindex all</button> 437 + <button class="px-3 py-1.5 rounded-lg text-xs danger-btn">Remove model</button> 438 + </div> 439 + </div> 440 + </div> 441 + </section> 442 + 443 + <!-- Section 6: Services --> 444 + <section class="settings-card"> 445 + <div class="flex items-center gap-3 mb-4"> 446 + <svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" style="color: var(--primary);"> 447 + <circle cx="12" cy="12" r="10"/> 448 + <line x1="2" y1="12" x2="22" y2="12"/> 449 + <path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/> 450 + </svg> 451 + <h2 class="font-medium">Services</h2> 452 + </div> 453 + <div class="space-y-4"> 454 + <div> 455 + <label class="text-sm font-medium mb-2 block">Constellation URL</label> 456 + <div class="flex gap-2"> 457 + <input type="text" value="https://constellation.microcosm.blue" class="input-field flex-1"> 458 + <button class="px-4 py-2 rounded-lg text-sm border border-white/20 hover:bg-white/5">Test</button> 459 + </div> 460 + </div> 461 + <div> 462 + <label class="text-sm font-medium mb-2 block">Spacedust URL</label> 463 + <div class="flex gap-2"> 464 + <input type="text" value="https://spacedust.microcosm.blue" class="input-field flex-1"> 465 + <button class="px-4 py-2 rounded-lg text-sm border border-white/20 hover:bg-white/5">Test</button> 466 + </div> 467 + </div> 468 + <div class="flex items-center justify-between"> 469 + <div> 470 + <p class="text-sm font-medium">Use Spacedust for real-time</p> 471 + <p class="text-xs" style="color: var(--on-surface-variant);">WebSocket push notifications</p> 472 + </div> 473 + <div class="toggle-switch"></div> 474 + </div> 475 + <div class="flex items-center justify-between"> 476 + <div> 477 + <p class="text-sm font-medium">Instant mode</p> 478 + <p class="text-xs" style="color: var(--on-surface-variant);">Bypass 21s debounce buffer</p> 479 + </div> 480 + <div class="toggle-switch"></div> 481 + </div> 482 + </div> 483 + </section> 484 + 485 + <!-- Section 7: Data --> 486 + <section class="settings-card"> 487 + <div class="flex items-center gap-3 mb-4"> 488 + <svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" style="color: var(--primary);"> 489 + <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/> 490 + <polyline points="7 10 12 15 17 10"/> 491 + <line x1="12" y1="15" x2="12" y2="3"/> 492 + </svg> 493 + <h2 class="font-medium">Data</h2> 494 + </div> 495 + <div class="space-y-4"> 496 + <div class="grid grid-cols-3 gap-3"> 497 + <div class="p-4 rounded-xl text-center" style="background: rgba(0, 0, 0, 0.3);"> 498 + <p class="text-lg font-medium">156 MB</p> 499 + <p class="text-xs" style="color: var(--on-surface-variant);">Feeds cache</p> 500 + </div> 501 + <div class="p-4 rounded-xl text-center" style="background: rgba(0, 0, 0, 0.3);"> 502 + <p class="text-lg font-medium">274 MB</p> 503 + <p class="text-xs" style="color: var(--on-surface-variant);">Embeddings</p> 504 + </div> 505 + <div class="p-4 rounded-xl text-center" style="background: rgba(0, 0, 0, 0.3);"> 506 + <p class="text-lg font-medium">89 MB</p> 507 + <p class="text-xs" style="color: var(--on-surface-variant);">Search index</p> 508 + </div> 509 + </div> 510 + <div class="flex gap-2"> 511 + <button class="px-4 py-2 rounded-lg text-sm border border-white/20 hover:bg-white/5">Clear feeds</button> 512 + <button class="px-4 py-2 rounded-lg text-sm border border-white/20 hover:bg-white/5">Clear embeddings</button> 513 + <button class="px-4 py-2 rounded-lg text-sm border border-white/20 hover:bg-white/5">Clear all</button> 514 + </div> 515 + <div class="border-t border-white/10 pt-4"> 516 + <div class="flex items-center justify-between"> 517 + <div> 518 + <p class="text-sm font-medium">Export your data</p> 519 + <p class="text-xs" style="color: var(--on-surface-variant);">Download all your data as JSON or CSV</p> 520 + </div> 521 + <button class="px-4 py-2 rounded-lg text-sm border border-white/20 hover:bg-white/5">Export...</button> 522 + </div> 523 + </div> 524 + <div class="border-t border-white/10 pt-4"> 525 + <div class="flex items-center justify-between"> 526 + <div> 527 + <p class="text-sm font-medium danger-btn" style="background: transparent; border: none; padding: 0;">Reset application</p> 528 + <p class="text-xs" style="color: var(--on-surface-variant);">Remove all data and reset to defaults</p> 529 + </div> 530 + <button class="px-4 py-2 rounded-lg text-sm danger-btn">Reset...</button> 531 + </div> 532 + </div> 533 + </div> 534 + </section> 535 + 536 + <!-- Section 8: Logs --> 537 + <section class="settings-card"> 538 + <div class="flex items-center gap-3 mb-4"> 539 + <svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" style="color: var(--primary);"> 540 + <line x1="8" y1="6" x2="21" y2="6"/> 541 + <line x1="8" y1="12" x2="21" y2="12"/> 542 + <line x1="8" y1="18" x2="21" y2="18"/> 543 + <line x1="3" y1="6" x2="3.01" y2="6"/> 544 + <line x1="3" y1="12" x2="3.01" y2="12"/> 545 + <line x1="3" y1="18" x2="3.01" y2="18"/> 546 + </svg> 547 + <h2 class="font-medium">Logs</h2> 548 + </div> 549 + <div class="space-y-3"> 550 + <div class="segmented-control"> 551 + <button class="active">All</button> 552 + <button>Info</button> 553 + <button>Warn</button> 554 + <button>Error</button> 555 + </div> 556 + <div class="rounded-xl p-4 font-mono text-xs overflow-auto" style="background: rgba(0, 0, 0, 0.5); max-height: 200px;"> 557 + <div class="flex gap-3 mb-1"> 558 + <span style="color: var(--on-surface-variant);">10:23:45</span> 559 + <span style="color: #7dafff;">INFO</span> 560 + <span style="color: var(--on-secondary-container);">App initialized successfully</span> 561 + </div> 562 + <div class="flex gap-3 mb-1"> 563 + <span style="color: var(--on-surface-variant);">10:23:46</span> 564 + <span style="color: #7dafff;">INFO</span> 565 + <span style="color: var(--on-secondary-container);">Connected to constellation.microcosm.blue</span> 566 + </div> 567 + <div class="flex gap-3 mb-1"> 568 + <span style="color: var(--on-surface-variant);">10:24:12</span> 569 + <span style="color: #ffd93d;">WARN</span> 570 + <span style="color: var(--on-secondary-container);">Rate limit approaching for feed refresh</span> 571 + </div> 572 + <div class="flex gap-3 mb-1"> 573 + <span style="color: var(--on-surface-variant);">10:25:01</span> 574 + <span style="color: #7dafff;">INFO</span> 575 + <span style="color: var(--on-secondary-container);">Sync completed: 47 new posts</span> 576 + </div> 577 + </div> 578 + <div class="flex gap-2"> 579 + <button class="px-3 py-1.5 rounded-lg text-xs border border-white/20 hover:bg-white/5">Copy all</button> 580 + <button class="px-3 py-1.5 rounded-lg text-xs border border-white/20 hover:bg-white/5">Open log file</button> 581 + </div> 582 + </div> 583 + </section> 584 + 585 + <!-- Section 9: About --> 586 + <section class="settings-card"> 587 + <div class="flex items-center gap-3 mb-4"> 588 + <svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" style="color: var(--primary);"> 589 + <circle cx="12" cy="12" r="10"/> 590 + <line x1="12" y1="16" x2="12" y2="12"/> 591 + <line x1="12" y1="8" x2="12.01" y2="8"/> 592 + </svg> 593 + <h2 class="font-medium">About</h2> 594 + </div> 595 + <div class="space-y-4"> 596 + <div class="flex items-center justify-between"> 597 + <div> 598 + <p class="text-sm font-medium">Version</p> 599 + <p class="text-xs" style="color: var(--on-surface-variant);">0.1.0-alpha</p> 600 + </div> 601 + <button class="gradient-btn px-4 py-2 rounded-full text-sm font-medium">Check for updates</button> 602 + </div> 603 + <div class="flex items-center justify-between"> 604 + <div> 605 + <p class="text-sm font-medium">License</p> 606 + <p class="text-xs" style="color: var(--on-surface-variant);">MIT License</p> 607 + </div> 608 + <button class="px-4 py-2 rounded-lg text-sm border border-white/20 hover:bg-white/5">View license</button> 609 + </div> 610 + <div class="flex items-center justify-between"> 611 + <div> 612 + <p class="text-sm font-medium">Source code</p> 613 + <p class="text-xs" style="color: var(--on-surface-variant);">github.com/stormlightlabs/lazurite</p> 614 + </div> 615 + <button class="px-4 py-2 rounded-lg text-sm border border-white/20 hover:bg-white/5">Open</button> 616 + </div> 617 + <div class="p-4 rounded-xl" style="background: rgba(0, 0, 0, 0.3);"> 618 + <p class="text-sm font-medium mb-2">Contributors</p> 619 + <div class="flex gap-2"> 620 + <div class="w-8 h-8 rounded-full overflow-hidden" title="@contributor1"> 621 + <img src="https://placehold.co/32x32/7dafff/05080f?text=C1" alt="Contributor" class="w-full h-full object-cover"> 622 + </div> 623 + <div class="w-8 h-8 rounded-full overflow-hidden" title="@contributor2"> 624 + <img src="https://placehold.co/32x32/333/fff?text=C2" alt="Contributor" class="w-full h-full object-cover"> 625 + </div> 626 + <div class="w-8 h-8 rounded-full overflow-hidden" title="@contributor3"> 627 + <img src="https://placehold.co/32x32/555/fff?text=C3" alt="Contributor" class="w-full h-full object-cover"> 628 + </div> 629 + </div> 630 + </div> 631 + </div> 632 + </section> 633 + 634 + </div> 635 + </div> 636 + 637 + <!-- Footer spacing --> 638 + <div class="h-16"></div> 639 + </main> 640 + 641 + </body> 642 + </html>
+9 -9
docs/tasks/06-settings.md
··· 6 6 7 7 ### Backend - `src-tauri/src/settings.rs` 8 8 9 - - [ ] `get_settings()` - read user preferences from SQLite `settings` table, return as typed struct 10 - - [ ] `update_setting(key: String, value: String)` - upsert a key-value pair in `settings` table 11 - - [ ] `clear_cache()` - delete cached feed data, embedded vectors, and FTS5 index; vacuum database 12 - - [ ] `reset_app()` - drop all user data tables and re-run migrations; clear auth tokens 13 - - [ ] `export_data(format: String, path: String)` - export user data as JSON or CSV to chosen path 14 - - [ ] `get_log_entries(limit: u32, level: Option<String>)` - read recent log entries for the in-app log viewer 15 - - [ ] SQLite migration: `settings` table (`key TEXT PRIMARY KEY, value TEXT, updated_at TEXT`) 9 + - [x] `get_settings()` - read user preferences from SQLite `settings` table, return as typed struct 10 + - [x] `update_setting(key: String, value: String)` - upsert a key-value pair in `settings` table 11 + - [x] `clear_cache()` - delete cached feed data, embedded vectors, and FTS5 index; vacuum database 12 + - [x] `reset_app()` - drop all user data tables and re-run migrations; clear auth tokens 13 + - [x] `export_data(format: String, path: String)` - export user data as JSON or CSV to chosen path 14 + - [x] `get_log_entries(limit: u32, level: Option<String>)` - read recent log entries for the in-app log viewer 15 + - [x] SQLite migration: `settings` table (`key TEXT PRIMARY KEY, value TEXT, updated_at TEXT`) 16 16 17 17 ### Frontend - Settings View 18 18 19 - - [ ] Settings route (`/settings`) accessible from app rail icon (`i-ri-settings-3-line`) 20 - - [ ] Section-based layout using `surface_container` cards with `xl` radius: 19 + - [ ] Settings route (`/settings`) accessible from app rail icon (`Icon` with kind `settings`) 20 + - [ ] Section-based layout using `surface_container` cards with `lg` radius: 21 21 1. **Appearance** - Theme toggle (light/dark/auto), `Motion` crossfade on theme switch 22 22 2. **Timeline** - Refresh interval selector (30s, 1m, 2m, 5m, manual) 23 23 3. **Notifications** - Toggle desktop notifications, badge count, notification sound
+1
src-tauri/src/commands/mod.rs
··· 2 2 3 3 pub mod explorer; 4 4 pub mod search; 5 + pub mod settings; 5 6 6 7 use super::auth::{self, LoginSuggestion}; 7 8 use super::error::AppError;
+44
src-tauri/src/commands/settings.rs
··· 1 + #![allow(clippy::needless_pass_by_value)] 2 + 3 + use crate::error::AppError; 4 + use crate::settings::{self, AppSettings, CacheSize, LogEntry}; 5 + use crate::state::AppState; 6 + use tauri::{AppHandle, State}; 7 + 8 + #[tauri::command] 9 + pub fn get_settings(state: State<'_, AppState>) -> Result<AppSettings, AppError> { 10 + settings::get_settings(&state) 11 + } 12 + 13 + #[tauri::command] 14 + pub fn update_setting(key: String, value: String, app: AppHandle, state: State<'_, AppState>) -> Result<(), AppError> { 15 + settings::update_setting(&key, &value, &state, &app) 16 + } 17 + 18 + #[tauri::command] 19 + pub fn get_cache_size(state: State<'_, AppState>) -> Result<CacheSize, AppError> { 20 + settings::get_cache_size(&state) 21 + } 22 + 23 + #[tauri::command] 24 + pub fn clear_cache(scope: String, state: State<'_, AppState>) -> Result<(), AppError> { 25 + settings::clear_cache(&scope, &state) 26 + } 27 + 28 + #[tauri::command] 29 + pub fn export_data(format: String, path: String, state: State<'_, AppState>) -> Result<(), AppError> { 30 + settings::export_data(&format, &path, &state) 31 + } 32 + 33 + #[tauri::command] 34 + pub fn reset_app(app: AppHandle, state: State<'_, AppState>) -> Result<(), AppError> { 35 + settings::reset_app(&state, &app) 36 + } 37 + 38 + #[tauri::command] 39 + pub fn get_log_entries( 40 + limit: u32, level: Option<String>, app: AppHandle, state: State<'_, AppState>, 41 + ) -> Result<Vec<LogEntry>, AppError> { 42 + let _ = state; // AppState not needed; AppHandle provides log dir 43 + settings::get_log_entries(limit, level.as_deref(), &app) 44 + }
+25
src-tauri/src/db.rs
··· 134 134 } 135 135 } 136 136 137 + pub(crate) fn reset_database(connection: &Connection) -> Result<(), AppError> { 138 + connection.execute_batch( 139 + " 140 + DROP TRIGGER IF EXISTS posts_ai; 141 + DROP TRIGGER IF EXISTS posts_ad; 142 + DROP TRIGGER IF EXISTS posts_au; 143 + 144 + DROP TABLE IF EXISTS posts_vec; 145 + DROP TABLE IF EXISTS posts_fts; 146 + DROP TABLE IF EXISTS posts; 147 + DROP TABLE IF EXISTS sync_state; 148 + DROP TABLE IF EXISTS oauth_sessions; 149 + DROP TABLE IF EXISTS oauth_auth_requests; 150 + DROP TABLE IF EXISTS accounts; 151 + DROP TABLE IF EXISTS app_settings; 152 + DROP TABLE IF EXISTS schema_migrations; 153 + 154 + PRAGMA wal_checkpoint(TRUNCATE); 155 + VACUUM; 156 + ", 157 + )?; 158 + 159 + run_migrations(connection) 160 + } 161 + 137 162 #[cfg(test)] 138 163 mod tests { 139 164 use rusqlite::{params, Connection};
+9 -1
src-tauri/src/lib.rs
··· 6 6 mod feed; 7 7 mod notifications; 8 8 mod search; 9 + mod settings; 9 10 mod state; 10 11 mod tray; 11 12 ··· 111 112 cmd::search::set_embeddings_enabled, 112 113 cmd::search::get_embeddings_enabled, 113 114 cmd::search::get_embeddings_config, 114 - cmd::search::prepare_embeddings_model 115 + cmd::search::prepare_embeddings_model, 116 + cmd::settings::get_settings, 117 + cmd::settings::update_setting, 118 + cmd::settings::get_cache_size, 119 + cmd::settings::clear_cache, 120 + cmd::settings::export_data, 121 + cmd::settings::reset_app, 122 + cmd::settings::get_log_entries 115 123 ]) 116 124 .run(tauri::generate_context!()) 117 125 .expect("error while running tauri application");
+35 -1
src-tauri/src/notifications.rs
··· 1 1 use super::auth::LazuriteOAuthSession; 2 2 use super::error::{AppError, Result}; 3 + use super::settings::{self, AppSettings}; 3 4 use super::state::AppState; 4 5 use jacquard::api::app_bsky::notification::get_unread_count::GetUnreadCount; 5 6 use jacquard::api::app_bsky::notification::list_notifications::ListNotifications; ··· 146 147 .unwrap_or(false) 147 148 } 148 149 150 + fn load_notification_settings(state: &AppState) -> AppSettings { 151 + match settings::get_settings(state) { 152 + Ok(settings) => settings, 153 + Err(error) => { 154 + log::warn!("failed to load notification settings, using defaults: {error}"); 155 + AppSettings::default() 156 + } 157 + } 158 + } 159 + 160 + pub fn clear_unread_badge(app: &AppHandle) { 161 + if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) { 162 + if let Err(error) = window.set_badge_count(None) { 163 + log::debug!("failed to clear unread badge: {error}"); 164 + } 165 + } 166 + } 167 + 168 + fn sync_unread_badge(app: &AppHandle, badge_enabled: bool, count: i64) { 169 + let badge_count = if badge_enabled && count > 0 { Some(count) } else { None }; 170 + 171 + if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) { 172 + if let Err(error) = window.set_badge_count(badge_count) { 173 + log::debug!("failed to update unread badge: {error}"); 174 + } 175 + } 176 + } 177 + 149 178 fn collect_new_mention_notifications( 150 179 notifications_value: &serde_json::Value, notified_uris: &VecDeque<String>, 151 180 ) -> Vec<(String, String)> { ··· 240 269 last_count = -1; 241 270 last_did = None; 242 271 notified_uris.clear(); 272 + clear_unread_badge(&app); 243 273 tokio::time::sleep(POLL_INTERVAL).await; 244 274 continue; 245 275 } ··· 250 280 notified_uris.clear(); 251 281 } 252 282 283 + let notification_settings = load_notification_settings(&state); 284 + 253 285 match get_unread_count(&state).await { 254 286 Ok(count) => { 287 + sync_unread_badge(&app, notification_settings.notifications_badge, count); 288 + 255 289 if last_count >= 0 && count > last_count { 256 290 log::info!("new notifications: unread count increased from {last_count} to {count}"); 257 291 let _ = app.emit(NOTIFICATIONS_UNREAD_COUNT_EVENT, count); 258 292 259 - if !is_main_window_focused(&app) { 293 + if notification_settings.notifications_desktop && !is_main_window_focused(&app) { 260 294 if let Ok(value) = list_notifications(None, &state).await { 261 295 send_mention_system_notifications(&app, &value, &mut notified_uris); 262 296 }
+4 -4
src-tauri/src/search.rs
··· 947 947 } 948 948 949 949 pub fn search_posts(query: &str, mode: &str, limit: u32, app: &AppHandle, state: &AppState) -> Result<Vec<PostResult>> { 950 - validate_query(&query)?; 950 + validate_query(query)?; 951 951 let limit = validate_limit(limit)?; 952 - let mode = validate_search_mode(&mode)?; 952 + let mode = validate_search_mode(mode)?; 953 953 let owner_did = active_session_did(state)?.ok_or_else(|| AppError::validation("no active account"))?; 954 954 955 955 let embeddings_enabled = { ··· 961 961 SearchMode::Keyword => None, 962 962 SearchMode::Semantic | SearchMode::Hybrid if embeddings_enabled => { 963 963 let models_dir = resolve_models_dir(app)?; 964 - Some(embed_query_text(&query, models_dir)?) 964 + Some(embed_query_text(query, models_dir)?) 965 965 } 966 966 SearchMode::Semantic => { 967 967 return Err(AppError::validation( ··· 975 975 run_local_search( 976 976 &conn, 977 977 &owner_did, 978 - &query, 978 + query, 979 979 mode, 980 980 limit, 981 981 embeddings_enabled,
+1102
src-tauri/src/settings.rs
··· 1 + use super::auth; 2 + use super::db; 3 + use super::error::{AppError, Result}; 4 + use super::notifications; 5 + use super::state::AppState; 6 + use super::tray; 7 + use reqwest::Url; 8 + use rusqlite::{params, Connection}; 9 + use serde::Serialize; 10 + use std::fs; 11 + use std::io::{BufRead, BufReader}; 12 + use std::path::{Path, PathBuf}; 13 + use tauri::{AppHandle, Manager}; 14 + use tauri_plugin_global_shortcut::Shortcut; 15 + use tauri_plugin_log::log; 16 + 17 + const APP_DEFAULT_THEME: &str = "auto"; 18 + const APP_DEFAULT_TIMELINE_REFRESH_SECS: u32 = 60; 19 + const APP_DEFAULT_CONSTELLATION_URL: &str = "https://constellation.microcosm.blue"; 20 + const APP_DEFAULT_SPACEDUST_URL: &str = "https://spacedust.microcosm.blue"; 21 + const APP_DEFAULT_GLOBAL_SHORTCUT: &str = "Ctrl+Shift+N"; 22 + 23 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 24 + pub enum SettingsKey { 25 + Theme, 26 + TimelineRefreshSecs, 27 + NotificationsDesktop, 28 + NotificationsBadge, 29 + NotificationsSound, 30 + EmbeddingsEnabled, 31 + ConstellationUrl, 32 + SpacedustUrl, 33 + SpacedustInstant, 34 + SpacedustEnabled, 35 + GlobalShortcut, 36 + } 37 + 38 + impl SettingsKey { 39 + fn as_str(&self) -> &'static str { 40 + match self { 41 + SettingsKey::Theme => "theme", 42 + SettingsKey::TimelineRefreshSecs => "timeline_refresh_secs", 43 + SettingsKey::NotificationsDesktop => "notifications_desktop", 44 + SettingsKey::NotificationsBadge => "notifications_badge", 45 + SettingsKey::NotificationsSound => "notifications_sound", 46 + SettingsKey::EmbeddingsEnabled => "embeddings_enabled", 47 + SettingsKey::ConstellationUrl => "constellation_url", 48 + SettingsKey::SpacedustUrl => "spacedust_url", 49 + SettingsKey::SpacedustInstant => "spacedust_instant", 50 + SettingsKey::SpacedustEnabled => "spacedust_enabled", 51 + SettingsKey::GlobalShortcut => "global_shortcut", 52 + } 53 + } 54 + 55 + pub fn from_str(s: &str) -> Option<Self> { 56 + match s { 57 + "theme" => Some(Self::Theme), 58 + "timeline_refresh_secs" => Some(Self::TimelineRefreshSecs), 59 + "notifications_desktop" => Some(Self::NotificationsDesktop), 60 + "notifications_badge" => Some(Self::NotificationsBadge), 61 + "notifications_sound" => Some(Self::NotificationsSound), 62 + "embeddings_enabled" => Some(Self::EmbeddingsEnabled), 63 + "constellation_url" => Some(Self::ConstellationUrl), 64 + "spacedust_url" => Some(Self::SpacedustUrl), 65 + "spacedust_instant" => Some(Self::SpacedustInstant), 66 + "spacedust_enabled" => Some(Self::SpacedustEnabled), 67 + "global_shortcut" => Some(Self::GlobalShortcut), 68 + _ => None, 69 + } 70 + } 71 + 72 + fn valid_keys() -> &'static [Self] { 73 + &[ 74 + Self::Theme, 75 + Self::TimelineRefreshSecs, 76 + Self::NotificationsDesktop, 77 + Self::NotificationsBadge, 78 + Self::NotificationsSound, 79 + Self::EmbeddingsEnabled, 80 + Self::ConstellationUrl, 81 + Self::SpacedustUrl, 82 + Self::SpacedustInstant, 83 + Self::SpacedustEnabled, 84 + Self::GlobalShortcut, 85 + ] 86 + } 87 + } 88 + 89 + impl std::fmt::Display for SettingsKey { 90 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 91 + self.as_str().fmt(f) 92 + } 93 + } 94 + 95 + #[derive(Debug, Clone, Serialize)] 96 + #[serde(rename_all = "camelCase")] 97 + pub struct AppSettings { 98 + pub theme: String, 99 + pub timeline_refresh_secs: u32, 100 + pub notifications_desktop: bool, 101 + pub notifications_badge: bool, 102 + pub notifications_sound: bool, 103 + pub embeddings_enabled: bool, 104 + pub constellation_url: String, 105 + pub spacedust_url: String, 106 + pub spacedust_instant: bool, 107 + pub spacedust_enabled: bool, 108 + pub global_shortcut: String, 109 + } 110 + 111 + impl Default for AppSettings { 112 + fn default() -> Self { 113 + Self { 114 + theme: APP_DEFAULT_THEME.to_string(), 115 + timeline_refresh_secs: APP_DEFAULT_TIMELINE_REFRESH_SECS, 116 + notifications_desktop: true, 117 + notifications_badge: true, 118 + notifications_sound: false, 119 + embeddings_enabled: true, 120 + constellation_url: APP_DEFAULT_CONSTELLATION_URL.to_string(), 121 + spacedust_url: APP_DEFAULT_SPACEDUST_URL.to_string(), 122 + spacedust_instant: false, 123 + spacedust_enabled: false, 124 + global_shortcut: APP_DEFAULT_GLOBAL_SHORTCUT.to_string(), 125 + } 126 + } 127 + } 128 + 129 + #[derive(Debug, Clone, Serialize)] 130 + #[serde(rename_all = "camelCase")] 131 + pub struct CacheSize { 132 + pub feeds_bytes: u64, 133 + pub embeddings_bytes: u64, 134 + pub fts_bytes: u64, 135 + pub total_bytes: u64, 136 + } 137 + 138 + #[derive(Debug, Clone, Serialize)] 139 + #[serde(rename_all = "camelCase")] 140 + pub struct LogEntry { 141 + pub timestamp: Option<String>, 142 + pub level: String, 143 + pub target: Option<String>, 144 + pub message: String, 145 + } 146 + 147 + fn parse_bool(value: &str) -> bool { 148 + value != "0" && !value.eq_ignore_ascii_case("false") 149 + } 150 + 151 + fn normalize_bool_value(value: &str, key: &SettingsKey) -> Result<String> { 152 + let trimmed = value.trim(); 153 + match trimmed { 154 + "1" => Ok("1".to_string()), 155 + "0" => Ok("0".to_string()), 156 + _ if trimmed.eq_ignore_ascii_case("true") => Ok("1".to_string()), 157 + _ if trimmed.eq_ignore_ascii_case("false") => Ok("0".to_string()), 158 + _ => Err(AppError::validation(format!( 159 + "setting '{key}' must be a boolean ('0'/'1' or 'true'/'false')" 160 + ))), 161 + } 162 + } 163 + 164 + fn validate_refresh_interval(value: &str) -> Result<String> { 165 + let trimmed = value.trim(); 166 + let seconds: u32 = trimmed 167 + .parse() 168 + .map_err(|_| AppError::validation("timeline_refresh_secs must be one of 0, 30, 60, 120, 300"))?; 169 + 170 + match seconds { 171 + 0 | 30 | 60 | 120 | 300 => Ok(seconds.to_string()), 172 + _ => Err(AppError::validation( 173 + "timeline_refresh_secs must be one of 0, 30, 60, 120, 300", 174 + )), 175 + } 176 + } 177 + 178 + fn validate_url_setting(key: &SettingsKey, value: &str) -> Result<String> { 179 + let trimmed = value.trim(); 180 + let parsed = Url::parse(trimmed) 181 + .map_err(|error| AppError::validation(format!("setting '{key}' must be a valid URL: {error}")))?; 182 + 183 + match parsed.scheme() { 184 + "http" | "https" => {} 185 + _ => return Err(AppError::validation(format!("setting '{key}' must use http or https"))), 186 + } 187 + 188 + if parsed.host_str().is_none() { 189 + return Err(AppError::validation(format!("setting '{key}' must include a host"))); 190 + } 191 + 192 + if parsed.fragment().is_some() { 193 + return Err(AppError::validation(format!( 194 + "setting '{key}' must not include a fragment" 195 + ))); 196 + } 197 + 198 + Ok(parsed.to_string()) 199 + } 200 + 201 + fn validate_global_shortcut_value(value: &str) -> Result<String> { 202 + let trimmed = value.trim(); 203 + if trimmed.is_empty() { 204 + return Err(AppError::validation("global_shortcut must not be empty")); 205 + } 206 + 207 + trimmed 208 + .parse::<Shortcut>() 209 + .map_err(|error| AppError::validation(format!("invalid global_shortcut: {error}")))?; 210 + 211 + Ok(trimmed.to_string()) 212 + } 213 + 214 + fn validate_and_normalize_setting(key: SettingsKey, value: &str) -> Result<String> { 215 + match key { 216 + SettingsKey::Theme => { 217 + let theme = value.trim(); 218 + match theme { 219 + "light" | "dark" | "auto" => Ok(theme.to_string()), 220 + _ => Err(AppError::validation("theme must be 'light', 'dark', or 'auto'")), 221 + } 222 + } 223 + SettingsKey::TimelineRefreshSecs => validate_refresh_interval(value), 224 + SettingsKey::NotificationsDesktop 225 + | SettingsKey::NotificationsBadge 226 + | SettingsKey::NotificationsSound 227 + | SettingsKey::EmbeddingsEnabled 228 + | SettingsKey::SpacedustInstant 229 + | SettingsKey::SpacedustEnabled => normalize_bool_value(value, &key), 230 + SettingsKey::ConstellationUrl | SettingsKey::SpacedustUrl => validate_url_setting(&key, value), 231 + SettingsKey::GlobalShortcut => validate_global_shortcut_value(value), 232 + } 233 + } 234 + 235 + fn apply_setting_to_snapshot(settings: &mut AppSettings, key: SettingsKey, value: String) { 236 + match key { 237 + SettingsKey::Theme => settings.theme = value, 238 + SettingsKey::TimelineRefreshSecs => { 239 + if let Ok(seconds) = value.parse::<u32>() { 240 + settings.timeline_refresh_secs = seconds; 241 + } 242 + } 243 + SettingsKey::NotificationsDesktop => settings.notifications_desktop = parse_bool(&value), 244 + SettingsKey::NotificationsBadge => settings.notifications_badge = parse_bool(&value), 245 + SettingsKey::NotificationsSound => settings.notifications_sound = parse_bool(&value), 246 + SettingsKey::EmbeddingsEnabled => settings.embeddings_enabled = parse_bool(&value), 247 + SettingsKey::ConstellationUrl => settings.constellation_url = value, 248 + SettingsKey::SpacedustUrl => settings.spacedust_url = value, 249 + SettingsKey::SpacedustInstant => settings.spacedust_instant = parse_bool(&value), 250 + SettingsKey::SpacedustEnabled => settings.spacedust_enabled = parse_bool(&value), 251 + SettingsKey::GlobalShortcut => settings.global_shortcut = value, 252 + } 253 + } 254 + 255 + fn db_get_all_settings(conn: &Connection) -> Result<AppSettings> { 256 + let mut stmt = conn.prepare("SELECT key, value FROM app_settings")?; 257 + let rows = stmt.query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)))?; 258 + 259 + let mut settings = AppSettings::default(); 260 + for row in rows { 261 + let (key, value) = row?; 262 + let Some(key) = SettingsKey::from_str(&key) else { 263 + continue; 264 + }; 265 + 266 + match validate_and_normalize_setting(key, &value) { 267 + Ok(normalized_value) => apply_setting_to_snapshot(&mut settings, key, normalized_value), 268 + Err(error) => { 269 + log::warn!("ignoring invalid persisted setting '{}': {}", key, error); 270 + } 271 + } 272 + } 273 + 274 + Ok(settings) 275 + } 276 + 277 + fn db_upsert_setting(conn: &Connection, key: &SettingsKey, value: &str) -> Result<()> { 278 + conn.execute( 279 + "INSERT INTO app_settings(key, value) VALUES(?1, ?2) 280 + ON CONFLICT(key) DO UPDATE SET value = excluded.value", 281 + params![key.as_str(), value], 282 + )?; 283 + Ok(()) 284 + } 285 + 286 + fn db_get_cache_size(conn: &Connection) -> Result<CacheSize> { 287 + let feeds_bytes: u64 = conn 288 + .query_row( 289 + "SELECT COALESCE(SUM( 290 + LENGTH(uri) + LENGTH(cid) + LENGTH(author_did) 291 + + LENGTH(COALESCE(author_handle,'')) 292 + + LENGTH(COALESCE(text,'')) 293 + + LENGTH(COALESCE(json_record,'')) 294 + ), 0) FROM posts", 295 + [], 296 + |row| row.get::<_, i64>(0), 297 + ) 298 + .unwrap_or(0) as u64; 299 + 300 + let embedding_count: u64 = conn 301 + .query_row("SELECT COUNT(*) FROM posts_vec", [], |row| row.get::<_, i64>(0)) 302 + .unwrap_or(0) as u64; 303 + let embeddings_bytes = embedding_count * (768 * 4); 304 + 305 + let fts_text_bytes: u64 = conn 306 + .query_row( 307 + "SELECT COALESCE(SUM(LENGTH(COALESCE(text,''))), 0) FROM posts", 308 + [], 309 + |row| row.get::<_, i64>(0), 310 + ) 311 + .unwrap_or(0) as u64; 312 + 313 + let fts_bytes = fts_text_bytes * 2 / 5; 314 + 315 + let total_bytes = feeds_bytes + embeddings_bytes + fts_bytes; 316 + 317 + Ok(CacheSize { feeds_bytes, embeddings_bytes, fts_bytes, total_bytes }) 318 + } 319 + 320 + /// NOTE: When scope is set to fts, it rebuilds the index rather than leaving it empty. 321 + /// This preserves search correctness but we need to determine if the goal is disk reclamation 322 + /// rather than reindex/defrag behavior. 323 + fn db_clear_cache(conn: &Connection, scope: &str) -> Result<()> { 324 + match scope { 325 + "all" => { 326 + conn.execute("DELETE FROM posts", [])?; 327 + conn.execute("DELETE FROM posts_vec", [])?; 328 + conn.execute("DELETE FROM sync_state", [])?; 329 + } 330 + "feeds" => { 331 + conn.execute("DELETE FROM posts", [])?; 332 + conn.execute("DELETE FROM sync_state", [])?; 333 + } 334 + "embeddings" => { 335 + conn.execute("DELETE FROM posts_vec", [])?; 336 + } 337 + "fts" => { 338 + conn.execute_batch( 339 + "INSERT INTO posts_fts(posts_fts) VALUES('delete-all'); 340 + INSERT INTO posts_fts(posts_fts) VALUES('rebuild');", 341 + )?; 342 + } 343 + _ => {} 344 + } 345 + conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE); VACUUM;")?; 346 + Ok(()) 347 + } 348 + 349 + fn export_posts_as_json(conn: &Connection, source: &str) -> Result<Vec<serde_json::Value>> { 350 + let mut stmt = conn.prepare( 351 + "SELECT storage_key, owner_did, uri, cid, author_did, author_handle, text, created_at, source 352 + FROM posts 353 + WHERE source = ?1 354 + ORDER BY created_at DESC, uri DESC", 355 + )?; 356 + 357 + let rows = stmt.query_map(params![source], |row| { 358 + Ok(serde_json::json!({ 359 + "storageKey": row.get::<_, Option<String>>(0)?, 360 + "ownerDid": row.get::<_, Option<String>>(1)?, 361 + "uri": row.get::<_, Option<String>>(2)?, 362 + "cid": row.get::<_, Option<String>>(3)?, 363 + "authorDid": row.get::<_, Option<String>>(4)?, 364 + "authorHandle": row.get::<_, Option<String>>(5)?, 365 + "text": row.get::<_, Option<String>>(6)?, 366 + "createdAt": row.get::<_, Option<String>>(7)?, 367 + "source": row.get::<_, Option<String>>(8)?, 368 + })) 369 + })?; 370 + 371 + rows.collect::<rusqlite::Result<Vec<_>>>().map_err(AppError::from) 372 + } 373 + 374 + fn db_export_json(conn: &Connection, path: &str) -> Result<()> { 375 + let likes = export_posts_as_json(conn, "like")?; 376 + let bookmarks = export_posts_as_json(conn, "bookmark")?; 377 + let mut settings_stmt = conn.prepare("SELECT key, value FROM app_settings")?; 378 + let settings: serde_json::Map<String, serde_json::Value> = settings_stmt 379 + .query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)))? 380 + .filter_map(|r| r.ok()) 381 + .map(|(k, v)| (k, serde_json::Value::String(v))) 382 + .collect(); 383 + 384 + let export = serde_json::json!({ "likes": likes, "bookmarks": bookmarks, "settings": settings }); 385 + fs::write(path, serde_json::to_string_pretty(&export)?)?; 386 + Ok(()) 387 + } 388 + 389 + fn db_export_csv(conn: &Connection, path: &str) -> Result<()> { 390 + let mut stmt = conn.prepare( 391 + "SELECT storage_key, owner_did, uri, cid, author_did, author_handle, text, created_at, source 392 + FROM posts 393 + WHERE source IN ('like', 'bookmark') 394 + ORDER BY created_at DESC, uri DESC", 395 + )?; 396 + 397 + let mut out = 398 + String::from("recordType,source,storageKey,ownerDid,uri,cid,authorDid,authorHandle,text,createdAt,key,value\n"); 399 + let rows = stmt.query_map([], |row| { 400 + Ok([ 401 + "post".to_string(), 402 + row.get::<_, Option<String>>(8)?.unwrap_or_default(), 403 + row.get::<_, Option<String>>(0)?.unwrap_or_default(), 404 + row.get::<_, Option<String>>(1)?.unwrap_or_default(), 405 + row.get::<_, Option<String>>(2)?.unwrap_or_default(), 406 + row.get::<_, Option<String>>(3)?.unwrap_or_default(), 407 + row.get::<_, Option<String>>(4)?.unwrap_or_default(), 408 + row.get::<_, Option<String>>(5)?.unwrap_or_default(), 409 + row.get::<_, Option<String>>(6)?.unwrap_or_default(), 410 + row.get::<_, Option<String>>(7)?.unwrap_or_default(), 411 + String::new(), 412 + String::new(), 413 + ]) 414 + })?; 415 + 416 + for row in rows { 417 + let cols = row?; 418 + let line: Vec<String> = cols.iter().map(|c| csv_escape(c)).collect(); 419 + out.push_str(&line.join(",")); 420 + out.push('\n'); 421 + } 422 + 423 + let mut settings_stmt = conn.prepare("SELECT key, value FROM app_settings ORDER BY key ASC")?; 424 + let settings_rows = settings_stmt.query_map([], |row| { 425 + Ok([ 426 + "setting".to_string(), 427 + String::new(), 428 + String::new(), 429 + String::new(), 430 + String::new(), 431 + String::new(), 432 + String::new(), 433 + String::new(), 434 + String::new(), 435 + String::new(), 436 + row.get::<_, String>(0)?, 437 + row.get::<_, String>(1)?, 438 + ]) 439 + })?; 440 + 441 + for row in settings_rows { 442 + let cols = row?; 443 + let line: Vec<String> = cols.iter().map(|c| csv_escape(c)).collect(); 444 + out.push_str(&line.join(",")); 445 + out.push('\n'); 446 + } 447 + 448 + fs::write(path, out)?; 449 + Ok(()) 450 + } 451 + 452 + fn csv_escape(s: &str) -> String { 453 + if s.contains(',') || s.contains('"') || s.contains('\n') || s.contains('\r') { 454 + format!("\"{}\"", s.replace('"', "\"\"")) 455 + } else { 456 + s.to_string() 457 + } 458 + } 459 + 460 + fn db_reset_app(conn: &Connection) -> Result<()> { 461 + db::reset_database(conn) 462 + } 463 + 464 + /// Parse a single log line emitted by tauri-plugin-log v2. 465 + /// 466 + /// Expected format: `YYYY-MM-DDTHH:MM:SS.ffffffZ LEVEL target: message` 467 + fn parse_log_line(line: &str) -> LogEntry { 468 + let mut parts = line.splitn(3, ' '); 469 + let timestamp_token = parts.next().unwrap_or(""); 470 + let level_token = parts.next().unwrap_or("").to_uppercase(); 471 + let rest = parts.next().unwrap_or("").to_string(); 472 + 473 + let is_valid_level = matches!(level_token.as_str(), "TRACE" | "DEBUG" | "INFO" | "WARN" | "ERROR"); 474 + 475 + if is_valid_level { 476 + let timestamp = Some(timestamp_token.to_string()); 477 + if let Some(colon_pos) = rest.find(": ") { 478 + return LogEntry { 479 + timestamp, 480 + level: level_token, 481 + target: Some(rest[..colon_pos].to_string()), 482 + message: rest[colon_pos + 2..].to_string(), 483 + }; 484 + } 485 + return LogEntry { timestamp, level: level_token, target: None, message: rest }; 486 + } 487 + 488 + LogEntry { timestamp: None, level: "INFO".to_string(), target: None, message: line.to_string() } 489 + } 490 + 491 + fn validate_export_target(path: &str) -> Result<()> { 492 + let trimmed = path.trim(); 493 + if trimmed.is_empty() { 494 + return Err(AppError::validation("export path must not be empty")); 495 + } 496 + 497 + let export_path = Path::new(trimmed); 498 + let Some(parent) = export_path.parent() else { 499 + return Err(AppError::validation("export path must include a parent directory")); 500 + }; 501 + 502 + if !parent.exists() { 503 + return Err(AppError::validation(format!( 504 + "export path parent directory does not exist: {}", 505 + parent.display() 506 + ))); 507 + } 508 + 509 + Ok(()) 510 + } 511 + 512 + fn normalize_log_level_filter(level: Option<&str>) -> Result<Option<String>> { 513 + let Some(level) = level.map(str::trim).filter(|level| !level.is_empty()) else { 514 + return Ok(None); 515 + }; 516 + 517 + let normalized = level.to_ascii_uppercase(); 518 + match normalized.as_str() { 519 + "ALL" => Ok(None), 520 + "TRACE" | "DEBUG" | "INFO" | "WARN" | "ERROR" => Ok(Some(normalized)), 521 + _ => Err(AppError::validation(format!("invalid log level filter: {level}"))), 522 + } 523 + } 524 + 525 + fn collect_log_files(app: &AppHandle, log_dir: &Path) -> Result<Vec<PathBuf>> { 526 + let app_name = app.package_info().name.as_str(); 527 + let mut log_files = fs::read_dir(log_dir)? 528 + .filter_map(|entry| entry.ok()) 529 + .map(|entry| entry.path()) 530 + .filter(|path| path.is_file()) 531 + .filter(|path| { 532 + path.file_name() 533 + .and_then(|name| name.to_str()) 534 + .map(|name| name.starts_with(app_name) && name.ends_with(".log")) 535 + .unwrap_or(false) 536 + }) 537 + .collect::<Vec<_>>(); 538 + 539 + log_files.sort_by_key(|path| path.metadata().and_then(|metadata| metadata.modified()).ok()); 540 + 541 + Ok(log_files) 542 + } 543 + 544 + fn apply_post_persist_side_effects(key: SettingsKey, value: &str, app: &AppHandle) { 545 + match key { 546 + SettingsKey::NotificationsBadge if value == "0" => notifications::clear_unread_badge(app), 547 + _ => {} 548 + } 549 + } 550 + 551 + pub fn get_settings(state: &AppState) -> Result<AppSettings> { 552 + let conn = state.auth_store.lock_connection()?; 553 + db_get_all_settings(&conn) 554 + } 555 + 556 + pub fn update_setting(key: &str, value: &str, state: &AppState, app: &AppHandle) -> Result<()> { 557 + let valid_key = match SettingsKey::from_str(key) { 558 + Some(valid_key) if SettingsKey::valid_keys().contains(&valid_key) => valid_key, 559 + _ => return Err(AppError::Validation(format!("Unknown setting key: {key}"))), 560 + }; 561 + let normalized_value = validate_and_normalize_setting(valid_key, value)?; 562 + 563 + if valid_key == SettingsKey::GlobalShortcut { 564 + tray::update_global_shortcut(app, &normalized_value)?; 565 + } 566 + 567 + let conn = state.auth_store.lock_connection()?; 568 + db_upsert_setting(&conn, &valid_key, &normalized_value)?; 569 + drop(conn); 570 + 571 + apply_post_persist_side_effects(valid_key, &normalized_value, app); 572 + Ok(()) 573 + } 574 + 575 + pub fn get_cache_size(state: &AppState) -> Result<CacheSize> { 576 + let conn = state.auth_store.lock_connection()?; 577 + db_get_cache_size(&conn) 578 + } 579 + 580 + pub fn clear_cache(scope: &str, state: &AppState) -> Result<()> { 581 + match scope { 582 + "all" | "feeds" | "embeddings" | "fts" => {} 583 + _ => return Err(AppError::validation(format!("invalid cache scope: {scope}"))), 584 + } 585 + let conn = state.auth_store.lock_connection()?; 586 + db_clear_cache(&conn, scope) 587 + } 588 + 589 + pub fn export_data(format: &str, path: &str, state: &AppState) -> Result<()> { 590 + match format { 591 + "json" | "csv" => {} 592 + _ => return Err(AppError::validation(format!("invalid export format: {format}"))), 593 + } 594 + validate_export_target(path)?; 595 + log::info!("exporting data as {format} to {path}"); 596 + let conn = state.auth_store.lock_connection()?; 597 + match format { 598 + "json" => db_export_json(&conn, path), 599 + "csv" => db_export_csv(&conn, path), 600 + _ => unreachable!(), 601 + } 602 + } 603 + 604 + pub fn reset_app(state: &AppState, app: &AppHandle) -> Result<()> { 605 + log::warn!("resetting app — all user data will be erased"); 606 + let conn = state.auth_store.lock_connection()?; 607 + db_reset_app(&conn)?; 608 + drop(conn); 609 + 610 + state.clear_runtime_state()?; 611 + notifications::clear_unread_badge(app); 612 + tray::sync_global_shortcut(app)?; 613 + auth::emit_account_switch(app, None)?; 614 + Ok(()) 615 + } 616 + 617 + pub fn get_log_entries(limit: u32, level: Option<&str>, app: &AppHandle) -> Result<Vec<LogEntry>> { 618 + let log_dir = app 619 + .path() 620 + .app_log_dir() 621 + .map_err(|e| AppError::PathResolve(e.to_string()))?; 622 + let level_filter = normalize_log_level_filter(level)?; 623 + let log_files = collect_log_files(app, &log_dir)?; 624 + 625 + if log_files.is_empty() { 626 + return Ok(vec![]); 627 + } 628 + let mut entries: Vec<LogEntry> = Vec::new(); 629 + 630 + for log_file in log_files { 631 + let file = fs::File::open(&log_file)?; 632 + let reader = BufReader::new(file); 633 + 634 + for line in reader.lines() { 635 + let line = line?; 636 + if line.trim().is_empty() { 637 + continue; 638 + } 639 + let entry = parse_log_line(&line); 640 + if let Some(filter) = level_filter.as_deref() { 641 + if entry.level != filter { 642 + continue; 643 + } 644 + } 645 + entries.push(entry); 646 + } 647 + } 648 + 649 + entries.reverse(); 650 + entries.truncate(limit as usize); 651 + Ok(entries) 652 + } 653 + 654 + #[cfg(test)] 655 + mod tests { 656 + use super::*; 657 + use rusqlite::{ffi::sqlite3_auto_extension, Connection}; 658 + use sqlite_vec::sqlite3_vec_init; 659 + use std::time::{SystemTime, UNIX_EPOCH}; 660 + 661 + fn settings_db() -> Connection { 662 + let conn = Connection::open_in_memory().expect("in-memory db should open"); 663 + conn.execute_batch(include_str!("migrations/006_app_settings.sql")) 664 + .expect("settings migration should apply"); 665 + conn 666 + } 667 + 668 + fn full_db() -> Connection { 669 + unsafe { 670 + sqlite3_auto_extension(Some(std::mem::transmute(sqlite3_vec_init as *const ()))); 671 + } 672 + 673 + let conn = Connection::open_in_memory().expect("in-memory db should open"); 674 + conn.execute_batch(include_str!("migrations/001_initial.sql")) 675 + .expect("migration 001 should apply"); 676 + conn.execute_batch(include_str!("migrations/002_auth_storage.sql")) 677 + .expect("migration 002 should apply"); 678 + conn.execute_batch(include_str!("migrations/003_oauth_sessions_without_fk.sql")) 679 + .expect("migration 003 should apply"); 680 + conn.execute_batch(include_str!("migrations/004_account_avatars.sql")) 681 + .expect("migration 004 should apply"); 682 + conn.execute_batch(include_str!("migrations/005_sync_state.sql")) 683 + .expect("migration 005 should apply"); 684 + conn.execute_batch(include_str!("migrations/006_app_settings.sql")) 685 + .expect("migration 006 should apply"); 686 + conn.execute_batch(include_str!("migrations/007_search_owner_scope.sql")) 687 + .expect("migration 007 should apply"); 688 + conn 689 + } 690 + 691 + fn temp_export_path(extension: &str) -> PathBuf { 692 + let timestamp = SystemTime::now() 693 + .duration_since(UNIX_EPOCH) 694 + .expect("system clock should be after unix epoch") 695 + .as_nanos(); 696 + std::env::temp_dir().join(format!("lazurite-settings-test-{timestamp}.{extension}")) 697 + } 698 + 699 + #[test] 700 + fn get_settings_returns_defaults_when_table_is_empty() { 701 + let conn = Connection::open_in_memory().expect("in-memory db should open"); 702 + conn.execute_batch("CREATE TABLE app_settings (key TEXT PRIMARY KEY, value TEXT NOT NULL);") 703 + .expect("schema should apply"); 704 + 705 + let settings = db_get_all_settings(&conn).expect("get_settings should succeed"); 706 + assert_eq!(settings.theme, "auto"); 707 + assert_eq!(settings.timeline_refresh_secs, 60); 708 + assert!(settings.notifications_desktop); 709 + assert!(settings.notifications_badge); 710 + assert!(!settings.notifications_sound); 711 + assert!(settings.embeddings_enabled); 712 + assert_eq!(settings.constellation_url, "https://constellation.microcosm.blue"); 713 + assert_eq!(settings.spacedust_url, "https://spacedust.microcosm.blue"); 714 + assert!(!settings.spacedust_instant); 715 + assert!(!settings.spacedust_enabled); 716 + assert_eq!(settings.global_shortcut, "Ctrl+Shift+N"); 717 + } 718 + 719 + #[test] 720 + fn migration_006_seeds_embeddings_enabled() { 721 + let conn = settings_db(); 722 + let settings = db_get_all_settings(&conn).expect("get_settings should succeed"); 723 + assert!( 724 + settings.embeddings_enabled, 725 + "embeddings_enabled should default to true from seed" 726 + ); 727 + } 728 + 729 + #[test] 730 + fn upsert_setting_stores_and_overwrites_value() { 731 + let conn = settings_db(); 732 + 733 + db_upsert_setting(&conn, &SettingsKey::Theme, "dark").expect("upsert should succeed"); 734 + let s = db_get_all_settings(&conn).expect("get_settings should succeed"); 735 + assert_eq!(s.theme, "dark"); 736 + 737 + db_upsert_setting(&conn, &SettingsKey::Theme, "light").expect("second upsert should succeed"); 738 + let s2 = db_get_all_settings(&conn).expect("get_settings should succeed"); 739 + assert_eq!(s2.theme, "light"); 740 + } 741 + 742 + #[test] 743 + fn upsert_integer_setting_roundtrips() { 744 + let conn = settings_db(); 745 + db_upsert_setting(&conn, &SettingsKey::TimelineRefreshSecs, "120").expect("upsert should succeed"); 746 + let s = db_get_all_settings(&conn).expect("get_settings should succeed"); 747 + assert_eq!(s.timeline_refresh_secs, 120); 748 + } 749 + 750 + #[test] 751 + fn upsert_boolean_setting_roundtrips() { 752 + let conn = settings_db(); 753 + db_upsert_setting(&conn, &SettingsKey::NotificationsSound, "1").expect("upsert should succeed"); 754 + let s = db_get_all_settings(&conn).expect("get_settings should succeed"); 755 + assert!(s.notifications_sound); 756 + 757 + db_upsert_setting(&conn, &SettingsKey::NotificationsSound, "0").expect("upsert should succeed"); 758 + let s2 = db_get_all_settings(&conn).expect("get_settings should succeed"); 759 + assert!(!s2.notifications_sound); 760 + } 761 + 762 + #[test] 763 + fn unknown_setting_key_is_ignored_on_read() { 764 + let conn = settings_db(); 765 + conn.execute( 766 + "INSERT INTO app_settings(key, value) VALUES('unknown_key', 'some_value')", 767 + [], 768 + ) 769 + .expect("insert should succeed"); 770 + 771 + let result = db_get_all_settings(&conn); 772 + assert!(result.is_ok()); 773 + } 774 + 775 + #[test] 776 + fn update_setting_rejects_unknown_key() { 777 + let unknown = SettingsKey::from_str("nonexistent"); 778 + assert!(unknown.is_none(), "from_str should return None for unknown key"); 779 + } 780 + 781 + #[test] 782 + fn invalid_theme_value_is_rejected() { 783 + let error = validate_and_normalize_setting(SettingsKey::Theme, "midnight").expect_err("theme should reject"); 784 + assert!(error.to_string().contains("theme")); 785 + } 786 + 787 + #[test] 788 + fn boolean_values_are_normalized_to_zero_or_one() { 789 + let normalized = 790 + validate_and_normalize_setting(SettingsKey::NotificationsDesktop, "true").expect("bool should normalize"); 791 + assert_eq!(normalized, "1"); 792 + 793 + let normalized = 794 + validate_and_normalize_setting(SettingsKey::NotificationsDesktop, "0").expect("bool should normalize"); 795 + assert_eq!(normalized, "0"); 796 + } 797 + 798 + #[test] 799 + fn invalid_refresh_interval_is_rejected() { 800 + let error = validate_and_normalize_setting(SettingsKey::TimelineRefreshSecs, "45") 801 + .expect_err("refresh interval should reject unsupported values"); 802 + assert!(error.to_string().contains("timeline_refresh_secs")); 803 + } 804 + 805 + #[test] 806 + fn urls_are_validated_and_normalized() { 807 + let normalized = validate_and_normalize_setting(SettingsKey::ConstellationUrl, "https://example.com") 808 + .expect("url should validate"); 809 + assert_eq!(normalized, "https://example.com/"); 810 + } 811 + 812 + #[test] 813 + fn global_shortcut_values_are_validated() { 814 + let normalized = validate_and_normalize_setting(SettingsKey::GlobalShortcut, "Ctrl+Shift+N") 815 + .expect("shortcut should validate"); 816 + assert_eq!(normalized, "Ctrl+Shift+N"); 817 + 818 + let error = validate_and_normalize_setting(SettingsKey::GlobalShortcut, "not-a-shortcut") 819 + .expect_err("invalid shortcut should reject"); 820 + assert!(error.to_string().contains("global_shortcut")); 821 + } 822 + 823 + #[test] 824 + fn all_valid_keys_are_recognized() { 825 + for key in SettingsKey::valid_keys() { 826 + assert!(SettingsKey::valid_keys().contains(key), "{key} should be in VALID_KEYS"); 827 + } 828 + } 829 + 830 + #[test] 831 + fn cache_size_is_zero_on_empty_db() { 832 + let conn = full_db(); 833 + let size = db_get_cache_size(&conn).expect("get_cache_size should succeed"); 834 + assert_eq!(size.feeds_bytes, 0); 835 + assert_eq!(size.embeddings_bytes, 0); 836 + assert_eq!(size.fts_bytes, 0); 837 + assert_eq!(size.total_bytes, 0); 838 + } 839 + 840 + #[test] 841 + fn cache_size_grows_after_post_insert() { 842 + let conn = full_db(); 843 + 844 + conn.execute( 845 + "INSERT INTO posts(storage_key, owner_did, uri, cid, author_did, text, source) 846 + VALUES('k1','did:plc:owner','at://did/post/1','cid1','did:plc:author','Hello world','timeline')", 847 + [], 848 + ) 849 + .expect("post insert should succeed"); 850 + 851 + let size = db_get_cache_size(&conn).expect("get_cache_size should succeed"); 852 + assert!( 853 + size.feeds_bytes > 0, 854 + "feeds_bytes should be non-zero after inserting a post" 855 + ); 856 + } 857 + 858 + #[test] 859 + fn clear_cache_feeds_removes_all_posts() { 860 + let conn = full_db(); 861 + conn.execute( 862 + "INSERT INTO posts(storage_key, owner_did, uri, cid, author_did, source) 863 + VALUES('k1','did:plc:owner','at://uri','cid1','did:plc:author','timeline')", 864 + [], 865 + ) 866 + .expect("insert should succeed"); 867 + 868 + db_clear_cache(&conn, "feeds").expect("clear_cache feeds should succeed"); 869 + 870 + let count: i64 = conn 871 + .query_row("SELECT COUNT(*) FROM posts", [], |r| r.get(0)) 872 + .expect("count should succeed"); 873 + assert_eq!(count, 0); 874 + } 875 + 876 + #[test] 877 + fn clear_cache_invalid_scope_errors() { 878 + let bad_scope = "nonexistent"; 879 + assert!(!["all", "feeds", "embeddings", "fts"].contains(&bad_scope)); 880 + } 881 + 882 + #[test] 883 + fn reset_app_clears_all_user_tables() { 884 + let conn = full_db(); 885 + 886 + conn.execute( 887 + "INSERT INTO accounts(did, handle, pds_url, active) VALUES('did:plc:x','user','https://pds.example.com',1)", 888 + [], 889 + ) 890 + .expect("account insert should succeed"); 891 + conn.execute( 892 + "INSERT INTO posts(storage_key, owner_did, uri, cid, author_did, source) 893 + VALUES('k1','did:plc:x','at://uri','cid1','did:plc:x','timeline')", 894 + [], 895 + ) 896 + .expect("post insert should succeed"); 897 + conn.execute("INSERT INTO sync_state(did, source) VALUES('did:plc:x','timeline')", []) 898 + .expect("sync_state insert should succeed"); 899 + 900 + db_reset_app(&conn).expect("reset_app should succeed"); 901 + 902 + let post_count: i64 = conn.query_row("SELECT COUNT(*) FROM posts", [], |r| r.get(0)).unwrap(); 903 + let account_count: i64 = conn 904 + .query_row("SELECT COUNT(*) FROM accounts", [], |r| r.get(0)) 905 + .unwrap(); 906 + let sync_count: i64 = conn 907 + .query_row("SELECT COUNT(*) FROM sync_state", [], |r| r.get(0)) 908 + .unwrap(); 909 + 910 + assert_eq!(post_count, 0, "posts should be empty after reset"); 911 + assert_eq!(account_count, 0, "accounts should be empty after reset"); 912 + assert_eq!(sync_count, 0, "sync_state should be empty after reset"); 913 + } 914 + 915 + #[test] 916 + fn reset_app_re_seeds_embeddings_enabled() { 917 + let conn = full_db(); 918 + 919 + conn.execute("UPDATE app_settings SET value='0' WHERE key='embeddings_enabled'", []) 920 + .expect("update should succeed"); 921 + db_reset_app(&conn).expect("reset_app should succeed"); 922 + 923 + let val: Option<String> = conn 924 + .query_row( 925 + "SELECT value FROM app_settings WHERE key='embeddings_enabled'", 926 + [], 927 + |r| r.get(0), 928 + ) 929 + .unwrap(); 930 + assert_eq!( 931 + val.as_deref(), 932 + Some("1"), 933 + "embeddings_enabled should be re-seeded to '1'" 934 + ); 935 + } 936 + 937 + #[test] 938 + fn export_json_only_includes_user_owned_search_sources_and_settings() { 939 + let conn = full_db(); 940 + let export_path = temp_export_path("json"); 941 + 942 + conn.execute( 943 + "INSERT INTO posts(storage_key, owner_did, uri, cid, author_did, source) 944 + VALUES('like-key','did:plc:alice','at://did/post/1','cid1','did:plc:author','like')", 945 + [], 946 + ) 947 + .expect("like insert should succeed"); 948 + conn.execute( 949 + "INSERT INTO posts(storage_key, owner_did, uri, cid, author_did, source) 950 + VALUES('bookmark-key','did:plc:alice','at://did/post/2','cid2','did:plc:author','bookmark')", 951 + [], 952 + ) 953 + .expect("bookmark insert should succeed"); 954 + conn.execute( 955 + "INSERT INTO posts(storage_key, owner_did, uri, cid, author_did, source) 956 + VALUES('timeline-key','did:plc:alice','at://did/post/3','cid3','did:plc:author','timeline')", 957 + [], 958 + ) 959 + .expect("timeline insert should succeed"); 960 + conn.execute("INSERT INTO app_settings(key, value) VALUES('theme', 'dark')", []) 961 + .expect("settings insert should succeed"); 962 + 963 + db_export_json(&conn, export_path.to_str().expect("path should be utf-8")).expect("json export should work"); 964 + 965 + let exported = fs::read_to_string(&export_path).expect("export file should read"); 966 + let parsed: serde_json::Value = serde_json::from_str(&exported).expect("json should parse"); 967 + 968 + assert_eq!(parsed["likes"].as_array().map(Vec::len), Some(1)); 969 + assert_eq!(parsed["bookmarks"].as_array().map(Vec::len), Some(1)); 970 + assert_eq!(parsed["settings"]["theme"].as_str(), Some("dark")); 971 + 972 + let _ = fs::remove_file(export_path); 973 + } 974 + 975 + #[test] 976 + fn export_csv_includes_settings_rows() { 977 + let conn = full_db(); 978 + let export_path = temp_export_path("csv"); 979 + 980 + conn.execute( 981 + "INSERT INTO posts(storage_key, owner_did, uri, cid, author_did, source) 982 + VALUES('like-key','did:plc:alice','at://did/post/1','cid1','did:plc:author','like')", 983 + [], 984 + ) 985 + .expect("like insert should succeed"); 986 + conn.execute("INSERT INTO app_settings(key, value) VALUES('theme', 'dark')", []) 987 + .expect("settings insert should succeed"); 988 + 989 + db_export_csv(&conn, export_path.to_str().expect("path should be utf-8")).expect("csv export should work"); 990 + 991 + let exported = fs::read_to_string(&export_path).expect("export file should read"); 992 + assert!(exported.contains("recordType,source,storageKey")); 993 + assert!(exported.contains("post,like,like-key")); 994 + assert!(exported.contains("setting,,,,,,,,,,theme,dark")); 995 + 996 + let _ = fs::remove_file(export_path); 997 + } 998 + 999 + #[test] 1000 + fn csv_escape_leaves_simple_strings_unchanged() { 1001 + assert_eq!(csv_escape("hello"), "hello"); 1002 + assert_eq!(csv_escape(""), ""); 1003 + } 1004 + 1005 + #[test] 1006 + fn csv_escape_wraps_strings_with_commas() { 1007 + assert_eq!(csv_escape("hello,world"), "\"hello,world\""); 1008 + } 1009 + 1010 + #[test] 1011 + fn csv_escape_doubles_internal_quotes() { 1012 + assert_eq!(csv_escape("say \"hi\""), "\"say \"\"hi\"\"\""); 1013 + } 1014 + 1015 + #[test] 1016 + fn csv_escape_wraps_strings_with_newlines() { 1017 + assert_eq!(csv_escape("line1\nline2"), "\"line1\nline2\""); 1018 + } 1019 + 1020 + #[test] 1021 + fn parse_log_line_handles_well_formed_line() { 1022 + let line = "2024-01-15T10:30:00.000000Z INFO lazurite_desktop_lib::auth: session restored"; 1023 + let entry = parse_log_line(line); 1024 + assert_eq!(entry.timestamp.as_deref(), Some("2024-01-15T10:30:00.000000Z")); 1025 + assert_eq!(entry.level, "INFO"); 1026 + assert_eq!(entry.target.as_deref(), Some("lazurite_desktop_lib::auth")); 1027 + assert_eq!(entry.message, "session restored"); 1028 + } 1029 + 1030 + #[test] 1031 + fn parse_log_line_normalises_level_to_uppercase() { 1032 + let line = "2024-01-15T10:30:00Z warn some::target: something happened"; 1033 + let entry = parse_log_line(line); 1034 + assert_eq!(entry.level, "WARN"); 1035 + } 1036 + 1037 + #[test] 1038 + fn parse_log_line_falls_back_on_unrecognised_format() { 1039 + let line = "not a valid log line at all"; 1040 + let entry = parse_log_line(line); 1041 + assert_eq!(entry.level, "INFO"); 1042 + assert_eq!(entry.message, line); 1043 + } 1044 + 1045 + #[test] 1046 + fn parse_log_line_handles_missing_target() { 1047 + let line = "2024-01-15T10:30:00Z ERROR something went wrong"; 1048 + let entry = parse_log_line(line); 1049 + assert_eq!(entry.level, "ERROR"); 1050 + assert!(entry.target.is_none()); 1051 + assert_eq!(entry.message, "something went wrong"); 1052 + } 1053 + 1054 + #[test] 1055 + fn parse_bool_treats_zero_and_false_as_false() { 1056 + assert!(!parse_bool("0")); 1057 + assert!(!parse_bool("false")); 1058 + assert!(!parse_bool("FALSE")); 1059 + assert!(!parse_bool("False")); 1060 + } 1061 + 1062 + #[test] 1063 + fn parse_bool_treats_other_values_as_true() { 1064 + assert!(parse_bool("1")); 1065 + assert!(parse_bool("true")); 1066 + assert!(parse_bool("yes")); 1067 + assert!(parse_bool("TRUE")); 1068 + } 1069 + 1070 + #[test] 1071 + fn collect_log_files_filters_to_matching_log_prefix() { 1072 + let temp_dir = std::env::temp_dir().join(format!( 1073 + "lazurite-log-files-{}", 1074 + SystemTime::now() 1075 + .duration_since(UNIX_EPOCH) 1076 + .expect("system clock should be after unix epoch") 1077 + .as_nanos() 1078 + )); 1079 + fs::create_dir_all(&temp_dir).expect("temp dir should create"); 1080 + fs::write(temp_dir.join("lazurite-desktop.log"), "line").expect("log file should write"); 1081 + fs::write(temp_dir.join("lazurite-desktop.1.log"), "line").expect("rotated log should write"); 1082 + fs::write(temp_dir.join("different-app.log"), "line").expect("foreign log should write"); 1083 + 1084 + let app_name = "lazurite-desktop"; 1085 + let mut log_files = fs::read_dir(&temp_dir) 1086 + .expect("temp dir should read") 1087 + .filter_map(|entry| entry.ok()) 1088 + .map(|entry| entry.path()) 1089 + .filter(|path| { 1090 + path.file_name() 1091 + .and_then(|name| name.to_str()) 1092 + .map(|name| name.starts_with(app_name) && name.ends_with(".log")) 1093 + .unwrap_or(false) 1094 + }) 1095 + .collect::<Vec<_>>(); 1096 + log_files.sort(); 1097 + 1098 + assert_eq!(log_files.len(), 2); 1099 + 1100 + let _ = fs::remove_dir_all(temp_dir); 1101 + } 1102 + }
+18 -9
src-tauri/src/state.rs
··· 108 108 .clone()) 109 109 } 110 110 111 + pub fn clear_runtime_state(&self) -> Result<(), AppError> { 112 + self.sessions 113 + .write() 114 + .map_err(|_| AppError::StatePoisoned("sessions"))? 115 + .clear(); 116 + self.account_list 117 + .write() 118 + .map_err(|_| AppError::StatePoisoned("account_list"))? 119 + .clear(); 120 + *self 121 + .active_session 122 + .write() 123 + .map_err(|_| AppError::StatePoisoned("active_session"))? = None; 124 + Ok(()) 125 + } 126 + 111 127 pub async fn login(&self, app: &AppHandle, identifier: String) -> Result<AccountSummary, AppError> { 112 128 log::info!("starting login flow for {}", identifier.trim()); 113 129 let session = Arc::new(login_with_loopback(&self.oauth_client, identifier.trim()).await?); ··· 237 253 Ok(session) 238 254 } 239 255 240 - async fn resolve_restorable_session_id( 241 - &self, account: &StoredAccount, did: &Did<'_>, 242 - ) -> Result<String, AppError> { 256 + async fn resolve_restorable_session_id(&self, account: &StoredAccount, did: &Did<'_>) -> Result<String, AppError> { 243 257 let configured_session_id = account.session_id.as_deref().ok_or_else(|| { 244 258 AppError::Validation(format!("account {} does not have a stored oauth session", account.did)) 245 259 })?; 246 260 247 - if self 248 - .auth_store 249 - .get_session(did, configured_session_id) 250 - .await? 251 - .is_some() 252 - { 261 + if self.auth_store.get_session(did, configured_session_id).await?.is_some() { 253 262 return Ok(configured_session_id.to_string()); 254 263 } 255 264
+73 -10
src-tauri/src/tray.rs
··· 1 + use crate::error::{AppError, Result as AppResult}; 2 + use crate::settings; 3 + use crate::state::AppState; 4 + use std::sync::Mutex; 1 5 use tauri::{ 2 6 image::Image, 3 7 menu::{Menu, MenuItem}, 4 8 tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, 5 9 AppHandle, Manager, WebviewUrl, WebviewWindow, WebviewWindowBuilder, 6 10 }; 7 - use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState}; 11 + use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState}; 12 + use tauri_plugin_log::log; 8 13 9 14 const COMPOSER_WINDOW_LABEL: &str = "composer"; 10 15 const APP_INDEX_PATH: &str = "index.html"; ··· 18 23 const MENU_NEW_POST: &str = "new_post"; 19 24 const MENU_TOGGLE_WINDOW: &str = "toggle_window"; 20 25 const MENU_QUIT: &str = "quit"; 26 + const DEFAULT_GLOBAL_SHORTCUT: &str = "Ctrl+Shift+N"; 21 27 22 - pub fn setup_tray(app: &AppHandle) -> Result<(), Box<dyn std::error::Error>> { 28 + #[derive(Default)] 29 + struct ComposerShortcutState { 30 + current_shortcut: Mutex<Option<String>>, 31 + } 32 + 33 + pub fn setup_tray(app: &AppHandle) -> std::result::Result<(), Box<dyn std::error::Error>> { 23 34 let new_post_i = MenuItem::with_id(app, MENU_NEW_POST, "New Post…", true, None::<&str>)?; 24 35 let toggle_window_i = MenuItem::with_id(app, MENU_TOGGLE_WINDOW, "Show / Hide", true, None::<&str>)?; 25 36 let quit_i = MenuItem::with_id(app, MENU_QUIT, "Quit", true, None::<&str>)?; ··· 51 62 Ok(()) 52 63 } 53 64 54 - pub fn setup_global_shortcut(app: &AppHandle) -> Result<(), Box<dyn std::error::Error>> { 55 - let shortcut = Shortcut::new(Some(Modifiers::CONTROL | Modifiers::SHIFT), Code::KeyN); 65 + pub fn setup_global_shortcut(app: &AppHandle) -> std::result::Result<(), Box<dyn std::error::Error>> { 66 + app.manage(ComposerShortcutState::default()); 67 + sync_global_shortcut(app)?; 68 + Ok(()) 69 + } 70 + 71 + pub fn sync_global_shortcut(app: &AppHandle) -> AppResult<()> { 72 + let configured_shortcut = app 73 + .try_state::<AppState>() 74 + .and_then(|state| { 75 + settings::get_settings(&state) 76 + .ok() 77 + .map(|settings| settings.global_shortcut) 78 + }) 79 + .unwrap_or_else(|| DEFAULT_GLOBAL_SHORTCUT.to_string()); 80 + 81 + update_global_shortcut(app, &configured_shortcut) 82 + } 83 + 84 + pub fn update_global_shortcut(app: &AppHandle, shortcut: &str) -> AppResult<()> { 85 + let shortcut = shortcut.trim(); 86 + if shortcut.is_empty() { 87 + return Err(AppError::validation("global shortcut must not be empty")); 88 + } 89 + 90 + let parsed_shortcut: Shortcut = shortcut 91 + .parse() 92 + .map_err(|error| AppError::validation(format!("invalid global shortcut '{shortcut}': {error}")))?; 93 + 94 + let shortcut_state = app.state::<ComposerShortcutState>(); 95 + let mut current_shortcut = shortcut_state 96 + .current_shortcut 97 + .lock() 98 + .map_err(|_| AppError::StatePoisoned("composer_shortcut"))?; 99 + 100 + if current_shortcut.as_deref() == Some(shortcut) { 101 + return Ok(()); 102 + } 103 + 104 + if let Some(existing_shortcut) = current_shortcut.as_ref() { 105 + let existing_shortcut = existing_shortcut 106 + .parse::<Shortcut>() 107 + .map_err(|error| AppError::validation(format!("invalid registered global shortcut: {error}")))?; 108 + app.global_shortcut().unregister(existing_shortcut).map_err(|error| { 109 + AppError::validation(format!( 110 + "failed to unregister existing global shortcut '{}': {error}", 111 + current_shortcut.as_deref().unwrap_or_default() 112 + )) 113 + })?; 114 + } 56 115 57 - app.global_shortcut().on_shortcut(shortcut, |app, _, event| { 58 - if event.state == ShortcutState::Pressed { 59 - let _ = open_composer_window(app); 60 - } 61 - })?; 116 + app.global_shortcut() 117 + .on_shortcut(parsed_shortcut, |app, _, event| { 118 + if event.state == ShortcutState::Pressed { 119 + let _ = open_composer_window(app); 120 + } 121 + }) 122 + .map_err(|error| AppError::validation(format!("failed to register global shortcut '{shortcut}': {error}")))?; 62 123 124 + log::info!("registered global composer shortcut: {shortcut}"); 125 + *current_shortcut = Some(shortcut.to_string()); 63 126 Ok(()) 64 127 } 65 128 ··· 77 140 window.is_visible().unwrap_or(false) && !window.is_minimized().unwrap_or(false) 78 141 } 79 142 80 - fn open_composer_window(app: &AppHandle) -> Result<(), Box<dyn std::error::Error>> { 143 + fn open_composer_window(app: &AppHandle) -> std::result::Result<(), Box<dyn std::error::Error>> { 81 144 if let Some(window) = app.get_webview_window(COMPOSER_WINDOW_LABEL) { 82 145 route_window_to_composer(&window); 83 146 show_window(&window);
+4
src/components/shared/Icon.tsx
··· 8 8 | "loader" 9 9 | "logout" 10 10 | "notifications" 11 + | "settings" 11 12 | "profile" 12 13 | "refresh" 13 14 | "search" ··· 132 133 </Match> 133 134 <Match when={local.kind === "download"}> 134 135 <i class="i-ri-download-cloud-line" /> 136 + </Match> 137 + <Match when={local.kind === "settings"}> 138 + <i class="i-ri-settings-3-line" /> 135 139 </Match> 136 140 </Switch> 137 141 </span>