@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.
- package/README.md +45 -16
- package/package.json +1 -1
- package/skills/nextjs-app-router/SKILL.md +228 -175
- package/skills/nextjs-app-router/references/anti-patterns.md +163 -99
- package/skills/nextjs-app-router/references/folder-layout.md +79 -46
- package/skills/nextjs-app-router/references/good-patterns.md +233 -99
- package/skills/nextjs-app-router/references/templates/api-base.md +24 -21
- package/skills/nextjs-app-router/references/templates/auth-slice.md +82 -78
- package/skills/nextjs-app-router/references/templates/ci-and-hooks.md +58 -6
- package/skills/nextjs-app-router/references/templates/db-client.md +48 -0
- package/skills/nextjs-app-router/references/templates/env-and-utils.md +90 -23
- package/skills/nextjs-app-router/references/templates/feature-slice.md +110 -47
- package/skills/nextjs-app-router/references/templates/form-with-zod.md +23 -15
- package/skills/nextjs-app-router/references/templates/middleware.md +69 -59
- package/skills/nextjs-app-router/references/templates/next-config.md +4 -14
- package/skills/nextjs-app-router/references/templates/nextauth-config.md +178 -0
- package/skills/nextjs-app-router/references/templates/package.md +35 -5
- package/skills/nextjs-app-router/references/templates/prisma-schema.md +162 -0
- package/skills/nextjs-app-router/references/templates/providers-and-store.md +35 -21
- package/skills/nextjs-app-router/references/templates/root-layout.md +99 -20
- package/skills/nextjs-app-router/references/templates/route-group-layouts.md +46 -6
- package/skills/nextjs-app-router/references/templates/route-handler.md +168 -0
- package/skills/nextjs-app-router/references/templates/testing.md +105 -31
- package/skills/tauri-2-app/SKILL.md +381 -0
- package/skills/tauri-2-app/references/anti-patterns.md +434 -0
- package/skills/tauri-2-app/references/folder-layout.md +161 -0
- package/skills/tauri-2-app/references/good-patterns.md +477 -0
- package/skills/tauri-2-app/references/templates/build-rs.md +51 -0
- package/skills/tauri-2-app/references/templates/capabilities.md +68 -0
- package/skills/tauri-2-app/references/templates/cargo-toml.md +118 -0
- package/skills/tauri-2-app/references/templates/ci-workflow.md +99 -0
- package/skills/tauri-2-app/references/templates/encryption-mod.md +228 -0
- package/skills/tauri-2-app/references/templates/error-mod.md +126 -0
- package/skills/tauri-2-app/references/templates/lib-rs.md +98 -0
- package/skills/tauri-2-app/references/templates/macos-plist.md +89 -0
- package/skills/tauri-2-app/references/templates/main-rs.md +21 -0
- package/skills/tauri-2-app/references/templates/platform-traits.md +217 -0
- package/skills/tauri-2-app/references/templates/storage-mod.md +172 -0
- package/skills/tauri-2-app/references/templates/tauri-conf.md +136 -0
- package/skills/tauri-2-app/references/templates/use-tauri-command.md +194 -0
- 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.
|