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: backend scaffolding

+851 -265
+2 -2
docs/specs/auth.md
··· 5 5 Uses `jacquard::oauth` with `LoopbackConfig` to authenticate: 6 6 7 7 1. User enters handle or DID 8 - 2. Resolve authorization server via `jacquard_oauth::resolver` 8 + 2. Resolve authorization server via `jacquard::oauth::resolver` 9 9 3. Build `AtprotoClientMetadata` with app identity 10 10 4. `OAuthClient` initiates PAR + DPoP flow 11 11 5. Loopback server captures redirect on `127.0.0.1:<port>` ··· 17 17 ## Multi-Account 18 18 19 19 - SQLite table `accounts`: `did TEXT PK, handle TEXT, pds_url TEXT, active INTEGER` 20 - - Encrypted token storage via `jacquard_oauth::authstore` trait with a persistent implementation backed by SQLite + OS keychain (Tauri's `tauri-plugin-keychain` or raw `security-framework`) 20 + - Encrypted token storage via `jacquard::oauth::authstore` trait with a persistent implementation backed by SQLite + OS keychain (Tauri's `tauri-plugin-keychain` or raw `security-framework`) 21 21 - Account switcher in sidebar — click to swap active session 22 22 - Each account gets its own `OAuthSession` instance 23 23 - Active account DID stored in app state; Tauri events notify frontend on switch
+11 -9
docs/tasks/01-backend-setup.md
··· 5 5 ## Steps 6 6 7 7 - [x] Add Cargo dependencies: `jacquard`, `rusqlite` (bundled), `sqlite-vec`, `fastembed`, `tokio` 8 - - [x] Add Tauri plugins: `tauri-plugin-deep-link`, `tauri-plugin-notification`, `tauri-plugin-updater`, `tauri-plugin-log` 8 + - [x] Add Tauri plugins: `tauri-plugin-deep-link`, `tauri-plugin-notification`, `tauri-plugin-log` 9 9 - [x] Add frontend deps: `solid-motionone` (animation), install via npm 10 - - [ ] Create `src-tauri/src/db.rs` — initialize SQLite, run migrations, load `sqlite-vec` extension 11 - - [ ] Create migration system: `accounts`, `posts`, `posts_fts`, `posts_vec` tables 10 + - [x] Create `src-tauri/src/db.rs` — initialize SQLite, run migrations, load `sqlite-vec` extension 11 + - [x] Create migration system: `accounts`, `posts`, `posts_fts`, `posts_vec` tables 12 12 - Embedded files via `include_str!` for SQL schema 13 - - [ ] Create `src-tauri/src/state.rs` — `AppState` struct holding DB pool, active session, account list 14 - - [ ] Register `AppState` as Tauri managed state 15 - - [ ] Create Tauri command scaffold with error handling pattern using `thiserror` crate 16 - - [ ] Set up dark/light theme: CSS custom properties, OS preference detection via `prefers-color-scheme` 13 + - [x] Create `src-tauri/src/state.rs` — `AppState` struct holding DB pool, active session, account list 14 + - [x] Register `AppState` as Tauri managed state 15 + - [x] Create Tauri command scaffold with error handling pattern using `thiserror` crate 16 + - [x] Set up dark/light theme: CSS custom properties, OS preference detection via `prefers-color-scheme` 17 17 - Follow the design spec 18 - - [ ] Create global error toast component using `Presence` for enter/exit animations 19 - - [ ] Verify build compiles on macOS with `pnpm tauri dev` 18 + - [x] Create global error toast component using `Presence` for enter/exit animations 19 + - [x] Verify build compiles on macOS with `pnpm tauri dev` 20 + 21 + - Note: `tauri-plugin-updater` will come after the first release
+1 -1
docs/tasks/02-auth.md
··· 4 4 5 5 ## Steps 6 6 7 - - [ ] Implement `PersistentAuthStore` backed by SQLite (impl `jacquard_oauth::authstore` trait) 7 + - [ ] Implement `PersistentAuthStore` backed by SQLite (impl `jacquard::oauth::authstore` trait) 8 8 - [ ] Create Tauri command `login(handle: String)`: 9 9 - Resolve handle → authorization server 10 10 - Build `AtprotoClientMetadata` for Lazurite
+1 -1
package.json
··· 12 12 }, 13 13 "license": "MIT", 14 14 "dependencies": { 15 + "@fontsource-variable/google-sans": "^5.2.1", 15 16 "@solidjs/router": "^0.15.4", 16 17 "@tauri-apps/api": "^2", 17 18 "@tauri-apps/plugin-deep-link": "~2.4.7", 18 19 "@tauri-apps/plugin-log": "~2", 19 20 "@tauri-apps/plugin-notification": "~2.3.3", 20 21 "@tauri-apps/plugin-opener": "^2", 21 - "@tauri-apps/plugin-updater": "~2.10.0", 22 22 "solid-js": "^1.9.3", 23 23 "solid-motionone": "^1.0.4" 24 24 },
+8 -10
pnpm-lock.yaml
··· 8 8 9 9 .: 10 10 dependencies: 11 + '@fontsource-variable/google-sans': 12 + specifier: ^5.2.1 13 + version: 5.2.1 11 14 '@solidjs/router': 12 15 specifier: ^0.15.4 13 16 version: 0.15.4(solid-js@1.9.12) ··· 26 29 '@tauri-apps/plugin-opener': 27 30 specifier: ^2 28 31 version: 2.5.3 29 - '@tauri-apps/plugin-updater': 30 - specifier: ~2.10.0 31 - version: 2.10.0 32 32 solid-js: 33 33 specifier: ^1.9.3 34 34 version: 1.9.12 ··· 522 522 '@noble/hashes': 523 523 optional: true 524 524 525 + '@fontsource-variable/google-sans@5.2.1': 526 + resolution: {integrity: sha512-dMyMlJVRJgQvvYlJLcrg2oiVzlKAuIMrbDxIMchEiMBGmpOA6VnVblj3cPfJyA0/fNPQS6krZaiJyJjHJZmkzw==} 527 + 525 528 '@humanfs/core@0.19.1': 526 529 resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} 527 530 engines: {node: '>=18.18.0'} ··· 927 930 928 931 '@tauri-apps/plugin-opener@2.5.3': 929 932 resolution: {integrity: sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ==} 930 - 931 - '@tauri-apps/plugin-updater@2.10.0': 932 - resolution: {integrity: sha512-ljN8jPlnT0aSn8ecYhuBib84alxfMx6Hc8vJSKMJyzGbTPFZAC44T2I1QNFZssgWKrAlofvJqCC6Rr472JWfkQ==} 933 933 934 934 '@testing-library/dom@10.4.1': 935 935 resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} ··· 2783 2783 2784 2784 '@exodus/bytes@1.15.0': {} 2785 2785 2786 + '@fontsource-variable/google-sans@5.2.1': {} 2787 + 2786 2788 '@humanfs/core@0.19.1': {} 2787 2789 2788 2790 '@humanfs/node@0.16.7': ··· 3105 3107 '@tauri-apps/api': 2.10.1 3106 3108 3107 3109 '@tauri-apps/plugin-opener@2.5.3': 3108 - dependencies: 3109 - '@tauri-apps/api': 2.10.1 3110 - 3111 - '@tauri-apps/plugin-updater@2.10.0': 3112 3110 dependencies: 3113 3111 '@tauri-apps/api': 2.10.1 3114 3112
+6
rustfmt.toml
··· 1 + max_width = 120 2 + fn_params_layout = "Compressed" 3 + single_line_if_else_max_width = 100 4 + single_line_let_else_max_width = 100 5 + use_field_init_shorthand = true 6 + struct_lit_width = 100
+8 -187
src-tauri/Cargo.lock
··· 130 130 version = "1.4.2" 131 131 source = "registry+https://github.com/rust-lang/crates.io-index" 132 132 checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" 133 - dependencies = [ 134 - "derive_arbitrary", 135 - ] 136 133 137 134 [[package]] 138 135 name = "arg_enum_proc_macro" ··· 1426 1423 ] 1427 1424 1428 1425 [[package]] 1429 - name = "derive_arbitrary" 1430 - version = "1.4.2" 1431 - source = "registry+https://github.com/rust-lang/crates.io-index" 1432 - checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" 1433 - dependencies = [ 1434 - "proc-macro2", 1435 - "quote", 1436 - "syn 2.0.117", 1437 - ] 1438 - 1439 - [[package]] 1440 1426 name = "derive_builder" 1441 1427 version = "0.20.2" 1442 1428 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2708 2694 2709 2695 [[package]] 2710 2696 name = "hashlink" 2711 - version = "0.11.0" 2697 + version = "0.10.0" 2712 2698 source = "registry+https://github.com/rust-lang/crates.io-index" 2713 - checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" 2699 + checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" 2714 2700 dependencies = [ 2715 - "hashbrown 0.16.1", 2701 + "hashbrown 0.15.5", 2716 2702 ] 2717 2703 2718 2704 [[package]] ··· 3754 3740 "tauri-plugin-log", 3755 3741 "tauri-plugin-notification", 3756 3742 "tauri-plugin-opener", 3757 - "tauri-plugin-updater", 3758 3743 "thiserror 2.0.18", 3759 3744 "tokio", 3760 3745 ] ··· 3850 3835 3851 3836 [[package]] 3852 3837 name = "libsqlite3-sys" 3853 - version = "0.37.0" 3838 + version = "0.35.0" 3854 3839 source = "registry+https://github.com/rust-lang/crates.io-index" 3855 - checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" 3840 + checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" 3856 3841 dependencies = [ 3842 + "cc", 3857 3843 "pkg-config", 3858 3844 "vcpkg", 3859 3845 ] ··· 4165 4151 version = "0.2.1" 4166 4152 source = "registry+https://github.com/rust-lang/crates.io-index" 4167 4153 checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" 4168 - 4169 - [[package]] 4170 - name = "minisign-verify" 4171 - version = "0.2.5" 4172 - source = "registry+https://github.com/rust-lang/crates.io-index" 4173 - checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" 4174 4154 4175 4155 [[package]] 4176 4156 name = "miniz_oxide" ··· 4663 4643 ] 4664 4644 4665 4645 [[package]] 4666 - name = "objc2-osa-kit" 4667 - version = "0.3.2" 4668 - source = "registry+https://github.com/rust-lang/crates.io-index" 4669 - checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" 4670 - dependencies = [ 4671 - "bitflags 2.11.0", 4672 - "objc2", 4673 - "objc2-app-kit", 4674 - "objc2-foundation", 4675 - ] 4676 - 4677 - [[package]] 4678 4646 name = "objc2-quartz-core" 4679 4647 version = "0.3.2" 4680 4648 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4847 4815 ] 4848 4816 4849 4817 [[package]] 4850 - name = "osakit" 4851 - version = "0.3.1" 4852 - source = "registry+https://github.com/rust-lang/crates.io-index" 4853 - checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" 4854 - dependencies = [ 4855 - "objc2", 4856 - "objc2-foundation", 4857 - "objc2-osa-kit", 4858 - "serde", 4859 - "serde_json", 4860 - "thiserror 2.0.18", 4861 - ] 4862 - 4863 - [[package]] 4864 4818 name = "ouroboros" 4865 4819 version = "0.18.5" 4866 4820 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 6010 5964 "http-body", 6011 5965 "http-body-util", 6012 5966 "hyper", 6013 - "hyper-rustls", 6014 5967 "hyper-util", 6015 5968 "js-sys", 6016 5969 "log", 6017 5970 "percent-encoding", 6018 5971 "pin-project-lite", 6019 - "rustls", 6020 - "rustls-pki-types", 6021 - "rustls-platform-verifier", 6022 5972 "serde", 6023 5973 "serde_json", 6024 5974 "sync_wrapper", 6025 5975 "tokio", 6026 - "tokio-rustls", 6027 5976 "tokio-util", 6028 5977 "tower", 6029 5978 "tower-http", ··· 6145 6094 ] 6146 6095 6147 6096 [[package]] 6148 - name = "rsqlite-vfs" 6149 - version = "0.1.0" 6150 - source = "registry+https://github.com/rust-lang/crates.io-index" 6151 - checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d" 6152 - dependencies = [ 6153 - "hashbrown 0.16.1", 6154 - "thiserror 2.0.18", 6155 - ] 6156 - 6157 - [[package]] 6158 6097 name = "rusqlite" 6159 - version = "0.39.0" 6098 + version = "0.37.0" 6160 6099 source = "registry+https://github.com/rust-lang/crates.io-index" 6161 - checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e" 6100 + checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" 6162 6101 dependencies = [ 6163 6102 "bitflags 2.11.0", 6164 6103 "fallible-iterator", ··· 6166 6105 "hashlink", 6167 6106 "libsqlite3-sys", 6168 6107 "smallvec", 6169 - "sqlite-wasm-rs", 6170 6108 ] 6171 6109 6172 6110 [[package]] ··· 6240 6178 ] 6241 6179 6242 6180 [[package]] 6243 - name = "rustls-native-certs" 6244 - version = "0.8.3" 6245 - source = "registry+https://github.com/rust-lang/crates.io-index" 6246 - checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" 6247 - dependencies = [ 6248 - "openssl-probe", 6249 - "rustls-pki-types", 6250 - "schannel", 6251 - "security-framework", 6252 - ] 6253 - 6254 - [[package]] 6255 6181 name = "rustls-pki-types" 6256 6182 version = "1.14.0" 6257 6183 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 6262 6188 ] 6263 6189 6264 6190 [[package]] 6265 - name = "rustls-platform-verifier" 6266 - version = "0.6.2" 6267 - source = "registry+https://github.com/rust-lang/crates.io-index" 6268 - checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" 6269 - dependencies = [ 6270 - "core-foundation 0.10.1", 6271 - "core-foundation-sys", 6272 - "jni 0.21.1", 6273 - "log", 6274 - "once_cell", 6275 - "rustls", 6276 - "rustls-native-certs", 6277 - "rustls-platform-verifier-android", 6278 - "rustls-webpki", 6279 - "security-framework", 6280 - "security-framework-sys", 6281 - "webpki-root-certs", 6282 - "windows-sys 0.61.2", 6283 - ] 6284 - 6285 - [[package]] 6286 - name = "rustls-platform-verifier-android" 6287 - version = "0.1.1" 6288 - source = "registry+https://github.com/rust-lang/crates.io-index" 6289 - checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" 6290 - 6291 - [[package]] 6292 6191 name = "rustls-webpki" 6293 6192 version = "0.103.10" 6294 6193 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 6962 6861 ] 6963 6862 6964 6863 [[package]] 6965 - name = "sqlite-wasm-rs" 6966 - version = "0.5.2" 6967 - source = "registry+https://github.com/rust-lang/crates.io-index" 6968 - checksum = "2f4206ed3a67690b9c29b77d728f6acc3ce78f16bf846d83c94f76400320181b" 6969 - dependencies = [ 6970 - "cc", 6971 - "js-sys", 6972 - "rsqlite-vfs", 6973 - "wasm-bindgen", 6974 - ] 6975 - 6976 - [[package]] 6977 6864 name = "stable_deref_trait" 6978 6865 version = "1.2.1" 6979 6866 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 7195 7082 checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" 7196 7083 7197 7084 [[package]] 7198 - name = "tar" 7199 - version = "0.4.45" 7200 - source = "registry+https://github.com/rust-lang/crates.io-index" 7201 - checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" 7202 - dependencies = [ 7203 - "filetime", 7204 - "libc", 7205 - "xattr", 7206 - ] 7207 - 7208 - [[package]] 7209 7085 name = "target-lexicon" 7210 7086 version = "0.12.16" 7211 7087 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 7424 7300 "url", 7425 7301 "windows", 7426 7302 "zbus", 7427 - ] 7428 - 7429 - [[package]] 7430 - name = "tauri-plugin-updater" 7431 - version = "2.10.0" 7432 - source = "registry+https://github.com/rust-lang/crates.io-index" 7433 - checksum = "3fe8e9bebd88fc222938ffdfbdcfa0307081423bd01e3252fc337d8bde81fc61" 7434 - dependencies = [ 7435 - "base64 0.22.1", 7436 - "dirs", 7437 - "flate2", 7438 - "futures-util", 7439 - "http", 7440 - "infer", 7441 - "log", 7442 - "minisign-verify", 7443 - "osakit", 7444 - "percent-encoding", 7445 - "reqwest 0.13.2", 7446 - "rustls", 7447 - "semver", 7448 - "serde", 7449 - "serde_json", 7450 - "tar", 7451 - "tauri", 7452 - "tauri-plugin", 7453 - "tempfile", 7454 - "thiserror 2.0.18", 7455 - "time", 7456 - "tokio", 7457 - "url", 7458 - "windows-sys 0.60.2", 7459 - "zip", 7460 7303 ] 7461 7304 7462 7305 [[package]] ··· 9423 9266 ] 9424 9267 9425 9268 [[package]] 9426 - name = "xattr" 9427 - version = "1.6.1" 9428 - source = "registry+https://github.com/rust-lang/crates.io-index" 9429 - checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" 9430 - dependencies = [ 9431 - "libc", 9432 - "rustix", 9433 - ] 9434 - 9435 - [[package]] 9436 9269 name = "xml5ever" 9437 9270 version = "0.18.1" 9438 9271 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 9620 9453 "proc-macro2", 9621 9454 "quote", 9622 9455 "syn 2.0.117", 9623 - ] 9624 - 9625 - [[package]] 9626 - name = "zip" 9627 - version = "4.6.1" 9628 - source = "registry+https://github.com/rust-lang/crates.io-index" 9629 - checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" 9630 - dependencies = [ 9631 - "arbitrary", 9632 - "crc32fast", 9633 - "indexmap 2.13.0", 9634 - "memchr", 9635 9456 ] 9636 9457 9637 9458 [[package]]
+40 -4
src-tauri/Cargo.toml
··· 2 2 name = "lazurite-desktop" 3 3 version = "0.1.0" 4 4 description = "A Tauri App" 5 - authors = ["you"] 5 + authors = ["Owais J. <me@desertthunder.dev>"] 6 6 edition = "2021" 7 7 8 8 # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html ··· 22 22 tauri-plugin-opener = "2" 23 23 serde = { version = "1", features = ["derive"] } 24 24 serde_json = "1" 25 - rusqlite = "0.39.0" 25 + rusqlite = { version = "0.37.0", features = ["bundled"] } 26 26 jacquard = "0.11.0" 27 27 sqlite-vec = "0.1.7" 28 28 fastembed = "5.13.0" ··· 32 32 tauri-plugin-notification = "2" 33 33 thiserror = "2.0.18" 34 34 35 - [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] 36 - tauri-plugin-updater = "2" 35 + # TODO: add this later 36 + # [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] 37 + # tauri-plugin-updater = "2" 38 + 37 39 40 + [lints.clippy] 41 + bool_comparison = "deny" 42 + duplicate_mod = "deny" 43 + inconsistent_struct_constructor = "deny" 44 + invalid_regex = "deny" 45 + mem_forget = "deny" 46 + mixed_case_hex_literals = "deny" 47 + suspicious_arithmetic_impl = "deny" 48 + uninit_assumed_init = "deny" 49 + suspicious_else_formatting = "deny" 50 + suspicious_op_assign_impl = "deny" 51 + suspicious_to_owned = "deny" 52 + cmp_owned = "deny" 53 + cmp_null = "deny" 54 + manual_map = "deny" 55 + 56 + too_many_arguments = "warn" 57 + cognitive_complexity = "warn" 58 + large_enum_variant = "warn" 59 + needless_borrow = "warn" 60 + needless_pass_by_value = "warn" 61 + redundant_clone = "warn" 62 + unnecessary_cast = "warn" 63 + inefficient_to_string = "warn" 64 + or_fun_call = "warn" 65 + unnecessary_to_owned = "warn" 66 + map_clone = "warn" 67 + flat_map_identity = "warn" 68 + needless_collect = "warn" 69 + vec_init_then_push = "warn" 70 + 71 + len_zero = "allow" 72 + range_plus_one = "allow" 73 + manual_range_contains = "allow"
+4 -12
src-tauri/capabilities/desktop.json
··· 1 1 { 2 2 "identifier": "desktop-capability", 3 - "platforms": [ 4 - "macOS", 5 - "windows", 6 - "linux" 7 - ], 8 - "windows": [ 9 - "main" 10 - ], 11 - "permissions": [ 12 - "updater:default" 13 - ] 14 - } 3 + "platforms": ["macOS", "windows", "linux"], 4 + "windows": ["main"], 5 + "permissions": [] 6 + }
+19
src-tauri/src/commands.rs
··· 1 + use tauri::State; 2 + 3 + use crate::error::AppError; 4 + use crate::state::{AccountSummary, AppBootstrap, AppState}; 5 + 6 + #[tauri::command] 7 + pub fn get_app_bootstrap(state: State<'_, AppState>) -> Result<AppBootstrap, AppError> { 8 + state.snapshot() 9 + } 10 + 11 + #[tauri::command] 12 + pub fn list_accounts(state: State<'_, AppState>) -> Result<Vec<AccountSummary>, AppError> { 13 + state.accounts() 14 + } 15 + 16 + #[tauri::command] 17 + pub fn set_active_account(did: String, state: State<'_, AppState>) -> Result<(), AppError> { 18 + state.set_active_account(&did) 19 + }
+107
src-tauri/src/db.rs
··· 1 + use std::collections::HashSet; 2 + use std::fs; 3 + use std::path::PathBuf; 4 + use std::sync::{Arc, Mutex}; 5 + 6 + use rusqlite::ffi::sqlite3_auto_extension; 7 + use rusqlite::{params, Connection, OpenFlags, OptionalExtension}; 8 + use sqlite_vec::sqlite3_vec_init; 9 + use tauri::{AppHandle, Manager}; 10 + 11 + use crate::error::AppError; 12 + 13 + pub type DbPool = Arc<Mutex<Connection>>; 14 + 15 + struct Migration { 16 + version: i64, 17 + name: &'static str, 18 + sql: &'static str, 19 + } 20 + 21 + const MIGRATIONS: &[Migration] = 22 + &[Migration { version: 1, name: "initial_schema", sql: include_str!("migrations/001_initial.sql") }]; 23 + 24 + pub fn initialize_database(app: &AppHandle) -> Result<DbPool, AppError> { 25 + // Registers sqlite-vec for all future rusqlite connections. 26 + unsafe { 27 + sqlite3_auto_extension(Some(std::mem::transmute(sqlite3_vec_init as *const ()))); 28 + } 29 + 30 + let database_path = resolve_database_path(app)?; 31 + if let Some(parent) = database_path.parent() { 32 + fs::create_dir_all(parent)?; 33 + } 34 + 35 + let connection = Connection::open_with_flags( 36 + database_path, 37 + OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE, 38 + )?; 39 + 40 + connection.pragma_update(None, "journal_mode", "WAL")?; 41 + connection.pragma_update(None, "foreign_keys", "ON")?; 42 + 43 + run_migrations(&connection)?; 44 + validate_sqlite_vec(&connection)?; 45 + 46 + Ok(Arc::new(Mutex::new(connection))) 47 + } 48 + 49 + fn resolve_database_path(app: &AppHandle) -> Result<PathBuf, AppError> { 50 + let mut app_data_dir = app 51 + .path() 52 + .app_data_dir() 53 + .map_err(|error| AppError::PathResolve(error.to_string()))?; 54 + 55 + app_data_dir.push("lazurite.db"); 56 + Ok(app_data_dir) 57 + } 58 + 59 + fn run_migrations(connection: &Connection) -> Result<(), AppError> { 60 + connection.execute_batch( 61 + " 62 + CREATE TABLE IF NOT EXISTS schema_migrations ( 63 + version INTEGER PRIMARY KEY, 64 + name TEXT NOT NULL, 65 + applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP 66 + ); 67 + ", 68 + )?; 69 + 70 + let mut applied_statement = connection.prepare("SELECT version FROM schema_migrations")?; 71 + let applied_rows = applied_statement.query_map([], |row| row.get::<_, i64>(0))?; 72 + 73 + let mut applied_versions = HashSet::new(); 74 + for version in applied_rows { 75 + applied_versions.insert(version?); 76 + } 77 + 78 + for migration in MIGRATIONS { 79 + if applied_versions.contains(&migration.version) { 80 + continue; 81 + } 82 + 83 + let transaction = connection.unchecked_transaction()?; 84 + transaction.execute_batch(migration.sql)?; 85 + transaction.execute( 86 + "INSERT INTO schema_migrations(version, name) VALUES (?1, ?2)", 87 + params![migration.version, migration.name], 88 + )?; 89 + transaction.commit()?; 90 + } 91 + 92 + Ok(()) 93 + } 94 + 95 + fn validate_sqlite_vec(connection: &Connection) -> Result<(), AppError> { 96 + let version: Option<String> = connection 97 + .query_row("SELECT vec_version()", [], |row| row.get(0)) 98 + .optional()?; 99 + 100 + if version.is_none() { 101 + return Err(AppError::Validation( 102 + "sqlite-vec extension did not report a version".to_string(), 103 + )); 104 + } 105 + 106 + Ok(()) 107 + }
+25
src-tauri/src/error.rs
··· 1 + use serde::ser::Serializer; 2 + use thiserror::Error; 3 + 4 + #[derive(Debug, Error)] 5 + pub enum AppError { 6 + #[error("database error: {0}")] 7 + Database(#[from] rusqlite::Error), 8 + #[error("io error: {0}")] 9 + Io(#[from] std::io::Error), 10 + #[error("path resolution failed: {0}")] 11 + PathResolve(String), 12 + #[error("state lock poisoned: {0}")] 13 + StatePoisoned(&'static str), 14 + #[error("{0}")] 15 + Validation(String), 16 + } 17 + 18 + impl serde::Serialize for AppError { 19 + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> 20 + where 21 + S: Serializer, 22 + { 23 + serializer.serialize_str(&self.to_string()) 24 + } 25 + }
+23 -7
src-tauri/src/lib.rs
··· 1 - // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ 2 - #[tauri::command] 3 - fn greet(name: &str) -> String { 4 - format!("Hello, {}! You've been greeted from Rust!", name) 5 - } 1 + mod commands; 2 + mod db; 3 + mod error; 4 + mod state; 5 + 6 + use commands::{get_app_bootstrap, list_accounts, set_active_account}; 7 + use db::initialize_database; 8 + use state::AppState; 9 + use tauri::Manager; 6 10 7 11 #[cfg_attr(mobile, tauri::mobile_entry_point)] 8 12 pub fn run() { 9 13 tauri::Builder::default() 10 - .plugin(tauri_plugin_updater::Builder::new().build()) 14 + .setup(|app| { 15 + let db_pool = 16 + initialize_database(app.handle()).expect("database initialization should succeed during startup"); 17 + let app_state = 18 + AppState::bootstrap(db_pool).expect("application state should be bootstrapped from database"); 19 + 20 + app.manage(app_state); 21 + Ok(()) 22 + }) 11 23 .plugin(tauri_plugin_notification::init()) 12 24 .plugin( 13 25 tauri_plugin_log::Builder::new() ··· 16 28 ) 17 29 .plugin(tauri_plugin_deep_link::init()) 18 30 .plugin(tauri_plugin_opener::init()) 19 - .invoke_handler(tauri::generate_handler![greet]) 31 + .invoke_handler(tauri::generate_handler![ 32 + get_app_bootstrap, 33 + list_accounts, 34 + set_active_account 35 + ]) 20 36 .run(tauri::generate_context!()) 21 37 .expect("error while running tauri application"); 22 38 }
+45
src-tauri/src/migrations/001_initial.sql
··· 1 + CREATE TABLE IF NOT EXISTS accounts ( 2 + did TEXT PRIMARY KEY, 3 + handle TEXT, 4 + pds_url TEXT, 5 + active INTEGER NOT NULL DEFAULT 0 CHECK(active IN (0, 1)) 6 + ); 7 + 8 + CREATE TABLE IF NOT EXISTS posts ( 9 + uri TEXT PRIMARY KEY, 10 + cid TEXT NOT NULL, 11 + author_did TEXT NOT NULL, 12 + author_handle TEXT, 13 + text TEXT, 14 + created_at TEXT, 15 + indexed_at TEXT DEFAULT CURRENT_TIMESTAMP, 16 + json_record TEXT, 17 + source TEXT NOT NULL 18 + ); 19 + 20 + CREATE VIRTUAL TABLE IF NOT EXISTS posts_fts USING fts5( 21 + text, 22 + uri UNINDEXED, 23 + content=posts, 24 + content_rowid=rowid 25 + ); 26 + 27 + CREATE VIRTUAL TABLE IF NOT EXISTS posts_vec USING vec0( 28 + uri TEXT PRIMARY KEY, 29 + embedding float[768] 30 + ); 31 + 32 + CREATE TRIGGER IF NOT EXISTS posts_ai AFTER INSERT ON posts BEGIN 33 + INSERT INTO posts_fts(rowid, text, uri) VALUES (new.rowid, new.text, new.uri); 34 + END; 35 + 36 + CREATE TRIGGER IF NOT EXISTS posts_ad AFTER DELETE ON posts BEGIN 37 + INSERT INTO posts_fts(posts_fts, rowid, text, uri) 38 + VALUES('delete', old.rowid, old.text, old.uri); 39 + END; 40 + 41 + CREATE TRIGGER IF NOT EXISTS posts_au AFTER UPDATE ON posts BEGIN 42 + INSERT INTO posts_fts(posts_fts, rowid, text, uri) 43 + VALUES('delete', old.rowid, old.text, old.uri); 44 + INSERT INTO posts_fts(rowid, text, uri) VALUES (new.rowid, new.text, new.uri); 45 + END;
+138
src-tauri/src/state.rs
··· 1 + use std::sync::RwLock; 2 + 3 + use rusqlite::params; 4 + use serde::Serialize; 5 + 6 + use crate::db::DbPool; 7 + use crate::error::AppError; 8 + 9 + #[derive(Clone, Debug, Serialize)] 10 + #[serde(rename_all = "camelCase")] 11 + pub struct AccountSummary { 12 + pub did: String, 13 + pub handle: String, 14 + pub pds_url: String, 15 + pub active: bool, 16 + } 17 + 18 + #[derive(Clone, Debug, Serialize)] 19 + #[serde(rename_all = "camelCase")] 20 + pub struct ActiveSession { 21 + pub did: String, 22 + pub handle: String, 23 + } 24 + 25 + #[derive(Clone, Debug, Serialize)] 26 + #[serde(rename_all = "camelCase")] 27 + pub struct AppBootstrap { 28 + pub active_session: Option<ActiveSession>, 29 + pub account_list: Vec<AccountSummary>, 30 + } 31 + 32 + pub struct AppState { 33 + pub db_pool: DbPool, 34 + pub active_session: RwLock<Option<ActiveSession>>, 35 + pub account_list: RwLock<Vec<AccountSummary>>, 36 + } 37 + 38 + impl AppState { 39 + pub fn bootstrap(db_pool: DbPool) -> Result<Self, AppError> { 40 + let account_list = load_accounts(&db_pool)?; 41 + let active_session = account_list 42 + .iter() 43 + .find(|account| account.active) 44 + .map(|account| ActiveSession { did: account.did.clone(), handle: account.handle.clone() }); 45 + 46 + Ok(Self { db_pool, active_session: RwLock::new(active_session), account_list: RwLock::new(account_list) }) 47 + } 48 + 49 + pub fn snapshot(&self) -> Result<AppBootstrap, AppError> { 50 + let active_session = self 51 + .active_session 52 + .read() 53 + .map_err(|_| AppError::StatePoisoned("active_session"))? 54 + .clone(); 55 + let account_list = self 56 + .account_list 57 + .read() 58 + .map_err(|_| AppError::StatePoisoned("account_list"))? 59 + .clone(); 60 + 61 + Ok(AppBootstrap { active_session, account_list }) 62 + } 63 + 64 + pub fn accounts(&self) -> Result<Vec<AccountSummary>, AppError> { 65 + Ok(self 66 + .account_list 67 + .read() 68 + .map_err(|_| AppError::StatePoisoned("account_list"))? 69 + .clone()) 70 + } 71 + 72 + pub fn set_active_account(&self, did: &str) -> Result<(), AppError> { 73 + { 74 + let mut connection = self.db_pool.lock().map_err(|_| AppError::StatePoisoned("db_pool"))?; 75 + 76 + let transaction = connection.transaction()?; 77 + transaction.execute("UPDATE accounts SET active = 0 WHERE active = 1", [])?; 78 + let rows_updated = transaction.execute("UPDATE accounts SET active = 1 WHERE did = ?1", params![did])?; 79 + 80 + if rows_updated == 0 { 81 + return Err(AppError::Validation(format!( 82 + "cannot activate unknown account did: {did}" 83 + ))); 84 + } 85 + 86 + transaction.commit()?; 87 + } 88 + 89 + let refreshed_accounts = load_accounts(&self.db_pool)?; 90 + let refreshed_session = refreshed_accounts 91 + .iter() 92 + .find(|account| account.active) 93 + .map(|account| ActiveSession { did: account.did.clone(), handle: account.handle.clone() }); 94 + 95 + *self 96 + .account_list 97 + .write() 98 + .map_err(|_| AppError::StatePoisoned("account_list"))? = refreshed_accounts; 99 + *self 100 + .active_session 101 + .write() 102 + .map_err(|_| AppError::StatePoisoned("active_session"))? = refreshed_session; 103 + 104 + Ok(()) 105 + } 106 + } 107 + 108 + fn load_accounts(db_pool: &DbPool) -> Result<Vec<AccountSummary>, AppError> { 109 + let connection = db_pool.lock().map_err(|_| AppError::StatePoisoned("db_pool"))?; 110 + 111 + let mut statement = connection.prepare( 112 + " 113 + SELECT 114 + did, 115 + COALESCE(handle, ''), 116 + COALESCE(pds_url, ''), 117 + active 118 + FROM accounts 119 + ORDER BY active DESC, handle COLLATE NOCASE ASC 120 + ", 121 + )?; 122 + 123 + let rows = statement.query_map([], |row| { 124 + Ok(AccountSummary { 125 + did: row.get(0)?, 126 + handle: row.get(1)?, 127 + pds_url: row.get(2)?, 128 + active: row.get::<_, i64>(3)? == 1, 129 + }) 130 + })?; 131 + 132 + let mut accounts = Vec::new(); 133 + for row in rows { 134 + accounts.push(row?); 135 + } 136 + 137 + Ok(accounts) 138 + }
+255
src/App.css
··· 1 1 @import "tailwindcss"; 2 2 @plugin "@egoist/tailwindcss-icons"; 3 3 @plugin "@tailwindcss/forms"; 4 + 5 + @theme inline { 6 + --font-sans: var(--font-stack); 7 + --color-surface-container-lowest: var(--surface-container-lowest); 8 + --color-surface: var(--surface); 9 + --color-surface-container: var(--surface-container); 10 + --color-surface-container-high: var(--surface-container-high); 11 + --color-surface-bright: var(--surface-bright); 12 + --color-primary: var(--primary); 13 + --color-on-primary-fixed: var(--on-primary-fixed); 14 + --color-on-surface: var(--on-surface); 15 + --color-on-surface-variant: var(--on-surface-variant); 16 + --color-on-secondary-container: var(--on-secondary-container); 17 + --color-error: var(--error); 18 + --color-error-surface: var(--error-surface); 19 + --radius-app-lg: var(--radius-lg); 20 + --radius-app-xl: var(--radius-xl); 21 + --radius-app-full: var(--radius-full); 22 + --shadow-ambient: var(--shadow-ambient); 23 + } 24 + 25 + :root { 26 + color-scheme: dark light; 27 + --surface-container-lowest: #000000; 28 + --surface: #0e0e0e; 29 + --surface-container: #191919; 30 + --surface-container-high: #1f1f1f; 31 + --surface-container-highest: rgba(36, 36, 36, 0.7); 32 + --surface-bright: rgba(255, 255, 255, 0.05); 33 + --outline-ghost: rgba(72, 72, 72, 0.2); 34 + --primary: #7dafff; 35 + --primary-dim: #0073de; 36 + --on-primary-fixed: #05080f; 37 + --on-surface: #f4f6fb; 38 + --on-surface-variant: #ababab; 39 + --on-secondary-container: #c9d1dd; 40 + --error: #ff8080; 41 + --error-surface: rgba(138, 31, 31, 0.72); 42 + --radius-lg: 1rem; 43 + --radius-xl: 1.5rem; 44 + --radius-full: 999px; 45 + --space-2: 0.5rem; 46 + --space-4: 1rem; 47 + --space-6: 1.5rem; 48 + --space-8: 2rem; 49 + --shadow-ambient: 0 24px 40px rgba(125, 175, 255, 0.05); 50 + --font-stack: "Google Sans Variable", "Segoe UI", "Avenir Next", sans-serif; 51 + } 52 + 53 + @media (prefers-color-scheme: light) { 54 + :root { 55 + --surface-container-lowest: #f2f5ff; 56 + --surface: #ffffff; 57 + --surface-container: #ebeffb; 58 + --surface-container-high: #e1e8fa; 59 + --surface-container-highest: rgba(234, 241, 255, 0.72); 60 + --surface-bright: rgba(24, 37, 66, 0.07); 61 + --outline-ghost: rgba(84, 106, 148, 0.26); 62 + --on-surface: #0f1523; 63 + --on-surface-variant: #4e5e7e; 64 + --on-secondary-container: #334368; 65 + --error: #8f1f1f; 66 + --error-surface: rgba(255, 179, 179, 0.75); 67 + --shadow-ambient: 0 24px 40px rgba(0, 115, 222, 0.12); 68 + } 69 + } 70 + 71 + * { 72 + @apply box-border; 73 + } 74 + 75 + body { 76 + @apply m-0 min-h-screen font-sans text-on-surface; 77 + background: 78 + radial-gradient(circle at 16% 15%, rgba(125, 175, 255, 0.16), transparent 40%), 79 + radial-gradient(circle at 90% 75%, rgba(0, 115, 222, 0.2), transparent 44%), 80 + var(--surface-container-lowest); 81 + } 82 + 83 + #root { 84 + @apply min-h-screen; 85 + } 86 + 87 + .app-shell { 88 + @apply min-h-screen grid; 89 + grid-template-columns: 4.5rem minmax(0, 1fr); 90 + } 91 + 92 + .app-rail { 93 + @apply flex flex-col bg-surface-container-lowest; 94 + gap: var(--space-4); 95 + padding: var(--space-6) var(--space-2); 96 + } 97 + 98 + .rail-button { 99 + @apply border-0 rounded-app-full bg-transparent grid place-items-center cursor-pointer text-on-surface-variant; 100 + height: 3rem; 101 + transition: background-color 180ms ease, color 180ms ease, transform 180ms ease; 102 + } 103 + 104 + .rail-button:hover { 105 + @apply -translate-y-px text-on-surface bg-surface-bright; 106 + } 107 + 108 + .rail-button--active { 109 + @apply text-primary bg-surface-container; 110 + } 111 + 112 + .work-surface { 113 + @apply grid rounded-app-xl shadow-ambient; 114 + margin: var(--space-6); 115 + padding: var(--space-8); 116 + background: linear-gradient(160deg, var(--surface) 0%, var(--surface-container) 100%); 117 + grid-template-rows: auto auto 1fr; 118 + gap: var(--space-6); 119 + } 120 + 121 + .surface-header { 122 + @apply flex items-baseline justify-between; 123 + gap: var(--space-8); 124 + } 125 + 126 + .headline { 127 + @apply m-0 leading-none; 128 + font-size: clamp(2rem, 3.2vw, 3.5rem); 129 + letter-spacing: -0.02em; 130 + } 131 + 132 + .meta { 133 + @apply m-0 uppercase text-on-surface-variant; 134 + font-size: 0.75rem; 135 + letter-spacing: 0.12em; 136 + } 137 + 138 + .session-panel, 139 + .accounts-panel { 140 + @apply rounded-app-lg bg-surface-container-high; 141 + padding: var(--space-6); 142 + } 143 + 144 + .panel-title { 145 + @apply m-0 uppercase text-on-surface-variant; 146 + font-size: 0.75rem; 147 + letter-spacing: 0.08em; 148 + } 149 + 150 + .panel-copy { 151 + @apply mt-2 text-on-secondary-container; 152 + font-size: 0.875rem; 153 + } 154 + 155 + .panel-subtle { 156 + @apply block text-on-surface-variant; 157 + margin-top: 0.2rem; 158 + font-size: 0.75rem; 159 + } 160 + 161 + .accounts-head { 162 + @apply flex justify-between items-baseline; 163 + gap: var(--space-4); 164 + } 165 + 166 + .account-list { 167 + @apply mt-4 grid; 168 + gap: var(--space-2); 169 + } 170 + 171 + .account-chip { 172 + @apply w-full text-left border-0 cursor-pointer text-on-surface rounded-app-lg; 173 + padding: 0.9rem 1rem; 174 + background: rgba(255, 255, 255, 0.02); 175 + transition: background-color 180ms ease, transform 180ms ease; 176 + } 177 + 178 + .account-chip:hover { 179 + @apply -translate-y-px; 180 + background: var(--surface-bright); 181 + } 182 + 183 + .account-chip--active { 184 + @apply text-on-primary-fixed; 185 + background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dim) 100%); 186 + } 187 + 188 + .account-handle, 189 + .account-meta { 190 + @apply block; 191 + } 192 + 193 + .account-handle { 194 + font-size: 0.9rem; 195 + } 196 + 197 + .account-meta { 198 + margin-top: 0.25rem; 199 + font-size: 0.75rem; 200 + opacity: 0.8; 201 + } 202 + 203 + .error-toast { 204 + @apply fixed left-1/2 grid items-center rounded-app-full text-on-surface shadow-ambient bg-error-surface; 205 + bottom: 1.5rem; 206 + transform: translateX(-50%); 207 + max-width: min(30rem, calc(100vw - 2rem)); 208 + width: max-content; 209 + grid-template-columns: auto 1fr auto; 210 + gap: 0.75rem; 211 + padding: 0.75rem 1rem; 212 + backdrop-filter: blur(20px); 213 + } 214 + 215 + .error-toast__glyph { 216 + @apply text-error; 217 + } 218 + 219 + .error-toast__message { 220 + @apply m-0 text-on-surface; 221 + font-size: 0.875rem; 222 + } 223 + 224 + .error-toast__dismiss { 225 + @apply border-0 rounded-full bg-transparent cursor-pointer; 226 + padding: 0.35rem; 227 + color: inherit; 228 + } 229 + 230 + .error-toast__dismiss:hover { 231 + background: var(--surface-bright); 232 + } 233 + 234 + @media (max-width: 820px) { 235 + .app-shell { 236 + grid-template-columns: 1fr; 237 + } 238 + 239 + .app-rail { 240 + @apply flex-row justify-center; 241 + padding: var(--space-4); 242 + } 243 + 244 + .work-surface { 245 + @apply m-0 rounded-none; 246 + min-height: calc(100vh - 5rem); 247 + padding: var(--space-6); 248 + } 249 + 250 + .surface-header { 251 + @apply flex-col items-start; 252 + gap: var(--space-2); 253 + } 254 + 255 + .error-toast { 256 + width: calc(100vw - 1.5rem); 257 + } 258 + }
+121 -32
src/App.tsx
··· 1 1 import { invoke } from "@tauri-apps/api/core"; 2 - import { createSignal } from "solid-js"; 3 - import logo from "./assets/logo.svg"; 2 + import { createSignal, For, onMount, Show } from "solid-js"; 3 + import "@fontsource-variable/google-sans"; 4 4 import "./App.css"; 5 + import { ErrorToast } from "./components/ErrorToast"; 6 + import { AccountSummary, ActiveSession, AppBootstrap } from "./lib/types"; 7 + 8 + type RailButtonProps = { label: string; icon: string; active?: boolean }; 9 + 10 + function RailButton(props: RailButtonProps) { 11 + return ( 12 + <button 13 + class="rail-button" 14 + classList={{ "rail-button--active": !!props.active }} 15 + type="button" 16 + aria-label={props.label}> 17 + <span class="flex items-center" aria-hidden="true"> 18 + <i class={props.icon} /> 19 + </span> 20 + </button> 21 + ); 22 + } 23 + 24 + type SessionPanelProps = { activeSession: ActiveSession | null }; 25 + 26 + function SessionPanel(props: SessionPanelProps) { 27 + return ( 28 + <article class="session-panel"> 29 + <p class="panel-title">Active session</p> 30 + <Show 31 + when={props.activeSession} 32 + fallback={<p class="panel-copy">No active account yet. Authenticate to start syncing.</p>}> 33 + {(session) => ( 34 + <p class="panel-copy"> 35 + {session().handle} 36 + <span class="panel-subtle">{session().did}</span> 37 + </p> 38 + )} 39 + </Show> 40 + </article> 41 + ); 42 + } 43 + 44 + type AccountsPanelProps = { accounts: AccountSummary[]; onActivate: (did: string) => void }; 45 + 46 + function AccountsPanel(props: AccountsPanelProps) { 47 + return ( 48 + <article class="accounts-panel"> 49 + <div class="accounts-head"> 50 + <p class="panel-title">Known accounts</p> 51 + <p class="panel-copy">{props.accounts.length} loaded</p> 52 + </div> 53 + <div class="account-list" role="list"> 54 + <Show when={props.accounts.length > 0} fallback={<p class="panel-copy">No accounts stored yet.</p>}> 55 + <For each={props.accounts}> 56 + {(account) => <AccountChip account={account} onActivate={props.onActivate} />} 57 + </For> 58 + </Show> 59 + </div> 60 + </article> 61 + ); 62 + } 63 + 64 + type AccountChipProps = { account: AccountSummary; onActivate: (did: string) => void }; 65 + 66 + function AccountChip(props: AccountChipProps) { 67 + return ( 68 + <button 69 + class="account-chip" 70 + classList={{ "account-chip--active": props.account.active }} 71 + type="button" 72 + role="listitem" 73 + onClick={() => props.onActivate(props.account.did)}> 74 + <span class="account-handle">{props.account.handle || props.account.did}</span> 75 + <span class="account-meta">{props.account.pdsUrl || "PDS unavailable"}</span> 76 + </button> 77 + ); 78 + } 5 79 6 80 function App() { 7 - const [greetMsg, setGreetMsg] = createSignal(""); 8 - const [name, setName] = createSignal(""); 81 + const [bootstrapped, setBootstrapped] = createSignal(false); 82 + const [activeSession, setActiveSession] = createSignal<ActiveSession | null>(null); 83 + const [accounts, setAccounts] = createSignal<AccountSummary[]>([]); 84 + const [errorMessage, setErrorMessage] = createSignal<string | null>(null); 9 85 10 - async function greet() { 11 - // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ 12 - setGreetMsg(await invoke("greet", { name: name() })); 86 + async function loadBootstrap() { 87 + try { 88 + const payload = await invoke<AppBootstrap>("get_app_bootstrap"); 89 + setActiveSession(payload.activeSession); 90 + setAccounts(payload.accountList); 91 + setBootstrapped(true); 92 + } catch (error) { 93 + setErrorMessage(`Failed to load app bootstrap: ${String(error)}`); 94 + } 13 95 } 14 96 97 + async function activateAccount(did: string) { 98 + try { 99 + await invoke("set_active_account", { did }); 100 + await loadBootstrap(); 101 + } catch (error) { 102 + setErrorMessage(`Failed to switch account: ${String(error)}`); 103 + } 104 + } 105 + 106 + onMount(() => { 107 + void loadBootstrap(); 108 + }); 109 + 15 110 return ( 16 - <main class="container"> 17 - <h1>Welcome to Tauri + Solid</h1> 18 - <div class="row"> 19 - <a href="https://vite.dev" target="_blank"> 20 - <img src="/vite.svg" class="logo vite" alt="Vite logo" /> 21 - </a> 22 - <a href="https://tauri.app" target="_blank"> 23 - <img src="/tauri.svg" class="logo tauri" alt="Tauri logo" /> 24 - </a> 25 - <a href="https://solidjs.com" target="_blank"> 26 - <img src={logo} class="logo solid" alt="Solid logo" /> 27 - </a> 28 - </div> 29 - <p>Click on the Tauri, Vite, and Solid logos to learn more.</p> 111 + <> 112 + <main class="app-shell"> 113 + <aside class="app-rail" aria-label="Primary navigation"> 114 + <RailButton label="Accounts" icon="i-ri-user-3-line" active /> 115 + <RailButton label="Search" icon="i-ri-search-line" /> 116 + </aside> 30 117 31 - <form 32 - class="row" 33 - onSubmit={(e) => { 34 - e.preventDefault(); 35 - greet(); 36 - }}> 37 - <input id="greet-input" onChange={(e) => setName(e.currentTarget.value)} placeholder="Enter a name..." /> 38 - <button type="submit">Greet</button> 39 - </form> 40 - <p>{greetMsg()}</p> 41 - </main> 118 + <section class="work-surface" aria-busy={!bootstrapped()}> 119 + <header class="surface-header"> 120 + <h1 class="headline">Lazurite</h1> 121 + <p class="meta">backend bootstrap complete</p> 122 + </header> 123 + 124 + <SessionPanel activeSession={activeSession()} /> 125 + <AccountsPanel accounts={accounts()} onActivate={(did) => void activateAccount(did)} /> 126 + </section> 127 + </main> 128 + 129 + <ErrorToast message={errorMessage} onDismiss={() => setErrorMessage(null)} /> 130 + </> 42 131 ); 43 132 } 44 133
+32
src/components/ErrorToast.tsx
··· 1 + import type { Accessor } from "solid-js"; 2 + import { Motion, Presence } from "solid-motionone"; 3 + 4 + type ErrorToastProps = { message: Accessor<string | null>; onDismiss: () => void }; 5 + 6 + export function ErrorToast(props: ErrorToastProps) { 7 + return ( 8 + <Presence> 9 + {props.message() && ( 10 + <Motion.div 11 + role="alert" 12 + aria-live="assertive" 13 + class="error-toast" 14 + initial={{ opacity: 0, y: 20, scale: 0.96 }} 15 + animate={{ opacity: 1, y: 0, scale: 1 }} 16 + exit={{ opacity: 0, y: 16, scale: 0.94 }} 17 + transition={{ duration: 0.2 }}> 18 + <span class="flex items-center error-toast__glyph" aria-hidden="true"> 19 + <i class="i-ri-error-warning-line" /> 20 + </span> 21 + <p class="error-toast__message">{props.message()}</p> 22 + <button type="button" class="error-toast__dismiss" onClick={props.onDismiss}> 23 + <span class="flex items-center" aria-hidden="true"> 24 + <i class="i-ri-close-line" /> 25 + </span> 26 + <span class="sr-only">Dismiss error</span> 27 + </button> 28 + </Motion.div> 29 + )} 30 + </Presence> 31 + ); 32 + }
+5
src/lib/types.ts
··· 1 + export type AccountSummary = { did: string; handle: string; pdsUrl: string; active: boolean }; 2 + 3 + export type ActiveSession = { did: string; handle: string }; 4 + 5 + export type AppBootstrap = { activeSession: ActiveSession | null; accountList: AccountSummary[] };