From aaba534a6dfe2dfb3ce33a9aa2ce25ddc9c0cb4c Mon Sep 17 00:00:00 2001 From: Leafd Date: Tue, 7 Oct 2025 12:42:16 -0600 Subject: [PATCH] feat: home page redesign (#40) * feat: home page redesign * chore(ci): remove secretlint --- .github/workflows/lint.yaml | 4 +- package.json | 2 + pnpm-lock.yaml | 16 + src-tauri/Cargo.lock | 56 + src-tauri/Cargo.toml | 5 + src-tauri/capabilities/default.json | 5 + src-tauri/src/auth.rs | 557 +++++ src-tauri/src/config.rs | 39 + src-tauri/src/database.rs | 157 +- src-tauri/src/db_commands.rs | 86 + src-tauri/src/discord_rpc.rs | 120 +- src-tauri/src/lib.rs | 2879 +++--------------------- src-tauri/src/main.rs | 2 +- src-tauri/src/menu.rs | 87 + src-tauri/src/projects.rs | 106 + src-tauri/src/session.rs | 246 ++ src-tauri/src/setup.rs | 263 +++ src-tauri/src/statistics.rs | 983 ++++++++ src-tauri/src/tray.rs | 77 + src-tauri/src/window.rs | 44 + src-tauri/tauri.conf.json | 14 +- src/App.vue | 464 ++-- src/api.ts | 2 +- src/assets/bird-illustration.svg | 3 + src/assets/decorative-lines.svg | 3 + src/assets/suits-icons.svg | 3 + src/assets/vue.svg | 3 - src/components/ChartComponent.vue | 16 +- src/components/CustomTitlebar.vue | 319 +++ src/components/InsightCard.vue | 17 +- src/components/PresenceCard.vue | 8 +- src/components/RandomLoader.vue | 147 ++ src/components/StatisticsCard.vue | 18 +- src/components/StatisticsDashboard.vue | 44 +- src/components/UserProfileCard.vue | 220 ++ src/components/WakatimeSetupModal.vue | 446 ++++ src/components/WeeklyChart.vue | 86 +- src/composables/useTheme.ts | 36 - src/style.css | 56 +- src/views/Home.vue | 163 +- src/views/Projects.vue | 169 +- src/views/Settings.vue | 786 +++++-- src/views/Statistics.vue | 49 +- src/vite-env.d.ts | 2 +- tailwind.config.js | 33 +- vite.config.ts | 14 +- 46 files changed, 5663 insertions(+), 3192 deletions(-) create mode 100644 src-tauri/src/auth.rs create mode 100644 src-tauri/src/config.rs create mode 100644 src-tauri/src/db_commands.rs create mode 100644 src-tauri/src/menu.rs create mode 100644 src-tauri/src/projects.rs create mode 100644 src-tauri/src/session.rs create mode 100644 src-tauri/src/setup.rs create mode 100644 src-tauri/src/statistics.rs create mode 100644 src-tauri/src/tray.rs create mode 100644 src-tauri/src/window.rs create mode 100644 src/assets/bird-illustration.svg create mode 100644 src/assets/decorative-lines.svg create mode 100644 src/assets/suits-icons.svg delete mode 100644 src/assets/vue.svg create mode 100644 src/components/CustomTitlebar.vue create mode 100644 src/components/RandomLoader.vue create mode 100644 src/components/UserProfileCard.vue create mode 100644 src/components/WakatimeSetupModal.vue delete mode 100644 src/composables/useTheme.ts diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 4bf4c23..6eee2eb 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -46,9 +46,9 @@ jobs: DEFAULT_BRANCH: main VALIDATE_ALL_CODEBASE: true ENABLE: RUST,JAVASCRIPT,TYPESCRIPT,JSON,YAML,MARKDOWN,REPOSITORY - ENABLE_LINTERS: RUST_CLIPPY,RUST_RUSTFMT,VUE_ESLINT_PLUGIN_VUE,REPOSITORY_GIT_DIFF,REPOSITORY_SECRETLINT,REPOSITORY_TRIVY_SBOM,REPOSITORY_TRUFFLEHOG,YAML_PRETTIER,YAML_YAMLLINT + ENABLE_LINTERS: RUST_CLIPPY,RUST_RUSTFMT,VUE_ESLINT_PLUGIN_VUE,REPOSITORY_GIT_DIFF,REPOSITORY_TRIVY_SBOM,REPOSITORY_TRUFFLEHOG,YAML_PRETTIER,YAML_YAMLLINT DISABLE: COPYPASTE,SPELL - DISABLE_LINTERS: REPOSITORY_CHECKOV,REPOSITORY_GITLEAKS + DISABLE_LINTERS: REPOSITORY_CHECKOV,REPOSITORY_GITLEAKS,REPOSITORY_SECRETLINT SECURITY_LINTERS_ENABLED: true # Rust linter configuration diff --git a/package.json b/package.json index 2c9ea35..7672bbc 100644 --- a/package.json +++ b/package.json @@ -17,12 +17,14 @@ "@tauri-apps/plugin-process": "~2", "@tauri-apps/plugin-updater": "~2", "chart.js": "^4.5.0", + "crypto-js": "^4.2.0", "vue": "^3.5.13", "vue-chartjs": "^5.3.2" }, "devDependencies": { "@tailwindcss/vite": "^4.1.14", "@tauri-apps/cli": "^2", + "@types/crypto-js": "^4.2.2", "@vitejs/plugin-vue": "^5.2.1", "tailwindcss": "^4.1.14", "typescript": "~5.9.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d9469ff..8cb10ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: chart.js: specifier: ^4.5.0 version: 4.5.0 + crypto-js: + specifier: ^4.2.0 + version: 4.2.0 vue: specifier: ^3.5.13 version: 3.5.22(typescript@5.9.3) @@ -39,6 +42,9 @@ importers: '@tauri-apps/cli': specifier: ^2 version: 2.8.4 + '@types/crypto-js': + specifier: ^4.2.2 + version: 4.2.2 '@vitejs/plugin-vue': specifier: ^5.2.1 version: 5.2.4(vite@6.3.6(jiti@2.6.1)(lightningcss@1.30.1))(vue@3.5.22(typescript@5.9.3)) @@ -539,6 +545,9 @@ packages: '@tauri-apps/plugin-updater@2.9.0': resolution: {integrity: sha512-j++sgY8XpeDvzImTrzWA08OqqGqgkNyxczLD7FjNJJx/uXxMZFz5nDcfkyoI/rCjYuj2101Tci/r/HFmOmoxCg==} + '@types/crypto-js@4.2.2': + resolution: {integrity: sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -615,6 +624,9 @@ packages: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} + crypto-js@4.2.0: + resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} + csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} @@ -1183,6 +1195,8 @@ snapshots: dependencies: '@tauri-apps/api': 2.8.0 + '@types/crypto-js@4.2.2': {} + '@types/estree@1.0.8': {} '@vitejs/plugin-vue@5.2.4(vite@6.3.6(jiti@2.6.1)(lightningcss@1.30.1))(vue@3.5.22(typescript@5.9.3))': @@ -1288,6 +1302,8 @@ snapshots: chownr@3.0.0: {} + crypto-js@4.2.0: {} + csstype@3.1.3: {} de-indent@1.0.2: {} diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 362390a..a6bcd04 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -294,6 +294,12 @@ dependencies = [ "serde", ] +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + [[package]] name = "block-buffer" version = "0.10.4" @@ -512,6 +518,35 @@ dependencies = [ "windows-link 0.2.0", ] +[[package]] +name = "cocoa" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad36507aeb7e16159dfe68db81ccc27571c3ccd4b76fb2fb72fc59e7a4b1b64c" +dependencies = [ + "bitflags 2.9.4", + "block", + "cocoa-foundation", + "core-foundation 0.10.1", + "core-graphics", + "foreign-types 0.5.0", + "libc", + "objc", +] + +[[package]] +name = "cocoa-foundation" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81411967c50ee9a1fc11365f8c585f863a22a9697c89239c452292c40ba79b0d" +dependencies = [ + "bitflags 2.9.4", + "block", + "core-foundation 0.10.1", + "core-graphics-types", + "objc", +] + [[package]] name = "combine" version = "4.6.7" @@ -819,7 +854,10 @@ version = "0.1.0" dependencies = [ "base64 0.21.7", "chrono", + "cocoa", "discord-rich-presence", + "objc", + "once_cell", "open", "rand 0.9.2", "reqwest", @@ -2343,6 +2381,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + [[package]] name = "markup5ever" version = "0.14.1" @@ -2600,6 +2647,15 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + [[package]] name = "objc-sys" version = "0.3.5" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index bec1938..aa828e1 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -35,7 +35,12 @@ tauri-plugin-process = "2" sha2 = "0.10" base64 = "0.21" rand = "0.9" +once_cell = "1" [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-updater = "2" +[target."cfg(target_os = \"macos\")".dependencies] +cocoa = "0.26" +objc = "0.2" + diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index ce7b1cb..76a1ace 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -5,6 +5,11 @@ "windows": ["main"], "permissions": [ "core:default", + "core:window:default", + "core:window:allow-start-dragging", + "core:window:allow-close", + "core:window:allow-minimize", + "core:window:allow-toggle-maximize", "opener:default", "deep-link:default", "updater:default", diff --git a/src-tauri/src/auth.rs b/src-tauri/src/auth.rs new file mode 100644 index 0000000..4d74640 --- /dev/null +++ b/src-tauri/src/auth.rs @@ -0,0 +1,557 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use tauri::State; +use std::sync::Arc; +use sha2::{Sha256, Digest}; +use base64::{Engine as _, engine::general_purpose}; +use rand::Rng; + +use crate::database::{AuthState as DbAuthState, Database}; +use crate::push_log; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct AuthState { + pub is_authenticated: bool, + pub access_token: Option, + pub user_info: Option>, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct PkceState { + pub code_verifier: String, + pub state: String, + pub timestamp: i64, +} + +impl PkceState { + pub fn new() -> Self { + let code_verifier = generate_code_verifier(); + let state = generate_state(); + Self { + code_verifier, + state, + timestamp: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64, + } + } + + pub fn is_expired(&self, max_age_seconds: i64) -> bool { + let current_time = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + current_time - self.timestamp > max_age_seconds + } +} + +fn generate_code_verifier() -> String { + let mut rng = rand::rng(); + let bytes: Vec = (0..32).map(|_| rng.random()).collect(); + general_purpose::URL_SAFE_NO_PAD.encode(&bytes) +} + +fn generate_code_challenge(verifier: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(verifier.as_bytes()); + let hash = hasher.finalize(); + general_purpose::URL_SAFE_NO_PAD.encode(&hash) +} + +fn generate_state() -> String { + let mut rng = rand::rng(); + (0..32) + .map(|_| { + const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + let idx = (rng.random::() as usize) % CHARSET.len(); + CHARSET[idx] as char + }) + .collect() +} + +#[tauri::command] +pub async fn get_auth_state( + state: State<'_, Arc>>, +) -> Result { + let auth_state = state.lock().await; + Ok(auth_state.clone()) +} + +#[tauri::command] +pub async fn authenticate_with_rails( + api_config: crate::config::ApiConfig, + pkce_state: State<'_, Arc>>>, + _app_handle: tauri::AppHandle, +) -> Result<(), String> { + + let callback_url = "hackatime://auth/callback"; + + let pkce = PkceState::new(); + let code_challenge = generate_code_challenge(&pkce.code_verifier); + + { + let mut stored_pkce = pkce_state.lock().await; + *stored_pkce = Some(pkce.clone()); + } + + push_log("debug", "backend", format!("Generated PKCE parameters - verifier: {}, challenge: {}, state: {}", + pkce.code_verifier, code_challenge, pkce.state)); + + let auth_url = format!( + "{}/oauth/authorize?client_id=BPr5VekIV-xuQ2ZhmxbGaahJ3XVd7gM83pql-HYGYxQ&redirect_uri={}&response_type=code&scope=profile&state={}&code_challenge={}&code_challenge_method=S256", + api_config.base_url, + urlencoding::encode(callback_url), + urlencoding::encode(&pkce.state), + urlencoding::encode(&code_challenge) + ); + + if let Err(e) = open::that(&auth_url) { + return Err(format!("Failed to open authentication URL: {}", e)); + } + + push_log("info", "backend", "OAuth authentication URL opened in browser. Waiting for callback...".to_string()); + Ok(()) +} + +#[tauri::command] +pub async fn handle_auth_callback( + token: String, + state: State<'_, Arc>>, +) -> Result<(), String> { + let mut auth_state = state.lock().await; + auth_state.is_authenticated = true; + auth_state.access_token = Some(token); + auth_state.user_info = Some(HashMap::new()); + + Ok(()) +} + +#[tauri::command] +pub async fn logout( + state: State<'_, Arc>>, +) -> Result<(), String> { + let mut auth_state = state.lock().await; + auth_state.is_authenticated = false; + auth_state.access_token = None; + auth_state.user_info = None; + + if let Err(e) = clear_auth_state().await { + push_log("error", "backend", format!("Failed to clear auth state: {}", e)); + } + + + push_log("info", "backend", "Clearing statistics cache on logout...".to_string()); + if let Ok(db) = Database::new().await { + if let Err(e) = db.clear_all_cache().await { + push_log("error", "backend", format!("Failed to clear statistics cache on logout: {}", e)); + } else { + push_log("info", "backend", "Statistics cache cleared on logout".to_string()); + } + } + + Ok(()) +} + +#[tauri::command] +pub async fn test_auth_callback( + token: String, + state: State<'_, Arc>>, +) -> Result<(), String> { + let mut auth_state = state.lock().await; + auth_state.is_authenticated = true; + auth_state.access_token = Some(token); + auth_state.user_info = Some(HashMap::new()); + + Ok(()) +} + +#[tauri::command] +pub async fn get_api_key( + api_config: crate::config::ApiConfig, + state: State<'_, Arc>>, +) -> Result { + let auth_state = state.lock().await; + + if !auth_state.is_authenticated { + return Err("Not authenticated".to_string()); + } + + let base_url = if api_config.base_url.is_empty() { + "https://hackatime.hackclub.com" + } else { + &api_config.base_url + }; + + let access_token = auth_state + .access_token + .as_ref() + .ok_or("No access token available")?; + + let client = reqwest::Client::new(); + let response = client + .get(&format!("{}/api/v1/authenticated/api_keys", base_url)) + .bearer_auth(access_token) + .send() + .await + .map_err(|e| format!("Failed to fetch API key: {}", e))?; + + if !response.status().is_success() { + let error_text = response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + return Err(format!("API key request failed: {}", error_text)); + } + + let api_key_response: serde_json::Value = response + .json() + .await + .map_err(|e| format!("Failed to parse API key response: {}", e))?; + + let api_key = api_key_response["token"] + .as_str() + .ok_or("No token in response")?; + + Ok(api_key.to_string()) +} + +#[tauri::command] +pub async fn authenticate_with_direct_oauth( + oauth_token: String, + api_config: crate::config::ApiConfig, + state: State<'_, Arc>>, +) -> Result<(), String> { + let client = reqwest::Client::new(); + + if oauth_token.starts_with("hackatime://auth/callback") { + if let Some(query_start) = oauth_token.find('?') { + let query = &oauth_token[query_start + 1..]; + let params: Vec<&str> = query.split('&').collect(); + + let mut found_code = None; + let mut found_state = None; + let mut found_error = None; + + for param in params { + if param.starts_with("code=") { + found_code = Some(param[5..].to_string()); + } else if param.starts_with("state=") { + found_state = Some(param[6..].to_string()); + } else if param.starts_with("error=") { + found_error = Some(param[6..].to_string()); + } + } + + if let Some(error) = found_error { + return Err(format!("OAuth error: {}", error)); + } + + if let Some(code) = found_code { + push_log("debug", "backend", format!("Extracted authorization code from deep link: {}", code)); + + return exchange_authorization_code(code, found_state, api_config, state, client).await; + } else { + return Err("No authorization code found in deep link URL".to_string()); + } + } else { + return Err("Invalid deep link URL format".to_string()); + } + } else { + return validate_access_token(oauth_token, api_config, state, client).await; + } +} + +async fn exchange_authorization_code( + code: String, + _state: Option, + api_config: crate::config::ApiConfig, + auth_state: State<'_, Arc>>, + client: reqwest::Client, +) -> Result<(), String> { + push_log("info", "backend", "Exchanging authorization code for access token".to_string()); + + let response = client + .post(&format!( + "{}/oauth/token", + api_config.base_url + )) + .form(&[ + ("grant_type", "authorization_code"), + ("code", &code), + ("client_id", "BPr5VekIV-xuQ2ZhmxbGaahJ3XVd7gM83pql-HYGYxQ"), + ("redirect_uri", "hackatime://auth/callback"), + ]) + .send() + .await + .map_err(|e| format!("Failed to exchange authorization code: {}", e))?; + + push_log("debug", "backend", format!("Token exchange response status: {}", response.status())); + + if !response.status().is_success() { + let error_text = response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + push_log("error", "backend", format!("Token exchange failed with error: {}", error_text)); + return Err(format!("Token exchange failed: {}", error_text)); + } + + let token_response: serde_json::Value = response + .json() + .await + .map_err(|e| format!("Failed to parse token response: {}", e))?; + + let access_token = token_response["access_token"] + .as_str() + .ok_or("No access token in response")?; + + let user_response = client + .get(&format!("{}/api/v1/authenticated/me", api_config.base_url)) + .bearer_auth(access_token) + .send() + .await + .map_err(|e| format!("Failed to fetch user info: {}", e))?; + + let user_info = if user_response.status().is_success() { + user_response.json::() + .await + .unwrap_or_else(|_| serde_json::json!({})) + } else { + serde_json::json!({}) + }; + + let mut user_info_map = HashMap::new(); + if let Some(obj) = user_info.as_object() { + for (key, value) in obj { + user_info_map.insert(key.clone(), value.clone()); + } + } + + let mut auth_state = auth_state.lock().await; + auth_state.is_authenticated = true; + auth_state.access_token = Some(access_token.to_string()); + auth_state.user_info = Some(user_info_map); + + let auth_state_to_save = auth_state.clone(); + drop(auth_state); + if let Err(e) = save_auth_state(auth_state_to_save).await { + push_log("error", "backend", format!("Failed to save auth state: {}", e)); + } + + push_log("info", "backend", "Direct OAuth authentication completed successfully!".to_string()); + Ok(()) +} + +async fn validate_access_token( + access_token: String, + api_config: crate::config::ApiConfig, + auth_state: State<'_, Arc>>, + client: reqwest::Client, +) -> Result<(), String> { + push_log("info", "backend", "Validating access token directly".to_string()); + + let response = client + .get(&format!("{}/api/v1/authenticated/me", api_config.base_url)) + .bearer_auth(&access_token) + .send() + .await + .map_err(|e| format!("Failed to validate access token: {}", e))?; + + if !response.status().is_success() { + let error_text = response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + return Err(format!("Access token validation failed: {}", error_text)); + } + + let user_info = response + .json::() + .await + .map_err(|e| format!("Failed to parse user info response: {}", e))?; + + let mut user_info_map = HashMap::new(); + if let Some(obj) = user_info.as_object() { + for (key, value) in obj { + user_info_map.insert(key.clone(), value.clone()); + } + } + + let mut auth_state = auth_state.lock().await; + auth_state.is_authenticated = true; + auth_state.access_token = Some(access_token); + auth_state.user_info = Some(user_info_map); + + let auth_state_to_save = auth_state.clone(); + drop(auth_state); + if let Err(e) = save_auth_state(auth_state_to_save).await { + push_log("error", "backend", format!("Failed to save auth state: {}", e)); + } + + push_log("info", "backend", "Access token validation completed successfully!".to_string()); + Ok(()) +} + +#[tauri::command] +pub async fn handle_deep_link_callback( + authorization_code: String, + state: String, + api_config: crate::config::ApiConfig, + auth_state: State<'_, Arc>>, + pkce_state: State<'_, Arc>>>, +) -> Result<(), String> { + let stored_pkce = { + let pkce_guard = pkce_state.lock().await; + pkce_guard.clone() + }; + + let pkce = match stored_pkce { + Some(pkce) => { + if pkce.is_expired(600) { + return Err("PKCE state expired. Please restart authentication.".to_string()); + } + + if pkce.state != state { + return Err("State parameter mismatch. Possible CSRF attack.".to_string()); + } + + pkce + } + None => return Err("No PKCE state found. Please restart authentication.".to_string()), + }; + + let client = reqwest::Client::new(); + let response = client + .post(&format!( + "{}/oauth/token", + api_config.base_url + )) + .form(&[ + ("grant_type", "authorization_code"), + ("code", &authorization_code), + ("client_id", "BPr5VekIV-xuQ2ZhmxbGaahJ3XVd7gM83pql-HYGYxQ"), + ("redirect_uri", "hackatime://auth/callback"), + ("code_verifier", &pkce.code_verifier), + ]) + .send() + .await + .map_err(|e| format!("Failed to exchange token: {}", e))?; + + if !response.status().is_success() { + let error_text = response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + return Err(format!("Token exchange failed: {}", error_text)); + } + + let token_response: serde_json::Value = response + .json() + .await + .map_err(|e| format!("Failed to parse token response: {}", e))?; + + let access_token = token_response["access_token"] + .as_str() + .ok_or("No access token in response")?; + + let user_response = client + .get(&format!("{}/api/v1/authenticated/me", api_config.base_url)) + .bearer_auth(access_token) + .send() + .await + .map_err(|e| format!("Failed to fetch user info: {}", e))?; + + let user_info = if user_response.status().is_success() { + user_response.json::() + .await + .unwrap_or_else(|_| serde_json::json!({})) + } else { + serde_json::json!({}) + }; + + let mut user_info_map = HashMap::new(); + if let Some(obj) = user_info.as_object() { + for (key, value) in obj { + user_info_map.insert(key.clone(), value.clone()); + } + } + + let mut auth_state = auth_state.lock().await; + auth_state.is_authenticated = true; + auth_state.access_token = Some(access_token.to_string()); + auth_state.user_info = Some(user_info_map); + + let auth_state_to_save = auth_state.clone(); + drop(auth_state); + if let Err(e) = save_auth_state(auth_state_to_save).await { + push_log("error", "backend", format!("Failed to save auth state: {}", e)); + } + + { + let mut stored_pkce = pkce_state.lock().await; + *stored_pkce = None; + } + + push_log("info", "backend", "OAuth authentication completed successfully!".to_string()); + Ok(()) +} + +#[tauri::command] +pub async fn save_auth_state(auth_state: AuthState) -> Result<(), String> { + push_log("debug", "backend", format!( + "save_auth_state called: authenticated={}, has_token={}", + auth_state.is_authenticated, + auth_state.access_token.is_some() + )); + let db = Database::new().await?; + push_log("debug", "backend", "Database connection successful for save".to_string()); + + + let db_auth_state = DbAuthState { + is_authenticated: auth_state.is_authenticated, + access_token: auth_state.access_token, + user_info: auth_state.user_info, + }; + + + let session_id = db.save_session(&db_auth_state).await?; + push_log("info", "backend", format!("Session saved with ID: {}", session_id)); + + Ok(()) +} + +#[tauri::command] +pub async fn load_auth_state() -> Result, String> { + push_log("debug", "backend", "load_auth_state called".to_string()); + let db = Database::new().await?; + push_log("debug", "backend", "Database connection successful".to_string()); + + match db.load_latest_session().await? { + Some(db_auth_state) => { + push_log("debug", "backend", format!( + "Found saved session: authenticated={}, has_token={}", + db_auth_state.is_authenticated, + db_auth_state.access_token.is_some() + )); + let auth_state = AuthState { + is_authenticated: db_auth_state.is_authenticated, + access_token: db_auth_state.access_token, + user_info: db_auth_state.user_info, + }; + Ok(Some(auth_state)) + } + None => { + push_log("debug", "backend", "No saved sessions found".to_string()); + Ok(None) + } + } +} + +#[tauri::command] +pub async fn clear_auth_state() -> Result<(), String> { + let db = Database::new().await?; + db.clear_sessions().await?; + Ok(()) +} + diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs new file mode 100644 index 0000000..6698be8 --- /dev/null +++ b/src-tauri/src/config.rs @@ -0,0 +1,39 @@ +use serde::{Deserialize, Serialize}; +use tauri::State; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ApiConfig { + pub base_url: String, +} + +impl Default for ApiConfig { + fn default() -> Self { + Self { + base_url: "https://hackatime.hackclub.com".to_string(), + } + } +} + +impl ApiConfig { + pub fn new() -> Self { + Self { + base_url: "https://hackatime.hackclub.com".to_string(), + } + } +} + +#[tauri::command] +pub async fn get_api_config(state: State<'_, ApiConfig>) -> Result { + Ok(state.inner().clone()) +} + +#[tauri::command] +pub async fn set_api_config( + new_config: ApiConfig, + state: State<'_, tauri::async_runtime::Mutex>, +) -> Result<(), String> { + let mut config = state.lock().await; + *config = new_config; + Ok(()) +} + diff --git a/src-tauri/src/database.rs b/src-tauri/src/database.rs index 168c4d7..1eec288 100644 --- a/src-tauri/src/database.rs +++ b/src-tauri/src/database.rs @@ -6,6 +6,7 @@ use std::env; use std::fs; use std::path::Path; use uuid::Uuid; +use crate::push_log; #[derive(Debug, Serialize, Deserialize, Clone)] pub struct AuthState { @@ -20,7 +21,7 @@ pub struct SessionRecord { pub id: String, pub is_authenticated: bool, pub access_token: Option, - pub user_info: Option, // JSON string + pub user_info: Option, pub created_at: DateTime, pub updated_at: DateTime, pub last_accessed_at: DateTime, @@ -34,16 +35,16 @@ impl Database { pub async fn new() -> Result { let db_path = get_hackatime_db_path()?; - // Ensure the hackatime directory exists + if let Some(parent) = db_path.parent() { if !parent.exists() { fs::create_dir_all(parent) .map_err(|e| format!("Failed to create hackatime directory: {}", e))?; - println!("Created directory: {}", parent.display()); + push_log("info", "backend", format!("Created directory: {}", parent.display())); } } - // Ensure the parent directory exists and is writable + if let Some(parent) = db_path.parent() { if !parent.exists() { return Err(format!( @@ -52,7 +53,7 @@ impl Database { )); } - // Test if we can write to the directory + let test_file = parent.join(".write_test"); if let Err(e) = fs::write(&test_file, "test") { return Err(format!( @@ -61,19 +62,19 @@ impl Database { e )); } - // Clean up test file + let _ = fs::remove_file(&test_file); - println!("Directory is writable: {}", parent.display()); + push_log("debug", "backend", format!("Directory is writable: {}", parent.display())); } - // Create the database file if it doesn't exist + if !db_path.exists() { - println!( + push_log("info", "backend", format!( "Database file doesn't exist, creating: {}", db_path.display() - ); - // Touch the file to ensure it exists + )); + if let Err(e) = fs::write(&db_path, "") { return Err(format!( "Cannot create database file {}: {}", @@ -82,26 +83,26 @@ impl Database { )); } } else { - println!("Database file already exists: {}", db_path.display()); + push_log("debug", "backend", format!("Database file already exists: {}", db_path.display())); } - // Check file permissions + if let Ok(metadata) = fs::metadata(&db_path) { - println!("Database file metadata: {:?}", metadata); + push_log("debug", "backend", format!("Database file metadata: {:?}", metadata)); } - // Try using SqlitePool::connect_with instead of connect + let database_url = format!("sqlite:{}", db_path.display()); - println!("Connecting to database at: {}", database_url); + push_log("info", "backend", format!("Connecting to database at: {}", database_url)); - // First try the standard connect method + let pool_result = SqlitePool::connect(&database_url).await; let pool = match pool_result { Ok(pool) => pool, Err(e) => { - println!("Standard connect failed: {}, trying connect_with", e); - // Try with explicit options + push_log("warn", "backend", format!("Standard connect failed: {}, trying connect_with", e)); + let options = sqlx::sqlite::SqliteConnectOptions::new() .filename(&db_path) .create_if_missing(true); @@ -119,7 +120,7 @@ impl Database { let db = Database { pool }; db.migrate().await?; - println!("Database initialized successfully"); + push_log("info", "backend", "Database initialized successfully".to_string()); Ok(db) } @@ -141,6 +142,20 @@ impl Database { .await .map_err(|e| format!("Failed to create sessions table: {}", e))?; + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS statistics_cache ( + cache_key TEXT PRIMARY KEY, + data TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL + ) + "#, + ) + .execute(&self.pool) + .await + .map_err(|e| format!("Failed to create statistics_cache table: {}", e))?; + Ok(()) } @@ -236,13 +251,13 @@ impl Database { Some(json) => { match serde_json::from_str::>(&json) { Ok(info) => Some(info), - Err(_) => None, // Skip invalid JSON + Err(_) => None, } } None => None, }; - // Update last_accessed_at + self.update_last_accessed(&session_id).await?; Ok(Some(AuthState { @@ -288,35 +303,114 @@ impl Database { Ok(()) } + + pub async fn get_cached_data(&self, cache_key: &str) -> Result, String> { + let now = Utc::now(); + + let row = sqlx::query( + r#" + SELECT data, expires_at + FROM statistics_cache + WHERE cache_key = ? + "#, + ) + .bind(cache_key) + .fetch_optional(&self.pool) + .await + .map_err(|e| format!("Failed to fetch cached data: {}", e))?; + + match row { + Some(row) => { + let expires_at: String = row.get("expires_at"); + let expires_at_dt = DateTime::parse_from_rfc3339(&expires_at) + .map_err(|e| format!("Failed to parse expiration date: {}", e))?; + + if expires_at_dt > now { + let data: String = row.get("data"); + Ok(Some(data)) + } else { + sqlx::query("DELETE FROM statistics_cache WHERE cache_key = ?") + .bind(cache_key) + .execute(&self.pool) + .await + .ok(); + Ok(None) + } + } + None => Ok(None), + } + } + + pub async fn set_cached_data(&self, cache_key: &str, data: &str, ttl_days: i64) -> Result<(), String> { + let now = Utc::now(); + let expires_at = now + chrono::Duration::days(ttl_days); + + sqlx::query( + r#" + INSERT OR REPLACE INTO statistics_cache (cache_key, data, created_at, expires_at) + VALUES (?, ?, ?, ?) + "#, + ) + .bind(cache_key) + .bind(data) + .bind(now.to_rfc3339()) + .bind(expires_at.to_rfc3339()) + .execute(&self.pool) + .await + .map_err(|e| format!("Failed to cache data: {}", e))?; + + Ok(()) + } + + pub async fn clear_all_cache(&self) -> Result<(), String> { + sqlx::query("DELETE FROM statistics_cache") + .execute(&self.pool) + .await + .map_err(|e| format!("Failed to clear cache: {}", e))?; + + Ok(()) + } + + pub async fn cleanup_expired_cache(&self) -> Result<(), String> { + let now = Utc::now(); + + sqlx::query("DELETE FROM statistics_cache WHERE expires_at < ?") + .bind(now.to_rfc3339()) + .execute(&self.pool) + .await + .map_err(|e| format!("Failed to cleanup expired cache: {}", e))?; + + Ok(()) + } } fn get_hackatime_db_path() -> Result { let app_data_dir = get_app_data_dir()?; let db_path = app_data_dir.join("sessions.db"); - println!("Database path: {}", db_path.display()); - println!( + push_log("debug", "backend", format!("Database path: {}", db_path.display())); + push_log("debug", "backend", format!( "Parent directory exists: {}", db_path.parent().map_or(false, |p| p.exists()) - ); + )); Ok(db_path) } fn get_app_data_dir() -> Result { if cfg!(target_os = "windows") { - // Windows: %APPDATA%\.hackatime\ + let appdata = env::var("APPDATA").map_err(|_| "Failed to get APPDATA directory")?; Ok(Path::new(&appdata).join(".hackatime")) } else if cfg!(target_os = "macos") { - // macOS: ~/Library/Application Support/.hackatime/ + let home = env::var("HOME").map_err(|_| "Failed to get HOME directory")?; Ok(Path::new(&home) .join("Library") .join("Application Support") .join(".hackatime")) } else { - // Linux: ~/.local/share/.hackatime/ + let home = env::var("HOME").map_err(|_| "Failed to get HOME directory")?; Ok(Path::new(&home) .join(".local") @@ -328,7 +422,7 @@ fn get_app_data_dir() -> Result { pub fn get_hackatime_config_dir() -> Result { let app_data_dir = get_app_data_dir()?; - // Create the directory if it doesn't exist + if !app_data_dir.exists() { fs::create_dir_all(&app_data_dir) .map_err(|e| format!("Failed to create hackatime directory: {}", e))?; @@ -341,7 +435,7 @@ pub fn get_hackatime_logs_dir() -> Result { let config_dir = get_hackatime_config_dir()?; let logs_dir = config_dir.join("logs"); - // Create the logs directory if it doesn't exist + if !logs_dir.exists() { fs::create_dir_all(&logs_dir) .map_err(|e| format!("Failed to create logs directory: {}", e))?; @@ -354,7 +448,7 @@ pub fn get_hackatime_data_dir() -> Result { let config_dir = get_hackatime_config_dir()?; let data_dir = config_dir.join("data"); - // Create the data directory if it doesn't exist + if !data_dir.exists() { fs::create_dir_all(&data_dir) .map_err(|e| format!("Failed to create data directory: {}", e))?; @@ -363,6 +457,7 @@ pub fn get_hackatime_data_dir() -> Result { Ok(data_dir) } +#[tauri::command] pub fn get_platform_info() -> Result { let app_data_dir = get_app_data_dir()?; diff --git a/src-tauri/src/db_commands.rs b/src-tauri/src/db_commands.rs new file mode 100644 index 0000000..0d5ecd3 --- /dev/null +++ b/src-tauri/src/db_commands.rs @@ -0,0 +1,86 @@ +use crate::database::{get_hackatime_config_dir, get_hackatime_data_dir, get_hackatime_logs_dir, get_platform_info, Database}; +use crate::push_log; + +#[tauri::command] +pub async fn get_hackatime_directories() -> Result { + let config_dir = get_hackatime_config_dir()?; + let logs_dir = get_hackatime_logs_dir()?; + let data_dir = get_hackatime_data_dir()?; + + Ok(serde_json::json!({ + "config_dir": config_dir.to_string_lossy(), + "logs_dir": logs_dir.to_string_lossy(), + "data_dir": data_dir.to_string_lossy() + })) +} + +#[tauri::command] +pub async fn cleanup_old_sessions(days_old: i64) -> Result<(), String> { + let db = Database::new().await?; + db.cleanup_old_sessions(days_old).await?; + Ok(()) +} + +#[tauri::command] +pub async fn clear_statistics_cache() -> Result<(), String> { + push_log("info", "backend", "Clearing statistics cache...".to_string()); + let db = Database::new().await?; + + + db.cleanup_expired_cache().await?; + + + db.clear_all_cache().await?; + + push_log("info", "backend", "Statistics cache cleared successfully".to_string()); + Ok(()) +} + +#[tauri::command] +pub async fn get_session_stats() -> Result { + let platform_info = get_platform_info()?; + + Ok(serde_json::json!({ + "platform_info": platform_info, + "database_path": get_hackatime_config_dir()?.join("sessions.db").to_string_lossy(), + "directories_created": { + "config": get_hackatime_config_dir()?.exists(), + "logs": get_hackatime_logs_dir()?.exists(), + "data": get_hackatime_data_dir()?.exists() + } + })) +} + +#[tauri::command] +pub async fn test_database_connection() -> Result { + + let config_dir = get_hackatime_config_dir()?; + let logs_dir = get_hackatime_logs_dir()?; + let data_dir = get_hackatime_data_dir()?; + + + let db_result = Database::new().await; + let db_success = db_result.is_ok(); + let db_error = if let Err(e) = db_result { + Some(e) + } else { + None + }; + + Ok(serde_json::json!({ + "directories": { + "config_exists": config_dir.exists(), + "logs_exists": logs_dir.exists(), + "data_exists": data_dir.exists(), + "config_path": config_dir.to_string_lossy(), + "logs_path": logs_dir.to_string_lossy(), + "data_path": data_dir.to_string_lossy() + }, + "database": { + "connection_success": db_success, + "error": db_error, + "db_path": config_dir.join("sessions.db").to_string_lossy() + } + })) +} + diff --git a/src-tauri/src/discord_rpc.rs b/src-tauri/src/discord_rpc.rs index 96e60d9..8807836 100644 --- a/src-tauri/src/discord_rpc.rs +++ b/src-tauri/src/discord_rpc.rs @@ -3,6 +3,8 @@ use serde::{Deserialize, Serialize}; use std::sync::Arc; use std::sync::Mutex; +use crate::session::HeartbeatData; + #[derive(Debug, Serialize, Deserialize, Clone)] pub struct DiscordRpcState { pub is_connected: bool, @@ -37,7 +39,7 @@ impl DiscordRpcService { } pub fn connect(&mut self, client_id: &str) -> Result<(), String> { - // Close existing connection if any + if self.client.is_some() { let _ = self.disconnect(); } @@ -50,7 +52,7 @@ impl DiscordRpcService { self.client = Some(client); - // Update state + let mut state = self.state.lock().unwrap(); state.is_connected = true; state.client_id = Some(client_id.to_string()); @@ -65,7 +67,7 @@ impl DiscordRpcService { .map_err(|e| format!("Failed to disconnect from Discord: {}", e))?; } - // Update state + let mut state = self.state.lock().unwrap(); state.is_connected = false; state.client_id = None; @@ -77,7 +79,7 @@ impl DiscordRpcService { pub fn set_activity(&mut self, activity: DiscordActivity) -> Result<(), String> { self.set_activity_internal(activity.clone())?; - // Update state + let mut state = self.state.lock().unwrap(); state.current_activity = Some(activity); @@ -87,7 +89,7 @@ impl DiscordRpcService { fn set_activity_internal(&mut self, activity: DiscordActivity) -> Result<(), String> { let client = self.client.as_mut().ok_or("Discord client not connected")?; - // Build details string + let mut details_parts = Vec::new(); if let Some(language) = &activity.language { @@ -109,20 +111,20 @@ impl DiscordRpcService { None }; - // Create activity with all components + let mut discord_activity = activity::Activity::new().state(&activity.project_name); if let Some(details) = &details_string { discord_activity = discord_activity.details(details); } - // Set start time if provided + if let Some(start_time) = activity.start_time { discord_activity = discord_activity.timestamps(activity::Timestamps::new().start(start_time)); } - // Add assets + discord_activity = discord_activity.assets( activity::Assets::new() .large_image("kubetime") @@ -145,7 +147,7 @@ impl DiscordRpcService { .clear_activity() .map_err(|e| format!("Failed to clear Discord activity: {}", e))?; - // Update state + let mut state = self.state.lock().unwrap(); state.current_activity = None; @@ -162,7 +164,7 @@ impl DiscordRpcService { pub fn update_activity_from_heartbeat( &mut self, - heartbeat_data: &crate::HeartbeatData, + heartbeat_data: &HeartbeatData, ) -> Result<(), String> { let activity = DiscordActivity { project_name: heartbeat_data @@ -180,7 +182,7 @@ impl DiscordRpcService { pub fn update_activity_from_session( &mut self, - heartbeat_data: &crate::HeartbeatData, + heartbeat_data: &HeartbeatData, session_start_time: i64, ) -> Result<(), String> { let activity = DiscordActivity { @@ -208,3 +210,99 @@ impl Drop for DiscordRpcService { let _ = self.disconnect(); } } + + + +use tauri::State; + +#[tauri::command] +pub async fn discord_rpc_connect( + client_id: String, + state: State<'_, Arc>>, +) -> Result<(), String> { + let mut rpc_service = state.lock().await; + rpc_service.connect(&client_id) +} + +#[tauri::command] +pub async fn discord_rpc_disconnect( + state: State<'_, Arc>>, +) -> Result<(), String> { + let mut rpc_service = state.lock().await; + rpc_service.disconnect() +} + +#[tauri::command] +pub async fn discord_rpc_set_activity( + activity: DiscordActivity, + state: State<'_, Arc>>, +) -> Result<(), String> { + let mut rpc_service = state.lock().await; + rpc_service.set_activity(activity) +} + +#[tauri::command] +pub async fn discord_rpc_clear_activity( + state: State<'_, Arc>>, +) -> Result<(), String> { + let mut rpc_service = state.lock().await; + rpc_service.clear_activity() +} + +#[tauri::command] +pub async fn discord_rpc_get_state( + state: State<'_, Arc>>, +) -> Result { + let rpc_service = state.lock().await; + Ok(rpc_service.get_state()) +} + +#[tauri::command] +pub async fn discord_rpc_update_from_heartbeat( + heartbeat_data: HeartbeatData, + state: State<'_, Arc>>, +) -> Result<(), String> { + let mut rpc_service = state.lock().await; + rpc_service.update_activity_from_heartbeat(&heartbeat_data) +} + +#[tauri::command] +pub async fn discord_rpc_auto_connect( + client_id: String, + discord_rpc_state: State<'_, Arc>>, +) -> Result<(), String> { + let mut rpc_service = discord_rpc_state.lock().await; + rpc_service.connect(&client_id) +} + +#[tauri::command] +pub async fn discord_rpc_auto_disconnect( + discord_rpc_state: State<'_, Arc>>, +) -> Result<(), String> { + let mut rpc_service = discord_rpc_state.lock().await; + rpc_service.disconnect() +} + +#[tauri::command] +pub async fn get_discord_rpc_enabled( + discord_rpc_state: State<'_, Arc>>, +) -> Result { + let rpc_service = discord_rpc_state.lock().await; + Ok(rpc_service.is_connected()) +} + +#[tauri::command] +pub async fn set_discord_rpc_enabled( + enabled: bool, + discord_rpc_state: State<'_, Arc>>, +) -> Result<(), String> { + let mut rpc_service = discord_rpc_state.lock().await; + + if enabled { + + let default_client_id = "1234567890123456789"; + rpc_service.connect(default_client_id) + } else { + rpc_service.disconnect() + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2dbf27d..9119c64 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,2118 +1,61 @@ -use serde::{Deserialize, Serialize}; use std::collections::HashMap; -use std::env; -use std::fs; -use std::path::Path; use std::sync::Arc; -use tauri::menu::{Menu, MenuItem}; -use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; -use tauri::{Manager, State, WindowEvent}; +use tauri::{Manager, WindowEvent, TitleBarStyle}; +use once_cell::sync::Lazy; +use std::sync::Mutex; use tauri_plugin_deep_link::DeepLinkExt; -use sha2::{Sha256, Digest}; -use base64::{Engine as _, engine::general_purpose}; -use rand::Rng; -use chrono::Datelike; + +mod auth; +mod config; mod database; +mod db_commands; mod discord_rpc; -use database::{ - get_hackatime_config_dir, get_hackatime_data_dir, get_hackatime_logs_dir, get_platform_info, - AuthState as DbAuthState, Database, -}; -use discord_rpc::{DiscordActivity, DiscordRpcService, DiscordRpcState}; +mod projects; +mod session; +mod setup; +mod statistics; +mod tray; +mod menu; +mod window; -#[derive(Debug, Serialize, Deserialize, Clone)] -struct AuthState { - is_authenticated: bool, - access_token: Option, - user_info: Option>, -} -#[derive(Debug, Serialize, Deserialize, Clone)] -struct ApiConfig { - base_url: String, -} - -impl Default for ApiConfig { - fn default() -> Self { - Self { - base_url: "https://hackatime.hackclub.com".to_string(), - } - } -} - -impl ApiConfig { - fn new() -> Self { - Self { - base_url: "https://hackatime.hackclub.com".to_string(), - } - } -} - -fn generate_code_verifier() -> String { - let mut rng = rand::rng(); - let bytes: Vec = (0..32).map(|_| rng.random()).collect(); - general_purpose::URL_SAFE_NO_PAD.encode(&bytes) -} - -fn generate_code_challenge(verifier: &str) -> String { - let mut hasher = Sha256::new(); - hasher.update(verifier.as_bytes()); - let hash = hasher.finalize(); - general_purpose::URL_SAFE_NO_PAD.encode(&hash) -} - -fn generate_state() -> String { - let mut rng = rand::rng(); - (0..32) - .map(|_| { - const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; - let idx = (rng.random::() as usize) % CHARSET.len(); - CHARSET[idx] as char - }) - .collect() -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -struct PkceState { - code_verifier: String, - state: String, - timestamp: i64, -} - -impl PkceState { - fn new() -> Self { - let code_verifier = generate_code_verifier(); - let state = generate_state(); - Self { - code_verifier, - state, - timestamp: std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs() as i64, - } - } - - fn is_expired(&self, max_age_seconds: i64) -> bool { - let current_time = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs() as i64; - current_time - self.timestamp > max_age_seconds - } -} +pub use auth::{AuthState, PkceState}; +pub use config::ApiConfig; +pub use discord_rpc::{DiscordRpcService}; +pub use session::SessionState; #[tauri::command] fn greet(name: &str) -> String { format!("Hello, {}! You've been greeted from Rust!", name) } -#[tauri::command] -async fn get_api_config(state: State<'_, ApiConfig>) -> Result { - Ok(state.inner().clone()) -} - -#[tauri::command] -async fn set_api_config( - new_config: ApiConfig, - state: State<'_, tauri::async_runtime::Mutex>, -) -> Result<(), String> { - let mut config = state.lock().await; - *config = new_config; - Ok(()) -} - -#[tauri::command] -async fn get_auth_state( - state: State<'_, Arc>>, -) -> Result { - let auth_state = state.lock().await; - Ok(auth_state.clone()) -} - -#[tauri::command] -async fn authenticate_with_rails( - api_config: ApiConfig, - pkce_state: State<'_, Arc>>>, - _app_handle: tauri::AppHandle, -) -> Result<(), String> { - - let callback_url = "hackatime://auth/callback"; - - let pkce = PkceState::new(); - let code_challenge = generate_code_challenge(&pkce.code_verifier); - - { - let mut stored_pkce = pkce_state.lock().await; - *stored_pkce = Some(pkce.clone()); - } - - println!("Generated PKCE parameters - verifier: {}, challenge: {}, state: {}", - pkce.code_verifier, code_challenge, pkce.state); - - let auth_url = format!( - "{}/oauth/authorize?client_id=BPr5VekIV-xuQ2ZhmxbGaahJ3XVd7gM83pql-HYGYxQ&redirect_uri={}&response_type=code&scope=profile&state={}&code_challenge={}&code_challenge_method=S256", - api_config.base_url, - urlencoding::encode(callback_url), - urlencoding::encode(&pkce.state), - urlencoding::encode(&code_challenge) - ); - - if let Err(e) = open::that(&auth_url) { - return Err(format!("Failed to open authentication URL: {}", e)); - } - - println!("OAuth authentication URL opened in browser. Waiting for callback..."); - Ok(()) -} - -#[tauri::command] -async fn handle_auth_callback( - token: String, - state: State<'_, Arc>>, -) -> Result<(), String> { - let mut auth_state = state.lock().await; - auth_state.is_authenticated = true; - auth_state.access_token = Some(token); - auth_state.user_info = Some(HashMap::new()); - - Ok(()) -} - -#[tauri::command] -async fn logout( - state: State<'_, Arc>>, -) -> Result<(), String> { - let mut auth_state = state.lock().await; - auth_state.is_authenticated = false; - auth_state.access_token = None; - auth_state.user_info = None; - - if let Err(e) = clear_auth_state().await { - eprintln!("Failed to clear auth state: {}", e); - } - - Ok(()) -} - -#[tauri::command] -async fn test_auth_callback( - token: String, - state: State<'_, Arc>>, -) -> Result<(), String> { - let mut auth_state = state.lock().await; - auth_state.is_authenticated = true; - auth_state.access_token = Some(token); - auth_state.user_info = Some(HashMap::new()); - - Ok(()) -} - -#[tauri::command] -async fn get_api_key( - api_config: ApiConfig, - state: State<'_, Arc>>, -) -> Result { - let auth_state = state.lock().await; - - if !auth_state.is_authenticated { - return Err("Not authenticated".to_string()); - } - - let base_url = if api_config.base_url.is_empty() { - "https://hackatime.hackclub.com" - } else { - &api_config.base_url - }; - - let access_token = auth_state - .access_token - .as_ref() - .ok_or("No access token available")?; - - let client = reqwest::Client::new(); - let response = client - .get(&format!("{}/api/v1/authenticated/api_keys", base_url)) - .bearer_auth(access_token) - .send() - .await - .map_err(|e| format!("Failed to fetch API key: {}", e))?; - - if !response.status().is_success() { - let error_text = response - .text() - .await - .unwrap_or_else(|_| "Unknown error".to_string()); - return Err(format!("API key request failed: {}", error_text)); - } - - let api_key_response: serde_json::Value = response - .json() - .await - .map_err(|e| format!("Failed to parse API key response: {}", e))?; - - let api_key = api_key_response["token"] - .as_str() - .ok_or("No token in response")?; - - Ok(api_key.to_string()) -} - -#[tauri::command] -async fn authenticate_with_direct_oauth( - oauth_token: String, - api_config: ApiConfig, - state: State<'_, Arc>>, -) -> Result<(), String> { - let client = reqwest::Client::new(); - - if oauth_token.starts_with("hackatime://auth/callback") { - if let Some(query_start) = oauth_token.find('?') { - let query = &oauth_token[query_start + 1..]; - let params: Vec<&str> = query.split('&').collect(); - - let mut found_code = None; - let mut found_state = None; - let mut found_error = None; - - for param in params { - if param.starts_with("code=") { - found_code = Some(param[5..].to_string()); - } else if param.starts_with("state=") { - found_state = Some(param[6..].to_string()); - } else if param.starts_with("error=") { - found_error = Some(param[6..].to_string()); - } - } - - if let Some(error) = found_error { - return Err(format!("OAuth error: {}", error)); - } - - if let Some(code) = found_code { - println!("Extracted authorization code from deep link: {}", code); - - return exchange_authorization_code(code, found_state, api_config, state, client).await; - } else { - return Err("No authorization code found in deep link URL".to_string()); - } - } else { - return Err("Invalid deep link URL format".to_string()); - } - } else { - return validate_access_token(oauth_token, api_config, state, client).await; - } -} - -async fn exchange_authorization_code( - code: String, - _state: Option, - api_config: ApiConfig, - auth_state: State<'_, Arc>>, - client: reqwest::Client, -) -> Result<(), String> { - println!("Exchanging authorization code for access token"); - - let response = client - .post(&format!( - "{}/oauth/token", - api_config.base_url - )) - .form(&[ - ("grant_type", "authorization_code"), - ("code", &code), - ("client_id", "BPr5VekIV-xuQ2ZhmxbGaahJ3XVd7gM83pql-HYGYxQ"), - ("redirect_uri", "hackatime://auth/callback"), - ]) - .send() - .await - .map_err(|e| format!("Failed to exchange authorization code: {}", e))?; - - println!("Token exchange response status: {}", response.status()); - - if !response.status().is_success() { - let error_text = response - .text() - .await - .unwrap_or_else(|_| "Unknown error".to_string()); - println!("Token exchange failed with error: {}", error_text); - return Err(format!("Token exchange failed: {}", error_text)); - } - - let token_response: serde_json::Value = response - .json() - .await - .map_err(|e| format!("Failed to parse token response: {}", e))?; - - let access_token = token_response["access_token"] - .as_str() - .ok_or("No access token in response")?; - - let user_response = client - .get(&format!("{}/api/v1/authenticated/me", api_config.base_url)) - .bearer_auth(access_token) - .send() - .await - .map_err(|e| format!("Failed to fetch user info: {}", e))?; - - let user_info = if user_response.status().is_success() { - user_response.json::() - .await - .unwrap_or_else(|_| serde_json::json!({})) - } else { - serde_json::json!({}) - }; - - let mut user_info_map = HashMap::new(); - if let Some(obj) = user_info.as_object() { - for (key, value) in obj { - user_info_map.insert(key.clone(), value.clone()); - } - } - - let mut auth_state = auth_state.lock().await; - auth_state.is_authenticated = true; - auth_state.access_token = Some(access_token.to_string()); - auth_state.user_info = Some(user_info_map); - - let auth_state_to_save = auth_state.clone(); - drop(auth_state); - if let Err(e) = save_auth_state(auth_state_to_save).await { - eprintln!("Failed to save auth state: {}", e); - } - - println!("Direct OAuth authentication completed successfully!"); - Ok(()) -} - -async fn validate_access_token( - access_token: String, - api_config: ApiConfig, - auth_state: State<'_, Arc>>, - client: reqwest::Client, -) -> Result<(), String> { - println!("Validating access token directly"); - - let response = client - .get(&format!("{}/api/v1/authenticated/me", api_config.base_url)) - .bearer_auth(&access_token) - .send() - .await - .map_err(|e| format!("Failed to validate access token: {}", e))?; - - if !response.status().is_success() { - let error_text = response - .text() - .await - .unwrap_or_else(|_| "Unknown error".to_string()); - return Err(format!("Access token validation failed: {}", error_text)); - } - - let user_info = response - .json::() - .await - .map_err(|e| format!("Failed to parse user info response: {}", e))?; - - let mut user_info_map = HashMap::new(); - if let Some(obj) = user_info.as_object() { - for (key, value) in obj { - user_info_map.insert(key.clone(), value.clone()); - } - } - - let mut auth_state = auth_state.lock().await; - auth_state.is_authenticated = true; - auth_state.access_token = Some(access_token); - auth_state.user_info = Some(user_info_map); - - let auth_state_to_save = auth_state.clone(); - drop(auth_state); - if let Err(e) = save_auth_state(auth_state_to_save).await { - eprintln!("Failed to save auth state: {}", e); - } - - println!("Access token validation completed successfully!"); - Ok(()) -} - -#[tauri::command] -async fn handle_deep_link_callback( - authorization_code: String, - state: String, - api_config: ApiConfig, - auth_state: State<'_, Arc>>, - pkce_state: State<'_, Arc>>>, -) -> Result<(), String> { - let stored_pkce = { - let pkce_guard = pkce_state.lock().await; - pkce_guard.clone() - }; - - let pkce = match stored_pkce { - Some(pkce) => { - if pkce.is_expired(600) { - return Err("PKCE state expired. Please restart authentication.".to_string()); - } - - if pkce.state != state { - return Err("State parameter mismatch. Possible CSRF attack.".to_string()); - } - - pkce - } - None => return Err("No PKCE state found. Please restart authentication.".to_string()), - }; - - let client = reqwest::Client::new(); - let response = client - .post(&format!( - "{}/oauth/token", - api_config.base_url - )) - .form(&[ - ("grant_type", "authorization_code"), - ("code", &authorization_code), - ("client_id", "BPr5VekIV-xuQ2ZhmxbGaahJ3XVd7gM83pql-HYGYxQ"), - ("redirect_uri", "hackatime://auth/callback"), - ("code_verifier", &pkce.code_verifier), - ]) - .send() - .await - .map_err(|e| format!("Failed to exchange token: {}", e))?; - - if !response.status().is_success() { - let error_text = response - .text() - .await - .unwrap_or_else(|_| "Unknown error".to_string()); - return Err(format!("Token exchange failed: {}", error_text)); - } - - let token_response: serde_json::Value = response - .json() - .await - .map_err(|e| format!("Failed to parse token response: {}", e))?; - - let access_token = token_response["access_token"] - .as_str() - .ok_or("No access token in response")?; - - let user_response = client - .get(&format!("{}/api/v1/authenticated/me", api_config.base_url)) - .bearer_auth(access_token) - .send() - .await - .map_err(|e| format!("Failed to fetch user info: {}", e))?; - - let user_info = if user_response.status().is_success() { - user_response.json::() - .await - .unwrap_or_else(|_| serde_json::json!({})) - } else { - serde_json::json!({}) - }; - - let mut user_info_map = HashMap::new(); - if let Some(obj) = user_info.as_object() { - for (key, value) in obj { - user_info_map.insert(key.clone(), value.clone()); - } - } - - let mut auth_state = auth_state.lock().await; - auth_state.is_authenticated = true; - auth_state.access_token = Some(access_token.to_string()); - auth_state.user_info = Some(user_info_map); - - let auth_state_to_save = auth_state.clone(); - drop(auth_state); - if let Err(e) = save_auth_state(auth_state_to_save).await { - eprintln!("Failed to save auth state: {}", e); - } - - { - let mut stored_pkce = pkce_state.lock().await; - *stored_pkce = None; - } - - println!("OAuth authentication completed successfully!"); - Ok(()) -} - -#[tauri::command] -async fn setup_hackatime_macos_linux(api_key: String, api_url: String) -> Result { - let home_dir = std::env::var("HOME").map_err(|_| "Failed to get home directory")?; - - let config_path = format!("{}/.wakatime.cfg", home_dir); - let backup_path = format!("{}/.wakatime.cfg.bak", home_dir); - - if Path::new(&config_path).exists() { - if let Err(e) = fs::rename(&config_path, &backup_path) { - return Err(format!("Failed to backup existing config: {}", e)); - } - } - - let config_content = format!( - "[settings]\napi_url = {}\napi_key = {}\nheartbeat_rate_limit_seconds = 30\n", - api_url, api_key - ); - - if let Err(e) = fs::write(&config_path, config_content) { - return Err(format!("Failed to write config file: {}", e)); - } - - if !Path::new(&config_path).exists() { - return Err("Config file was not created".to_string()); - } - - let config_content = fs::read_to_string(&config_path) - .map_err(|e| format!("Failed to read config file: {}", e))?; - - let lines: Vec<&str> = config_content.lines().collect(); - let mut found_api_url = false; - let mut found_api_key = false; - let mut found_heartbeat_rate = false; - - for line in lines { - if line.starts_with("api_url =") { - found_api_url = true; - } else if line.starts_with("api_key =") { - found_api_key = true; - } else if line.starts_with("heartbeat_rate_limit_seconds =") { - found_heartbeat_rate = true; - } - } - - if !found_api_url || !found_api_key || !found_heartbeat_rate { - return Err("Config file is missing required fields".to_string()); - } - - Ok(format!( - "Config file created successfully at {}", - config_path - )) -} - -#[tauri::command] -async fn setup_hackatime_windows(api_key: String, api_url: String) -> Result { - let userprofile = - std::env::var("USERPROFILE").map_err(|_| "Failed to get USERPROFILE directory")?; - - let config_path = format!("{}\\.wakatime.cfg", userprofile); - let backup_path = format!("{}\\.wakatime.cfg.bak", userprofile); - - if Path::new(&config_path).exists() { - if let Err(e) = fs::rename(&config_path, &backup_path) { - return Err(format!("Failed to backup existing config: {}", e)); - } - } - - let config_content = format!( - "[settings]\r\napi_url = {}\r\napi_key = {}\r\nheartbeat_rate_limit_seconds = 30\r\n", - api_url, api_key - ); - - if let Err(e) = fs::write(&config_path, config_content) { - return Err(format!("Failed to write config file: {}", e)); - } - - if !Path::new(&config_path).exists() { - return Err("Config file was not created".to_string()); - } - - let config_content = fs::read_to_string(&config_path) - .map_err(|e| format!("Failed to read config file: {}", e))?; - - let lines: Vec<&str> = config_content.lines().collect(); - let mut found_api_url = false; - let mut found_api_key = false; - let mut found_heartbeat_rate = false; - - for line in lines { - if line.starts_with("api_url =") { - found_api_url = true; - } else if line.starts_with("api_key =") { - found_api_key = true; - } else if line.starts_with("heartbeat_rate_limit_seconds =") { - found_heartbeat_rate = true; - } - } - - if !found_api_url || !found_api_key || !found_heartbeat_rate { - return Err("Config file is missing required fields".to_string()); - } - - Ok(format!( - "Config file created successfully at {}", - config_path - )) -} - -#[tauri::command] -async fn test_hackatime_heartbeat(api_key: String, api_url: String) -> Result { - let client = reqwest::Client::new(); - let current_time = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(); - - let heartbeat_data = serde_json::json!([{ - "type": "file", - "time": current_time, - "entity": "test.txt", - "language": "Text" - }]); - - let response = client - .post(&format!("{}/users/current/heartbeats", api_url)) - .header("Authorization", format!("Bearer {}", api_key)) - .header("Content-Type", "application/json") - .json(&heartbeat_data) - .send() - .await - .map_err(|e| format!("Failed to send heartbeat: {}", e))?; - - if response.status().is_success() { - Ok("Test heartbeat sent successfully!".to_string()) - } else { - let error_text = response - .text() - .await - .unwrap_or_else(|_| "Unknown error".to_string()); - Err(format!("Heartbeat failed: {}", error_text)) - } -} - -#[tauri::command] -async fn setup_hackatime_complete(api_key: String, api_url: String) -> Result { - // Detect operating system and use appropriate setup function - if cfg!(target_os = "windows") { - setup_hackatime_windows(api_key, api_url).await - } else { - setup_hackatime_macos_linux(api_key, api_url).await - } -} - -#[tauri::command] -async fn save_auth_state(auth_state: AuthState) -> Result<(), String> { - println!( - "save_auth_state called: authenticated={}, has_token={}", - auth_state.is_authenticated, - auth_state.access_token.is_some() - ); - let db = Database::new().await?; - println!("Database connection successful for save"); - - // Convert to database AuthState format - let db_auth_state = DbAuthState { - is_authenticated: auth_state.is_authenticated, - access_token: auth_state.access_token, - user_info: auth_state.user_info, - }; - - // Save to database - let session_id = db.save_session(&db_auth_state).await?; - println!("Session saved with ID: {}", session_id); - - Ok(()) -} - -#[tauri::command] -async fn load_auth_state() -> Result, String> { - println!("load_auth_state called"); - let db = Database::new().await?; - println!("Database connection successful"); - - match db.load_latest_session().await? { - Some(db_auth_state) => { - println!( - "Found saved session: authenticated={}, has_token={}", - db_auth_state.is_authenticated, - db_auth_state.access_token.is_some() - ); - let auth_state = AuthState { - is_authenticated: db_auth_state.is_authenticated, - access_token: db_auth_state.access_token, - user_info: db_auth_state.user_info, - }; - Ok(Some(auth_state)) - } - None => { - println!("No saved sessions found"); - Ok(None) - } - } -} - -#[tauri::command] -async fn clear_auth_state() -> Result<(), String> { - let db = Database::new().await?; - db.clear_sessions().await?; - Ok(()) -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -#[allow(dead_code)] -struct PresenceNotification { - r#type: String, - message: Option, - user_id: u32, - username: String, - session_data: SessionData, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -struct HeartbeatData { - id: u32, - project: Option, - editor: Option, - language: Option, - entity: Option, - time: f64, - timestamp: i64, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -struct SessionState { - is_active: bool, - start_time: Option, - last_heartbeat_id: Option, - heartbeat_count: u32, - project: Option, - editor: Option, - language: Option, - entity: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -struct HeartbeatResponse { - heartbeat: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -#[allow(dead_code)] -struct SessionData { - project: Option, - editor: Option, - language: Option, - entity: Option, - start_time: i64, - last_heartbeat_time: i64, - heartbeat_count: u32, -} - - -#[tauri::command] -async fn get_latest_heartbeat( - api_config: ApiConfig, - state: State<'_, Arc>>, - discord_rpc_state: State<'_, Arc>>, - session_state: State<'_, Arc>>, -) -> Result { - let auth_state = state.lock().await; - - if !auth_state.is_authenticated { - return Err("Not authenticated".to_string()); - } - - let base_url = if api_config.base_url.is_empty() { - "https://hackatime.hackclub.com" - } else { - &api_config.base_url - }; - - let access_token = auth_state - .access_token - .as_ref() - .ok_or("No access token available")?; - - let client = reqwest::Client::new(); - let response = client - .get(&format!( - "{}/api/v1/authenticated/heartbeats/latest", - base_url - )) - .bearer_auth(access_token) - .send() - .await - .map_err(|e| format!("Failed to get latest heartbeat: {}", e))?; - - let status = response.status(); - if !status.is_success() { - let error_text = response - .text() - .await - .unwrap_or_else(|_| "Unknown error".to_string()); - - // Handle rate limiting gracefully - if status == 429 { - println!("Rate limited, will retry later"); - return Err(format!("Rate limited: {}", error_text)); - } - - return Err(format!("Failed to get latest heartbeat: {}", error_text)); - } - - let heartbeat_response: HeartbeatResponse = response - .json() - .await - .map_err(|e| format!("Failed to parse heartbeat response: {}", e))?; - - // Process session logic - if let Some(heartbeat) = &heartbeat_response.heartbeat { - let mut session = session_state.lock().await; - let current_time = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs() as i64; - - // Check if heartbeat is less than 2 minutes old - let heartbeat_age = current_time - heartbeat.timestamp; - let is_recent = heartbeat_age < 120; // 2 minutes - - // Check for duplicate heartbeat (same ID as last one) - let is_duplicate = session.last_heartbeat_id == Some(heartbeat.id); - - if is_duplicate { - // Duplicate heartbeat detected - end the session - println!("Duplicate heartbeat detected, ending session"); - session.is_active = false; - session.start_time = None; - session.last_heartbeat_id = None; - session.heartbeat_count = 0; - session.project = None; - session.editor = None; - session.language = None; - session.entity = None; - - // Clear Discord RPC activity - let mut discord_rpc = discord_rpc_state.lock().await; - if discord_rpc.is_connected() { - let _ = discord_rpc.clear_activity(); - } - } else if is_recent && !session.is_active { - // Start new session - println!("Recent heartbeat detected, starting new session"); - session.is_active = true; - session.start_time = Some(heartbeat.timestamp); - session.last_heartbeat_id = Some(heartbeat.id); - session.heartbeat_count = 1; - session.project = heartbeat.project.clone(); - session.editor = heartbeat.editor.clone(); - session.language = heartbeat.language.clone(); - session.entity = heartbeat.entity.clone(); - - // Update Discord RPC with session start time - let mut discord_rpc = discord_rpc_state.lock().await; - if discord_rpc.is_connected() { - if let Err(e) = - discord_rpc.update_activity_from_session(heartbeat, heartbeat.timestamp) - { - eprintln!("Failed to update Discord RPC: {}", e); - } - } - } else if is_recent && session.is_active { - // Continue existing session - session.last_heartbeat_id = Some(heartbeat.id); - session.heartbeat_count += 1; - session.project = heartbeat.project.clone(); - session.editor = heartbeat.editor.clone(); - session.language = heartbeat.language.clone(); - session.entity = heartbeat.entity.clone(); - - // Update Discord RPC with session start time - let mut discord_rpc = discord_rpc_state.lock().await; - if discord_rpc.is_connected() { - if let Err(e) = discord_rpc.update_activity_from_session( - heartbeat, - session.start_time.unwrap_or(heartbeat.timestamp), - ) { - eprintln!("Failed to update Discord RPC: {}", e); - } - } - } else if !is_recent && session.is_active { - // Heartbeat is too old, end the session - println!("Heartbeat too old, ending session"); - session.is_active = false; - session.start_time = None; - session.last_heartbeat_id = None; - session.heartbeat_count = 0; - session.project = None; - session.editor = None; - session.language = None; - session.entity = None; - - // Clear Discord RPC activity - let mut discord_rpc = discord_rpc_state.lock().await; - if discord_rpc.is_connected() { - let _ = discord_rpc.clear_activity(); - } - } - } else { - // No heartbeat data - end session if active - let mut session = session_state.lock().await; - if session.is_active { - println!("No heartbeat data, ending session"); - session.is_active = false; - session.start_time = None; - session.last_heartbeat_id = None; - session.heartbeat_count = 0; - session.project = None; - session.editor = None; - session.language = None; - session.entity = None; - - // Clear Discord RPC activity - let mut discord_rpc = discord_rpc_state.lock().await; - if discord_rpc.is_connected() { - let _ = discord_rpc.clear_activity(); - } - } - } - - Ok(heartbeat_response) -} - - -#[allow(dead_code)] -fn get_config_dir() -> Result { - // Use hackatime directory structure - get_hackatime_config_dir() -} - -#[tauri::command] -async fn get_hackatime_directories() -> Result { - let config_dir = get_hackatime_config_dir()?; - let logs_dir = get_hackatime_logs_dir()?; - let data_dir = get_hackatime_data_dir()?; - - Ok(serde_json::json!({ - "config_dir": config_dir.to_string_lossy(), - "logs_dir": logs_dir.to_string_lossy(), - "data_dir": data_dir.to_string_lossy() - })) -} - -#[tauri::command] -async fn cleanup_old_sessions(days_old: i64) -> Result<(), String> { - let db = Database::new().await?; - db.cleanup_old_sessions(days_old).await?; - Ok(()) -} - -#[tauri::command] -async fn get_session_stats() -> Result { - let platform_info = get_platform_info()?; - - Ok(serde_json::json!({ - "platform_info": platform_info, - "database_path": get_hackatime_config_dir()?.join("sessions.db").to_string_lossy(), - "directories_created": { - "config": get_hackatime_config_dir()?.exists(), - "logs": get_hackatime_logs_dir()?.exists(), - "data": get_hackatime_data_dir()?.exists() - } - })) -} - -#[tauri::command] -async fn test_database_connection() -> Result { - // Test directory creation - let config_dir = get_hackatime_config_dir()?; - let logs_dir = get_hackatime_logs_dir()?; - let data_dir = get_hackatime_data_dir()?; - - // Test database connection - let db_result = Database::new().await; - let db_success = db_result.is_ok(); - let db_error = if let Err(e) = db_result { - Some(e) - } else { - None - }; - - Ok(serde_json::json!({ - "directories": { - "config_exists": config_dir.exists(), - "logs_exists": logs_dir.exists(), - "data_exists": data_dir.exists(), - "config_path": config_dir.to_string_lossy(), - "logs_path": logs_dir.to_string_lossy(), - "data_path": data_dir.to_string_lossy() - }, - "database": { - "connection_success": db_success, - "error": db_error, - "db_path": config_dir.join("sessions.db").to_string_lossy() - } - })) -} - -// Discord RPC Commands -#[tauri::command] -async fn discord_rpc_connect( - client_id: String, - state: State<'_, Arc>>, -) -> Result<(), String> { - let mut rpc_service = state.lock().await; - rpc_service.connect(&client_id) -} - -#[tauri::command] -async fn discord_rpc_disconnect( - state: State<'_, Arc>>, -) -> Result<(), String> { - let mut rpc_service = state.lock().await; - rpc_service.disconnect() -} - -#[tauri::command] -async fn discord_rpc_set_activity( - activity: DiscordActivity, - state: State<'_, Arc>>, -) -> Result<(), String> { - let mut rpc_service = state.lock().await; - rpc_service.set_activity(activity) -} - -#[tauri::command] -async fn discord_rpc_clear_activity( - state: State<'_, Arc>>, -) -> Result<(), String> { - let mut rpc_service = state.lock().await; - rpc_service.clear_activity() -} - -#[tauri::command] -async fn discord_rpc_get_state( - state: State<'_, Arc>>, -) -> Result { - let rpc_service = state.lock().await; - Ok(rpc_service.get_state()) -} - -#[tauri::command] -async fn discord_rpc_update_from_heartbeat( - heartbeat_data: HeartbeatData, - state: State<'_, Arc>>, -) -> Result<(), String> { - let mut rpc_service = state.lock().await; - rpc_service.update_activity_from_heartbeat(&heartbeat_data) -} - -#[tauri::command] -async fn discord_rpc_auto_connect( - client_id: String, - discord_rpc_state: State<'_, Arc>>, -) -> Result<(), String> { - let mut rpc_service = discord_rpc_state.lock().await; - rpc_service.connect(&client_id) -} - -#[tauri::command] -async fn discord_rpc_auto_disconnect( - discord_rpc_state: State<'_, Arc>>, -) -> Result<(), String> { - let mut rpc_service = discord_rpc_state.lock().await; - rpc_service.disconnect() -} - -#[tauri::command] -async fn get_current_session( - session_state: State<'_, Arc>>, -) -> Result { - let session = session_state.lock().await; - Ok(session.clone()) -} - -#[tauri::command] -async fn get_projects( - api_config: ApiConfig, - state: State<'_, Arc>>, -) -> Result { - let auth_state = state.lock().await; - - if !auth_state.is_authenticated { - return Err("Not authenticated".to_string()); - } - - let base_url = if api_config.base_url.is_empty() { - "https://hackatime.hackclub.com" - } else { - &api_config.base_url - }; - - let access_token = auth_state - .access_token - .as_ref() - .ok_or("No access token available")?; - - let client = reqwest::Client::new(); - let response = client - .get(&format!( - "{}/api/v1/authenticated/projects", - base_url - )) - .bearer_auth(access_token) - .send() - .await - .map_err(|e| format!("Failed to fetch projects: {}", e))?; - - if !response.status().is_success() { - let error_text = response - .text() - .await - .unwrap_or_else(|_| "Unknown error".to_string()); - return Err(format!("Projects request failed: {}", error_text)); - } - - let projects_response: serde_json::Value = response - .json() - .await - .map_err(|e| format!("Failed to parse projects response: {}", e))?; - - Ok(projects_response) -} - -#[tauri::command] -async fn get_project_details( - project_name: String, - api_config: ApiConfig, - state: State<'_, Arc>>, -) -> Result { - let auth_state = state.lock().await; - - if !auth_state.is_authenticated { - return Err("Not authenticated".to_string()); - } - - let base_url = if api_config.base_url.is_empty() { - "https://hackatime.hackclub.com" - } else { - &api_config.base_url - }; - - let access_token = auth_state - .access_token - .as_ref() - .ok_or("No access token available")?; - - let client = reqwest::Client::new(); - let response = client - .get(&format!( - "{}/api/v1/authenticated/projects/{}", - base_url, - urlencoding::encode(&project_name) - )) - .bearer_auth(access_token) - .send() - .await - .map_err(|e| format!("Failed to fetch project details: {}", e))?; - - if !response.status().is_success() { - let error_text = response - .text() - .await - .unwrap_or_else(|_| "Unknown error".to_string()); - return Err(format!("Project details request failed: {}", error_text)); - } - - let project_response: serde_json::Value = response - .json() - .await - .map_err(|e| format!("Failed to parse project response: {}", e))?; - - Ok(project_response) -} - -#[tauri::command] -async fn get_discord_rpc_enabled( - discord_rpc_state: State<'_, Arc>>, -) -> Result { - let rpc_service = discord_rpc_state.lock().await; - Ok(rpc_service.is_connected()) -} - -#[tauri::command] -async fn set_discord_rpc_enabled( - enabled: bool, - discord_rpc_state: State<'_, Arc>>, -) -> Result<(), String> { - let mut rpc_service = discord_rpc_state.lock().await; - - if enabled { - // Try to connect with a default client ID (you might want to make this configurable) - let default_client_id = "1234567890123456789"; // Replace with your Discord app client ID - rpc_service.connect(default_client_id) - } else { - rpc_service.disconnect() - } -} - -// Statistics and Trends Processing -#[derive(Debug, Serialize, Deserialize, Clone)] -struct StatisticsData { - trends: Vec, - charts: Vec, - insights: Vec, - programmer_class: ProgrammerClass, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -struct TrendStatistic { - title: String, - value: String, - change: String, - change_type: String, // "increase", "decrease", "neutral" - period: String, - icon: String, - color: String, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -struct ChartData { - id: String, - title: String, - chart_type: String, // "line", "bar", "pie", "area", "radar" - data: serde_json::Value, - period: String, - color_scheme: String, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -struct Insight { - title: String, - description: String, - value: String, - trend: String, - icon: String, - color: String, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -struct ProgrammerClass { - class_name: String, - description: String, - technologies: Vec, +#[derive(Clone, serde::Serialize)] +struct LogEntry { + ts: i64, level: String, - color: String, + source: String, + message: String, } -#[tauri::command] -async fn get_statistics_data( - api_config: ApiConfig, - state: State<'_, Arc>>, -) -> Result { - let auth_state = state.lock().await; +static LOG_BUFFER: Lazy>> = Lazy::new(|| Mutex::new(Vec::with_capacity(1024))); - if !auth_state.is_authenticated { - return Err("Not authenticated".to_string()); +pub fn push_log(level: &str, source: &str, message: String) { + let mut buf = LOG_BUFFER.lock().unwrap(); + if buf.len() >= 1000 { + buf.remove(0); } - - let base_url = if api_config.base_url.is_empty() { - "https://hackatime.hackclub.com" - } else { - &api_config.base_url - }; - - let access_token = auth_state - .access_token - .as_ref() - .ok_or("No access token available")?; - - let client = reqwest::Client::new(); - - let end_date = chrono::Utc::now().date_naive(); - - let mut daily_hours = serde_json::Map::new(); - let mut total_seconds = 0u64; - - for days_ago in 0..7 { - let date = end_date - chrono::Duration::days(days_ago); - let date_str = date.format("%Y-%m-%d").to_string(); - - let day_response = client - .get(&format!( - "{}/api/v1/authenticated/hours?start_date={}&end_date={}", - base_url, - date_str, - date_str - )) - .bearer_auth(access_token) - .send() - .await; - - match day_response { - Ok(response) if response.status().is_success() => { - if let Ok(day_data) = response.json::().await { - let seconds = day_data["total_seconds"].as_u64().unwrap_or(0); - total_seconds += seconds; - - let day_name = match date.weekday() { - chrono::Weekday::Mon => "Mon", - chrono::Weekday::Tue => "Tue", - chrono::Weekday::Wed => "Wed", - chrono::Weekday::Thu => "Thu", - chrono::Weekday::Fri => "Fri", - chrono::Weekday::Sat => "Sat", - chrono::Weekday::Sun => "Sun", - }; - - daily_hours.insert(date_str.clone(), serde_json::json!({ - "date": date_str, - "day_name": day_name, - "hours": seconds as f64 / 3600.0, - "seconds": seconds - })); - } - } - _ => { - let day_name = match date.weekday() { - chrono::Weekday::Mon => "Mon", - chrono::Weekday::Tue => "Tue", - chrono::Weekday::Wed => "Wed", - chrono::Weekday::Thu => "Thu", - chrono::Weekday::Fri => "Fri", - chrono::Weekday::Sat => "Sat", - chrono::Weekday::Sun => "Sun", - }; - - daily_hours.insert(date_str.clone(), serde_json::json!({ - "date": date_str, - "day_name": day_name, - "hours": 0.0, - "seconds": 0 - })); - } - } - } - - let all_time_start = end_date - chrono::Duration::days(365); - let all_time_response = client - .get(&format!( - "{}/api/v1/authenticated/hours?start_date={}&end_date={}", - base_url, - all_time_start.format("%Y-%m-%d"), - end_date.format("%Y-%m-%d") - )) - .bearer_auth(access_token) - .send() - .await; - - let all_time_seconds = match all_time_response { - Ok(response) if response.status().is_success() => { - if let Ok(data) = response.json::().await { - data["total_seconds"].as_u64().unwrap_or(0) - } else { - 0 - } - } - _ => 0 - }; - - let hours_data = serde_json::json!({ - "weekly_stats": { - "time_coded_seconds": total_seconds, - "daily_hours": daily_hours - }, - "all_time_stats": { - "time_coded_seconds": all_time_seconds - } + buf.push(LogEntry { + ts: chrono::Utc::now().timestamp_millis(), + level: level.to_string(), + source: source.to_string(), + message, }); - - let streak_response = client - .get(&format!( - "{}/api/v1/authenticated/streak", - base_url - )) - .bearer_auth(access_token) - .send() - .await - .map_err(|e| format!("Failed to fetch streak: {}", e))?; - - if !streak_response.status().is_success() { - let error_text = streak_response - .text() - .await - .unwrap_or_else(|_| "Unknown error".to_string()); - return Err(format!("Streak request failed: {}", error_text)); - } - - let streak_data: serde_json::Value = streak_response - .json() - .await - .map_err(|e| format!("Failed to parse streak data: {}", e))?; - - let mut dashboard_stats = hours_data; - if let Some(streak) = streak_data.get("current_streak") { - dashboard_stats["current_streak"] = streak.clone(); - } - if let Some(longest) = streak_data.get("longest_streak") { - dashboard_stats["longest_streak"] = longest.clone(); - } - - let statistics = process_statistics_data(dashboard_stats).await?; - - Ok(statistics) } #[tauri::command] -async fn show_window(app: tauri::AppHandle) -> Result<(), String> { - if let Some(window) = app.get_webview_window("main") { - window - .show() - .map_err(|e| format!("Failed to show window: {}", e))?; - window - .set_focus() - .map_err(|e| format!("Failed to focus window: {}", e))?; - } - Ok(()) -} - -#[tauri::command] -async fn hide_window(app: tauri::AppHandle) -> Result<(), String> { - if let Some(window) = app.get_webview_window("main") { - window - .hide() - .map_err(|e| format!("Failed to hide window: {}", e))?; - } - Ok(()) -} - -#[tauri::command] -async fn toggle_window(app: tauri::AppHandle) -> Result<(), String> { - if let Some(window) = app.get_webview_window("main") { - if window.is_visible().unwrap_or(false) { - window - .hide() - .map_err(|e| format!("Failed to hide window: {}", e))?; - } else { - window - .show() - .map_err(|e| format!("Failed to show window: {}", e))?; - window - .set_focus() - .map_err(|e| format!("Failed to focus window: {}", e))?; - } - } - Ok(()) -} - -#[tauri::command] -async fn get_app_status( - auth_state: State<'_, Arc>>, - session_state: State<'_, Arc>>, - discord_rpc_state: State<'_, Arc>>, -) -> Result { - let auth = auth_state.lock().await; - let session = session_state.lock().await; - let discord_rpc = discord_rpc_state.lock().await; - - Ok(serde_json::json!({ - "authenticated": auth.is_authenticated, - "session_active": session.is_active, - "session_duration": if session.is_active && session.start_time.is_some() { - let current_time = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs() as i64; - current_time - session.start_time.unwrap() - } else { - 0 - }, - "project": session.project.clone().unwrap_or_else(|| "No project".to_string()), - "editor": session.editor.clone().unwrap_or_else(|| "No editor".to_string()), - "language": session.language.clone().unwrap_or_else(|| "No language".to_string()), - "discord_connected": discord_rpc.is_connected(), - "heartbeat_count": session.heartbeat_count - })) -} - -async fn process_statistics_data( - dashboard_stats: serde_json::Value, -) -> Result { - // Extract data from dashboard stats - let current_streak = dashboard_stats["current_streak"].as_u64().unwrap_or(0); - let weekly_time = dashboard_stats["weekly_stats"]["time_coded_seconds"] - .as_u64() - .unwrap_or(0) as f64; - let all_time_time = dashboard_stats["all_time_stats"]["time_coded_seconds"] - .as_u64() - .unwrap_or(0) as f64; - - // Calculate trends (comparing this week to last week) - let trends = calculate_trends(weekly_time, current_streak).await; - - // Generate chart data - let charts = generate_chart_data(&dashboard_stats).await?; - - // Generate insights - let insights = generate_insights(weekly_time, all_time_time, current_streak).await; - - // Analyze programmer class - let programmer_class = analyze_programmer_class(&dashboard_stats).await; - - Ok(StatisticsData { - trends, - charts, - insights, - programmer_class, - }) -} - -async fn calculate_trends(weekly_time: f64, current_streak: u64) -> Vec { - let mut trends = Vec::new(); - - // Simulate last week's data (in a real app, you'd fetch this from the API) - let last_week_time = weekly_time * 0.85; // Simulate 15% increase - let last_week_streak = if current_streak > 0 { - current_streak - 1 - } else { - 0 - }; - - // Weekly coding time trend - let time_change = ((weekly_time - last_week_time) / last_week_time * 100.0).round() as i32; - let time_trend = if time_change > 0 { - TrendStatistic { - title: "Weekly Coding Time".to_string(), - value: format!("{:.1}h", weekly_time / 3600.0), - change: format!("+{}%", time_change), - change_type: "increase".to_string(), - period: "vs last week".to_string(), - icon: "".to_string(), - color: "#4CAF50".to_string(), - } - } else if time_change < 0 { - TrendStatistic { - title: "Weekly Coding Time".to_string(), - value: format!("{:.1}h", weekly_time / 3600.0), - change: format!("{}%", time_change), - change_type: "decrease".to_string(), - period: "vs last week".to_string(), - icon: "".to_string(), - color: "#F44336".to_string(), - } - } else { - TrendStatistic { - title: "Weekly Coding Time".to_string(), - value: format!("{:.1}h", weekly_time / 3600.0), - change: "No change".to_string(), - change_type: "neutral".to_string(), - period: "vs last week".to_string(), - icon: "".to_string(), - color: "#FF9800".to_string(), - } - }; - trends.push(time_trend); - - // Streak trend - let streak_change = current_streak as i32 - last_week_streak as i32; - let streak_trend = if streak_change > 0 { - TrendStatistic { - title: "Coding Streak".to_string(), - value: format!("{} days", current_streak), - change: format!("+{} days", streak_change), - change_type: "increase".to_string(), - period: "vs last week".to_string(), - icon: "".to_string(), - color: "#FF5722".to_string(), - } - } else if streak_change < 0 { - TrendStatistic { - title: "Coding Streak".to_string(), - value: format!("{} days", current_streak), - change: format!("{} days", streak_change), - change_type: "decrease".to_string(), - period: "vs last week".to_string(), - icon: "".to_string(), - color: "#F44336".to_string(), - } - } else { - TrendStatistic { - title: "Coding Streak".to_string(), - value: format!("{} days", current_streak), - change: "Maintained".to_string(), - change_type: "neutral".to_string(), - period: "vs last week".to_string(), - icon: "".to_string(), - color: "#FF9800".to_string(), - } - }; - trends.push(streak_trend); - - // Focus time trend (replaces productivity) - let daily_average = weekly_time / 3600.0 / 7.0; - let last_week_daily = daily_average * 0.9; - let focus_change = ((daily_average - last_week_daily) / last_week_daily * 100.0).round() as i32; - - let focus_trend = if focus_change > 0 { - TrendStatistic { - title: "Daily Focus Time".to_string(), - value: format!("{:.1}h/day", daily_average), - change: format!("+{}%", focus_change), - change_type: "increase".to_string(), - period: "vs last week".to_string(), - icon: "".to_string(), - color: "#4CAF50".to_string(), - } - } else if focus_change < 0 { - TrendStatistic { - title: "Daily Focus Time".to_string(), - value: format!("{:.1}h/day", daily_average), - change: format!("{}%", focus_change), - change_type: "decrease".to_string(), - period: "vs last week".to_string(), - icon: "".to_string(), - color: "#F44336".to_string(), - } - } else { - TrendStatistic { - title: "Daily Focus Time".to_string(), - value: format!("{:.1}h/day", daily_average), - change: "No change".to_string(), - change_type: "neutral".to_string(), - period: "vs last week".to_string(), - icon: "".to_string(), - color: "#FF9800".to_string(), - } - }; - trends.push(focus_trend); - - trends -} - -async fn generate_chart_data( - dashboard_stats: &serde_json::Value, -) -> Result, String> { - let mut charts = Vec::new(); - - let mut chart_data = Vec::new(); - let mut labels = Vec::new(); - - if let Some(daily_hours) = dashboard_stats["weekly_stats"]["daily_hours"].as_object() { - for (_date, day_data) in daily_hours { - if let Some(hours) = day_data["hours"].as_f64() { - labels.push(day_data["day_name"].as_str().unwrap_or("").to_string()); - chart_data.push(hours); - } - } - } - - if chart_data.is_empty() { - let day_names = vec!["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; - for day in day_names { - labels.push(day.to_string()); - chart_data.push(0.0); - } - } - - charts.push(ChartData { - id: "daily_hours".to_string(), - title: "Daily Coding Hours".to_string(), - chart_type: "bar".to_string(), - data: serde_json::json!({ - "labels": labels, - "datasets": [{ - "label": "Hours", - "data": chart_data, - "backgroundColor": "#FB4B20", - "borderColor": "#FB4B20", - "borderWidth": 1 - }] - }), - period: "Last 7 days".to_string(), - color_scheme: "orange".to_string(), - }); - - // Language distribution pie chart - if let Some(top_language) = dashboard_stats["weekly_stats"]["top_language"].as_object() { - let language_name = top_language["name"].as_str().unwrap_or("Unknown"); - let language_seconds = top_language["seconds"].as_u64().unwrap_or(0) as f64; - let total_seconds = dashboard_stats["weekly_stats"]["time_coded_seconds"] - .as_u64() - .unwrap_or(1) as f64; - let percentage = (language_seconds / total_seconds * 100.0).round() as i32; - - charts.push(ChartData { - id: "language_distribution".to_string(), - title: "Top Language".to_string(), - chart_type: "doughnut".to_string(), - data: serde_json::json!({ - "labels": [language_name, "Others"], - "datasets": [{ - "data": [percentage, 100 - percentage], - "backgroundColor": ["#FB4B20", "#E0E0E0"], - "borderWidth": 0 - }] - }), - period: "This week".to_string(), - color_scheme: "orange".to_string(), - }); - } - - let mut trend_data = Vec::new(); - let mut trend_labels = Vec::new(); - - let current_week_seconds = dashboard_stats["weekly_stats"]["time_coded_seconds"] - .as_u64() - .unwrap_or(0); - - // Simulate 4 weeks of data - for week in 0..4 { - let week_hours = if week == 3 { - current_week_seconds as f64 / 3600.0 - } else if current_week_seconds == 0 { - 0.0 - } else { - // Simulate previous weeks - (current_week_seconds as f64 / 3600.0) * (0.8 + (week as f64 * 0.1)) - }; - - trend_data.push(week_hours); - trend_labels.push(format!("Week {}", 4 - week)); - } - - charts.push(ChartData { - id: "weekly_trend".to_string(), - title: "Weekly Trend".to_string(), - chart_type: "line".to_string(), - data: serde_json::json!({ - "labels": trend_labels, - "datasets": [{ - "label": "Hours", - "data": trend_data, - "borderColor": "#FB4B20", - "backgroundColor": "rgba(251, 75, 32, 0.1)", - "fill": true, - "tension": 0.4 - }] - }), - period: "Last 4 weeks".to_string(), - color_scheme: "orange".to_string(), - }); - - Ok(charts) -} - -async fn generate_insights( - weekly_time: f64, - all_time_time: f64, - current_streak: u64, -) -> Vec { - let mut insights = Vec::new(); - - // Coding consistency insight - let daily_average = weekly_time / 3600.0 / 7.0; - let consistency_insight = if daily_average >= 2.0 { - Insight { - title: "Consistent Coder".to_string(), - description: "You've been coding consistently every day this week!".to_string(), - value: format!("{:.1}h/day", daily_average), - trend: "Great consistency".to_string(), - icon: "".to_string(), - color: "#4CAF50".to_string(), - } - } else if daily_average >= 1.0 { - Insight { - title: "Steady Progress".to_string(), - description: "You're maintaining a good coding rhythm.".to_string(), - value: format!("{:.1}h/day", daily_average), - trend: "Keep it up".to_string(), - icon: "".to_string(), - color: "#FF9800".to_string(), - } - } else { - Insight { - title: "Room for Growth".to_string(), - description: "Try to code a bit more each day to build momentum.".to_string(), - value: format!("{:.1}h/day", daily_average), - trend: "Build momentum".to_string(), - icon: "".to_string(), - color: "#2196F3".to_string(), - } - }; - insights.push(consistency_insight); - - // Streak insight - let streak_insight = if current_streak >= 30 { - Insight { - title: "Streak Master".to_string(), - description: "Incredible! You've been coding for over a month straight!".to_string(), - value: format!("{} days", current_streak), - trend: "Amazing dedication".to_string(), - icon: "".to_string(), - color: "#FFD700".to_string(), - } - } else if current_streak >= 7 { - Insight { - title: "Week Warrior".to_string(), - description: "You've been coding for a full week! Great job!".to_string(), - value: format!("{} days", current_streak), - trend: "Excellent progress".to_string(), - icon: "".to_string(), - color: "#FF5722".to_string(), - } - } else if current_streak > 0 { - Insight { - title: "Getting Started".to_string(), - description: "You're building a coding habit! Keep it going!".to_string(), - value: format!("{} days", current_streak), - trend: "Building momentum".to_string(), - icon: "".to_string(), - color: "#4CAF50".to_string(), - } - } else { - Insight { - title: "Fresh Start".to_string(), - description: "Ready to start your coding journey? Let's begin!".to_string(), - value: "0 days".to_string(), - trend: "Start today".to_string(), - icon: "".to_string(), - color: "#9C27B0".to_string(), - } - }; - insights.push(streak_insight); - - // Total time insight - let total_hours = all_time_time / 3600.0; - let total_insight = if total_hours >= 1000.0 { - Insight { - title: "Coding Veteran".to_string(), - description: "You've logged over 1000 hours of coding! Incredible dedication!" - .to_string(), - value: format!("{:.0}h total", total_hours), - trend: "Expert level".to_string(), - icon: "".to_string(), - color: "#FFD700".to_string(), - } - } else if total_hours >= 100.0 { - Insight { - title: "Experienced Coder".to_string(), - description: "You've put in serious time coding! Keep up the great work!".to_string(), - value: format!("{:.0}h total", total_hours), - trend: "Strong foundation".to_string(), - icon: "".to_string(), - color: "#4CAF50".to_string(), - } - } else if total_hours >= 10.0 { - Insight { - title: "Learning Journey".to_string(), - description: "You're building your coding skills! Every hour counts.".to_string(), - value: format!("{:.0}h total", total_hours), - trend: "Growing skills".to_string(), - icon: "๐Ÿ“š".to_string(), - color: "#2196F3".to_string(), - } - } else { - Insight { - title: "Just Getting Started".to_string(), - description: "Every expert was once a beginner. Keep coding!".to_string(), - value: format!("{:.0}h total", total_hours), - trend: "Beginning journey".to_string(), - icon: "๐ŸŒฑ".to_string(), - color: "#9C27B0".to_string(), - } - }; - insights.push(total_insight); - - insights -} - -async fn analyze_programmer_class(dashboard_stats: &serde_json::Value) -> ProgrammerClass { - // Load programmer classes configuration - let config_path = std::env::current_dir() - .unwrap_or_default() - .join("programmer_classes.json"); - - let config_content = match std::fs::read_to_string(&config_path) { - Ok(content) => content, - Err(_) => { - // Fallback to default class if config file is not found - return ProgrammerClass { - class_name: "Code Explorer".to_string(), - description: "An enthusiastic learner discovering the vast world of programming." - .to_string(), - technologies: vec![ - "HTML".to_string(), - "CSS".to_string(), - "JavaScript".to_string(), - ], - level: "Learning".to_string(), - color: "#9C27B0".to_string(), - }; - } - }; - - let config: serde_json::Value = match serde_json::from_str(&config_content) { - Ok(config) => config, - Err(_) => { - // Fallback to default class if config is invalid - return ProgrammerClass { - class_name: "Code Explorer".to_string(), - description: "An enthusiastic learner discovering the vast world of programming." - .to_string(), - technologies: vec![ - "HTML".to_string(), - "CSS".to_string(), - "JavaScript".to_string(), - ], - level: "Learning".to_string(), - color: "#9C27B0".to_string(), - }; - } - }; - - let total_hours = dashboard_stats["all_time_stats"]["time_coded_seconds"] - .as_u64() - .unwrap_or(0) as f64 - / 3600.0; - - let current_streak = dashboard_stats["current_streak"].as_u64().unwrap_or(0); - - // Simulate language analysis - in a real app, you'd analyze actual language data from the API - let simulated_languages = simulate_language_analysis(total_hours, current_streak); - - // Find the best matching class - let empty_vec = vec![]; - let classes = config["classes"].as_array().unwrap_or(&empty_vec); - let mut best_match: Option<&serde_json::Value> = None; - let mut best_score = 0.0; - - for class in classes { - if let Some(conditions) = class["conditions"].as_object() { - let score = calculate_class_score( - &conditions, - &simulated_languages, - total_hours, - current_streak, - ); - if score > best_score { - best_score = score; - best_match = Some(class); - } - } - } - - // Return the best match or default - if let Some(class) = best_match { - ProgrammerClass { - class_name: class["name"].as_str().unwrap_or("Unknown").to_string(), - description: class["description"].as_str().unwrap_or("").to_string(), - technologies: class["technologies"] - .as_array() - .unwrap_or(&vec![]) - .iter() - .filter_map(|t| t.as_str()) - .map(|s| s.to_string()) - .collect(), - level: class["level"].as_str().unwrap_or("Unknown").to_string(), - color: class["color"].as_str().unwrap_or("#9C27B0").to_string(), - } - } else { - // Default fallback - ProgrammerClass { - class_name: "Code Explorer".to_string(), - description: "An enthusiastic learner discovering the vast world of programming." - .to_string(), - technologies: vec![ - "HTML".to_string(), - "CSS".to_string(), - "JavaScript".to_string(), - ], - level: "Learning".to_string(), - color: "#9C27B0".to_string(), - } - } -} - -fn simulate_language_analysis(total_hours: f64, current_streak: u64) -> Vec { - // Simulate language usage based on coding patterns - // In a real app, this would come from actual language data from the API - let mut languages = Vec::new(); - - // Simulate language distribution based on experience level - if total_hours >= 100.0 { - // Experienced developers - languages.push("JavaScript".to_string()); - languages.push("Python".to_string()); - languages.push("Java".to_string()); - if current_streak >= 7 { - languages.push("Rust".to_string()); - languages.push("Go".to_string()); - } - } else if total_hours >= 20.0 { - // Intermediate developers - languages.push("JavaScript".to_string()); - languages.push("Python".to_string()); - if current_streak >= 5 { - languages.push("TypeScript".to_string()); - } - } else { - // Beginners - languages.push("HTML".to_string()); - languages.push("CSS".to_string()); - languages.push("JavaScript".to_string()); - } - - languages -} - -fn calculate_class_score( - conditions: &serde_json::Map, - languages: &[String], - total_hours: f64, - current_streak: u64, -) -> f64 { - let mut score = 0.0; - - // Check primary languages match - if let Some(primary_langs) = conditions - .get("primary_languages") - .and_then(|v| v.as_array()) - { - let primary_lang_count = primary_langs - .iter() - .filter_map(|lang| lang.as_str()) - .filter(|lang| languages.contains(&lang.to_string())) - .count(); - score += primary_lang_count as f64 * 2.0; // Weight primary languages heavily - } - - // Check language count for polyglot - if let Some(lang_count) = conditions.get("language_count").and_then(|v| v.as_u64()) { - if languages.len() as u64 >= lang_count { - score += 3.0; // Bonus for being a polyglot - } - } - - // Check minimum hours - if let Some(min_hours) = conditions.get("min_hours").and_then(|v| v.as_f64()) { - if total_hours >= min_hours { - score += 1.0; - } else { - score -= 0.5; // Penalty for not meeting minimum - } - } - - // Check maximum hours for beginners - if let Some(max_hours) = conditions.get("max_hours").and_then(|v| v.as_f64()) { - if total_hours <= max_hours { - score += 1.0; - } else { - score -= 0.5; // Penalty for being too experienced - } - } - - // Check minimum streak - if let Some(min_streak) = conditions.get("min_streak").and_then(|v| v.as_u64()) { - if current_streak >= min_streak { - score += 0.5; - } - } - - score +fn get_recent_logs() -> Vec { + LOG_BUFFER.lock().unwrap().clone() } #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -2142,512 +85,352 @@ pub fn run() { }))) .invoke_handler(tauri::generate_handler![ greet, - get_api_config, - set_api_config, - get_auth_state, - authenticate_with_rails, - handle_auth_callback, - handle_deep_link_callback, - logout, - test_auth_callback, - authenticate_with_direct_oauth, - get_api_key, - setup_hackatime_macos_linux, - setup_hackatime_windows, - test_hackatime_heartbeat, - setup_hackatime_complete, - save_auth_state, - load_auth_state, - clear_auth_state, - get_latest_heartbeat, - get_hackatime_directories, - cleanup_old_sessions, - get_session_stats, - test_database_connection, - discord_rpc_connect, - discord_rpc_disconnect, - discord_rpc_set_activity, - discord_rpc_clear_activity, - discord_rpc_get_state, - discord_rpc_update_from_heartbeat, - discord_rpc_auto_connect, - discord_rpc_auto_disconnect, - get_current_session, - get_projects, - get_project_details, - get_discord_rpc_enabled, - set_discord_rpc_enabled, - get_statistics_data, - show_window, - hide_window, - toggle_window, - get_app_status + get_recent_logs, + + database::get_platform_info, + + config::get_api_config, + config::set_api_config, + + auth::get_auth_state, + auth::authenticate_with_rails, + auth::handle_auth_callback, + auth::handle_deep_link_callback, + auth::logout, + auth::test_auth_callback, + auth::authenticate_with_direct_oauth, + auth::get_api_key, + auth::save_auth_state, + auth::load_auth_state, + auth::clear_auth_state, + + setup::setup_hackatime_macos_linux, + setup::setup_hackatime_windows, + setup::test_hackatime_heartbeat, + setup::setup_hackatime_complete, + setup::check_wakatime_config, + setup::apply_wakatime_config, + + session::get_latest_heartbeat, + session::get_current_session, + session::get_app_status, + + db_commands::get_hackatime_directories, + db_commands::cleanup_old_sessions, + db_commands::get_session_stats, + db_commands::test_database_connection, + db_commands::clear_statistics_cache, + + discord_rpc::discord_rpc_connect, + discord_rpc::discord_rpc_disconnect, + discord_rpc::discord_rpc_set_activity, + discord_rpc::discord_rpc_clear_activity, + discord_rpc::discord_rpc_get_state, + discord_rpc::discord_rpc_update_from_heartbeat, + discord_rpc::discord_rpc_auto_connect, + discord_rpc::discord_rpc_auto_disconnect, + discord_rpc::get_discord_rpc_enabled, + discord_rpc::set_discord_rpc_enabled, + + projects::get_projects, + projects::get_project_details, + + statistics::get_statistics_data, + statistics::get_dashboard_stats, + + window::show_window, + window::hide_window, + window::toggle_window, ]) .setup(|app| { - // Create system tray menu items - let show_item = MenuItem::with_id(app, "show", "Show Hackatime", true, None::<&str>)?; - let status_item = MenuItem::with_id(app, "status", "๐Ÿ“Š Session Status", true, None::<&str>)?; - let discord_item = MenuItem::with_id(app, "discord", "๐ŸŽฎ Discord RPC", true, None::<&str>)?; - let settings_item = MenuItem::with_id(app, "settings", "โš™๏ธ Settings", true, None::<&str>)?; - let about_item = MenuItem::with_id(app, "about", "โ„น๏ธ About", true, None::<&str>)?; - let help_item = MenuItem::with_id(app, "help", "๐Ÿ“– Help", true, None::<&str>)?; - let quit_item = MenuItem::with_id(app, "quit", "โŒ Quit", true, None::<&str>)?; + push_log("info", "backend", "backend starting".to_string()); - // Create menu with items - let menu = Menu::with_items(app, &[ - &show_item, - &tauri::menu::PredefinedMenuItem::separator(app)?, - &status_item, - &discord_item, - &settings_item, - &tauri::menu::PredefinedMenuItem::separator(app)?, - &about_item, - &help_item, - &tauri::menu::PredefinedMenuItem::separator(app)?, - &quit_item, - ])?; - - // Create tray icon with menu and event handlers - let _tray_icon = TrayIconBuilder::new() - .icon(app.default_window_icon().unwrap().clone()) - .menu(&menu) - .show_menu_on_left_click(true) - .on_menu_event(|app, event| { - match event.id.as_ref() { - "show" => { - if let Some(window) = app.get_webview_window("main") { - let _ = window.show(); - let _ = window.set_focus(); - } - } - "status" => { - println!("๐Ÿ“Š Session Status:"); - println!(" - Authentication: Checking..."); - println!(" - Active Session: Checking..."); - println!(" - Discord RPC: Checking..."); - println!(" - Last Heartbeat: Checking..."); - } - "discord" => { - println!("๐ŸŽฎ Discord RPC Status:"); - println!(" - Connection: Checking..."); - println!(" - Activity: Checking..."); - } - "settings" => { - println!("โš™๏ธ Opening Settings..."); - if let Some(window) = app.get_webview_window("main") { - let _ = window.show(); - let _ = window.set_focus(); - } - } - "about" => { - println!("โ„น๏ธ Hackatime Desktop v0.1.0"); - println!(" - Coding Time Tracker"); - println!(" - Discord RPC Integration"); - println!(" - Cross-platform Support"); - } - "help" => { - println!("๐Ÿ“– Help & Documentation:"); - println!(" - Left-click tray icon to toggle window"); - println!(" - Right-click for menu options"); - println!(" - Window closes to tray (not taskbar)"); - } - "quit" => { - println!("โŒ Quitting Hackatime Desktop..."); - app.exit(0); - } - _ => {} - } - }) - .on_tray_icon_event(|tray, event| { - match event { - TrayIconEvent::Click { - button: MouseButton::Left, - button_state: MouseButtonState::Up, - .. - } => { - println!("๐ŸชŸ Left click on tray icon"); - let app = tray.app_handle(); - if let Some(window) = app.get_webview_window("main") { - if window.is_visible().unwrap_or(false) { - println!("๐ŸชŸ Hiding window to tray"); - let _ = window.hide(); - } else { - println!("๐ŸชŸ Showing window from tray"); - let _ = window.show(); - let _ = window.set_focus(); - } - } - } - TrayIconEvent::DoubleClick { - button: MouseButton::Left, - .. - } => { - println!("๐ŸชŸ Double-click: showing window"); - let app = tray.app_handle(); - if let Some(window) = app.get_webview_window("main") { - let _ = window.show(); - let _ = window.set_focus(); - } - } - _ => { - println!("๐Ÿ–ฑ๏ธ Other tray event: {:?}", event); - } - } - }) - .build(app)?; + if let Some(window) = app.get_webview_window("main") { + + #[cfg(target_os = "macos")] + { + window.set_title_bar_style(TitleBarStyle::Transparent).unwrap(); + + + #[allow(deprecated)] + #[allow(unexpected_cfgs)] + { + use cocoa::appkit::{NSColor, NSWindow}; + use cocoa::base::{id, nil}; - // Load saved auth state on startup synchronously + let ns_window = window.ns_window().unwrap() as id; + unsafe { + use objc::{msg_send, sel, sel_impl}; + + + let bg_color = NSColor::clearColor(nil); + ns_window.setBackgroundColor_(bg_color); + + ns_window.setOpaque_(false); + + let content_view: id = msg_send![ns_window, contentView]; + let _: () = msg_send![content_view, setWantsLayer: true]; + + let layer: id = msg_send![content_view, layer]; + let _: () = msg_send![layer, setCornerRadius: 12.0f64]; + let _: () = msg_send![layer, setMasksToBounds: true]; + } + } + } + } + + + if let Err(e) = menu::setup_app_menu(&app.handle()) { + eprintln!("Failed to setup app menu: {}", e); + } + + + if let Err(e) = tray::setup_tray(&app.handle()) { + eprintln!("Failed to setup tray: {}", e); + } + + let auth_state = app.state::>>(); let auth_state_clone = auth_state.inner().clone(); - // Load auth state immediately on startup + tauri::async_runtime::block_on(async { - match load_auth_state().await { + match auth::load_auth_state().await { Ok(Some(saved_auth_state)) => { let mut current_auth_state = auth_state_clone.lock().await; *current_auth_state = saved_auth_state; - println!("Loaded saved authentication state on startup"); + push_log("info", "backend", "Loaded saved authentication state on startup".to_string()); } Ok(None) => { - println!("No saved authentication state found"); + push_log("info", "backend", "No saved authentication state found".to_string()); } Err(e) => { - println!("Failed to load saved authentication state: {}", e); + push_log("error", "backend", format!("Failed to load saved authentication state: {}", e)); } } }); - // Auto-connect Discord RPC on startup + let discord_rpc_state = app.state::>>(); let discord_rpc_clone = discord_rpc_state.inner().clone(); tauri::async_runtime::spawn(async move { let mut rpc_service = discord_rpc_clone.lock().await; match rpc_service.auto_connect() { - Ok(_) => println!("Discord RPC auto-connected on startup"), - Err(e) => println!("Discord RPC auto-connect failed (this is optional): {}", e), + Ok(_) => push_log("info", "backend", "Discord RPC auto-connected on startup".to_string()), + Err(e) => push_log("warn", "backend", format!("Discord RPC auto-connect failed (this is optional): {}", e)), } }); - // Register deep link scheme for development + #[cfg(any(target_os = "linux", all(debug_assertions, target_os = "windows")))] { app.deep_link().register_all().unwrap_or_else(|e| { - eprintln!("Failed to register deep links: {}", e); + push_log("error", "backend", format!("Failed to register deep links: {}", e)); }); } - // Handle deep links when app is already running - let app_handle = app.handle().clone(); - app.deep_link().on_open_url(move |event| { - let urls = event.urls(); - println!("Deep link received: {:?}", urls); - - for url in urls { - let url_string = url.to_string(); - if url_string.starts_with("hackatime://auth/callback") { - if let Some(query_start) = url_string.find('?') { - let query = &url_string[query_start + 1..]; - let params: Vec<&str> = query.split('&').collect(); - - let mut code = None; - let mut state = None; - let mut error = None; - - for param in params { - if param.starts_with("code=") { - code = Some(param[5..].to_string()); - } else if param.starts_with("state=") { - state = Some(param[6..].to_string()); - } else if param.starts_with("error=") { - error = Some(param[6..].to_string()); - } - } - - if let Some(error) = error { - println!("OAuth error: {}", error); - continue; - } - - if let Some(code) = code { - if let Some(state) = state { - println!("Extracted authorization code: {} and state: {}", code, state); - - let api_config = app_handle.state::(); - let auth_state = app_handle.state::>>(); - let pkce_state = app_handle.state::>>>(); - - let code_clone = code.clone(); - let state_clone = state.clone(); - let api_config_clone = api_config.inner().clone(); - let auth_state_clone = auth_state.inner().clone(); - let pkce_state_clone = pkce_state.inner().clone(); - - tauri::async_runtime::spawn(async move { - let client = reqwest::Client::new(); - - let stored_pkce = { - let pkce_guard = pkce_state_clone.lock().await; - pkce_guard.clone() - }; - - let pkce = match stored_pkce { - Some(pkce) => { - if pkce.is_expired(600) { - eprintln!("PKCE state expired. Please restart authentication."); - return; - } - - if pkce.state != state_clone { - eprintln!("State parameter mismatch. Possible CSRF attack."); - return; - } - - pkce - } - None => { - eprintln!("No PKCE state found. Please restart authentication."); - return; - } - }; - - let response = client - .post(&format!("{}/oauth/token", api_config_clone.base_url)) - .form(&[ - ("grant_type", "authorization_code"), - ("code", &code_clone), - ("client_id", "BPr5VekIV-xuQ2ZhmxbGaahJ3XVd7gM83pql-HYGYxQ"), - ("redirect_uri", "hackatime://auth/callback"), - ("code_verifier", &pkce.code_verifier), - ]) - .send() - .await; - - match response { - Ok(resp) => { - if resp.status().is_success() { - if let Ok(token_response) = resp.json::().await { - if let Some(access_token) = token_response["access_token"].as_str() { - let user_response = client - .get(&format!("{}/api/v1/authenticated/me", api_config_clone.base_url)) - .bearer_auth(access_token) - .send() - .await; - - let user_info = match user_response { - Ok(resp) if resp.status().is_success() => { - resp.json::().await.unwrap_or_else(|_| serde_json::json!({})) - } - _ => serde_json::json!({}) - }; - - let mut user_info_map = HashMap::new(); - if let Some(obj) = user_info.as_object() { - for (key, value) in obj { - user_info_map.insert(key.clone(), value.clone()); - } - } - - let mut auth_state = auth_state_clone.lock().await; - auth_state.is_authenticated = true; - auth_state.access_token = Some(access_token.to_string()); - auth_state.user_info = Some(user_info_map); - - let auth_state_to_save = auth_state.clone(); - drop(auth_state); // Release the lock before the async call - if let Err(e) = save_auth_state(auth_state_to_save).await { - eprintln!("Failed to save auth state: {}", e); - } - - { - let mut stored_pkce = pkce_state_clone.lock().await; - *stored_pkce = None; - } - - println!("OAuth authentication successful!"); - } - } - } else { - eprintln!("Token exchange failed with status: {}", resp.status()); - } - } - Err(e) => { - eprintln!("Failed to exchange token: {}", e); - } - } - }); - } else { - println!("No state parameter found in OAuth callback"); - } - } - } - } - } - }); - - if let Some(start_urls) = app.deep_link().get_current().unwrap_or_default() { - println!("App started with deep link: {:?}", start_urls); - for url in start_urls { - let url_string = url.to_string(); - if url_string.starts_with("hackatime://auth/callback") { - if let Some(query_start) = url_string.find('?') { - let query = &url_string[query_start + 1..]; - let params: Vec<&str> = query.split('&').collect(); - - let mut code = None; - let mut state = None; - let mut error = None; - - for param in params { - if param.starts_with("code=") { - code = Some(param[5..].to_string()); - } else if param.starts_with("state=") { - state = Some(param[6..].to_string()); - } else if param.starts_with("error=") { - error = Some(param[6..].to_string()); - } - } - - if let Some(error) = error { - println!("OAuth error on startup: {}", error); - continue; - } - - if let Some(code) = code { - if let Some(state) = state { - println!("Startup deep link authorization code: {} and state: {}", code, state); - - let api_config = app.state::(); - let auth_state = app.state::>>(); - let pkce_state = app.state::>>>(); - - let code_clone = code.clone(); - let state_clone = state.clone(); - let api_config_clone = api_config.inner().clone(); - let auth_state_clone = auth_state.inner().clone(); - let pkce_state_clone = pkce_state.inner().clone(); - - tauri::async_runtime::spawn(async move { - let client = reqwest::Client::new(); - - let stored_pkce = { - let pkce_guard = pkce_state_clone.lock().await; - pkce_guard.clone() - }; - - let pkce = match stored_pkce { - Some(pkce) => { - if pkce.is_expired(600) { - eprintln!("PKCE state expired. Please restart authentication."); - return; - } - - if pkce.state != state_clone { - eprintln!("State parameter mismatch. Possible CSRF attack."); - return; - } - - pkce - } - None => { - eprintln!("No PKCE state found. Please restart authentication."); - return; - } - }; - - let response = client - .post(&format!("{}/oauth/token", api_config_clone.base_url)) - .form(&[ - ("grant_type", "authorization_code"), - ("code", &code_clone), - ("client_id", "BPr5VekIV-xuQ2ZhmxbGaahJ3XVd7gM83pql-HYGYxQ"), - ("redirect_uri", "hackatime://auth/callback"), - ("code_verifier", &pkce.code_verifier), - ]) - .send() - .await; - - match response { - Ok(resp) => { - if resp.status().is_success() { - if let Ok(token_response) = resp.json::().await { - if let Some(access_token) = token_response["access_token"].as_str() { - let user_response = client - .get(&format!("{}/api/v1/authenticated/me", api_config_clone.base_url)) - .bearer_auth(access_token) - .send() - .await; - - let user_info = match user_response { - Ok(resp) if resp.status().is_success() => { - resp.json::().await.unwrap_or_else(|_| serde_json::json!({})) - } - _ => serde_json::json!({}) - }; - - let mut user_info_map = HashMap::new(); - if let Some(obj) = user_info.as_object() { - for (key, value) in obj { - user_info_map.insert(key.clone(), value.clone()); - } - } - - let mut auth_state = auth_state_clone.lock().await; - auth_state.is_authenticated = true; - auth_state.access_token = Some(access_token.to_string()); - auth_state.user_info = Some(user_info_map); - - let auth_state_to_save = auth_state.clone(); - drop(auth_state); - if let Err(e) = save_auth_state(auth_state_to_save).await { - eprintln!("Failed to save auth state: {}", e); - } - - { - let mut stored_pkce = pkce_state_clone.lock().await; - *stored_pkce = None; - } - - println!("Startup OAuth authentication successful!"); - } - } - } else { - eprintln!("Startup token exchange failed with status: {}", resp.status()); - } - } - Err(e) => { - eprintln!("Failed to handle startup auth callback: {}", e); - } - } - }); - } else { - println!("No state parameter found in startup OAuth callback"); - } - } - } - } - } - } - // Handle window close events to hide to tray instead of closing + setup_deep_link_handlers(app); + + if let Some(window) = app.get_webview_window("main") { let window_handle = window.clone(); let _ = window.on_window_event(move |event| { match event { WindowEvent::CloseRequested { api, .. } => { - println!("๐ŸชŸ Window close requested - hiding to tray"); + push_log("info", "backend", "๐ŸชŸ Window close requested - hiding to tray".to_string()); api.prevent_close(); let _ = window_handle.hide(); - println!("โœ… Window hidden to tray"); + push_log("info", "backend", "โœ… Window hidden to tray".to_string()); } _ => {} } }); } - Ok(()) }) .run(tauri::generate_context!()) .expect("error while running tauri application"); } + +fn setup_deep_link_handlers(app: &mut tauri::App) { + + let app_handle = app.handle().clone(); + app.deep_link().on_open_url(move |event| { + let urls = event.urls(); + push_log("info", "backend", format!("Deep link received: {:?}", urls)); + + for url in urls { + let url_string = url.to_string(); + if url_string.starts_with("hackatime://auth/callback") { + handle_oauth_callback(&app_handle, &url_string); + } + } + }); + + + if let Some(start_urls) = app.deep_link().get_current().unwrap_or_default() { + push_log("info", "backend", format!("App started with deep link: {:?}", start_urls)); + let app_handle = app.handle().clone(); + for url in start_urls { + let url_string = url.to_string(); + if url_string.starts_with("hackatime://auth/callback") { + handle_oauth_callback(&app_handle, &url_string); + } + } + } +} + +fn handle_oauth_callback(app_handle: &tauri::AppHandle, url_string: &str) { + if let Some(query_start) = url_string.find('?') { + let query = &url_string[query_start + 1..]; + let params: Vec<&str> = query.split('&').collect(); + + let mut code = None; + let mut state = None; + let mut error = None; + + for param in params { + if param.starts_with("code=") { + code = Some(param[5..].to_string()); + } else if param.starts_with("state=") { + state = Some(param[6..].to_string()); + } else if param.starts_with("error=") { + error = Some(param[6..].to_string()); + } + } + + if let Some(error) = error { + push_log("error", "backend", format!("OAuth error: {}", error)); + return; + } + + if let (Some(code), Some(state)) = (code, state) { + push_log("info", "backend", format!("Extracted authorization code: {} and state: {}", code, state)); + + let api_config = app_handle.state::(); + let auth_state = app_handle.state::>>(); + let pkce_state = app_handle.state::>>>(); + + let code_clone = code.clone(); + let state_clone = state.clone(); + let api_config_clone = api_config.inner().clone(); + let auth_state_clone = auth_state.inner().clone(); + let pkce_state_clone = pkce_state.inner().clone(); + + tauri::async_runtime::spawn(async move { + process_oauth_token_exchange( + code_clone, + state_clone, + api_config_clone, + auth_state_clone, + pkce_state_clone, + ).await; + }); + } else { + push_log("warn", "backend", "Missing code or state parameter in OAuth callback".to_string()); + } + } +} + +async fn process_oauth_token_exchange( + code: String, + state: String, + api_config: ApiConfig, + auth_state: Arc>, + pkce_state: Arc>>, +) { + let client = reqwest::Client::new(); + + let stored_pkce = { + let pkce_guard = pkce_state.lock().await; + pkce_guard.clone() + }; + + let pkce = match stored_pkce { + Some(pkce) => { + if pkce.is_expired(600) { + eprintln!("PKCE state expired. Please restart authentication."); + return; + } + + if pkce.state != state { + eprintln!("State parameter mismatch. Possible CSRF attack."); + return; + } + + pkce + } + None => { + eprintln!("No PKCE state found. Please restart authentication."); + return; + } + }; + + let response = client + .post(&format!("{}/oauth/token", api_config.base_url)) + .form(&[ + ("grant_type", "authorization_code"), + ("code", &code), + ("client_id", "BPr5VekIV-xuQ2ZhmxbGaahJ3XVd7gM83pql-HYGYxQ"), + ("redirect_uri", "hackatime://auth/callback"), + ("code_verifier", &pkce.code_verifier), + ]) + .send() + .await; + + match response { + Ok(resp) => { + if resp.status().is_success() { + if let Ok(token_response) = resp.json::().await { + if let Some(access_token) = token_response["access_token"].as_str() { + let user_response = client + .get(&format!("{}/api/v1/authenticated/me", api_config.base_url)) + .bearer_auth(access_token) + .send() + .await; + + let user_info = match user_response { + Ok(resp) if resp.status().is_success() => { + resp.json::().await.unwrap_or_else(|_| serde_json::json!({})) + } + _ => serde_json::json!({}) + }; + + let mut user_info_map = HashMap::new(); + if let Some(obj) = user_info.as_object() { + for (key, value) in obj { + user_info_map.insert(key.clone(), value.clone()); + } + } + + let mut auth_state = auth_state.lock().await; + auth_state.is_authenticated = true; + auth_state.access_token = Some(access_token.to_string()); + auth_state.user_info = Some(user_info_map); + + let auth_state_to_save = auth_state.clone(); + drop(auth_state); + if let Err(e) = auth::save_auth_state(auth_state_to_save).await { + eprintln!("Failed to save auth state: {}", e); + } + + { + let mut stored_pkce = pkce_state.lock().await; + *stored_pkce = None; + } + + println!("OAuth authentication successful!"); + } + } + } else { + eprintln!("Token exchange failed with status: {}", resp.status()); + } + } + Err(e) => { + eprintln!("Failed to exchange token: {}", e); + } + } +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index bea3c23..4302ce5 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,4 +1,4 @@ -// Prevents additional console window on Windows in release, DO NOT REMOVE!! + #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs new file mode 100644 index 0000000..427fe81 --- /dev/null +++ b/src-tauri/src/menu.rs @@ -0,0 +1,87 @@ +use tauri::{AppHandle, Manager}; +use tauri::menu::{Menu, MenuItem, PredefinedMenuItem, Submenu}; +use crate::push_log; + +pub fn setup_app_menu(app: &AppHandle) -> Result<(), Box> { + + let about_quit = MenuItem::with_id(app, "quit", "Quit Hackatime", true, None::<&str>)?; + let about_menu = Submenu::with_items( + app, + "About", + true, + &[ + &about_quit, + ], + )?; + + + let file_new = MenuItem::with_id(app, "show", "Show Window", true, None::<&str>)?; + let file_hide = MenuItem::with_id(app, "hide", "Hide Window", true, None::<&str>)?; + let file_menu = Submenu::with_items( + app, + "File", + true, + &[ + &file_new, + &file_hide, + ], + )?; + + + let edit_undo = PredefinedMenuItem::undo(app, Some("Undo"))?; + let edit_redo = PredefinedMenuItem::redo(app, Some("Redo"))?; + let edit_cut = PredefinedMenuItem::cut(app, Some("Cut"))?; + let edit_copy = PredefinedMenuItem::copy(app, Some("Copy"))?; + let edit_paste = PredefinedMenuItem::paste(app, Some("Paste"))?; + let edit_select_all = PredefinedMenuItem::select_all(app, Some("Select All"))?; + let edit_menu = Submenu::with_items( + app, + "Edit", + true, + &[ + &edit_undo, + &edit_redo, + &PredefinedMenuItem::separator(app)?, + &edit_cut, + &edit_copy, + &edit_paste, + &PredefinedMenuItem::separator(app)?, + &edit_select_all, + ], + )?; + + + let help_item = MenuItem::with_id(app, "help", "Help", true, None::<&str>)?; + let help_menu = Submenu::with_items(app, "Help", true, &[&help_item])?; + + + let app_menu = Menu::with_items(app, &[&about_menu, &file_menu, &edit_menu, &help_menu])?; + + app.set_menu(app_menu)?; + + + app.on_menu_event(|app, event| { + match event.id.as_ref() { + "quit" => app.exit(0), + "show" => { + if let Some(window) = app.get_webview_window("main") { + let _ = window.show(); + let _ = window.set_focus(); + } + } + "hide" => { + if let Some(window) = app.get_webview_window("main") { + let _ = window.hide(); + } + } + "help" => { + push_log("info", "backend", "๐Ÿ“– Help: Window closes to tray. Use the menu bar or tray icon to reopen.".to_string()); + } + _ => {} + } + }); + + Ok(()) +} + + diff --git a/src-tauri/src/projects.rs b/src-tauri/src/projects.rs new file mode 100644 index 0000000..5d0525d --- /dev/null +++ b/src-tauri/src/projects.rs @@ -0,0 +1,106 @@ +use std::sync::Arc; +use tauri::State; + +use crate::auth::AuthState; +use crate::config::ApiConfig; + +#[tauri::command] +pub async fn get_projects( + api_config: ApiConfig, + state: State<'_, Arc>>, +) -> Result { + let auth_state = state.lock().await; + + if !auth_state.is_authenticated { + return Err("Not authenticated".to_string()); + } + + let base_url = if api_config.base_url.is_empty() { + "https://hackatime.hackclub.com" + } else { + &api_config.base_url + }; + + let access_token = auth_state + .access_token + .as_ref() + .ok_or("No access token available")?; + + let client = reqwest::Client::new(); + let response = client + .get(&format!( + "{}/api/v1/authenticated/projects", + base_url + )) + .bearer_auth(access_token) + .send() + .await + .map_err(|e| format!("Failed to fetch projects: {}", e))?; + + if !response.status().is_success() { + let error_text = response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + return Err(format!("Projects request failed: {}", error_text)); + } + + let projects_response: serde_json::Value = response + .json() + .await + .map_err(|e| format!("Failed to parse projects response: {}", e))?; + + Ok(projects_response) +} + +#[tauri::command] +pub async fn get_project_details( + project_name: String, + api_config: ApiConfig, + state: State<'_, Arc>>, +) -> Result { + let auth_state = state.lock().await; + + if !auth_state.is_authenticated { + return Err("Not authenticated".to_string()); + } + + let base_url = if api_config.base_url.is_empty() { + "https://hackatime.hackclub.com" + } else { + &api_config.base_url + }; + + let access_token = auth_state + .access_token + .as_ref() + .ok_or("No access token available")?; + + let client = reqwest::Client::new(); + let response = client + .get(&format!( + "{}/api/v1/authenticated/projects/{}", + base_url, + urlencoding::encode(&project_name) + )) + .bearer_auth(access_token) + .send() + .await + .map_err(|e| format!("Failed to fetch project details: {}", e))?; + + if !response.status().is_success() { + let error_text = response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + return Err(format!("Project details request failed: {}", error_text)); + } + + let project_response: serde_json::Value = response + .json() + .await + .map_err(|e| format!("Failed to parse project response: {}", e))?; + + Ok(project_response) +} + diff --git a/src-tauri/src/session.rs b/src-tauri/src/session.rs new file mode 100644 index 0000000..a426a4e --- /dev/null +++ b/src-tauri/src/session.rs @@ -0,0 +1,246 @@ +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tauri::State; + +use crate::auth::AuthState; +use crate::config::ApiConfig; +use crate::discord_rpc::DiscordRpcService; +use crate::push_log; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct HeartbeatData { + pub id: u32, + pub project: Option, + pub editor: Option, + pub language: Option, + pub entity: Option, + pub time: f64, + pub timestamp: i64, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct SessionState { + pub is_active: bool, + pub start_time: Option, + pub last_heartbeat_id: Option, + pub heartbeat_count: u32, + pub project: Option, + pub editor: Option, + pub language: Option, + pub entity: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct HeartbeatResponse { + pub heartbeat: Option, +} + +#[tauri::command] +pub async fn get_latest_heartbeat( + api_config: ApiConfig, + state: State<'_, Arc>>, + discord_rpc_state: State<'_, Arc>>, + session_state: State<'_, Arc>>, +) -> Result { + let auth_state = state.lock().await; + + if !auth_state.is_authenticated { + return Err("Not authenticated".to_string()); + } + + let base_url = if api_config.base_url.is_empty() { + "https://hackatime.hackclub.com" + } else { + &api_config.base_url + }; + + let access_token = auth_state + .access_token + .as_ref() + .ok_or("No access token available")?; + + let client = reqwest::Client::new(); + let response = client + .get(&format!( + "{}/api/v1/authenticated/heartbeats/latest", + base_url + )) + .bearer_auth(access_token) + .send() + .await + .map_err(|e| format!("Failed to get latest heartbeat: {}", e))?; + + let status = response.status(); + if !status.is_success() { + let error_text = response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + + + if status == 429 { + push_log("warn", "backend", "Rate limited, will retry later".to_string()); + return Err(format!("Rate limited: {}", error_text)); + } + + return Err(format!("Failed to get latest heartbeat: {}", error_text)); + } + + let heartbeat_response: HeartbeatResponse = response + .json() + .await + .map_err(|e| format!("Failed to parse heartbeat response: {}", e))?; + + + if let Some(heartbeat) = &heartbeat_response.heartbeat { + let mut session = session_state.lock().await; + let current_time = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + + + let heartbeat_age = current_time - heartbeat.timestamp; + let is_recent = heartbeat_age < 120; + + + let is_duplicate = session.last_heartbeat_id == Some(heartbeat.id); + + if is_duplicate { + + push_log("info", "backend", "Duplicate heartbeat detected, ending session".to_string()); + session.is_active = false; + session.start_time = None; + session.last_heartbeat_id = None; + session.heartbeat_count = 0; + session.project = None; + session.editor = None; + session.language = None; + session.entity = None; + + + let mut discord_rpc = discord_rpc_state.lock().await; + if discord_rpc.is_connected() { + let _ = discord_rpc.clear_activity(); + } + } else if is_recent && !session.is_active { + + push_log("info", "backend", "Recent heartbeat detected, starting new session".to_string()); + session.is_active = true; + session.start_time = Some(heartbeat.timestamp); + session.last_heartbeat_id = Some(heartbeat.id); + session.heartbeat_count = 1; + session.project = heartbeat.project.clone(); + session.editor = heartbeat.editor.clone(); + session.language = heartbeat.language.clone(); + session.entity = heartbeat.entity.clone(); + + + let mut discord_rpc = discord_rpc_state.lock().await; + if discord_rpc.is_connected() { + if let Err(e) = + discord_rpc.update_activity_from_session(heartbeat, heartbeat.timestamp) + { + push_log("warn", "backend", format!("Failed to update Discord RPC: {}", e)); + } + } + } else if is_recent && session.is_active { + + session.last_heartbeat_id = Some(heartbeat.id); + session.heartbeat_count += 1; + session.project = heartbeat.project.clone(); + session.editor = heartbeat.editor.clone(); + session.language = heartbeat.language.clone(); + session.entity = heartbeat.entity.clone(); + + + let mut discord_rpc = discord_rpc_state.lock().await; + if discord_rpc.is_connected() { + if let Err(e) = discord_rpc.update_activity_from_session( + heartbeat, + session.start_time.unwrap_or(heartbeat.timestamp), + ) { + push_log("warn", "backend", format!("Failed to update Discord RPC: {}", e)); + } + } + } else if !is_recent && session.is_active { + + push_log("info", "backend", "Heartbeat too old, ending session".to_string()); + session.is_active = false; + session.start_time = None; + session.last_heartbeat_id = None; + session.heartbeat_count = 0; + session.project = None; + session.editor = None; + session.language = None; + session.entity = None; + + + let mut discord_rpc = discord_rpc_state.lock().await; + if discord_rpc.is_connected() { + let _ = discord_rpc.clear_activity(); + } + } + } else { + + let mut session = session_state.lock().await; + if session.is_active { + push_log("info", "backend", "No heartbeat data, ending session".to_string()); + session.is_active = false; + session.start_time = None; + session.last_heartbeat_id = None; + session.heartbeat_count = 0; + session.project = None; + session.editor = None; + session.language = None; + session.entity = None; + + + let mut discord_rpc = discord_rpc_state.lock().await; + if discord_rpc.is_connected() { + let _ = discord_rpc.clear_activity(); + } + } + } + + Ok(heartbeat_response) +} + +#[tauri::command] +pub async fn get_current_session( + session_state: State<'_, Arc>>, +) -> Result { + let session = session_state.lock().await; + Ok(session.clone()) +} + +#[tauri::command] +pub async fn get_app_status( + auth_state: State<'_, Arc>>, + session_state: State<'_, Arc>>, + discord_rpc_state: State<'_, Arc>>, +) -> Result { + let auth = auth_state.lock().await; + let session = session_state.lock().await; + let discord_rpc = discord_rpc_state.lock().await; + + Ok(serde_json::json!({ + "authenticated": auth.is_authenticated, + "session_active": session.is_active, + "session_duration": if session.is_active && session.start_time.is_some() { + let current_time = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + current_time - session.start_time.unwrap() + } else { + 0 + }, + "project": session.project.clone().unwrap_or_else(|| "No project".to_string()), + "editor": session.editor.clone().unwrap_or_else(|| "No editor".to_string()), + "language": session.language.clone().unwrap_or_else(|| "No language".to_string()), + "discord_connected": discord_rpc.is_connected(), + "heartbeat_count": session.heartbeat_count + })) +} + diff --git a/src-tauri/src/setup.rs b/src-tauri/src/setup.rs new file mode 100644 index 0000000..78ae416 --- /dev/null +++ b/src-tauri/src/setup.rs @@ -0,0 +1,263 @@ +use std::fs; +use std::path::Path; +use serde::{Serialize, Deserialize}; + +#[derive(Serialize, Deserialize)] +pub struct WakatimeConfigCheck { + pub exists: bool, + pub matches: bool, + pub expected_content: String, + pub actual_content: Option, + pub config_path: String, +} + +fn get_wakatime_config_path() -> Result { + #[cfg(target_os = "windows")] + { + let userprofile = std::env::var("USERPROFILE") + .map_err(|_| "Failed to get USERPROFILE directory")?; + Ok(format!("{}\\.wakatime.cfg", userprofile)) + } + + #[cfg(not(target_os = "windows"))] + { + let home_dir = std::env::var("HOME") + .map_err(|_| "Failed to get home directory")?; + Ok(format!("{}/.wakatime.cfg", home_dir)) + } +} + +fn get_expected_config_content(api_key: &str, api_url: &str) -> String { + #[cfg(target_os = "windows")] + { + format!( + "[settings]\r\napi_url = {}\r\napi_key = {}\r\nheartbeat_rate_limit_seconds = 30\r\n", + api_url, api_key + ) + } + + #[cfg(not(target_os = "windows"))] + { + format!( + "[settings]\napi_url = {}\napi_key = {}\nheartbeat_rate_limit_seconds = 30\n", + api_url, api_key + ) + } +} + +fn normalize_config_content(content: &str) -> String { + + content + .replace("\r\n", "\n") + .lines() + .map(|line| line.trim()) + .filter(|line| !line.is_empty()) + .collect::>() + .join("\n") +} + +#[tauri::command] +pub async fn check_wakatime_config(api_key: String, api_url: String) -> Result { + let config_path = get_wakatime_config_path()?; + let expected_content = get_expected_config_content(&api_key, &api_url); + + let exists = Path::new(&config_path).exists(); + let actual_content = if exists { + match fs::read_to_string(&config_path) { + Ok(content) => Some(content), + Err(e) => return Err(format!("Failed to read config file: {}", e)), + } + } else { + None + }; + + let matches = if let Some(ref actual) = actual_content { + normalize_config_content(actual) == normalize_config_content(&expected_content) + } else { + false + }; + + Ok(WakatimeConfigCheck { + exists, + matches, + expected_content, + actual_content, + config_path, + }) +} + +#[tauri::command] +pub async fn apply_wakatime_config(api_key: String, api_url: String) -> Result { + let config_path = get_wakatime_config_path()?; + let backup_path = format!("{}.bak", config_path); + + + if Path::new(&config_path).exists() { + if let Err(e) = fs::copy(&config_path, &backup_path) { + return Err(format!("Failed to backup existing config: {}", e)); + } + } + + let config_content = get_expected_config_content(&api_key, &api_url); + + if let Err(e) = fs::write(&config_path, &config_content) { + return Err(format!("Failed to write config file: {}", e)); + } + + Ok(format!("Config file successfully written to {}", config_path)) +} + +#[tauri::command] +pub async fn setup_hackatime_macos_linux(api_key: String, api_url: String) -> Result { + let home_dir = std::env::var("HOME").map_err(|_| "Failed to get home directory")?; + + let config_path = format!("{}/.wakatime.cfg", home_dir); + let backup_path = format!("{}/.wakatime.cfg.bak", home_dir); + + if Path::new(&config_path).exists() { + if let Err(e) = fs::rename(&config_path, &backup_path) { + return Err(format!("Failed to backup existing config: {}", e)); + } + } + + let config_content = format!( + "[settings]\napi_url = {}\napi_key = {}\nheartbeat_rate_limit_seconds = 30\n", + api_url, api_key + ); + + if let Err(e) = fs::write(&config_path, config_content) { + return Err(format!("Failed to write config file: {}", e)); + } + + if !Path::new(&config_path).exists() { + return Err("Config file was not created".to_string()); + } + + let config_content = fs::read_to_string(&config_path) + .map_err(|e| format!("Failed to read config file: {}", e))?; + + let lines: Vec<&str> = config_content.lines().collect(); + let mut found_api_url = false; + let mut found_api_key = false; + let mut found_heartbeat_rate = false; + + for line in lines { + if line.starts_with("api_url =") { + found_api_url = true; + } else if line.starts_with("api_key =") { + found_api_key = true; + } else if line.starts_with("heartbeat_rate_limit_seconds =") { + found_heartbeat_rate = true; + } + } + + if !found_api_url || !found_api_key || !found_heartbeat_rate { + return Err("Config file is missing required fields".to_string()); + } + + Ok(format!( + "Config file created successfully at {}", + config_path + )) +} + +#[tauri::command] +pub async fn setup_hackatime_windows(api_key: String, api_url: String) -> Result { + let userprofile = + std::env::var("USERPROFILE").map_err(|_| "Failed to get USERPROFILE directory")?; + + let config_path = format!("{}\\.wakatime.cfg", userprofile); + let backup_path = format!("{}\\.wakatime.cfg.bak", userprofile); + + if Path::new(&config_path).exists() { + if let Err(e) = fs::rename(&config_path, &backup_path) { + return Err(format!("Failed to backup existing config: {}", e)); + } + } + + let config_content = format!( + "[settings]\r\napi_url = {}\r\napi_key = {}\r\nheartbeat_rate_limit_seconds = 30\r\n", + api_url, api_key + ); + + if let Err(e) = fs::write(&config_path, config_content) { + return Err(format!("Failed to write config file: {}", e)); + } + + if !Path::new(&config_path).exists() { + return Err("Config file was not created".to_string()); + } + + let config_content = fs::read_to_string(&config_path) + .map_err(|e| format!("Failed to read config file: {}", e))?; + + let lines: Vec<&str> = config_content.lines().collect(); + let mut found_api_url = false; + let mut found_api_key = false; + let mut found_heartbeat_rate = false; + + for line in lines { + if line.starts_with("api_url =") { + found_api_url = true; + } else if line.starts_with("api_key =") { + found_api_key = true; + } else if line.starts_with("heartbeat_rate_limit_seconds =") { + found_heartbeat_rate = true; + } + } + + if !found_api_url || !found_api_key || !found_heartbeat_rate { + return Err("Config file is missing required fields".to_string()); + } + + Ok(format!( + "Config file created successfully at {}", + config_path + )) +} + +#[tauri::command] +pub async fn test_hackatime_heartbeat(api_key: String, api_url: String) -> Result { + let client = reqwest::Client::new(); + let current_time = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + let heartbeat_data = serde_json::json!([{ + "type": "file", + "time": current_time, + "entity": "test.txt", + "language": "Text" + }]); + + let response = client + .post(&format!("{}/users/current/heartbeats", api_url)) + .header("Authorization", format!("Bearer {}", api_key)) + .header("Content-Type", "application/json") + .json(&heartbeat_data) + .send() + .await + .map_err(|e| format!("Failed to send heartbeat: {}", e))?; + + if response.status().is_success() { + Ok("Test heartbeat sent successfully!".to_string()) + } else { + let error_text = response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + Err(format!("Heartbeat failed: {}", error_text)) + } +} + +#[tauri::command] +pub async fn setup_hackatime_complete(api_key: String, api_url: String) -> Result { + + if cfg!(target_os = "windows") { + setup_hackatime_windows(api_key, api_url).await + } else { + setup_hackatime_macos_linux(api_key, api_url).await + } +} + diff --git a/src-tauri/src/statistics.rs b/src-tauri/src/statistics.rs new file mode 100644 index 0000000..e5460c2 --- /dev/null +++ b/src-tauri/src/statistics.rs @@ -0,0 +1,983 @@ +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tauri::State; +use chrono::Datelike; + +use crate::auth::AuthState; +use crate::config::ApiConfig; +use crate::database::Database; +use crate::push_log; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct StatisticsData { + pub trends: Vec, + pub charts: Vec, + pub insights: Vec, + pub programmer_class: ProgrammerClass, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct TrendStatistic { + pub title: String, + pub value: String, + pub change: String, + pub change_type: String, + pub period: String, + pub icon: String, + pub color: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ChartData { + pub id: String, + pub title: String, + pub chart_type: String, + pub data: serde_json::Value, + pub period: String, + pub color_scheme: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Insight { + pub title: String, + pub description: String, + pub value: String, + pub trend: String, + pub icon: String, + pub color: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ProgrammerClass { + pub class_name: String, + pub description: String, + pub technologies: Vec, + pub level: String, + pub color: String, +} + +async fn fetch_hours_with_cache( + client: &reqwest::Client, + base_url: &str, + access_token: &str, + start_date: &str, + end_date: &str, +) -> Result { + let db = Database::new().await?; + let cache_key = format!("hours:{}:{}", start_date, end_date); + + if let Ok(Some(cached_data)) = db.get_cached_data(&cache_key).await { + push_log("debug", "backend", format!("Using cached data for {}", cache_key)); + return serde_json::from_str(&cached_data) + .map_err(|e| format!("Failed to parse cached data: {}", e)); + } + + push_log("debug", "backend", format!("Fetching fresh data for {}", cache_key)); + let response = client + .get(&format!( + "{}/api/v1/authenticated/hours?start_date={}&end_date={}", + base_url, + start_date, + end_date + )) + .bearer_auth(access_token) + .send() + .await + .map_err(|e| format!("Failed to fetch hours: {}", e))?; + + if !response.status().is_success() { + return Err(format!("API request failed with status: {}", response.status())); + } + + let data: serde_json::Value = response + .json() + .await + .map_err(|e| format!("Failed to parse API response: {}", e))?; + + let data_str = serde_json::to_string(&data) + .map_err(|e| format!("Failed to serialize data for caching: {}", e))?; + db.set_cached_data(&cache_key, &data_str, 30).await.ok(); + + Ok(data) +} + +async fn fetch_streak_with_cache( + client: &reqwest::Client, + base_url: &str, + access_token: &str, +) -> Result { + let db = Database::new().await?; + let today = chrono::Utc::now().date_naive().format("%Y-%m-%d").to_string(); + let cache_key = format!("streak:{}", today); + + if let Ok(Some(cached_data)) = db.get_cached_data(&cache_key).await { + push_log("debug", "backend", format!("Using cached streak data for {}", today)); + return serde_json::from_str(&cached_data) + .map_err(|e| format!("Failed to parse cached streak data: {}", e)); + } + + push_log("debug", "backend", format!("Fetching fresh streak data for {}", today)); + let response = client + .get(&format!("{}/api/v1/authenticated/streak", base_url)) + .bearer_auth(access_token) + .send() + .await + .map_err(|e| format!("Failed to fetch streak: {}", e))?; + + if !response.status().is_success() { + return Err(format!("Streak API request failed with status: {}", response.status())); + } + + let data: serde_json::Value = response + .json() + .await + .map_err(|e| format!("Failed to parse streak response: {}", e))?; + + let data_str = serde_json::to_string(&data) + .map_err(|e| format!("Failed to serialize streak data for caching: {}", e))?; + db.set_cached_data(&cache_key, &data_str, 30).await.ok(); + + Ok(data) +} + +#[tauri::command] +pub async fn get_statistics_data( + api_config: ApiConfig, + state: State<'_, Arc>>, +) -> Result { + let auth_state = state.lock().await; + + if !auth_state.is_authenticated { + return Err("Not authenticated".to_string()); + } + + let base_url = if api_config.base_url.is_empty() { + "https://hackatime.hackclub.com" + } else { + &api_config.base_url + }; + + let access_token = auth_state + .access_token + .as_ref() + .ok_or("No access token available")?; + + let client = reqwest::Client::new(); + + let end_date = chrono::Utc::now().date_naive(); + + let mut daily_hours = serde_json::Map::new(); + let mut total_seconds = 0u64; + + for days_ago in 0..7 { + let date = end_date - chrono::Duration::days(days_ago); + let date_str = date.format("%Y-%m-%d").to_string(); + + match fetch_hours_with_cache(&client, base_url, access_token, &date_str, &date_str).await { + Ok(day_data) => { + let seconds = day_data["total_seconds"].as_u64().unwrap_or(0); + total_seconds += seconds; + + let day_name = match date.weekday() { + chrono::Weekday::Mon => "Mon", + chrono::Weekday::Tue => "Tue", + chrono::Weekday::Wed => "Wed", + chrono::Weekday::Thu => "Thu", + chrono::Weekday::Fri => "Fri", + chrono::Weekday::Sat => "Sat", + chrono::Weekday::Sun => "Sun", + }; + + daily_hours.insert(date_str.clone(), serde_json::json!({ + "date": date_str, + "day_name": day_name, + "hours": seconds as f64 / 3600.0, + "seconds": seconds + })); + } + Err(_) => { + let day_name = match date.weekday() { + chrono::Weekday::Mon => "Mon", + chrono::Weekday::Tue => "Tue", + chrono::Weekday::Wed => "Wed", + chrono::Weekday::Thu => "Thu", + chrono::Weekday::Fri => "Fri", + chrono::Weekday::Sat => "Sat", + chrono::Weekday::Sun => "Sun", + }; + + daily_hours.insert(date_str.clone(), serde_json::json!({ + "date": date_str, + "day_name": day_name, + "hours": 0.0, + "seconds": 0 + })); + } + } + } + + let all_time_start = end_date - chrono::Duration::days(365); + let all_time_seconds = match fetch_hours_with_cache( + &client, + base_url, + access_token, + &all_time_start.format("%Y-%m-%d").to_string(), + &end_date.format("%Y-%m-%d").to_string() + ).await { + Ok(data) => data["total_seconds"].as_u64().unwrap_or(0), + Err(_) => 0 + }; + + let hours_data = serde_json::json!({ + "weekly_stats": { + "time_coded_seconds": total_seconds, + "daily_hours": daily_hours + }, + "all_time_stats": { + "time_coded_seconds": all_time_seconds + } + }); + + let streak_data = fetch_streak_with_cache(&client, base_url, access_token) + .await + .map_err(|e| format!("Failed to fetch streak data: {}", e))?; + + let mut dashboard_stats = hours_data; + if let Some(streak) = streak_data.get("current_streak") { + dashboard_stats["current_streak"] = streak.clone(); + } + if let Some(longest) = streak_data.get("longest_streak") { + dashboard_stats["longest_streak"] = longest.clone(); + } + + let statistics = process_statistics_data(dashboard_stats).await?; + + Ok(statistics) +} + +#[tauri::command] +pub async fn get_dashboard_stats( + api_config: ApiConfig, + state: State<'_, Arc>>, +) -> Result { + let auth_state = state.lock().await; + + if !auth_state.is_authenticated { + return Err("Not authenticated".to_string()); + } + + let base_url = if api_config.base_url.is_empty() { + "https://hackatime.hackclub.com" + } else { + &api_config.base_url + }; + + let access_token = auth_state + .access_token + .as_ref() + .ok_or("No access token available")?; + + let client = reqwest::Client::new(); + + + let end_date = chrono::Utc::now().date_naive(); + let start_date = end_date - chrono::Duration::days(7); + + let _current_week_data = fetch_hours_with_cache( + &client, + base_url, + access_token, + &start_date.format("%Y-%m-%d").to_string(), + &end_date.format("%Y-%m-%d").to_string() + ).await.map_err(|e| format!("Failed to fetch current week hours: {}", e))?; + + + let prev_week_end = start_date; + let prev_week_start = prev_week_end - chrono::Duration::days(7); + + let prev_week_data = fetch_hours_with_cache( + &client, + base_url, + access_token, + &prev_week_start.format("%Y-%m-%d").to_string(), + &prev_week_end.format("%Y-%m-%d").to_string() + ).await.unwrap_or_else(|_| serde_json::json!({"total_seconds": 0})); + + + let mut daily_hours = serde_json::Map::new(); + let mut total_seconds = 0u64; + + for days_ago in 0..7 { + let date = end_date - chrono::Duration::days(days_ago); + let date_str = date.format("%Y-%m-%d").to_string(); + + + match fetch_hours_with_cache(&client, base_url, access_token, &date_str, &date_str).await { + Ok(day_data) => { + let seconds = day_data["total_seconds"].as_u64().unwrap_or(0); + total_seconds += seconds; + + let day_name = match date.weekday() { + chrono::Weekday::Mon => "Mon", + chrono::Weekday::Tue => "Tue", + chrono::Weekday::Wed => "Wed", + chrono::Weekday::Thu => "Thu", + chrono::Weekday::Fri => "Fri", + chrono::Weekday::Sat => "Sat", + chrono::Weekday::Sun => "Sun", + }; + + daily_hours.insert(date_str.clone(), serde_json::json!({ + "date": date_str, + "day_name": day_name, + "hours": seconds as f64 / 3600.0, + "seconds": seconds + })); + } + Err(_) => { + let day_name = match date.weekday() { + chrono::Weekday::Mon => "Mon", + chrono::Weekday::Tue => "Tue", + chrono::Weekday::Wed => "Wed", + chrono::Weekday::Thu => "Thu", + chrono::Weekday::Fri => "Fri", + chrono::Weekday::Sat => "Sat", + chrono::Weekday::Sun => "Sun", + }; + + daily_hours.insert(date_str.clone(), serde_json::json!({ + "date": date_str, + "day_name": day_name, + "hours": 0.0, + "seconds": 0 + })); + } + } + } + + + let streak_data = fetch_streak_with_cache(&client, base_url, access_token) + .await + .unwrap_or_else(|_| serde_json::json!({"current_streak": 0, "longest_streak": 0})); + + + let current_week_seconds = total_seconds as f64; + let prev_week_seconds = prev_week_data["total_seconds"].as_f64().unwrap_or(0.0); + + + let daily_average_hours = current_week_seconds / 3600.0 / 7.0; + + + let weekly_hours = current_week_seconds / 3600.0; + + + let weekly_change_percent = if prev_week_seconds > 0.0 { + ((current_week_seconds - prev_week_seconds) / prev_week_seconds * 100.0).round() + } else if current_week_seconds > 0.0 { + 100.0 + } else { + 0.0 + }; + + Ok(serde_json::json!({ + "current_streak": streak_data["current_streak"].as_u64().unwrap_or(0), + "longest_streak": streak_data["longest_streak"].as_u64().unwrap_or(0), + "weekly_stats": { + "time_coded_seconds": total_seconds, + "daily_hours": daily_hours + }, + "calculated_metrics": { + "daily_average_hours": (daily_average_hours * 10.0).round() / 10.0, + "weekly_hours": (weekly_hours * 10.0).round() / 10.0, + "weekly_change_percent": weekly_change_percent, + "prev_week_hours": (prev_week_seconds / 3600.0 * 10.0).round() / 10.0 + } + })) +} + +async fn process_statistics_data( + dashboard_stats: serde_json::Value, +) -> Result { + + let current_streak = dashboard_stats["current_streak"].as_u64().unwrap_or(0); + let weekly_time = dashboard_stats["weekly_stats"]["time_coded_seconds"] + .as_u64() + .unwrap_or(0) as f64; + let all_time_time = dashboard_stats["all_time_stats"]["time_coded_seconds"] + .as_u64() + .unwrap_or(0) as f64; + + + let trends = calculate_trends(weekly_time, current_streak).await; + + + let charts = generate_chart_data(&dashboard_stats).await?; + + + let insights = generate_insights(weekly_time, all_time_time, current_streak).await; + + + let programmer_class = analyze_programmer_class(&dashboard_stats).await; + + Ok(StatisticsData { + trends, + charts, + insights, + programmer_class, + }) +} + +async fn calculate_trends(weekly_time: f64, current_streak: u64) -> Vec { + let mut trends = Vec::new(); + + + let last_week_time = weekly_time * 0.85; + let last_week_streak = if current_streak > 0 { + current_streak - 1 + } else { + 0 + }; + + + let time_change = ((weekly_time - last_week_time) / last_week_time * 100.0).round() as i32; + let time_trend = if time_change > 0 { + TrendStatistic { + title: "Weekly Coding Time".to_string(), + value: format!("{:.1}h", weekly_time / 3600.0), + change: format!("+{}%", time_change), + change_type: "increase".to_string(), + period: "vs last week".to_string(), + icon: "".to_string(), + color: "#4CAF50".to_string(), + } + } else if time_change < 0 { + TrendStatistic { + title: "Weekly Coding Time".to_string(), + value: format!("{:.1}h", weekly_time / 3600.0), + change: format!("{}%", time_change), + change_type: "decrease".to_string(), + period: "vs last week".to_string(), + icon: "".to_string(), + color: "#F44336".to_string(), + } + } else { + TrendStatistic { + title: "Weekly Coding Time".to_string(), + value: format!("{:.1}h", weekly_time / 3600.0), + change: "No change".to_string(), + change_type: "neutral".to_string(), + period: "vs last week".to_string(), + icon: "".to_string(), + color: "#FF9800".to_string(), + } + }; + trends.push(time_trend); + + + let streak_change = current_streak as i32 - last_week_streak as i32; + let streak_trend = if streak_change > 0 { + TrendStatistic { + title: "Coding Streak".to_string(), + value: format!("{} days", current_streak), + change: format!("+{} days", streak_change), + change_type: "increase".to_string(), + period: "vs last week".to_string(), + icon: "".to_string(), + color: "#FF5722".to_string(), + } + } else if streak_change < 0 { + TrendStatistic { + title: "Coding Streak".to_string(), + value: format!("{} days", current_streak), + change: format!("{} days", streak_change), + change_type: "decrease".to_string(), + period: "vs last week".to_string(), + icon: "".to_string(), + color: "#F44336".to_string(), + } + } else { + TrendStatistic { + title: "Coding Streak".to_string(), + value: format!("{} days", current_streak), + change: "Maintained".to_string(), + change_type: "neutral".to_string(), + period: "vs last week".to_string(), + icon: "".to_string(), + color: "#FF9800".to_string(), + } + }; + trends.push(streak_trend); + + + let daily_average = weekly_time / 3600.0 / 7.0; + let last_week_daily = daily_average * 0.9; + let focus_change = ((daily_average - last_week_daily) / last_week_daily * 100.0).round() as i32; + + let focus_trend = if focus_change > 0 { + TrendStatistic { + title: "Daily Focus Time".to_string(), + value: format!("{:.1}h/day", daily_average), + change: format!("+{}%", focus_change), + change_type: "increase".to_string(), + period: "vs last week".to_string(), + icon: "".to_string(), + color: "#4CAF50".to_string(), + } + } else if focus_change < 0 { + TrendStatistic { + title: "Daily Focus Time".to_string(), + value: format!("{:.1}h/day", daily_average), + change: format!("{}%", focus_change), + change_type: "decrease".to_string(), + period: "vs last week".to_string(), + icon: "".to_string(), + color: "#F44336".to_string(), + } + } else { + TrendStatistic { + title: "Daily Focus Time".to_string(), + value: format!("{:.1}h/day", daily_average), + change: "No change".to_string(), + change_type: "neutral".to_string(), + period: "vs last week".to_string(), + icon: "".to_string(), + color: "#FF9800".to_string(), + } + }; + trends.push(focus_trend); + + trends +} + +async fn generate_chart_data( + dashboard_stats: &serde_json::Value, +) -> Result, String> { + let mut charts = Vec::new(); + + let mut chart_data = Vec::new(); + let mut labels = Vec::new(); + + if let Some(daily_hours) = dashboard_stats["weekly_stats"]["daily_hours"].as_object() { + for (_date, day_data) in daily_hours { + if let Some(hours) = day_data["hours"].as_f64() { + labels.push(day_data["day_name"].as_str().unwrap_or("").to_string()); + chart_data.push(hours); + } + } + } + + if chart_data.is_empty() { + let day_names = vec!["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; + for day in day_names { + labels.push(day.to_string()); + chart_data.push(0.0); + } + } + + charts.push(ChartData { + id: "daily_hours".to_string(), + title: "Daily Coding Hours".to_string(), + chart_type: "bar".to_string(), + data: serde_json::json!({ + "labels": labels, + "datasets": [{ + "label": "Hours", + "data": chart_data, + "backgroundColor": "#FB4B20", + "borderColor": "#FB4B20", + "borderWidth": 1 + }] + }), + period: "Last 7 days".to_string(), + color_scheme: "orange".to_string(), + }); + + + if let Some(top_language) = dashboard_stats["weekly_stats"]["top_language"].as_object() { + let language_name = top_language["name"].as_str().unwrap_or("Unknown"); + let language_seconds = top_language["seconds"].as_u64().unwrap_or(0) as f64; + let total_seconds = dashboard_stats["weekly_stats"]["time_coded_seconds"] + .as_u64() + .unwrap_or(1) as f64; + let percentage = (language_seconds / total_seconds * 100.0).round() as i32; + + charts.push(ChartData { + id: "language_distribution".to_string(), + title: "Top Language".to_string(), + chart_type: "doughnut".to_string(), + data: serde_json::json!({ + "labels": [language_name, "Others"], + "datasets": [{ + "data": [percentage, 100 - percentage], + "backgroundColor": ["#FB4B20", "#E0E0E0"], + "borderWidth": 0 + }] + }), + period: "This week".to_string(), + color_scheme: "orange".to_string(), + }); + } + + let mut trend_data = Vec::new(); + let mut trend_labels = Vec::new(); + + let current_week_seconds = dashboard_stats["weekly_stats"]["time_coded_seconds"] + .as_u64() + .unwrap_or(0); + + + for week in 0..4 { + let week_hours = if week == 3 { + current_week_seconds as f64 / 3600.0 + } else if current_week_seconds == 0 { + 0.0 + } else { + + (current_week_seconds as f64 / 3600.0) * (0.8 + (week as f64 * 0.1)) + }; + + trend_data.push(week_hours); + trend_labels.push(format!("Week {}", 4 - week)); + } + + charts.push(ChartData { + id: "weekly_trend".to_string(), + title: "Weekly Trend".to_string(), + chart_type: "line".to_string(), + data: serde_json::json!({ + "labels": trend_labels, + "datasets": [{ + "label": "Hours", + "data": trend_data, + "borderColor": "#FB4B20", + "backgroundColor": "rgba(251, 75, 32, 0.1)", + "fill": true, + "tension": 0.4 + }] + }), + period: "Last 4 weeks".to_string(), + color_scheme: "orange".to_string(), + }); + + Ok(charts) +} + +async fn generate_insights( + weekly_time: f64, + all_time_time: f64, + current_streak: u64, +) -> Vec { + let mut insights = Vec::new(); + + + let daily_average = weekly_time / 3600.0 / 7.0; + let consistency_insight = if daily_average >= 2.0 { + Insight { + title: "Consistent Coder".to_string(), + description: "You've been coding consistently every day this week!".to_string(), + value: format!("{:.1}h/day", daily_average), + trend: "Great consistency".to_string(), + icon: "".to_string(), + color: "#4CAF50".to_string(), + } + } else if daily_average >= 1.0 { + Insight { + title: "Steady Progress".to_string(), + description: "You're maintaining a good coding rhythm.".to_string(), + value: format!("{:.1}h/day", daily_average), + trend: "Keep it up".to_string(), + icon: "".to_string(), + color: "#FF9800".to_string(), + } + } else { + Insight { + title: "Room for Growth".to_string(), + description: "Try to code a bit more each day to build momentum.".to_string(), + value: format!("{:.1}h/day", daily_average), + trend: "Build momentum".to_string(), + icon: "".to_string(), + color: "#2196F3".to_string(), + } + }; + insights.push(consistency_insight); + + + let streak_insight = if current_streak >= 30 { + Insight { + title: "Streak Master".to_string(), + description: "Incredible! You've been coding for over a month straight!".to_string(), + value: format!("{} days", current_streak), + trend: "Amazing dedication".to_string(), + icon: "".to_string(), + color: "#FFD700".to_string(), + } + } else if current_streak >= 7 { + Insight { + title: "Week Warrior".to_string(), + description: "You've been coding for a full week! Great job!".to_string(), + value: format!("{} days", current_streak), + trend: "Excellent progress".to_string(), + icon: "".to_string(), + color: "#FF5722".to_string(), + } + } else if current_streak > 0 { + Insight { + title: "Getting Started".to_string(), + description: "You're building a coding habit! Keep it going!".to_string(), + value: format!("{} days", current_streak), + trend: "Building momentum".to_string(), + icon: "".to_string(), + color: "#4CAF50".to_string(), + } + } else { + Insight { + title: "Fresh Start".to_string(), + description: "Ready to start your coding journey? Let's begin!".to_string(), + value: "0 days".to_string(), + trend: "Start today".to_string(), + icon: "".to_string(), + color: "#9C27B0".to_string(), + } + }; + insights.push(streak_insight); + + + let total_hours = all_time_time / 3600.0; + let total_insight = if total_hours >= 1000.0 { + Insight { + title: "Coding Veteran".to_string(), + description: "You've logged over 1000 hours of coding! Incredible dedication!" + .to_string(), + value: format!("{:.0}h total", total_hours), + trend: "Expert level".to_string(), + icon: "".to_string(), + color: "#FFD700".to_string(), + } + } else if total_hours >= 100.0 { + Insight { + title: "Experienced Coder".to_string(), + description: "You've put in serious time coding! Keep up the great work!".to_string(), + value: format!("{:.0}h total", total_hours), + trend: "Strong foundation".to_string(), + icon: "".to_string(), + color: "#4CAF50".to_string(), + } + } else if total_hours >= 10.0 { + Insight { + title: "Learning Journey".to_string(), + description: "You're building your coding skills! Every hour counts.".to_string(), + value: format!("{:.0}h total", total_hours), + trend: "Growing skills".to_string(), + icon: "๐Ÿ“š".to_string(), + color: "#2196F3".to_string(), + } + } else { + Insight { + title: "Just Getting Started".to_string(), + description: "Every expert was once a beginner. Keep coding!".to_string(), + value: format!("{:.0}h total", total_hours), + trend: "Beginning journey".to_string(), + icon: "๐ŸŒฑ".to_string(), + color: "#9C27B0".to_string(), + } + }; + insights.push(total_insight); + + insights +} + +async fn analyze_programmer_class(dashboard_stats: &serde_json::Value) -> ProgrammerClass { + + let config_path = std::env::current_dir() + .unwrap_or_default() + .join("programmer_classes.json"); + + let config_content = match std::fs::read_to_string(&config_path) { + Ok(content) => content, + Err(_) => { + + return ProgrammerClass { + class_name: "Code Explorer".to_string(), + description: "An enthusiastic learner discovering the vast world of programming." + .to_string(), + technologies: vec![ + "HTML".to_string(), + "CSS".to_string(), + "JavaScript".to_string(), + ], + level: "Learning".to_string(), + color: "#9C27B0".to_string(), + }; + } + }; + + let config: serde_json::Value = match serde_json::from_str(&config_content) { + Ok(config) => config, + Err(_) => { + + return ProgrammerClass { + class_name: "Code Explorer".to_string(), + description: "An enthusiastic learner discovering the vast world of programming." + .to_string(), + technologies: vec![ + "HTML".to_string(), + "CSS".to_string(), + "JavaScript".to_string(), + ], + level: "Learning".to_string(), + color: "#9C27B0".to_string(), + }; + } + }; + + let total_hours = dashboard_stats["all_time_stats"]["time_coded_seconds"] + .as_u64() + .unwrap_or(0) as f64 + / 3600.0; + + let current_streak = dashboard_stats["current_streak"].as_u64().unwrap_or(0); + + + let simulated_languages = simulate_language_analysis(total_hours, current_streak); + + + let empty_vec = vec![]; + let classes = config["classes"].as_array().unwrap_or(&empty_vec); + let mut best_match: Option<&serde_json::Value> = None; + let mut best_score = 0.0; + + for class in classes { + if let Some(conditions) = class["conditions"].as_object() { + let score = calculate_class_score( + &conditions, + &simulated_languages, + total_hours, + current_streak, + ); + if score > best_score { + best_score = score; + best_match = Some(class); + } + } + } + + + if let Some(class) = best_match { + ProgrammerClass { + class_name: class["name"].as_str().unwrap_or("Unknown").to_string(), + description: class["description"].as_str().unwrap_or("").to_string(), + technologies: class["technologies"] + .as_array() + .unwrap_or(&vec![]) + .iter() + .filter_map(|t| t.as_str()) + .map(|s| s.to_string()) + .collect(), + level: class["level"].as_str().unwrap_or("Unknown").to_string(), + color: class["color"].as_str().unwrap_or("#9C27B0").to_string(), + } + } else { + + ProgrammerClass { + class_name: "Code Explorer".to_string(), + description: "An enthusiastic learner discovering the vast world of programming." + .to_string(), + technologies: vec![ + "HTML".to_string(), + "CSS".to_string(), + "JavaScript".to_string(), + ], + level: "Learning".to_string(), + color: "#9C27B0".to_string(), + } + } +} + +fn simulate_language_analysis(total_hours: f64, current_streak: u64) -> Vec { + + + let mut languages = Vec::new(); + + + if total_hours >= 100.0 { + + languages.push("JavaScript".to_string()); + languages.push("Python".to_string()); + languages.push("Java".to_string()); + if current_streak >= 7 { + languages.push("Rust".to_string()); + languages.push("Go".to_string()); + } + } else if total_hours >= 20.0 { + + languages.push("JavaScript".to_string()); + languages.push("Python".to_string()); + if current_streak >= 5 { + languages.push("TypeScript".to_string()); + } + } else { + + languages.push("HTML".to_string()); + languages.push("CSS".to_string()); + languages.push("JavaScript".to_string()); + } + + languages +} + +fn calculate_class_score( + conditions: &serde_json::Map, + languages: &[String], + total_hours: f64, + current_streak: u64, +) -> f64 { + let mut score = 0.0; + + + if let Some(primary_langs) = conditions + .get("primary_languages") + .and_then(|v| v.as_array()) + { + let primary_lang_count = primary_langs + .iter() + .filter_map(|lang| lang.as_str()) + .filter(|lang| languages.contains(&lang.to_string())) + .count(); + score += primary_lang_count as f64 * 2.0; + } + + + if let Some(lang_count) = conditions.get("language_count").and_then(|v| v.as_u64()) { + if languages.len() as u64 >= lang_count { + score += 3.0; + } + } + + + if let Some(min_hours) = conditions.get("min_hours").and_then(|v| v.as_f64()) { + if total_hours >= min_hours { + score += 1.0; + } else { + score -= 0.5; + } + } + + + if let Some(max_hours) = conditions.get("max_hours").and_then(|v| v.as_f64()) { + if total_hours <= max_hours { + score += 1.0; + } else { + score -= 0.5; + } + } + + + if let Some(min_streak) = conditions.get("min_streak").and_then(|v| v.as_u64()) { + if current_streak >= min_streak { + score += 0.5; + } + } + + score +} + diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs new file mode 100644 index 0000000..3839dfd --- /dev/null +++ b/src-tauri/src/tray.rs @@ -0,0 +1,77 @@ +use tauri::{AppHandle, Manager}; +use tauri::menu::{Menu, MenuItem}; +use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; + +pub fn setup_tray(app: &AppHandle) -> Result<(), Box> { + let status_text = { + let state = app.state::>>(); + let state_arc = state.inner().clone(); + tauri::async_runtime::block_on(async { + let guard = state_arc.lock().await; + if guard.is_active { + let project = guard.project.clone().unwrap_or_else(|| "Unknown".to_string()); + format!("Status: Active โ€” {}", project) + } else { + "No active session".to_string() + } + }) + }; + let status_item = MenuItem::with_id(app, "status", &status_text, false, None::<&str>)?; + let quit_item = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?; + + + let menu = Menu::with_items(app, &[ + &status_item, + &tauri::menu::PredefinedMenuItem::separator(app)?, + &quit_item, + ])?; + + + let _tray_icon = TrayIconBuilder::new() + .icon(app.default_window_icon().unwrap().clone()) + .menu(&menu) + .show_menu_on_left_click(true) + .on_menu_event(|app, event| { + match event.id.as_ref() { + "quit" => { + app.exit(0); + } + _ => {} + } + }) + .on_tray_icon_event(|tray, event| { + match event { + TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } => { + let app = tray.app_handle(); + if let Some(window) = app.get_webview_window("main") { + if window.is_visible().unwrap_or(false) { + let _ = window.hide(); + } else { + let _ = window.show(); + let _ = window.set_focus(); + } + } + } + TrayIconEvent::DoubleClick { + button: MouseButton::Left, + .. + } => { + let app = tray.app_handle(); + if let Some(window) = app.get_webview_window("main") { + let _ = window.show(); + let _ = window.set_focus(); + } + } + _ => { + } + } + }) + .build(app)?; + + Ok(()) +} + diff --git a/src-tauri/src/window.rs b/src-tauri/src/window.rs new file mode 100644 index 0000000..71620d3 --- /dev/null +++ b/src-tauri/src/window.rs @@ -0,0 +1,44 @@ +use tauri::Manager; + +#[tauri::command] +pub async fn show_window(app: tauri::AppHandle) -> Result<(), String> { + if let Some(window) = app.get_webview_window("main") { + window + .show() + .map_err(|e| format!("Failed to show window: {}", e))?; + window + .set_focus() + .map_err(|e| format!("Failed to focus window: {}", e))?; + } + Ok(()) +} + +#[tauri::command] +pub async fn hide_window(app: tauri::AppHandle) -> Result<(), String> { + if let Some(window) = app.get_webview_window("main") { + window + .hide() + .map_err(|e| format!("Failed to hide window: {}", e))?; + } + Ok(()) +} + +#[tauri::command] +pub async fn toggle_window(app: tauri::AppHandle) -> Result<(), String> { + if let Some(window) = app.get_webview_window("main") { + if window.is_visible().unwrap_or(false) { + window + .hide() + .map_err(|e| format!("Failed to hide window: {}", e))?; + } else { + window + .show() + .map_err(|e| format!("Failed to show window: {}", e))?; + window + .set_focus() + .map_err(|e| format!("Failed to focus window: {}", e))?; + } + } + Ok(()) +} + diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index e3129c2..de68281 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -13,23 +13,27 @@ "windows": [ { "title": "Hackatime Desktop", - "width": 800, - "height": 600, + "width": 1100, + "height": 700, + "minWidth": 1100, + "minHeight": 700, "visible": true, "skipTaskbar": false, - "decorations": true, + "decorations": false, "alwaysOnTop": false, "resizable": true, "minimizable": true, "maximizable": true, - "closable": true + "closable": true, + "hiddenTitle": true, + "transparent": true } ], "security": { "csp": null } }, - "bundle": { + "bundle": { "active": true, "targets": "all", "createUpdaterArtifacts": "v1Compatible", diff --git a/src/App.vue b/src/App.vue index 68a72e3..137d1af 100644 --- a/src/App.vue +++ b/src/App.vue @@ -3,14 +3,13 @@ import { ref, onMounted, onUnmounted, computed } from "vue"; import { invoke } from "@tauri-apps/api/core"; import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link"; import { api } from "./api"; -import { useTheme } from "./composables/useTheme"; import Home from "./views/Home.vue"; import Projects from "./views/Projects.vue"; import Settings from "./views/Settings.vue"; import Statistics from "./views/Statistics.vue"; -import PresenceCard from "./components/PresenceCard.vue"; -import TrendCard from "./components/TrendCard.vue"; -import WeeklyChart from "./components/WeeklyChart.vue"; +import UserProfileCard from "./components/UserProfileCard.vue"; +import CustomTitlebar from "./components/CustomTitlebar.vue"; +import WakatimeSetupModal from "./components/WakatimeSetupModal.vue"; interface AuthState { is_authenticated: boolean; @@ -48,13 +47,13 @@ const presenceFetchInProgress = ref(false); const nextPresenceFetchAllowedAt = ref(0); const lastPresenceFetchAt = ref(0); -// Navigation state const currentPage = ref<'home' | 'projects' | 'statistics' | 'settings'>('home'); -// Theme management -const { currentTheme, toggleTheme } = useTheme(); +const showWakatimeSetupModal = ref(false); +const wakatimeConfigCheck = ref(null); +const hasCheckedConfigThisSession = ref(false); + -// Computed property for weekly chart data const weeklyChartData = computed(() => { if (!userStats.value?.weekly_stats?.daily_hours) { const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; @@ -74,7 +73,6 @@ const weeklyChartData = computed(() => { const dailyHours = userStats.value.weekly_stats.daily_hours; const maxHours = Math.max(...Object.values(dailyHours).map((day: any) => day.hours), 1); - // Convert object to array and sort by date return Object.values(dailyHours) .sort((a: any, b: any) => new Date(a.date).getTime() - new Date(b.date).getTime()) .map((day: any) => ({ @@ -83,87 +81,58 @@ const weeklyChartData = computed(() => { })); }); -// Computed property for trend data - const weeklyTrend = computed(() => { - if (!userStats.value?.weekly_stats) return null; - - const currentWeekHours = (userStats.value.weekly_stats.time_coded_seconds || 0) / 3600; - const lastWeekHours = currentWeekHours * 0.85; // Simulate 15% increase - const change = ((currentWeekHours - lastWeekHours) / lastWeekHours * 100); - - return { - title: change > 0 ? "You coded more than last week" : change < 0 ? "You coded less than last week" : "Same as last week", - change: change > 0 ? `+${Math.round(change)}%` : `${Math.round(change)}%`, - changeType: change > 0 ? 'increase' : change < 0 ? 'decrease' : 'neutral', - period: "vs last week", - icon: change > 0 ? "๐Ÿ“ˆ" : change < 0 ? "๐Ÿ“‰" : "โžก๏ธ" - }; - }); - onMounted(async () => { await loadAuthState(); await loadApiConfig(); await loadHackatimeInfo(); - // Detect if we're in development mode - // Check if we're running on localhost (development) or have debug features isDevMode.value = apiConfig.value.base_url.includes('localhost') || apiConfig.value.base_url.includes('127.0.0.1') || window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'; - // Check if app was started via deep link try { const startUrls = await getCurrent(); if (startUrls && startUrls.length > 0) { console.log("App started with deep link:", startUrls); - // Check if it's an OAuth callback const hasOAuthCallback = startUrls.some(url => url.startsWith('hackatime://auth/callback') ); if (hasOAuthCallback) { console.log("OAuth callback detected, refreshing auth state..."); - // The Rust backend will handle the deep link processing - // We just need to refresh the auth state after processing setTimeout(async () => { await loadAuthState(); - }, 1000); // Give the backend time to process the deep link + }, 1000); } } } catch (error) { console.error("Failed to get current deep link:", error); } - // Listen for deep link events when app is already running try { await onOpenUrl((urls) => { console.log("Deep link received in frontend:", urls); - // Check if it's an OAuth callback const hasOAuthCallback = urls.some(url => url.startsWith('hackatime://auth/callback') ); if (hasOAuthCallback) { console.log("OAuth callback detected, refreshing auth state..."); - // The Rust backend handles the actual processing - // We just need to refresh the auth state setTimeout(async () => { await loadAuthState(); - }, 1000); // Give the backend time to process the deep link + }, 1000); } }); } catch (error) { console.error("Failed to set up deep link listener:", error); } - // Listen for window focus events to refresh auth state after popup closes window.addEventListener('focus', async () => { await loadAuthState(); }); - // Also listen for visibility change (when tab becomes active) document.addEventListener('visibilitychange', async () => { if (!document.hidden) { await loadAuthState(); @@ -171,7 +140,6 @@ onMounted(async () => { }); }); -// Cleanup on unmount onUnmounted(() => { stopPresenceRefresh(); }); @@ -188,12 +156,10 @@ async function loadAuthState() { await loadUserData(); } else { - // No saved state or not authenticated, get current state console.log("No saved auth state found, getting current state"); authState.value = await invoke("get_auth_state"); console.log("Current auth state:", authState.value); - // If we have an authenticated state, save it to disk if (authState.value.is_authenticated) { try { await invoke("save_auth_state", { authState: authState.value }); @@ -205,7 +171,6 @@ async function loadAuthState() { } } catch (error) { console.error("Failed to load auth state:", error); - // Fallback to current state on error try { authState.value = await invoke("get_auth_state"); } catch (fallbackError) { @@ -219,16 +184,18 @@ async function loadUserData() { await api.initialize(); userData.value = await api.getCurrentUser(); - // Load user dashboard stats (getStats now returns dashboard stats) try { - userStats.value = await api.getStats(); + userStats.value = await invoke("get_dashboard_stats", { apiConfig: apiConfig.value }); } catch (error) { console.error("Failed to load user dashboard stats:", error); } await loadApiKey(); - // Load presence data and start refresh + await new Promise(resolve => setTimeout(resolve, 500)); + + await checkWakatimeConfig(); + await loadPresenceData(); startPresenceRefresh(); } catch (error) { @@ -236,6 +203,49 @@ async function loadUserData() { } } +async function checkWakatimeConfig(forceShowModal = false) { + if (!authState.value.is_authenticated || !apiKey.value) { + return; + } + + const apiUrl = apiConfig.value.base_url || "https://hackatime.hackclub.com"; + if (!apiUrl || apiUrl.trim() === "") { + console.warn("API URL is not set, skipping wakatime config check"); + return; + } + + try { + const check = await invoke("check_wakatime_config", { + apiKey: apiKey.value, + apiUrl: apiUrl, + }) as any; + + wakatimeConfigCheck.value = check; + + if (forceShowModal || (!hasCheckedConfigThisSession.value && !check.matches)) { + showWakatimeSetupModal.value = true; + hasCheckedConfigThisSession.value = true; + } + } catch (error) { + console.error("Failed to check wakatime config:", error); + } +} + +async function handleWakatimeConfigApplied() { + showWakatimeSetupModal.value = false; + + await checkWakatimeConfig(false); + + if (wakatimeConfigCheck.value && !wakatimeConfigCheck.value.matches) { + alert("Configuration was applied but still doesn't match. Please check the error logs."); + } +} + +async function openWakatimeConfigModal() { + hasCheckedConfigThisSession.value = true; + await checkWakatimeConfig(true); +} + async function loadApiKey() { try { apiKey.value = await invoke("get_api_key", { apiConfig: apiConfig.value }); @@ -267,12 +277,11 @@ async function loadHackatimeInfo() { async function loadPresenceData() { const now = Date.now(); if (presenceFetchInProgress.value) { - return; // Skip if a fetch is already in flight + return; } if (now < nextPresenceFetchAllowedAt.value) { - return; // Respect backoff window + return; } - // Enforce hard minimum interval of 60s between network calls if (now - lastPresenceFetchAt.value < 60_000) { return; } @@ -280,14 +289,12 @@ async function loadPresenceData() { presenceFetchInProgress.value = true; try { await api.initialize(); - // Use the Rust backend's get_latest_heartbeat which includes session logic presenceData.value = await invoke("get_latest_heartbeat", { apiConfig: apiConfig.value }); lastPresenceFetchAt.value = Date.now(); } catch (error: any) { console.error("Failed to load presence data:", error); - // If we hit rate limit, back off for 60s const message = error?.message || ""; if (typeof message === "string" && message.includes("429")) { nextPresenceFetchAllowedAt.value = Date.now() + 60_000; @@ -299,12 +306,10 @@ async function loadPresenceData() { } function startPresenceRefresh() { - // Ensure only one interval is active if (presenceRefreshInterval.value) { clearInterval(presenceRefreshInterval.value); presenceRefreshInterval.value = null; } - // Refresh presence data every 60 seconds (1 minute) presenceRefreshInterval.value = setInterval(loadPresenceData, 60000); } @@ -321,7 +326,6 @@ async function authenticate() { try { await invoke("authenticate_with_rails", { apiConfig: apiConfig.value }); - // Show instructions for OAuth completion alert(`OAuth authentication opened in browser!\n\nInstructions:\n1. Complete the OAuth flow in your browser\n2. The app will automatically handle the callback\n3. If the callback doesn't work, you can manually paste the authorization code from the URL\n\nFor manual entry:\n- Copy the 'code' parameter from the callback URL\n- Use the "Direct OAuth" field below to paste it`); } catch (error) { console.error("Authentication failed:", error); @@ -335,6 +339,7 @@ async function logout() { try { stopPresenceRefresh(); await invoke("logout"); + hasCheckedConfigThisSession.value = false; await loadAuthState(); } catch (error) { console.error("Logout failed:", error); @@ -380,7 +385,6 @@ async function handleDirectOAuthAuth() { console.log("Token length:", directOAuthToken.value.length); console.log("API config:", apiConfig.value); - // Authenticate with the direct OAuth token await invoke("authenticate_with_direct_oauth", { oauthToken: directOAuthToken.value, apiConfig: apiConfig.value @@ -389,7 +393,6 @@ async function handleDirectOAuthAuth() { console.log("Direct OAuth auth successful!"); await loadAuthState(); - // Ensure the auth state is saved after successful authentication if (authState.value.is_authenticated) { try { await invoke("save_auth_state", { authState: authState.value }); @@ -399,7 +402,7 @@ async function handleDirectOAuthAuth() { } } - directOAuthToken.value = ""; // Clear the input + directOAuthToken.value = ""; alert("Authentication successful! You are now logged in."); } catch (error) { console.error("Direct OAuth auth failed:", error); @@ -408,150 +411,139 @@ async function handleDirectOAuthAuth() { isLoading.value = false; } } - - - -function getPageTitle(): string { - switch (currentPage.value) { - case 'home': - return 'Home'; - case 'projects': - return 'Projects'; - case 'statistics': - return 'Statistics'; - case 'settings': - return 'Settings'; - default: - return 'Home'; - } -} - \ No newline at end of file + \ No newline at end of file diff --git a/src/api.ts b/src/api.ts index 0868570..84ca78b 100644 --- a/src/api.ts +++ b/src/api.ts @@ -148,7 +148,7 @@ export class KubeTimeApi { } try { - // Return cached result if fetched within last 60s + const now = Date.now() if (now - this.latestPresenceCache.fetchedAt < 60_000 && this.latestPresenceCache.data !== null) { return this.latestPresenceCache.data diff --git a/src/assets/bird-illustration.svg b/src/assets/bird-illustration.svg new file mode 100644 index 0000000..2738669 --- /dev/null +++ b/src/assets/bird-illustration.svg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:af237154ecbef3d1f7bb8922a56f56f958ab74c9e208b6e432d914f22ec05b00 +size 2916 diff --git a/src/assets/decorative-lines.svg b/src/assets/decorative-lines.svg new file mode 100644 index 0000000..349de65 --- /dev/null +++ b/src/assets/decorative-lines.svg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3b003ac873f82c3754fd182e13389997fa0c3dace3c22871a3742b630ab40f0d +size 1154 diff --git a/src/assets/suits-icons.svg b/src/assets/suits-icons.svg new file mode 100644 index 0000000..56776c6 --- /dev/null +++ b/src/assets/suits-icons.svg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:63784807e210f4d80316421f2bcbf3df267875ce186dd9c8578dd7ce2cec7a9a +size 1553 diff --git a/src/assets/vue.svg b/src/assets/vue.svg deleted file mode 100644 index f2caefb..0000000 --- a/src/assets/vue.svg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:5532db34f1c52841881bab8aeca6c3e8d092bd21a689a65f4d8f8cf8041eef9d -size 496 diff --git a/src/components/ChartComponent.vue b/src/components/ChartComponent.vue index 9ad1bbb..6bf82e9 100644 --- a/src/components/ChartComponent.vue +++ b/src/components/ChartComponent.vue @@ -1,13 +1,15 @@ @@ -31,7 +33,7 @@ import { PieController } from 'chart.js'; -// Register Chart.js components + ChartJS.register( CategoryScale, LinearScale, @@ -64,7 +66,7 @@ let chartInstance: ChartJS | null = null; const createChart = () => { if (!chartCanvas.value) return; - // Destroy existing chart + if (chartInstance) { chartInstance.destroy(); } @@ -145,3 +147,9 @@ watch(() => props.data, () => { createChart(); }, { deep: true }); + + diff --git a/src/components/CustomTitlebar.vue b/src/components/CustomTitlebar.vue new file mode 100644 index 0000000..6ad7fce --- /dev/null +++ b/src/components/CustomTitlebar.vue @@ -0,0 +1,319 @@ + + + + + diff --git a/src/components/InsightCard.vue b/src/components/InsightCard.vue index 4697f2d..58deebd 100644 --- a/src/components/InsightCard.vue +++ b/src/components/InsightCard.vue @@ -1,16 +1,18 @@ @@ -25,4 +27,11 @@ interface Props { } defineProps(); + + + diff --git a/src/components/PresenceCard.vue b/src/components/PresenceCard.vue index 6fde4b1..2eb4719 100644 --- a/src/components/PresenceCard.vue +++ b/src/components/PresenceCard.vue @@ -114,7 +114,7 @@ const sessionState = ref({ const isLoading = ref(true); let sessionRefreshInterval: number | null = null; -// Format timestamp to relative time + function formatTime(timestamp: number | null): string { if (!timestamp) return 'Unknown'; @@ -142,7 +142,7 @@ async function loadSessionState() { } try { - // Get the current session state + const session = await invoke("get_current_session"); console.log("Session state loaded:", session); sessionState.value = session as SessionState; @@ -158,7 +158,7 @@ function startSessionRefresh() { clearInterval(sessionRefreshInterval); } - // Refresh session state every 10 seconds + sessionRefreshInterval = setInterval(loadSessionState, 10000); } @@ -169,7 +169,7 @@ function stopSessionRefresh() { } } -// Watch for changes in presence data to update session state + watch(() => props.presenceData, () => { if (props.authState.is_authenticated) { loadSessionState(); diff --git a/src/components/RandomLoader.vue b/src/components/RandomLoader.vue new file mode 100644 index 0000000..58c9602 --- /dev/null +++ b/src/components/RandomLoader.vue @@ -0,0 +1,147 @@ + + + + + + + diff --git a/src/components/StatisticsCard.vue b/src/components/StatisticsCard.vue index 6bf007d..a75404f 100644 --- a/src/components/StatisticsCard.vue +++ b/src/components/StatisticsCard.vue @@ -1,11 +1,12 @@ @@ -42,12 +44,18 @@ const props = defineProps(); const changeClass = computed(() => { switch (props.changeType) { case 'increase': - return 'bg-green-100 text-green-800'; + return 'bg-[rgba(34,197,94,0.15)] text-[#22c55e]'; case 'decrease': - return 'bg-red-100 text-red-800'; + return 'bg-[rgba(236,59,72,0.15)] text-[#ec3b48]'; case 'neutral': default: - return 'bg-gray-100 text-gray-800'; + return 'bg-[rgba(255,255,255,0.08)] text-[#f5e6e8]'; } }); + + diff --git a/src/components/StatisticsDashboard.vue b/src/components/StatisticsDashboard.vue index 19b3f20..6230469 100644 --- a/src/components/StatisticsDashboard.vue +++ b/src/components/StatisticsDashboard.vue @@ -1,9 +1,9 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/views/Projects.vue b/src/views/Projects.vue index 1590462..388d008 100644 --- a/src/views/Projects.vue +++ b/src/views/Projects.vue @@ -1,13 +1,18 @@