@dennisrongo/skills 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +45 -16
  2. package/package.json +1 -1
  3. package/skills/nextjs-app-router/SKILL.md +228 -175
  4. package/skills/nextjs-app-router/references/anti-patterns.md +163 -99
  5. package/skills/nextjs-app-router/references/folder-layout.md +79 -46
  6. package/skills/nextjs-app-router/references/good-patterns.md +233 -99
  7. package/skills/nextjs-app-router/references/templates/api-base.md +24 -21
  8. package/skills/nextjs-app-router/references/templates/auth-slice.md +82 -78
  9. package/skills/nextjs-app-router/references/templates/ci-and-hooks.md +58 -6
  10. package/skills/nextjs-app-router/references/templates/db-client.md +48 -0
  11. package/skills/nextjs-app-router/references/templates/env-and-utils.md +90 -23
  12. package/skills/nextjs-app-router/references/templates/feature-slice.md +110 -47
  13. package/skills/nextjs-app-router/references/templates/form-with-zod.md +23 -15
  14. package/skills/nextjs-app-router/references/templates/middleware.md +69 -59
  15. package/skills/nextjs-app-router/references/templates/next-config.md +4 -14
  16. package/skills/nextjs-app-router/references/templates/nextauth-config.md +178 -0
  17. package/skills/nextjs-app-router/references/templates/package.md +35 -5
  18. package/skills/nextjs-app-router/references/templates/prisma-schema.md +162 -0
  19. package/skills/nextjs-app-router/references/templates/providers-and-store.md +35 -21
  20. package/skills/nextjs-app-router/references/templates/root-layout.md +99 -20
  21. package/skills/nextjs-app-router/references/templates/route-group-layouts.md +46 -6
  22. package/skills/nextjs-app-router/references/templates/route-handler.md +168 -0
  23. package/skills/nextjs-app-router/references/templates/testing.md +105 -31
  24. package/skills/tauri-2-app/SKILL.md +381 -0
  25. package/skills/tauri-2-app/references/anti-patterns.md +434 -0
  26. package/skills/tauri-2-app/references/folder-layout.md +161 -0
  27. package/skills/tauri-2-app/references/good-patterns.md +477 -0
  28. package/skills/tauri-2-app/references/templates/build-rs.md +51 -0
  29. package/skills/tauri-2-app/references/templates/capabilities.md +68 -0
  30. package/skills/tauri-2-app/references/templates/cargo-toml.md +118 -0
  31. package/skills/tauri-2-app/references/templates/ci-workflow.md +99 -0
  32. package/skills/tauri-2-app/references/templates/encryption-mod.md +228 -0
  33. package/skills/tauri-2-app/references/templates/error-mod.md +126 -0
  34. package/skills/tauri-2-app/references/templates/lib-rs.md +98 -0
  35. package/skills/tauri-2-app/references/templates/macos-plist.md +89 -0
  36. package/skills/tauri-2-app/references/templates/main-rs.md +21 -0
  37. package/skills/tauri-2-app/references/templates/platform-traits.md +217 -0
  38. package/skills/tauri-2-app/references/templates/storage-mod.md +172 -0
  39. package/skills/tauri-2-app/references/templates/tauri-conf.md +136 -0
  40. package/skills/tauri-2-app/references/templates/use-tauri-command.md +194 -0
  41. package/skills/tauri-2-app/references/templates/vite-and-tsconfig.md +189 -0
@@ -0,0 +1,118 @@
1
+ # `src-tauri/Cargo.toml`
2
+
3
+ Pin **Rust edition** to `2021`. Resolve crate **versions** at scaffold time via crates.io / context7 — never hard-paste a version into a generated file. The version markers below are placeholders.
4
+
5
+ ```toml
6
+ [package]
7
+ name = "{{app_name}}"
8
+ version = "0.1.0"
9
+ description = "{{description}}"
10
+ authors = ["{{author}}"]
11
+ license = "MIT"
12
+ edition = "2021"
13
+ default-run = "{{app_name}}"
14
+
15
+ [lib]
16
+ # The `_lib` suffix avoids a Windows linker conflict between the bin and lib targets.
17
+ # See https://github.com/rust-lang/cargo/issues/8519
18
+ name = "{{app_name}}_lib"
19
+ crate-type = ["staticlib", "cdylib", "rlib"]
20
+
21
+ [[bin]]
22
+ name = "{{app_name}}"
23
+ path = "src/main.rs"
24
+
25
+ [build-dependencies]
26
+ tauri-build = { version = "<latest 2.x>", features = [] }
27
+
28
+ [dependencies]
29
+ # Tauri core (2.x). Add features as needed: "tray-icon", "macos-private-api"
30
+ tauri = { version = "<latest 2.x>", features = [] }
31
+
32
+ # Tauri plugins — only those actually wired in lib.rs
33
+ tauri-plugin-opener = "<latest 2.x>"
34
+ tauri-plugin-dialog = "<latest 2.x>"
35
+ tauri-plugin-fs = "<latest 2.x>"
36
+ tauri-plugin-notification = "<latest 2.x>"
37
+ tauri-plugin-global-shortcut = "<latest 2.x>"
38
+ tauri-plugin-single-instance = "<latest 2.x>"
39
+ # tauri-plugin-updater = "<latest 2.x>" # only if updater is wired
40
+ # tauri-plugin-process = "<latest 2.x>"
41
+
42
+ # Serialization
43
+ serde = { version = "<latest 1.x>", features = ["derive"] }
44
+ serde_json = "<latest 1.x>"
45
+
46
+ # Errors
47
+ thiserror = "<latest>"
48
+ anyhow = "<latest>"
49
+
50
+ # Async runtime
51
+ tokio = { version = "<latest 1.x>", features = ["sync", "rt-multi-thread", "macros"] }
52
+
53
+ # Logging
54
+ tracing = "<latest>"
55
+ tracing-subscriber = { version = "<latest>", features = ["env-filter"] }
56
+
57
+ # Date/time — DO NOT hand-roll
58
+ chrono = "<latest>"
59
+
60
+ # Paths
61
+ dirs = "<latest>"
62
+
63
+ # Optional: encrypted secrets at rest
64
+ aes-gcm = "<latest>"
65
+ argon2 = "<latest>"
66
+ base64 = "<latest>"
67
+ rand = "<latest>"
68
+
69
+ # macOS-specific
70
+ [target.'cfg(target_os = "macos")'.dependencies]
71
+ embed_plist = "<latest>"
72
+ # Add when used:
73
+ # security-framework = "<latest>"
74
+ # cocoa = "<latest>"
75
+ # objc = "<latest>"
76
+ # core-graphics = "<latest>"
77
+
78
+ # Windows-specific
79
+ [target.'cfg(target_os = "windows")'.dependencies]
80
+ # Add only the windows-sys features actually referenced in code:
81
+ # windows-sys = { version = "<latest>", features = [
82
+ # "Win32_Foundation",
83
+ # "Win32_System_ProcessStatus",
84
+ # ] }
85
+
86
+ # Linux-specific
87
+ [target.'cfg(target_os = "linux")'.dependencies]
88
+ # machine-uid = "<latest>" # only if encryption module needs a stable machine ID
89
+
90
+ # Release profile — full LTO + smaller binary
91
+ [profile.release]
92
+ lto = "fat"
93
+ codegen-units = 1
94
+ opt-level = 3
95
+ strip = true
96
+ panic = "abort"
97
+
98
+ # Dev profile — light optimization keeps debug builds bearable
99
+ [profile.dev]
100
+ opt-level = 1
101
+
102
+ [dev-dependencies]
103
+ tempfile = "<latest>" # temporary fixtures
104
+ assert_matches = "<latest>"
105
+ pretty_assertions = "<latest>" # readable diffs in test failures
106
+ tokio-test = "<latest>" # async test helpers
107
+ # mockall = "<latest>" # only if trait-based mocking is set up
108
+ # proptest = "<latest>" # only if property tests are in scope
109
+ ```
110
+
111
+ ## Why
112
+
113
+ - **`[lib].name = "{{app_name}}_lib"`** — Without the `_lib` suffix, Windows fails to link because the bin and lib targets generate conflicting symbols.
114
+ - **`crate-type = ["staticlib", "cdylib", "rlib"]`** — `cdylib` is for mobile (Android/iOS) targets, `staticlib` for some embedded uses, `rlib` for integration tests. Tauri's mobile work needs all three.
115
+ - **Target-specific `[target.'cfg(...)'.dependencies]`** — Platform-only crates (`cocoa`, `windows-sys`) only compile on the matching target. The `[target.cfg(...)]` syntax means a `cargo build` on a different OS skips them entirely.
116
+ - **`tauri-plugin-single-instance` listed under regular deps** — It works cross-platform; the plugin internally handles per-OS specifics.
117
+ - **Release profile** — `lto = "fat"` enables cross-crate inlining (faster, smaller). `codegen-units = 1` is required for full LTO. `strip = true` drops symbols (~30% binary size reduction). `panic = "abort"` skips unwinding tables.
118
+ - **Dev profile `opt-level = 1`** — Cuts debug-build runtime by 2–3× vs. `opt-level = 0`, with only a minor compile-time hit. Especially noticeable for tests that exercise crypto or audio processing.
@@ -0,0 +1,99 @@
1
+ # `.github/workflows/unit-tests.yml`
2
+
3
+ Cross-platform matrix that runs `cargo test` on every push and PR. Ubuntu needs GTK system deps for Tauri to link.
4
+
5
+ ```yaml
6
+ name: Unit Tests
7
+
8
+ on:
9
+ push:
10
+ branches: ['**']
11
+ pull_request:
12
+
13
+ jobs:
14
+ test:
15
+ strategy:
16
+ fail-fast: false
17
+ matrix:
18
+ platform: [macos-latest, windows-latest, ubuntu-latest]
19
+ runs-on: ${{ matrix.platform }}
20
+
21
+ steps:
22
+ - uses: actions/checkout@v4
23
+
24
+ - uses: dtolnay/rust-toolchain@stable
25
+
26
+ - uses: actions/setup-node@v4
27
+ with:
28
+ node-version: '20'
29
+ cache: 'npm'
30
+
31
+ - name: Cache cargo registry + build
32
+ uses: actions/cache@v4
33
+ with:
34
+ path: |
35
+ ~/.cargo/registry
36
+ ~/.cargo/git
37
+ src-tauri/target
38
+ key: ${{ runner.os }}-cargo-${{ hashFiles('src-tauri/Cargo.lock') }}
39
+ restore-keys: |
40
+ ${{ runner.os }}-cargo-
41
+
42
+ - run: npm ci
43
+
44
+ - name: Install Ubuntu system deps
45
+ if: matrix.platform == 'ubuntu-latest'
46
+ run: |
47
+ sudo apt-get update
48
+ sudo apt-get install -y \
49
+ libgtk-3-dev \
50
+ libayatana-appindicator3-dev \
51
+ librsvg2-dev \
52
+ libwebkit2gtk-4.1-dev \
53
+ pkg-config
54
+
55
+ - name: Run Rust tests
56
+ run: cd src-tauri && cargo test --all-features
57
+
58
+ - name: TypeScript build
59
+ run: npm run build
60
+ ```
61
+
62
+ ## Why these choices
63
+
64
+ - **`fail-fast: false`** — Without it, a failure on macOS cancels the Windows and Ubuntu jobs. You want to see all three results to know whether the bug is platform-specific.
65
+ - **Three runners** — Tauri's behavior diverges per OS (file pickers, permissions, hotkeys). Green on one runner means little.
66
+ - **Ubuntu system deps** — Tauri's webview integration on Linux needs GTK, AppIndicator, RSVG, WebKit2GTK. Without them, `cargo build` fails with cryptic linker errors.
67
+ - **Cache key includes `Cargo.lock`** — Re-key the cache when dependencies change. Without the `restore-keys` fallback, a single dep bump invalidates the whole cache.
68
+ - **`npm ci`** (not `npm install`) — Faster, deterministic, fails if `package-lock.json` is out of sync.
69
+ - **`cargo test --all-features`** — If you have feature flags, run tests across them. Drop `--all-features` if you have features that intentionally conflict.
70
+
71
+ ## Optional: TypeScript-only quick check
72
+
73
+ If the Rust matrix is slow and you want fast feedback on frontend changes, add a parallel job:
74
+
75
+ ```yaml
76
+ typecheck:
77
+ runs-on: ubuntu-latest
78
+ steps:
79
+ - uses: actions/checkout@v4
80
+ - uses: actions/setup-node@v4
81
+ with:
82
+ node-version: '20'
83
+ cache: 'npm'
84
+ - run: npm ci
85
+ - run: npx tsc --noEmit
86
+ ```
87
+
88
+ Runs in seconds and catches the majority of frontend regressions without spinning up the Rust toolchain.
89
+
90
+ ## Optional: Tag-triggered release workflow
91
+
92
+ Skipping the full template here because the publish pipeline depends heavily on the user's distribution choice (GitHub Releases, R2, S3, custom CDN, …) and their signing setup. Key points if you generate one:
93
+
94
+ - **Never hardcode the CDN URL** or any signing identity in the workflow file — read them from `secrets.*`.
95
+ - **Build matrix typically includes only signed targets** (Windows + macOS aarch64 / x86_64). Linux builds may not need signing depending on distribution.
96
+ - **Updater manifest generation** assembles the JSON shape documented in `references/templates/tauri-conf.md` from each platform's `.sig` file outputs.
97
+ - **Notarization on macOS** (`xcrun notarytool submit ... --wait`) is mandatory for unsigned-runtime users; budget 5–15 minutes per build.
98
+
99
+ Ask the user for the distribution target (GitHub Releases, R2, S3, custom) before generating a release workflow. Don't assume.
@@ -0,0 +1,228 @@
1
+ # `src-tauri/src/encryption/mod.rs` (optional)
2
+
3
+ Authenticated encryption for API keys / tokens / other secrets at rest. Pattern: AES-256-GCM ciphertext + Argon2id-derived key from a per-install salt + a hardware-bound machine ID for additional binding.
4
+
5
+ Only generate this module if the user explicitly asked for it. Many apps don't need it (they store no secrets) or use OS-native keychains (`security-framework` on macOS, Windows DPAPI, Linux Secret Service) instead.
6
+
7
+ ```rust
8
+ //! Encrypted secrets at rest. AES-256-GCM + Argon2id + hardware-bound salt.
9
+ //!
10
+ //! Threat model: protects against casual disk reads (backup syncs, malware
11
+ //! grepping files, accidental leaks via shared screenshots). Does NOT protect
12
+ //! against a privileged process on the same machine. For higher assurance,
13
+ //! integrate the OS-native keychain.
14
+
15
+ use aes_gcm::{
16
+ aead::{Aead, AeadCore, KeyInit, OsRng},
17
+ Aes256Gcm, Nonce,
18
+ };
19
+ use argon2::{Algorithm, Argon2, Params, Version};
20
+ use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
21
+ use serde::{Deserialize, Serialize};
22
+
23
+ #[derive(Debug, Clone, Serialize, Deserialize)]
24
+ pub struct EncryptedApiKey {
25
+ pub ciphertext: String, // base64
26
+ pub nonce: String, // base64 (12 bytes)
27
+ pub salt: String, // base64 (≥16 bytes)
28
+ }
29
+
30
+ #[derive(Debug, thiserror::Error)]
31
+ pub enum EncryptionError {
32
+ #[error("key derivation failed: {0}")]
33
+ KeyDerivation(String),
34
+ #[error("encryption failed: {0}")]
35
+ Encrypt(String),
36
+ #[error("decryption failed: {0}")]
37
+ Decrypt(String),
38
+ #[error("invalid base64: {0}")]
39
+ Base64(#[from] base64::DecodeError),
40
+ #[error("invalid machine ID: {0}")]
41
+ MachineId(String),
42
+ }
43
+
44
+ /// Hardware-bound machine identifier. Combined with the per-install salt.
45
+ fn get_machine_id() -> Result<String, EncryptionError> {
46
+ #[cfg(target_os = "macos")]
47
+ {
48
+ let out = std::process::Command::new("ioreg")
49
+ .args(["-rd1", "-c", "IOPlatformExpertDevice"])
50
+ .output()
51
+ .map_err(|e| EncryptionError::MachineId(e.to_string()))?;
52
+ let s = String::from_utf8_lossy(&out.stdout);
53
+ for line in s.lines() {
54
+ if line.contains("IOPlatformUUID") {
55
+ if let (Some(a), Some(b)) = (line.find('"'), line.rfind('"')) {
56
+ if a != b { return Ok(line[a + 1..b].to_string()); }
57
+ }
58
+ }
59
+ }
60
+ Err(EncryptionError::MachineId("IOPlatformUUID not found".into()))
61
+ }
62
+
63
+ #[cfg(target_os = "windows")]
64
+ {
65
+ use std::os::windows::process::CommandExt;
66
+ const CREATE_NO_WINDOW: u32 = 0x08000000;
67
+ let out = std::process::Command::new("cmd")
68
+ .creation_flags(CREATE_NO_WINDOW)
69
+ .args(["/C", "wmic csproduct get uuid"])
70
+ .output()
71
+ .map_err(|e| EncryptionError::MachineId(e.to_string()))?;
72
+ let s = String::from_utf8_lossy(&out.stdout);
73
+ for line in s.lines().map(str::trim) {
74
+ if !line.is_empty() && !line.contains("UUID") {
75
+ return Ok(line.to_string());
76
+ }
77
+ }
78
+ Err(EncryptionError::MachineId("UUID not found".into()))
79
+ }
80
+
81
+ #[cfg(target_os = "linux")]
82
+ {
83
+ // /etc/machine-id is stable per install on most distros.
84
+ match std::fs::read_to_string("/etc/machine-id") {
85
+ Ok(s) => Ok(s.trim().to_string()),
86
+ Err(_) => Err(EncryptionError::MachineId("/etc/machine-id unavailable".into())),
87
+ }
88
+ }
89
+ }
90
+
91
+ fn derive_key(machine_id: &str, salt: &[u8]) -> Result<[u8; 32], EncryptionError> {
92
+ // Argon2id parameters tuned for ~300ms on a modern laptop. Tune as the
93
+ // user-facing latency budget allows.
94
+ let params = Params::new(64 * 1024, 3, 1, Some(32))
95
+ .map_err(|e| EncryptionError::KeyDerivation(e.to_string()))?;
96
+ let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
97
+
98
+ let mut key = [0u8; 32];
99
+ argon2
100
+ .hash_password_into(machine_id.as_bytes(), salt, &mut key)
101
+ .map_err(|e| EncryptionError::KeyDerivation(e.to_string()))?;
102
+ Ok(key)
103
+ }
104
+
105
+ pub fn encrypt_api_key(plaintext: &str) -> Result<EncryptedApiKey, EncryptionError> {
106
+ let machine_id = get_machine_id()?;
107
+
108
+ // Per-encryption salt; never reuse.
109
+ let mut salt = [0u8; 16];
110
+ use rand::RngCore;
111
+ rand::rngs::OsRng.fill_bytes(&mut salt);
112
+
113
+ let key_bytes = derive_key(&machine_id, &salt)?;
114
+ let cipher = Aes256Gcm::new_from_slice(&key_bytes)
115
+ .map_err(|e| EncryptionError::Encrypt(e.to_string()))?;
116
+
117
+ let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
118
+ let ciphertext = cipher
119
+ .encrypt(&nonce, plaintext.as_bytes())
120
+ .map_err(|e| EncryptionError::Encrypt(e.to_string()))?;
121
+
122
+ Ok(EncryptedApiKey {
123
+ ciphertext: BASE64.encode(&ciphertext),
124
+ nonce: BASE64.encode(nonce),
125
+ salt: BASE64.encode(salt),
126
+ })
127
+ }
128
+
129
+ pub fn decrypt_api_key(enc: &EncryptedApiKey) -> Result<String, EncryptionError> {
130
+ let machine_id = get_machine_id()?;
131
+ let salt = BASE64.decode(&enc.salt)?;
132
+ let nonce_bytes = BASE64.decode(&enc.nonce)?;
133
+ let ciphertext = BASE64.decode(&enc.ciphertext)?;
134
+
135
+ let key_bytes = derive_key(&machine_id, &salt)?;
136
+ let cipher = Aes256Gcm::new_from_slice(&key_bytes)
137
+ .map_err(|e| EncryptionError::Decrypt(e.to_string()))?;
138
+
139
+ let nonce = Nonce::from_slice(&nonce_bytes);
140
+ let plaintext = cipher
141
+ .decrypt(nonce, ciphertext.as_ref())
142
+ .map_err(|e| EncryptionError::Decrypt(e.to_string()))?;
143
+
144
+ String::from_utf8(plaintext).map_err(|e| EncryptionError::Decrypt(e.to_string()))
145
+ }
146
+
147
+ #[cfg(test)]
148
+ mod tests {
149
+ use super::*;
150
+
151
+ #[test]
152
+ fn round_trip() {
153
+ let secret = "sk-test-1234567890";
154
+ let encrypted = encrypt_api_key(secret).expect("encrypt");
155
+ let decrypted = decrypt_api_key(&encrypted).expect("decrypt");
156
+ assert_eq!(decrypted, secret);
157
+ }
158
+
159
+ #[test]
160
+ fn different_ciphertext_each_time() {
161
+ // Different salts + nonces should produce different ciphertexts.
162
+ let a = encrypt_api_key("same secret").unwrap();
163
+ let b = encrypt_api_key("same secret").unwrap();
164
+ assert_ne!(a.ciphertext, b.ciphertext);
165
+ }
166
+ }
167
+ ```
168
+
169
+ ## Dependencies needed in `Cargo.toml`
170
+
171
+ ```toml
172
+ aes-gcm = "<latest>"
173
+ argon2 = "<latest>"
174
+ base64 = "<latest>"
175
+ rand = "<latest>"
176
+ thiserror = "<latest>"
177
+ ```
178
+
179
+ ## Auto-migrate plaintext on load
180
+
181
+ When deserializing settings, detect legacy plaintext API keys and re-encrypt:
182
+
183
+ ```rust
184
+ // In settings/storage.rs deserialize_settings(&Value):
185
+ if let Some(v) = json.get("openaiApiKey") {
186
+ if let Some(obj) = v.as_object() {
187
+ if let (Some(ct), Some(n), Some(s)) = (
188
+ obj.get("ciphertext").and_then(|x| x.as_str()),
189
+ obj.get("nonce").and_then(|x| x.as_str()),
190
+ obj.get("salt").and_then(|x| x.as_str()),
191
+ ) {
192
+ settings.openai_api_key = Some(EncryptedApiKey {
193
+ ciphertext: ct.into(), nonce: n.into(), salt: s.into(),
194
+ });
195
+ }
196
+ } else if let Some(plaintext) = v.as_str() {
197
+ // Try parsing as already-encrypted JSON string first.
198
+ if let Ok(enc) = serde_json::from_str::<EncryptedApiKey>(plaintext) {
199
+ settings.openai_api_key = Some(enc);
200
+ } else if !plaintext.is_empty() {
201
+ // Legacy plaintext — auto-migrate.
202
+ tracing::warn!("Found plaintext API key — migrating to encrypted format");
203
+ if let Ok(enc) = crate::encryption::encrypt_api_key(plaintext) {
204
+ settings.openai_api_key = Some(enc);
205
+ }
206
+ }
207
+ }
208
+ }
209
+ ```
210
+
211
+ ## Threat model — be honest
212
+
213
+ This module protects against:
214
+ - Disk reads by other users on a shared machine (when filesystem permissions are correct).
215
+ - Stolen settings files / backup leaks.
216
+ - Casual scanning by malware that doesn't escalate privileges.
217
+
218
+ This module does **not** protect against:
219
+ - A privileged process on the same user account (it can read `IOPlatformUUID` and reproduce the same key).
220
+ - A debugger attached to the running app (memory has plaintext after decrypt).
221
+ - Hardware-level attacks (cold boot, side channels).
222
+
223
+ For higher assurance, integrate the OS keychain:
224
+ - macOS: `security-framework` crate, `SecKeychainItem`.
225
+ - Windows: `windows-sys` + DPAPI (`CryptProtectData`).
226
+ - Linux: `libsecret` via the `secret-service` crate.
227
+
228
+ Document the threat model in your project's `SECURITY.md` so users have realistic expectations.
@@ -0,0 +1,126 @@
1
+ # `src-tauri/src/error/mod.rs`
2
+
3
+ The shared error-conversion utilities for Tauri commands. Internal APIs use `thiserror` enums; commands convert at the IPC boundary with `into_string_err!` or `ResultExt`.
4
+
5
+ ```rust
6
+ //! Error handling utilities — keeps command boilerplate tight.
7
+
8
+ /// Convert any error to a String at the call site.
9
+ ///
10
+ /// ```ignore
11
+ /// let r = into_string_err!(some_fallible()); // "<err>"
12
+ /// let r = into_string_err!(some_fallible(), "Failed to X"); // "Failed to X: <err>"
13
+ /// let r = into_string_err!(some_fallible(), "Loading {}", path);
14
+ /// ```
15
+ #[macro_export]
16
+ macro_rules! into_string_err {
17
+ ($expr:expr) => { $expr.map_err(|e| e.to_string()) };
18
+ ($expr:expr, $msg:expr) => { $expr.map_err(|e| format!("{}: {}", $msg, e)) };
19
+ ($expr:expr, $fmt:expr, $($arg:tt)*) => {
20
+ $expr.map_err(|e| format!("{}: {}", format!($fmt, $($arg)*), e))
21
+ };
22
+ }
23
+
24
+ /// `?`-propagating variant.
25
+ ///
26
+ /// ```ignore
27
+ /// fn cmd() -> Result<T, String> {
28
+ /// let x = try_into_string_err!(some_fallible());
29
+ /// Ok(x)
30
+ /// }
31
+ /// ```
32
+ #[macro_export]
33
+ macro_rules! try_into_string_err {
34
+ ($expr:expr) => { $expr.map_err(|e| e.to_string())? };
35
+ ($expr:expr, $msg:expr) => { $expr.map_err(|e| format!("{}: {}", $msg, e))? };
36
+ }
37
+
38
+ /// Extension trait so you can write `result.into_string()` / `.into_string_msg("...")`.
39
+ pub trait ResultExt<T, E>: Sized {
40
+ fn into_string(self) -> Result<T, String>;
41
+ fn into_string_msg(self, msg: &str) -> Result<T, String>;
42
+ }
43
+
44
+ impl<T, E: std::fmt::Display> ResultExt<T, E> for Result<T, E> {
45
+ fn into_string(self) -> Result<T, String> {
46
+ self.map_err(|e| e.to_string())
47
+ }
48
+ fn into_string_msg(self, msg: &str) -> Result<T, String> {
49
+ self.map_err(|e| format!("{}: {}", msg, e))
50
+ }
51
+ }
52
+
53
+ #[cfg(test)]
54
+ mod tests {
55
+ use super::*;
56
+
57
+ #[test]
58
+ fn macro_no_context() {
59
+ fn f() -> Result<(), &'static str> { Err("boom") }
60
+ let r: Result<(), String> = into_string_err!(f());
61
+ assert_eq!(r.unwrap_err(), "boom");
62
+ }
63
+
64
+ #[test]
65
+ fn macro_with_context() {
66
+ fn f() -> Result<(), &'static str> { Err("boom") }
67
+ let r: Result<(), String> = into_string_err!(f(), "Failed");
68
+ assert_eq!(r.unwrap_err(), "Failed: boom");
69
+ }
70
+
71
+ #[test]
72
+ fn ext_trait() {
73
+ fn f() -> Result<(), &'static str> { Err("boom") }
74
+ let r = f().into_string_msg("Context");
75
+ assert_eq!(r.unwrap_err(), "Context: boom");
76
+ }
77
+ }
78
+ ```
79
+
80
+ ## How to use it
81
+
82
+ Define rich internal error types with `thiserror`:
83
+
84
+ ```rust
85
+ // src-tauri/src/settings/mod.rs
86
+ #[derive(Debug, thiserror::Error)]
87
+ pub enum SettingsError {
88
+ #[error("settings file not found: {0}")]
89
+ NotFound(std::path::PathBuf),
90
+ #[error("invalid JSON: {0}")]
91
+ InvalidJson(#[from] serde_json::Error),
92
+ #[error("I/O error: {0}")]
93
+ Io(#[from] std::io::Error),
94
+ }
95
+
96
+ pub fn load(app: &AppHandle) -> Result<Settings, SettingsError> { /* ... */ }
97
+ ```
98
+
99
+ Then convert at the command boundary:
100
+
101
+ ```rust
102
+ // src-tauri/src/commands/settings.rs
103
+ use crate::error::ResultExt;
104
+
105
+ #[tauri::command]
106
+ pub fn get_settings(app: tauri::AppHandle) -> Result<Settings, String> {
107
+ crate::settings::load(&app).into_string_msg("Failed to load settings")
108
+ }
109
+ ```
110
+
111
+ ## Why two flavors
112
+
113
+ - `into_string_err!` is a macro because some `?`-heavy command bodies are cleaner with the macro form.
114
+ - `ResultExt` is a trait because it composes — `something.into_string().map(|v| ...)` flows naturally in chains.
115
+
116
+ Pick the one that reads better at the call site. Don't bikeshed; consistency within a single module matters more than which form you chose globally.
117
+
118
+ ## Why convert at the boundary, not throughout
119
+
120
+ If every internal function returned `Result<T, String>`:
121
+ - Type information is lost (was it I/O? parsing? permissions?).
122
+ - Adding `#[from]` conversions becomes impossible.
123
+ - Tests that assert on error variants can't.
124
+ - Callers can't decide to handle some errors and propagate others — every string looks the same.
125
+
126
+ Keep `thiserror` types as long as possible, convert only when crossing into a `#[tauri::command]`.
@@ -0,0 +1,98 @@
1
+ # `src-tauri/src/lib.rs`
2
+
3
+ The single source of truth for module wiring, plugin registration, and command handler registration. Keep `run()` lean — defer initialization work to background threads.
4
+
5
+ ```rust
6
+ // Module declarations
7
+ pub mod commands;
8
+ mod error;
9
+ mod platform;
10
+ mod settings;
11
+ mod state;
12
+ mod storage;
13
+ // add per-feature modules here: mod audio; mod history; ...
14
+
15
+ // Re-exports for integration tests
16
+ pub use settings::Settings;
17
+ pub use state::AppState;
18
+ pub use storage::{generate_id, get_app_data_dir, get_storage_path, load_json, save_json};
19
+ pub use error::ResultExt;
20
+
21
+ use tauri::Manager;
22
+ use tracing::info;
23
+
24
+ #[cfg_attr(mobile, tauri::mobile_entry_point)]
25
+ pub fn run() {
26
+ // Initialize tracing FIRST so plugin init logs are captured
27
+ tracing_subscriber::fmt()
28
+ .with_env_filter(
29
+ tracing_subscriber::EnvFilter::from_default_env()
30
+ .add_directive(tracing::Level::INFO.into()),
31
+ )
32
+ .init();
33
+
34
+ tauri::Builder::default()
35
+ // Single-instance MUST register first so a second launch is intercepted
36
+ // before any other plugin initializes resources.
37
+ .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
38
+ if let Some(window) = app.get_webview_window("main") {
39
+ let _ = window.show();
40
+ let _ = window.set_focus();
41
+ let _ = window.unminimize();
42
+ }
43
+ }))
44
+ .plugin(tauri_plugin_opener::init())
45
+ .plugin(tauri_plugin_dialog::init())
46
+ .plugin(tauri_plugin_fs::init())
47
+ .plugin(tauri_plugin_notification::init())
48
+ .plugin(tauri_plugin_global_shortcut::Builder::new().build())
49
+ .plugin(tauri_plugin_process::init())
50
+ // .plugin(tauri_plugin_updater::Builder::new().build()) // enable if updater is wired
51
+ .setup(|app| {
52
+ info!("App setup starting");
53
+
54
+ // Manage shared state
55
+ let state = std::sync::Mutex::new(state::AppState::new());
56
+ app.manage(state);
57
+
58
+ // Load settings synchronously (small file, fast)
59
+ let app_handle = app.handle().clone();
60
+ let settings = settings::load_settings(&app_handle);
61
+ if let Ok(mut s) = app.state::<std::sync::Mutex<state::AppState>>().lock() {
62
+ s.app_handle = Some(app_handle.clone());
63
+ s.settings = Some(settings);
64
+ }
65
+
66
+ // Heavy initialization on a background thread — keep .setup() fast
67
+ // so the window appears immediately.
68
+ std::thread::spawn(move || {
69
+ // model load, audio device enumeration, etc.
70
+ // emit progress back: let _ = app_handle.emit("init-progress", ...);
71
+ });
72
+
73
+ Ok(())
74
+ })
75
+ .invoke_handler(tauri::generate_handler![
76
+ // System commands
77
+ commands::write_file,
78
+ commands::save_file_dialog,
79
+ commands::open_file_dialog,
80
+ // Settings commands
81
+ settings::get_settings,
82
+ settings::save_settings_command,
83
+ settings::reset_settings_command,
84
+ // Add per-domain commands here
85
+ ])
86
+ .run(tauri::generate_context!())
87
+ .expect("error while running tauri application");
88
+ }
89
+ ```
90
+
91
+ ## Why
92
+
93
+ - **Tracing initialized first** — Plugins log during their `init()` call. If `tracing_subscriber` registers later, those logs are silently dropped.
94
+ - **`tauri_plugin_single_instance::init` registers first** — The plugin's IPC hook needs to fire before any other plugin claims a resource (lock file, IPC socket, audio device). Registering it last is a known footgun.
95
+ - **`.setup()` is fast** — The window appears as soon as `.setup()` returns. Anything synchronous in there delays first paint. Synchronous settings load is OK (small JSON file); model loading / network calls are not.
96
+ - **`pub mod commands; mod error;` pattern** — `commands` is `pub` so integration tests can call individual commands directly. Domain modules (`error`, `state`) stay private with selective re-exports.
97
+ - **Re-exports for tests** — Integration tests in `src-tauri/tests/` import via `use {{app_name}}_lib::{Settings, AppState};`. Re-exporting at the crate root means tests don't need to know the internal module path.
98
+ - **`tauri::generate_handler!` is one list** — Tauri verifies the macro expansion at compile time. Splitting it across files isn't supported; keep all commands in one `generate_handler![...]` block.