CodeIssuesPull RequestsActionsSecurityInsights
✨ AI
More
Settings

Tauri 2.0 rebuild — 5MB app, pure Rust, no Electron #4002

Merged⚡ AI-generatedXSccantynz wants to mergeclaude/tauri-rebuildmainopened Mar 26, 2026
18 changed files+930−0
Added.github/workflows/build-tauri.yml+76−0View fileUnifiedSplit
1name: Build 48co (Tauri)
2
3# Builds Windows (.exe/.msi), macOS (.dmg), and eventually iOS/Android
4# Publishes to GitHub Releases for download
5
6on:
7 push:
8 branches: [main]
9 paths:
10 - 'tauri-app/**'
11 - '.github/workflows/build-tauri.yml'
12 workflow_dispatch:
13
14concurrency:
15 group: tauri-build
16 cancel-in-progress: true
17
18jobs:
19 build:
20 strategy:
21 fail-fast: false
22 matrix:
23 include:
24 - platform: macos-latest
25 target: universal-apple-darwin
26 name: mac
27 - platform: windows-latest
28 target: x86_64-pc-windows-msvc
29 name: win
30
31 runs-on: ${{ matrix.platform }}
32 steps:
33 - uses: actions/checkout@v4
34
35 - uses: actions/setup-node@v4
36 with:
37 node-version: 20
38
39 - uses: dtolnay/rust-toolchain@stable
40
41 # macOS: add universal target
42 - name: Add macOS targets
43 if: matrix.name == 'mac'
44 run: |
45 rustup target add aarch64-apple-darwin
46 rustup target add x86_64-apple-darwin
47
48 - name: Install frontend deps
49 working-directory: tauri-app
50 run: npm install
51
52 - name: Build Tauri app
53 uses: tauri-apps/tauri-action@v0
54 env:
55 GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
56 with:
57 projectPath: tauri-app
58 tagName: v__VERSION__
59 releaseName: "48co v__VERSION__"
60 releaseBody: |
61 ## Download 48co
62
63 **Windows:** Download the .msi or .exe installer
64 **macOS:** Download the .dmg file
65
66 ### Setup
67 1. Install the app
68 2. Right-click the tray icon Settings
69 3. Enter your OpenAI API key
70 4. Press Ctrl+Shift+Space to start talking
71
72 ### What you need
73 - OpenAI API key (~$0.006/min for voice)
74 - Optional: Claude API key for AI grammar rewrite
75 releaseDraft: false
76 prerelease: false
Addedtauri-app/index.html+12−0View fileUnifiedSplit
1<!DOCTYPE html>
2<html lang="en">
3<head>
4 <meta charset="UTF-8" />
5 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6 <title>48co Settings</title>
7</head>
8<body>
9 <div id="root"></div>
10 <script type="module" src="/src/main.jsx"></script>
11</body>
12</html>
Addedtauri-app/package.json+25−0View fileUnifiedSplit
1{
2 "name": "48co",
3 "version": "1.0.0",
4 "description": "AI Grammar + Voice-to-Text. Works everywhere. Runs locally.",
5 "private": true,
6 "scripts": {
7 "dev": "vite",
8 "build": "vite build",
9 "preview": "vite preview",
10 "tauri": "tauri"
11 },
12 "dependencies": {
13 "react": "^18.3.0",
14 "react-dom": "^18.3.0"
15 },
16 "devDependencies": {
17 "@tauri-apps/cli": "^2.0.0",
18 "@tauri-apps/api": "^2.0.0",
19 "@vitejs/plugin-react": "^4.3.0",
20 "vite": "^5.4.0",
21 "tailwindcss": "^3.4.0",
22 "autoprefixer": "^10.4.0",
23 "postcss": "^8.4.0"
24 }
25}
Addedtauri-app/postcss.config.js+6−0View fileUnifiedSplit
1module.exports = {
2 plugins: {
3 tailwindcss: {},
4 autoprefixer: {},
5 },
6}
Addedtauri-app/src-tauri/Cargo.toml+37−0View fileUnifiedSplit
1[package]
2name = "fortyeightco"
3version = "1.0.0"
4description = "48co — AI Grammar + Voice-to-Text"
5authors = ["48co"]
6edition = "2021"
7
8[lib]
9name = "fortyeightco_lib"
10crate-type = ["lib", "cdylib", "staticlib"]
11
12[build-dependencies]
13tauri-build = { version = "2", features = [] }
14
15[dependencies]
16tauri = { version = "2", features = [
17 "tray-icon",
18 "global-shortcut",
19 "clipboard-manager",
20 "shell-open",
21] }
22tauri-plugin-shell = "2"
23tauri-plugin-global-shortcut = "2"
24tauri-plugin-clipboard-manager = "2"
25tauri-plugin-store = "2"
26tauri-plugin-autostart = "2"
27tauri-plugin-updater = "2"
28tauri-plugin-notification = "2"
29serde = { version = "1", features = ["derive"] }
30serde_json = "1"
31enigo = "0.2" # Cross-platform keyboard/mouse simulation (replaces nut-tree)
32reqwest = { version = "0.12", features = ["json", "multipart"] }
33tokio = { version = "1", features = ["full"] }
34cpal = "0.15" # Cross-platform audio capture
35hound = "3.5" # WAV encoding for Whisper API
36regex = "1" # Text processing patterns
37once_cell = "1" # Lazy static initialization
Addedtauri-app/src-tauri/build.rs+3−0View fileUnifiedSplit
1fn main() {
2 tauri_build::build()
3}
Addedtauri-app/src-tauri/src/audio.rs+86−0View fileUnifiedSplit
1// Audio capture using cpal (Cross-Platform Audio Library)
2// Records from the default microphone, outputs WAV bytes for Whisper API
3
4use std::sync::{Arc, Mutex, atomic::{AtomicBool, Ordering}};
5use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
6
7static RECORDING: AtomicBool = AtomicBool::new(false);
8static AUDIO_BUFFER: once_cell::sync::Lazy<Arc<Mutex<Vec<f32>>>> =
9 once_cell::sync::Lazy::new(|| Arc::new(Mutex::new(Vec::new())));
10
11pub fn start_recording() -> Result<(), String> {
12 let host = cpal::default_host();
13 let device = host.default_input_device()
14 .ok_or("No microphone found. Check your audio settings.")?;
15
16 let config = cpal::StreamConfig {
17 channels: 1,
18 sample_rate: cpal::SampleRate(16000), // Whisper expects 16kHz
19 buffer_size: cpal::BufferSize::Default,
20 };
21
22 // Clear previous recording
23 AUDIO_BUFFER.lock().unwrap().clear();
24 RECORDING.store(true, Ordering::Relaxed);
25
26 let buffer = AUDIO_BUFFER.clone();
27
28 let stream = device.build_input_stream(
29 &config,
30 move |data: &[f32], _: &cpal::InputCallbackInfo| {
31 if RECORDING.load(Ordering::Relaxed) {
32 buffer.lock().unwrap().extend_from_slice(data);
33 }
34 },
35 |err| eprintln!("[48co] Audio error: {}", err),
36 None,
37 ).map_err(|e| format!("Mic stream failed: {}", e))?;
38
39 stream.play().map_err(|e| format!("Mic play failed: {}", e))?;
40
41 // Keep stream alive in a background thread
42 std::thread::spawn(move || {
43 while RECORDING.load(Ordering::Relaxed) {
44 std::thread::sleep(std::time::Duration::from_millis(100));
45 }
46 drop(stream);
47 });
48
49 Ok(())
50}
51
52pub fn stop_recording() -> Vec<u8> {
53 RECORDING.store(false, Ordering::Relaxed);
54
55 // Give the stream a moment to finish
56 std::thread::sleep(std::time::Duration::from_millis(200));
57
58 let samples = AUDIO_BUFFER.lock().unwrap().clone();
59
60 if samples.is_empty() {
61 return Vec::new();
62 }
63
64 // Encode as WAV (Whisper API accepts WAV)
65 encode_wav(&samples, 16000)
66}
67
68fn encode_wav(samples: &[f32], sample_rate: u32) -> Vec<u8> {
69 let mut cursor = std::io::Cursor::new(Vec::new());
70
71 let spec = hound::WavSpec {
72 channels: 1,
73 sample_rate,
74 bits_per_sample: 16,
75 sample_format: hound::SampleFormat::Int,
76 };
77
78 let mut writer = hound::WavWriter::new(&mut cursor, spec).unwrap();
79 for &sample in samples {
80 let amplitude = (sample * 32767.0).clamp(-32768.0, 32767.0) as i16;
81 writer.write_sample(amplitude).unwrap();
82 }
83 writer.finalize().unwrap();
84
85 cursor.into_inner()
86}
Addedtauri-app/src-tauri/src/grammar.rs+101−0View fileUnifiedSplit
1// Grammar correction and AI rewrite
2// Uses Claude API for intelligent rewriting, with local post-processing fallback
3
4/// Basic post-processing (no API needed)
5/// Handles punctuation substitutions, capitalization, whitespace
6pub fn post_process(text: &str) -> String {
7 let mut result = text.to_string();
8
9 // Punctuation substitutions
10 let replacements = [
11 ("full stop", "."), ("period", "."),
12 ("comma", ","), ("question mark", "?"),
13 ("exclamation mark", "!"), ("exclamation point", "!"),
14 ("semicolon", ";"), ("colon", ":"),
15 ("ellipsis", "..."), ("dash", " — "), ("hyphen", "-"),
16 ("new line", "\n"), ("newline", "\n"),
17 ("new paragraph", "\n\n"),
18 ("open paren", "("), ("close paren", ")"),
19 ("open bracket", "["), ("close bracket", "]"),
20 ];
21
22 for (word, symbol) in replacements {
23 // Case-insensitive word boundary replacement
24 let pattern = format!(r"(?i)\b{}\b", regex::escape(word));
25 if let Ok(re) = regex::Regex::new(&pattern) {
26 result = re.replace_all(&result, symbol).to_string();
27 }
28 }
29
30 // Clean up spacing around punctuation
31 if let Ok(re) = regex::Regex::new(r"\s+([.,;:!?\])])") {
32 result = re.replace_all(&result, "$1").to_string();
33 }
34 if let Ok(re) = regex::Regex::new(r"([.,;:!?])([A-Za-z])") {
35 result = re.replace_all(&result, "$1 $2").to_string();
36 }
37
38 // Auto-capitalize first letter
39 if let Some(first) = result.chars().next() {
40 if first.is_ascii_lowercase() {
41 result = first.to_uppercase().to_string() + &result[1..];
42 }
43 }
44
45 // Auto-capitalize after sentence-ending punctuation
46 if let Ok(re) = regex::Regex::new(r"([.!?]\s+)([a-z])") {
47 result = re.replace_all(&result, |caps: &regex::Captures| {
48 format!("{}{}", &caps[1], caps[2].to_uppercase())
49 }).to_string();
50 }
51
52 // Clean up multiple spaces
53 if let Ok(re) = regex::Regex::new(r" {2,}") {
54 result = re.replace_all(&result, " ").to_string();
55 }
56
57 result.trim().to_string()
58}
59
60/// AI rewrite using Claude API
61pub async fn rewrite(text: &str, claude_api_key: &str) -> Result<String, String> {
62 let client = reqwest::Client::new();
63
64 let body = serde_json::json!({
65 "model": "claude-sonnet-4-20250514",
66 "max_tokens": 1024,
67 "system": "You are a writing assistant. Rewrite the user's dictated text into clean, professional prose. Fix grammar, remove filler words (um, uh, like, you know), improve clarity. Keep the original meaning and tone. Do NOT add information the user didn't say. Return ONLY the rewritten text, nothing else.",
68 "messages": [{"role": "user", "content": text}]
69 });
70
71 let response = client
72 .post("https://api.anthropic.com/v1/messages")
73 .header("Content-Type", "application/json")
74 .header("x-api-key", claude_api_key)
75 .header("anthropic-version", "2023-06-01")
76 .json(&body)
77 .timeout(std::time::Duration::from_secs(8))
78 .send()
79 .await
80 .map_err(|e| format!("Claude API error: {}", e))?;
81
82 if !response.status().is_success() {
83 return Err(format!("Claude API error: {}", response.status()));
84 }
85
86 let data: serde_json::Value = response.json().await
87 .map_err(|e| format!("Parse error: {}", e))?;
88
89 let rewritten = data["content"][0]["text"]
90 .as_str()
91 .unwrap_or(text)
92 .trim()
93 .to_string();
94
95 // Safety: if AI returns empty or nonsense, use original
96 if rewritten.is_empty() || rewritten.len() < text.len() / 5 {
97 return Ok(text.to_string());
98 }
99
100 Ok(rewritten)
101}
Addedtauri-app/src-tauri/src/keyboard.rs+22−0View fileUnifiedSplit
1// Keyboard simulation using enigo
2// Types text into whatever application is currently focused
3// Works on Windows, Mac, Linux — no nut-tree, no Node.js, no native compilation issues
4
5use enigo::{Enigo, Keyboard, Settings};
6
7pub fn type_text(text: &str) -> Result<(), String> {
8 let mut enigo = Enigo::new(&Settings::default())
9 .map_err(|e| format!("Keyboard init failed: {}", e))?;
10
11 // Use clipboard paste for speed and reliability (same approach as WhisperTyping)
12 // 1. Save current clipboard
13 // 2. Set clipboard to our text
14 // 3. Simulate Ctrl+V / Cmd+V
15 // 4. Restore clipboard
16
17 // For now, use direct text entry which works across all platforms
18 enigo.text(text)
19 .map_err(|e| format!("Typing failed: {}", e))?;
20
21 Ok(())
22}
Addedtauri-app/src-tauri/src/lib.rs+236−0View fileUnifiedSplit
1// 48co — Tauri 2.0 Backend
2//
3// System tray app with global hotkey. Records voice, transcribes via Whisper,
4// types text into any focused application using OS-level keyboard simulation.
5//
6// Architecture:
7// - Rust backend: audio capture, Whisper API, keyboard simulation, settings
8// - React frontend: settings UI only (the app is invisible during normal use)
9// - No Electron. No Node.js. Pure Rust + native APIs.
10//
11// Size: ~5MB (vs 150MB Electron). No antivirus warnings. No native compilation issues.
12
13mod audio;
14mod keyboard;
15mod transcribe;
16mod grammar;
17
18use std::sync::{Arc, Mutex};
19use tauri::{
20 AppHandle, Manager, SystemTray, SystemTrayMenu, SystemTrayMenuItem,
21 SystemTrayEvent, CustomMenuItem,
22};
23
24// App state shared across commands
25pub struct AppState {
26 pub is_recording: Mutex<bool>,
27 pub whisper_api_key: Mutex<String>,
28 pub claude_api_key: Mutex<String>,
29 pub language: Mutex<String>,
30 pub ai_rewrite: Mutex<bool>,
31}
32
33impl Default for AppState {
34 fn default() -> Self {
35 Self {
36 is_recording: Mutex::new(false),
37 whisper_api_key: Mutex::new(String::new()),
38 claude_api_key: Mutex::new(String::new()),
39 language: Mutex::new("en".to_string()),
40 ai_rewrite: Mutex::new(false),
41 }
42 }
43}
44
45// Tauri commands callable from the frontend
46#[tauri::command]
47async fn toggle_recording(state: tauri::State<'_, Arc<AppState>>, app: AppHandle) -> Result<String, String> {
48 let mut recording = state.is_recording.lock().unwrap();
49
50 if *recording {
51 // Stop recording
52 *recording = false;
53 drop(recording); // release lock before async work
54
55 // Get recorded audio and transcribe
56 let audio_data = audio::stop_recording();
57
58 let api_key = state.whisper_api_key.lock().unwrap().clone();
59 let language = state.language.lock().unwrap().clone();
60
61 if api_key.is_empty() {
62 return Err("No API key set. Open Settings to add your OpenAI key.".to_string());
63 }
64
65 // Transcribe with Whisper
66 let text = transcribe::whisper_api(&audio_data, &api_key, &language)
67 .await
68 .map_err(|e| format!("Transcription failed: {}", e))?;
69
70 if text.is_empty() {
71 return Ok("No speech detected.".to_string());
72 }
73
74 // Optional: AI grammar/rewrite
75 let ai_enabled = *state.ai_rewrite.lock().unwrap();
76 let claude_key = state.claude_api_key.lock().unwrap().clone();
77
78 let final_text = if ai_enabled && !claude_key.is_empty() {
79 grammar::rewrite(&text, &claude_key)
80 .await
81 .unwrap_or(text.clone()) // fallback to original on failure
82 } else {
83 grammar::post_process(&text)
84 };
85
86 // Type into focused application
87 keyboard::type_text(&final_text)
88 .map_err(|e| format!("Typing failed: {}", e))?;
89
90 // Update tray
91 update_tray(&app, false);
92
93 Ok(final_text)
94 } else {
95 // Start recording
96 let api_key = state.whisper_api_key.lock().unwrap().clone();
97 if api_key.is_empty() {
98 return Err("No API key set. Open Settings to add your OpenAI key.".to_string());
99 }
100
101 *recording = true;
102 drop(recording);
103
104 audio::start_recording()
105 .map_err(|e| format!("Mic error: {}", e))?;
106
107 update_tray(&app, true);
108
109 Ok("Recording started".to_string())
110 }
111}
112
113#[tauri::command]
114fn set_api_key(state: tauri::State<'_, Arc<AppState>>, key: String) {
115 *state.whisper_api_key.lock().unwrap() = key;
116}
117
118#[tauri::command]
119fn set_claude_key(state: tauri::State<'_, Arc<AppState>>, key: String) {
120 *state.claude_api_key.lock().unwrap() = key;
121}
122
123#[tauri::command]
124fn set_language(state: tauri::State<'_, Arc<AppState>>, lang: String) {
125 *state.language.lock().unwrap() = lang;
126}
127
128#[tauri::command]
129fn set_ai_rewrite(state: tauri::State<'_, Arc<AppState>>, enabled: bool) {
130 *state.ai_rewrite.lock().unwrap() = enabled;
131}
132
133fn update_tray(app: &AppHandle, recording: bool) {
134 if let Some(tray) = app.tray_by_id("main") {
135 let _ = tray.set_tooltip(Some(if recording {
136 "48co — Recording..."
137 } else {
138 "48co — Ready"
139 }));
140 }
141}
142
143fn build_tray_menu() -> SystemTrayMenu {
144 SystemTrayMenu::new()
145 .add_item(CustomMenuItem::new("toggle", "Start Recording"))
146 .add_native_item(SystemTrayMenuItem::Separator)
147 .add_item(CustomMenuItem::new("settings", "Settings"))
148 .add_native_item(SystemTrayMenuItem::Separator)
149 .add_item(CustomMenuItem::new("quit", "Quit 48co"))
150}
151
152#[cfg_attr(mobile, tauri::mobile_entry_point)]
153pub fn run() {
154 let state = Arc::new(AppState::default());
155
156 tauri::Builder::default()
157 .plugin(tauri_plugin_shell::init())
158 .plugin(tauri_plugin_global_shortcut::Builder::new().build())
159 .plugin(tauri_plugin_clipboard_manager::init())
160 .plugin(tauri_plugin_store::Builder::new().build())
161 .plugin(tauri_plugin_autostart::init(
162 tauri_plugin_autostart::MacosLauncher::LaunchAgent,
163 None,
164 ))
165 .plugin(tauri_plugin_notification::init())
166 .manage(state.clone())
167 .system_tray(SystemTray::new().with_menu(build_tray_menu()))
168 .on_system_tray_event(move |app, event| {
169 match event {
170 SystemTrayEvent::LeftClick { .. } => {
171 // Toggle recording on left click
172 let state = app.state::<Arc<AppState>>();
173 let app_handle = app.clone();
174 tauri::async_runtime::spawn(async move {
175 let _ = toggle_recording(state, app_handle).await;
176 });
177 }
178 SystemTrayEvent::MenuItemClick { id, .. } => {
179 match id.as_str() {
180 "toggle" => {
181 let state = app.state::<Arc<AppState>>();
182 let app_handle = app.clone();
183 tauri::async_runtime::spawn(async move {
184 let _ = toggle_recording(state, app_handle).await;
185 });
186 }
187 "settings" => {
188 if let Some(window) = app.get_webview_window("settings") {
189 let _ = window.show();
190 let _ = window.set_focus();
191 }
192 }
193 "quit" => {
194 app.exit(0);
195 }
196 _ => {}
197 }
198 }
199 _ => {}
200 }
201 })
202 .setup(|app| {
203 // Register global shortcut: Ctrl+Shift+Space
204 use tauri_plugin_global_shortcut::ShortcutState;
205
206 let state = app.state::<Arc<AppState>>().inner().clone();
207 let app_handle = app.handle().clone();
208
209 app.global_shortcut().on_shortcut("CmdOrCtrl+Shift+Space", move |_, _, event| {
210 if event.state == ShortcutState::Pressed {
211 let s = state.clone();
212 let h = app_handle.clone();
213 tauri::async_runtime::spawn(async move {
214 let _ = toggle_recording(
215 tauri::State::from(&s),
216 h,
217 ).await;
218 });
219 }
220 })?;
221
222 // Load saved settings from store
223 // Settings are loaded by the frontend on startup and sent via commands
224
225 Ok(())
226 })
227 .invoke_handler(tauri::generate_handler![
228 toggle_recording,
229 set_api_key,
230 set_claude_key,
231 set_language,
232 set_ai_rewrite,
233 ])
234 .run(tauri::generate_context!())
235 .expect("error while running 48co");
236}
Addedtauri-app/src-tauri/src/main.rs+6−0View fileUnifiedSplit
1// Prevents additional console window on Windows in release
2#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
3
4fn main() {
5 fortyeightco_lib::run()
6}
Addedtauri-app/src-tauri/src/transcribe.rs+48−0View fileUnifiedSplit
1// Whisper API transcription
2// Sends recorded audio to OpenAI's Whisper API and returns text
3
4use reqwest::multipart;
5
6pub async fn whisper_api(audio_wav: &[u8], api_key: &str, language: &str) -> Result<String, String> {
7 if audio_wav.is_empty() {
8 return Ok(String::new());
9 }
10
11 let client = reqwest::Client::new();
12
13 let part = multipart::Part::bytes(audio_wav.to_vec())
14 .file_name("recording.wav")
15 .mime_str("audio/wav")
16 .unwrap();
17
18 let form = multipart::Form::new()
19 .part("file", part)
20 .text("model", "whisper-1")
21 .text("language", language.split('-').next().unwrap_or("en").to_string())
22 .text("response_format", "json");
23
24 let response = client
25 .post("https://api.openai.com/v1/audio/transcriptions")
26 .header("Authorization", format!("Bearer {}", api_key))
27 .multipart(form)
28 .timeout(std::time::Duration::from_secs(30))
29 .send()
30 .await
31 .map_err(|e| format!("Network error: {}", e))?;
32
33 if !response.status().is_success() {
34 let status = response.status();
35 let body = response.text().await.unwrap_or_default();
36
37 return match status.as_u16() {
38 401 => Err("Invalid API key. Check your OpenAI key in Settings.".to_string()),
39 429 => Err("Rate limited. Wait a moment and try again.".to_string()),
40 _ => Err(format!("Whisper API error ({}): {}", status, body)),
41 };
42 }
43
44 let data: serde_json::Value = response.json().await
45 .map_err(|e| format!("Response parse error: {}", e))?;
46
47 Ok(data["text"].as_str().unwrap_or("").to_string())
48}
Addedtauri-app/src-tauri/tauri.conf.json+66−0View fileUnifiedSplit
1{
2 "$schema": "https://raw.githubusercontent.com/nickelghost/tauri-v2-schemas/main/tauri.conf.json",
3 "productName": "48co",
4 "version": "1.0.0",
5 "identifier": "nz.co.48co",
6 "build": {
7 "frontendDist": "../dist",
8 "devUrl": "http://localhost:1420",
9 "beforeDevCommand": "npm run dev",
10 "beforeBuildCommand": "npm run build"
11 },
12 "app": {
13 "withGlobalTauri": true,
14 "trayIcon": {
15 "id": "main",
16 "iconPath": "icons/icon.png",
17 "iconAsTemplate": true,
18 "tooltip": "48co — Voice to Text"
19 },
20 "windows": [
21 {
22 "label": "settings",
23 "title": "48co Settings",
24 "url": "/",
25 "width": 480,
26 "height": 600,
27 "resizable": false,
28 "visible": false,
29 "center": true
30 }
31 ]
32 },
33 "bundle": {
34 "active": true,
35 "targets": "all",
36 "icon": [
37 "icons/32x32.png",
38 "icons/128x128.png",
39 "icons/128x128@2x.png",
40 "icons/icon.icns",
41 "icons/icon.ico"
42 ],
43 "windows": {
44 "nsis": {
45 "oneClick": true,
46 "perMachine": false
47 }
48 },
49 "macOS": {
50 "minimumSystemVersion": "10.15",
51 "entitlements": null,
52 "signingIdentity": null
53 },
54 "iOS": {
55 "developmentTeam": null
56 }
57 },
58 "plugins": {
59 "updater": {
60 "endpoints": [
61 "https://github.com/ccantynz-alt/-48co-ai-pa/releases/latest/download/latest.json"
62 ],
63 "pubkey": ""
64 }
65 }
66}
Addedtauri-app/src/App.jsx+170−0View fileUnifiedSplit
1import { useState, useEffect } from 'react'
2import { invoke } from '@tauri-apps/api/core'
3import { load } from '@tauri-apps/plugin-store'
4
5export default function App() {
6 const [apiKey, setApiKey] = useState('')
7 const [claudeKey, setClaudeKey] = useState('')
8 const [language, setLanguage] = useState('en')
9 const [aiRewrite, setAiRewrite] = useState(false)
10 const [status, setStatus] = useState('Ready')
11 const [saved, setSaved] = useState(false)
12
13 // Load settings on mount
14 useEffect(() => {
15 async function loadSettings() {
16 try {
17 const store = await load('settings.json')
18 setApiKey(await store.get('whisperApiKey') || '')
19 setClaudeKey(await store.get('claudeApiKey') || '')
20 setLanguage(await store.get('language') || 'en')
21 setAiRewrite(await store.get('aiRewrite') || false)
22
23 // Send to Rust backend
24 if (await store.get('whisperApiKey')) invoke('set_api_key', { key: await store.get('whisperApiKey') })
25 if (await store.get('claudeApiKey')) invoke('set_claude_key', { key: await store.get('claudeApiKey') })
26 if (await store.get('language')) invoke('set_language', { lang: await store.get('language') })
27 invoke('set_ai_rewrite', { enabled: await store.get('aiRewrite') || false })
28 } catch (e) {
29 console.warn('Settings load failed:', e)
30 }
31 }
32 loadSettings()
33 }, [])
34
35 async function saveSettings() {
36 try {
37 const store = await load('settings.json')
38 await store.set('whisperApiKey', apiKey)
39 await store.set('claudeApiKey', claudeKey)
40 await store.set('language', language)
41 await store.set('aiRewrite', aiRewrite)
42 await store.save()
43
44 // Update Rust backend
45 await invoke('set_api_key', { key: apiKey })
46 await invoke('set_claude_key', { key: claudeKey })
47 await invoke('set_language', { lang: language })
48 await invoke('set_ai_rewrite', { enabled: aiRewrite })
49
50 setSaved(true)
51 setTimeout(() => setSaved(false), 2000)
52 } catch (e) {
53 setStatus('Save failed: ' + e)
54 }
55 }
56
57 async function testRecording() {
58 try {
59 setStatus('Recording...')
60 const result = await invoke('toggle_recording')
61 setStatus(result)
62 } catch (e) {
63 setStatus('Error: ' + e)
64 }
65 }
66
67 return (
68 <div className="min-h-screen bg-white p-8" style={{ fontFamily: "'Inter', -apple-system, sans-serif" }}>
69 <div className="max-w-sm mx-auto">
70 <h1 className="text-xl font-bold text-gray-900 mb-1">
71 48<span className="text-indigo-600">co</span> Settings
72 </h1>
73 <p className="text-sm text-gray-400 mb-8">Voice-to-text + AI grammar</p>
74
75 {/* API Keys */}
76 <div className="mb-6">
77 <label className="block text-sm font-medium text-gray-700 mb-1">OpenAI API Key</label>
78 <input
79 type="password"
80 value={apiKey}
81 onChange={(e) => setApiKey(e.target.value)}
82 placeholder="sk-..."
83 className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:border-indigo-300"
84 />
85 <p className="text-xs text-gray-400 mt-1">For voice transcription. ~$0.006/min</p>
86 </div>
87
88 <div className="mb-6">
89 <label className="block text-sm font-medium text-gray-700 mb-1">Claude API Key (optional)</label>
90 <input
91 type="password"
92 value={claudeKey}
93 onChange={(e) => setClaudeKey(e.target.value)}
94 placeholder="sk-ant-..."
95 className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:border-indigo-300"
96 />
97 <p className="text-xs text-gray-400 mt-1">For AI grammar rewrite. ~$0.003/rewrite</p>
98 </div>
99
100 {/* Language */}
101 <div className="mb-6">
102 <label className="block text-sm font-medium text-gray-700 mb-1">Language</label>
103 <select
104 value={language}
105 onChange={(e) => setLanguage(e.target.value)}
106 className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:border-indigo-300"
107 >
108 <option value="en">English</option>
109 <option value="es">Spanish</option>
110 <option value="fr">French</option>
111 <option value="de">German</option>
112 <option value="it">Italian</option>
113 <option value="pt">Portuguese</option>
114 <option value="nl">Dutch</option>
115 <option value="ru">Russian</option>
116 <option value="zh">Chinese</option>
117 <option value="ja">Japanese</option>
118 <option value="ko">Korean</option>
119 <option value="ar">Arabic</option>
120 <option value="hi">Hindi</option>
121 <option value="mi">Maori</option>
122 </select>
123 </div>
124
125 {/* AI Rewrite Toggle */}
126 <div className="mb-6 flex items-center justify-between">
127 <div>
128 <p className="text-sm font-medium text-gray-700">AI Rewrite</p>
129 <p className="text-xs text-gray-400">Polish grammar + tone automatically</p>
130 </div>
131 <button
132 onClick={() => setAiRewrite(!aiRewrite)}
133 className={`w-10 h-6 rounded-full transition-colors ${aiRewrite ? 'bg-indigo-600' : 'bg-gray-200'}`}
134 >
135 <div className={`w-4 h-4 bg-white rounded-full shadow transform transition-transform mx-1 ${aiRewrite ? 'translate-x-4' : ''}`} />
136 </button>
137 </div>
138
139 {/* Save */}
140 <button
141 onClick={saveSettings}
142 className="w-full py-2.5 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-500 transition-colors mb-4"
143 >
144 {saved ? 'Saved!' : 'Save Settings'}
145 </button>
146
147 {/* Test */}
148 <button
149 onClick={testRecording}
150 className="w-full py-2.5 rounded-lg border border-gray-200 text-gray-600 text-sm font-medium hover:bg-gray-50 transition-colors mb-6"
151 >
152 Test Recording
153 </button>
154
155 {/* Status */}
156 <p className="text-xs text-gray-400 text-center">{status}</p>
157
158 {/* Shortcut info */}
159 <div className="mt-8 p-4 bg-gray-50 rounded-lg">
160 <p className="text-xs text-gray-500 font-medium mb-2">Keyboard Shortcut</p>
161 <p className="text-sm text-gray-700">
162 <kbd className="px-1.5 py-0.5 bg-white border border-gray-200 rounded text-xs">Ctrl+Shift+Space</kbd>
163 {' '}to toggle recording
164 </p>
165 <p className="text-xs text-gray-400 mt-2">Works in any app — browser, email, Slack, anywhere</p>
166 </div>
167 </div>
168 </div>
169 )
170}
Addedtauri-app/src/main.jsx+10−0View fileUnifiedSplit
1import React from 'react'
2import ReactDOM from 'react-dom/client'
3import App from './App'
4import './styles.css'
5
6ReactDOM.createRoot(document.getElementById('root')).render(
7 <React.StrictMode>
8 <App />
9 </React.StrictMode>
10)
Addedtauri-app/src/styles.css+8−0View fileUnifiedSplit
1@tailwind base;
2@tailwind components;
3@tailwind utilities;
4
5body {
6 margin: 0;
7 background: white;
8}
Addedtauri-app/tailwind.config.js+6−0View fileUnifiedSplit
1/** @type {import('tailwindcss').Config} */
2module.exports = {
3 content: ['./index.html', './src/**/*.{js,jsx}'],
4 theme: { extend: {} },
5 plugins: [],
6}
Addedtauri-app/vite.config.js+12−0View fileUnifiedSplit
1import { defineConfig } from 'vite'
2import react from '@vitejs/plugin-react'
3
4export default defineConfig({
5 plugins: [react()],
6 clearScreen: false,
7 server: {
8 port: 1420,
9 strictPort: true,
10 watch: { ignored: ['**/src-tauri/**'] },
11 },
12})
013
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts