Compare commits

...

24 commits

Author SHA1 Message Date
leafdbot[bot]
832a610693
chore(main): release app 1.7.5 (#67)
Co-authored-by: leafdbot[bot] <192038741+leafdbot[bot]@users.noreply.github.com>
2025-10-24 15:57:29 -06:00
Leafd
472867d58f
fix: eliminate duplicate discord definitions 2025-10-24 17:56:07 -04:00
Leafd
d037bdb529
fix: make option enabled by default 2025-10-24 17:53:30 -04:00
leafdbot[bot]
6509fc85e0
chore(main): release app 1.7.4 (#66)
Co-authored-by: leafdbot[bot] <192038741+leafdbot[bot]@users.noreply.github.com>
2025-10-24 15:44:58 -06:00
Leafd
e84c1d6a37
fix: update hackatime url 2025-10-24 17:43:55 -04:00
leafdbot[bot]
a5746811a9
chore(main): release app 1.7.3 (#65)
Co-authored-by: leafdbot[bot] <192038741+leafdbot[bot]@users.noreply.github.com>
2025-10-24 15:33:36 -06:00
Leafd
3688e39424
fix: change hackatime icon 2025-10-24 17:32:45 -04:00
Leafd
59008e3849
fix: correct card alignment issues 2025-10-16 12:01:05 -04:00
Leafd
145b3b9422
chore: add security policy
Updated the security policy to include version support and reporting guidelines.
2025-10-14 19:33:46 -06:00
Leafd
ece57e2981
chore: add license 2025-10-14 19:24:52 -06:00
leafdbot[bot]
d0c183e71c
chore(main): release app 1.7.2 (#64)
Co-authored-by: leafdbot[bot] <192038741+leafdbot[bot]@users.noreply.github.com>
2025-10-10 15:08:23 -06:00
Leafd
1705082522
fix: fix update popup not showing 2025-10-10 17:07:55 -04:00
Leafd
3ec4f3386a
fix: add manual oauth link copy for linux 2025-10-10 16:52:03 -04:00
leafdbot[bot]
0feffa4a50
chore(main): release app 1.7.1 (#63)
Co-authored-by: leafdbot[bot] <192038741+leafdbot[bot]@users.noreply.github.com>
2025-10-10 13:53:24 -06:00
Leafd
bc8a8121b1
chore: update pnpm lock 2025-10-10 15:51:45 -04:00
leafdbot[bot]
fe6de62c2d
chore(main): release app 1.7.0 (#62)
Co-authored-by: leafdbot[bot] <192038741+leafdbot[bot]@users.noreply.github.com>
2025-10-10 13:48:02 -06:00
Leafd
48146f0091
fix: adjust project page to api response 2025-10-10 15:47:20 -04:00
Leafd
a266acc5d4
fix: app now hides on closing 2025-10-10 15:36:20 -04:00
Leafd
dab9a807a5
feat: add autostart functionality 2025-10-10 15:30:11 -04:00
Leafd
db732471b7
chore: update readme with download buttons 2025-10-10 12:34:28 -04:00
leafdbot[bot]
8680de90f1
chore(main): release app 1.6.2 (#61)
Co-authored-by: leafdbot[bot] <192038741+leafdbot[bot]@users.noreply.github.com>
2025-10-09 16:47:57 -06:00
Leafd
8171e059ae
fix: resolve sentry error reports and improve data validation 2025-10-09 18:46:56 -04:00
leafdbot[bot]
ca898884fc
chore(main): release app 1.6.1 (#60)
Co-authored-by: leafdbot[bot] <192038741+leafdbot[bot]@users.noreply.github.com>
2025-10-09 15:05:02 -06:00
Leafd
55d5875078
fix: update settings page layout 2025-10-09 17:04:32 -04:00
43 changed files with 868 additions and 310 deletions

View file

@ -250,7 +250,7 @@ jobs:
- name: Generate update manifest
id: generate_manifest
env:
DOWNLOAD_URL_BASE: 'https://pub-d35fbe65a5b5426bb6d62ff02a8c7d03.r2.dev'
DOWNLOAD_URL_BASE: 'https://desktop.hackatime.hackclub-assets.com'
VERSION: '${{ steps.get_version.outputs.version }}'
run: >
# Read signatures into variables

View file

@ -1,4 +1,4 @@
{
"app": "0.0.0",
".": "1.6.0"
".": "1.7.5"
}

View file

@ -1,5 +1,81 @@
# Changelog
## [1.7.5](https://github.com/hackclub/hackatime-desktop/compare/app-v1.7.4...app-v1.7.5) (2025-10-24)
### 🐛 Bugfixes
* eliminate duplicate discord definitions ([472867d](https://github.com/hackclub/hackatime-desktop/commit/472867d58f306d70241b07b5f3135c34055ad555))
* make option enabled by default ([d037bdb](https://github.com/hackclub/hackatime-desktop/commit/d037bdb529a4a078a5d10f7daa68698da9726e5f))
## [1.7.4](https://github.com/hackclub/hackatime-desktop/compare/app-v1.7.3...app-v1.7.4) (2025-10-24)
### 🐛 Bugfixes
* update hackatime url ([e84c1d6](https://github.com/hackclub/hackatime-desktop/commit/e84c1d6a37fdef397189e8a9108f68d4ddb11641))
## [1.7.3](https://github.com/hackclub/hackatime-desktop/compare/app-v1.7.2...app-v1.7.3) (2025-10-24)
### 🐛 Bugfixes
* change hackatime icon ([3688e39](https://github.com/hackclub/hackatime-desktop/commit/3688e39424c6d3e1c0441fde81e8451feded8178))
* correct card alignment issues ([59008e3](https://github.com/hackclub/hackatime-desktop/commit/59008e3849753ccaac037657d65bad2a70387e3c))
### 👽 Miscellaneous
* add license ([ece57e2](https://github.com/hackclub/hackatime-desktop/commit/ece57e29811ca228a255bef7bfe3117c3e3d236d))
* add security policy ([145b3b9](https://github.com/hackclub/hackatime-desktop/commit/145b3b9422bb5b5095a6e5aa59aa66b749338b5a))
## [1.7.2](https://github.com/hackclub/hackatime-desktop/compare/app-v1.7.1...app-v1.7.2) (2025-10-10)
### 🐛 Bugfixes
* add manual oauth link copy for linux ([3ec4f33](https://github.com/hackclub/hackatime-desktop/commit/3ec4f3386a3e2bbf5a2c4bddc80bf28d789b9705))
* fix update popup not showing ([1705082](https://github.com/hackclub/hackatime-desktop/commit/17050825223327da6e603cfe21500cf78c74e215))
## [1.7.1](https://github.com/hackclub/hackatime-desktop/compare/app-v1.7.0...app-v1.7.1) (2025-10-10)
### 👽 Miscellaneous
* update pnpm lock ([bc8a812](https://github.com/hackclub/hackatime-desktop/commit/bc8a8121b1a9feb7e6d3a578375e0c7db4b4970f))
## [1.7.0](https://github.com/hackclub/hackatime-desktop/compare/app-v1.6.2...app-v1.7.0) (2025-10-10)
### ✨ Features
* add autostart functionality ([dab9a80](https://github.com/hackclub/hackatime-desktop/commit/dab9a807a52d5cbcdf04022e7d440d59f612a75c))
### 🐛 Bugfixes
* adjust project page to api response ([48146f0](https://github.com/hackclub/hackatime-desktop/commit/48146f009150d13421f82642a7cc7a32ece36810))
* app now hides on closing ([a266acc](https://github.com/hackclub/hackatime-desktop/commit/a266acc5d44f8b6e8319d0f49889c2ee0e13d6e6))
### 👽 Miscellaneous
* update readme with download buttons ([db73247](https://github.com/hackclub/hackatime-desktop/commit/db732471b7f037f4b19ea20b9fdabbc3f5ce51b3))
## [1.6.2](https://github.com/hackclub/hackatime-desktop/compare/app-v1.6.1...app-v1.6.2) (2025-10-09)
### 🐛 Bugfixes
* resolve sentry error reports and improve data validation ([8171e05](https://github.com/hackclub/hackatime-desktop/commit/8171e059ae52aefa180c9c295ab71ba23c3e111f))
## [1.6.1](https://github.com/hackclub/hackatime-desktop/compare/app-v1.6.0...app-v1.6.1) (2025-10-09)
### 🐛 Bugfixes
* update settings page layout ([55d5875](https://github.com/hackclub/hackatime-desktop/commit/55d58750785eec0ac627f5c4a1b4a9882c14f2a7))
## [1.6.0](https://github.com/hackclub/hackatime-desktop/compare/app-v1.5.1...app-v1.6.0) (2025-10-09)

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Hack Club
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -1,5 +1,8 @@
# Hackatime Desktop
[![Download for macOS](https://img.shields.io/badge/Download-macOS-blue?style=for-the-badge&logo=apple)](https://hackatimerelease.leafd.workers.dev/latest?platform=macos)
[![Download for Windows](https://img.shields.io/badge/Download-Windows-blue?style=for-the-badge&logo=windows)](https://hackatimerelease.leafd.workers.dev/latest?platform=windows)
[![Release](https://github.com/hackclub/hackatime-desktop/actions/workflows/release.yaml/badge.svg)](https://github.com/hackclub/hackatime-desktop/actions/workflows/release.yaml)
Desktop app for [Hackatime](https://hackatime.hackclub.com). Built with Tauri, Vue 3, TypeScript, and Rust.

26
SECURITY.md Normal file
View file

@ -0,0 +1,26 @@
# Security Policy
> **Note**: This security policy is specifically for the **Hackatime Desktop** application. For vulnerabilities related to the main Hackatime web app, please refer to the [hackclub/hackatime repository](https://github.com/hackclub/hackatime).
## Supported Versions
We are currently providing security updates for the following versions:
| Version | Supported |
| ------- | ------------------ |
| 1.x.x | :white_check_mark: |
## Reporting a Vulnerability
If you discover a security vulnerability in this project, please report it through one of the following channels:
- **Email**: sebastian@hackclub.com or security@leafd.dev
- **Hack Club Slack**: Send a direct message to @lfd
Please include as much information as possible in your report:
- Description of the vulnerability
- Steps to reproduce
- Potential impact
- Any suggested fixes (optional)
Thank you for helping me keep this project secure :)

View file

@ -1,7 +1,7 @@
{
"name": "desktop",
"private": true,
"version": "1.6.0",
"version": "1.7.5",
"type": "module",
"packageManager": "pnpm@10.18.0",
"scripts": {
@ -13,6 +13,7 @@
"dependencies": {
"@sentry/vue": "^10.18.0",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-autostart": "^2",
"@tauri-apps/plugin-deep-link": "^2.4.3",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-process": "~2",

10
pnpm-lock.yaml generated
View file

@ -14,6 +14,9 @@ importers:
'@tauri-apps/api':
specifier: ^2
version: 2.8.0
'@tauri-apps/plugin-autostart':
specifier: ^2
version: 2.5.0
'@tauri-apps/plugin-deep-link':
specifier: ^2.4.3
version: 2.4.3
@ -579,6 +582,9 @@ packages:
engines: {node: '>= 10'}
hasBin: true
'@tauri-apps/plugin-autostart@2.5.0':
resolution: {integrity: sha512-smSt0vydfVB950AeYRbO2S/c01SZrgMVg4FOrFLQLom0R0amsu/8zYaxgttriBdxcofjBZuHv4hmROBQIBVXmA==}
'@tauri-apps/plugin-deep-link@2.4.3':
resolution: {integrity: sha512-yVCZpVG1ZrtfCvE7K5LRSrGqlyPlCrqlKgoREJHnfjyYdDtUhFmZqScOXpL8XL2PizJHDsoahEweuTaUPEokPA==}
@ -1290,6 +1296,10 @@ snapshots:
'@tauri-apps/cli-win32-ia32-msvc': 2.8.4
'@tauri-apps/cli-win32-x64-msvc': 2.8.4
'@tauri-apps/plugin-autostart@2.5.0':
dependencies:
'@tauri-apps/api': 2.8.0
'@tauri-apps/plugin-deep-link@2.4.3':
dependencies:
'@tauri-apps/api': 2.8.0

82
src-tauri/Cargo.lock generated
View file

@ -240,6 +240,17 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "auto-launch"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f012b8cc0c850f34117ec8252a44418f2e34a2cf501de89e29b241ae5f79471"
dependencies = [
"dirs 4.0.0",
"thiserror 1.0.69",
"winreg 0.10.1",
]
[[package]]
name = "autocfg"
version = "1.5.0"
@ -833,6 +844,7 @@ dependencies = [
"sqlx",
"tauri",
"tauri-build",
"tauri-plugin-autostart",
"tauri-plugin-deep-link",
"tauri-plugin-opener",
"tauri-plugin-process",
@ -855,13 +867,33 @@ dependencies = [
"subtle",
]
[[package]]
name = "dirs"
version = "4.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059"
dependencies = [
"dirs-sys 0.3.7",
]
[[package]]
name = "dirs"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
dependencies = [
"dirs-sys",
"dirs-sys 0.5.0",
]
[[package]]
name = "dirs-sys"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6"
dependencies = [
"libc",
"redox_users 0.4.6",
"winapi",
]
[[package]]
@ -872,7 +904,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
dependencies = [
"libc",
"option-ext",
"redox_users",
"redox_users 0.5.2",
"windows-sys 0.61.1",
]
@ -1012,7 +1044,7 @@ dependencies = [
"rustc_version",
"toml 0.9.7",
"vswhom",
"winreg",
"winreg 0.55.0",
]
[[package]]
@ -3633,6 +3665,17 @@ dependencies = [
"bitflags 2.9.4",
]
[[package]]
name = "redox_users"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
dependencies = [
"getrandom 0.2.16",
"libredox",
"thiserror 1.0.69",
]
[[package]]
name = "redox_users"
version = "0.5.2"
@ -4740,7 +4783,7 @@ dependencies = [
"anyhow",
"bytes",
"cookie",
"dirs",
"dirs 6.0.0",
"dunce",
"embed_plist",
"getrandom 0.3.3",
@ -4791,7 +4834,7 @@ checksum = "9c432ccc9ff661803dab74c6cd78de11026a578a9307610bbc39d3c55be7943f"
dependencies = [
"anyhow",
"cargo_toml",
"dirs",
"dirs 6.0.0",
"glob",
"heck 0.5.0",
"json-patch",
@ -4863,6 +4906,20 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-plugin-autostart"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "062cdcd483d5e3148c9a64dabf8c574e239e2aa1193cf208d95cf89a676f87a5"
dependencies = [
"auto-launch",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.17",
]
[[package]]
name = "tauri-plugin-deep-link"
version = "2.4.3"
@ -4938,7 +4995,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27cbc31740f4d507712550694749572ec0e43bdd66992db7599b89fbfd6b167b"
dependencies = [
"base64 0.22.1",
"dirs",
"dirs 6.0.0",
"flate2",
"futures-util",
"http",
@ -5447,7 +5504,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0d92153331e7d02ec09137538996a7786fe679c629c279e82a6be762b7e6fe2"
dependencies = [
"crossbeam-channel",
"dirs",
"dirs 6.0.0",
"libappindicator",
"muda",
"objc2 0.6.3",
@ -6469,6 +6526,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "winreg"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
dependencies = [
"winapi",
]
[[package]]
name = "winreg"
version = "0.55.0"
@ -6501,7 +6567,7 @@ dependencies = [
"block2 0.6.2",
"cookie",
"crossbeam-channel",
"dirs",
"dirs 6.0.0",
"dpi",
"dunce",
"gdkx11",

View file

@ -22,6 +22,7 @@ tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-opener = "2"
tauri-plugin-deep-link = "2"
tauri-plugin-single-instance = "2"
tauri-plugin-autostart = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
open = "5"

View file

@ -13,6 +13,9 @@
"opener:default",
"deep-link:default",
"updater:default",
"process:default"
"process:default",
"autostart:allow-enable",
"autostart:allow-disable",
"autostart:allow-is-enabled"
]
}

View file

@ -27,6 +27,9 @@
"opener:default",
"deep-link:default",
"updater:default",
"process:default"
"process:default",
"autostart:allow-enable",
"autostart:allow-disable",
"autostart:allow-is-enabled"
]
}

BIN
src-tauri/icons/128x128.png (Stored with Git LFS)

Binary file not shown.

BIN
src-tauri/icons/128x128@2x.png (Stored with Git LFS)

Binary file not shown.

BIN
src-tauri/icons/32x32.png (Stored with Git LFS)

Binary file not shown.

BIN
src-tauri/icons/Square107x107Logo.png (Stored with Git LFS)

Binary file not shown.

BIN
src-tauri/icons/Square142x142Logo.png (Stored with Git LFS)

Binary file not shown.

BIN
src-tauri/icons/Square150x150Logo.png (Stored with Git LFS)

Binary file not shown.

BIN
src-tauri/icons/Square284x284Logo.png (Stored with Git LFS)

Binary file not shown.

BIN
src-tauri/icons/Square30x30Logo.png (Stored with Git LFS)

Binary file not shown.

BIN
src-tauri/icons/Square310x310Logo.png (Stored with Git LFS)

Binary file not shown.

BIN
src-tauri/icons/Square44x44Logo.png (Stored with Git LFS)

Binary file not shown.

BIN
src-tauri/icons/Square71x71Logo.png (Stored with Git LFS)

Binary file not shown.

BIN
src-tauri/icons/Square89x89Logo.png (Stored with Git LFS)

Binary file not shown.

BIN
src-tauri/icons/StoreLogo.png (Stored with Git LFS)

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 45 KiB

BIN
src-tauri/icons/icon.png (Stored with Git LFS)

Binary file not shown.

View file

@ -283,26 +283,3 @@ pub async fn discord_rpc_auto_disconnect(
rpc_service.disconnect()
}
#[tauri::command]
pub async fn get_discord_rpc_enabled(
discord_rpc_state: State<'_, Arc<tauri::async_runtime::Mutex<DiscordRpcService>>>,
) -> Result<bool, String> {
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<tauri::async_runtime::Mutex<DiscordRpcService>>>,
) -> 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()
}
}

View file

@ -11,6 +11,7 @@ mod config;
mod database;
mod db_commands;
mod discord_rpc;
mod preferences;
mod projects;
mod session;
mod setup;
@ -35,6 +36,11 @@ fn get_app_version(app: tauri::AppHandle) -> String {
app.package_info().version.to_string()
}
#[tauri::command]
fn get_current_os() -> String {
std::env::consts::OS.to_string()
}
#[derive(Clone, serde::Serialize)]
struct LogEntry {
ts: i64,
@ -69,14 +75,15 @@ pub fn run() {
.plugin(tauri_plugin_single_instance::init(|app, args, cwd| {
push_log("info", "backend", format!("Single instance detected. Args: {:?}, CWD: {}", args, cwd));
// Show the existing window
if let Some(window) = app.get_webview_window("main") {
#[cfg(target_os = "macos")]
let _ = app.set_activation_policy(tauri::ActivationPolicy::Regular);
let _ = window.show();
let _ = window.set_focus();
push_log("info", "backend", "Brought existing window to front".to_string());
}
// Process any deep links from the new instance attempt
for arg in args {
if arg.starts_with("hackatime://") {
push_log("info", "backend", format!("Processing deep link from second instance: {}", arg));
@ -84,6 +91,7 @@ pub fn run() {
}
}
}))
.plugin(tauri_plugin_autostart::init(tauri_plugin_autostart::MacosLauncher::LaunchAgent, Some(vec!["--minimized"])))
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_opener::init())
@ -109,6 +117,7 @@ pub fn run() {
.invoke_handler(tauri::generate_handler![
greet,
get_app_version,
get_current_os,
get_recent_logs,
database::get_platform_info,
@ -128,6 +137,14 @@ pub fn run() {
auth::load_auth_state,
auth::clear_auth_state,
preferences::get_preferences,
preferences::set_autostart_enabled,
preferences::get_autostart_enabled,
preferences::set_notifications_enabled,
preferences::get_notifications_enabled,
preferences::set_discord_rpc_enabled,
preferences::get_discord_rpc_enabled,
setup::setup_hackatime_macos_linux,
setup::setup_hackatime_windows,
setup::test_hackatime_heartbeat,
@ -153,8 +170,6 @@ pub fn run() {
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,
@ -260,6 +275,27 @@ pub fn run() {
Err(e) => push_log("warn", "backend", format!("Discord RPC auto-connect failed (this is optional): {}", e)),
}
});
use tauri_plugin_autostart::ManagerExt;
let autolaunch_manager = app.autolaunch();
match preferences::load_preferences() {
Ok(prefs) => {
if prefs.autostart_enabled {
match autolaunch_manager.enable() {
Ok(_) => push_log("info", "backend", "Autostart enabled on app startup".to_string()),
Err(e) => push_log("error", "backend", format!("Failed to enable autostart: {}", e)),
}
} else {
match autolaunch_manager.disable() {
Ok(_) => push_log("info", "backend", "Autostart disabled on app startup".to_string()),
Err(e) => push_log("error", "backend", format!("Failed to disable autostart: {}", e)),
}
}
}
Err(e) => {
push_log("warn", "backend", format!("Failed to load preferences for autostart: {}", e));
}
}
#[cfg(any(target_os = "linux", target_os = "windows"))]
@ -281,22 +317,23 @@ pub fn run() {
push_log("info", "backend", "🪟 Window close requested - hiding to tray".to_string());
api.prevent_close();
// Use the app handle to get the window and hide it asynchronously
// This prevents potential re-entrancy issues
let app_clone = app_handle.clone();
std::thread::spawn(move || {
if let Some(win) = app_clone.get_webview_window("main") {
let _ = win.hide();
push_log("info", "backend", "✅ Window hidden to tray".to_string());
#[cfg(target_os = "macos")]
{
let _ = app_clone.set_activation_policy(tauri::ActivationPolicy::Accessory);
push_log("info", "backend", "✅ App removed from Dock".to_string());
}
}
});
}
WindowEvent::Resized(_) => {
// Handle resize events gracefully - no action needed
// This prevents potential crashes on macOS with transparent windows
}
WindowEvent::Moved(_) => {
// Handle move events gracefully
}
_ => {}
}

View file

@ -0,0 +1,134 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use tauri::AppHandle;
use tauri_plugin_autostart::ManagerExt;
use crate::database::get_hackatime_config_dir;
use crate::push_log;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Preferences {
pub autostart_enabled: bool,
pub notifications_enabled: bool,
pub discord_rpc_enabled: bool,
}
impl Default for Preferences {
fn default() -> Self {
Self {
autostart_enabled: true,
notifications_enabled: true,
discord_rpc_enabled: true,
}
}
}
fn get_preferences_path() -> Result<PathBuf, String> {
let config_dir = get_hackatime_config_dir()?;
Ok(config_dir.join("preferences.json"))
}
pub fn load_preferences() -> Result<Preferences, String> {
let path = get_preferences_path()?;
if !path.exists() {
push_log("info", "backend", "No preferences file found, using defaults".to_string());
return Ok(Preferences::default());
}
let contents = fs::read_to_string(&path)
.map_err(|e| format!("Failed to read preferences file: {}", e))?;
let preferences: Preferences = serde_json::from_str(&contents)
.map_err(|e| format!("Failed to parse preferences: {}", e))?;
push_log("info", "backend", "Loaded preferences successfully".to_string());
Ok(preferences)
}
pub fn save_preferences(preferences: &Preferences) -> Result<(), String> {
let path = get_preferences_path()?;
let contents = serde_json::to_string_pretty(preferences)
.map_err(|e| format!("Failed to serialize preferences: {}", e))?;
fs::write(&path, contents)
.map_err(|e| format!("Failed to write preferences file: {}", e))?;
push_log("info", "backend", "Saved preferences successfully".to_string());
Ok(())
}
#[tauri::command]
pub fn get_preferences() -> Result<Preferences, String> {
load_preferences()
}
#[tauri::command]
pub fn set_autostart_enabled(app: AppHandle, enabled: bool) -> Result<(), String> {
let mut preferences = load_preferences().unwrap_or_default();
preferences.autostart_enabled = enabled;
save_preferences(&preferences)?;
let autolaunch_manager = app.autolaunch();
if enabled {
autolaunch_manager.enable()
.map_err(|e| format!("Failed to enable autostart: {}", e))?;
push_log("info", "backend", "Autostart enabled".to_string());
} else {
autolaunch_manager.disable()
.map_err(|e| format!("Failed to disable autostart: {}", e))?;
push_log("info", "backend", "Autostart disabled".to_string());
}
Ok(())
}
#[tauri::command]
pub fn get_autostart_enabled() -> Result<bool, String> {
let preferences = load_preferences().unwrap_or_default();
Ok(preferences.autostart_enabled)
}
#[tauri::command]
pub fn set_notifications_enabled(enabled: bool) -> Result<(), String> {
let mut preferences = load_preferences().unwrap_or_default();
preferences.notifications_enabled = enabled;
save_preferences(&preferences)?;
if enabled {
push_log("info", "backend", "Notifications enabled".to_string());
} else {
push_log("info", "backend", "Notifications disabled".to_string());
}
Ok(())
}
#[tauri::command]
pub fn get_notifications_enabled() -> Result<bool, String> {
let preferences = load_preferences().unwrap_or_default();
Ok(preferences.notifications_enabled)
}
#[tauri::command]
pub fn set_discord_rpc_enabled(enabled: bool) -> Result<(), String> {
let mut preferences = load_preferences().unwrap_or_default();
preferences.discord_rpc_enabled = enabled;
save_preferences(&preferences)?;
if enabled {
push_log("info", "backend", "Discord RPC enabled".to_string());
} else {
push_log("info", "backend", "Discord RPC disabled".to_string());
}
Ok(())
}
#[tauri::command]
pub fn get_discord_rpc_enabled() -> Result<bool, String> {
let preferences = load_preferences().unwrap_or_default();
Ok(preferences.discord_rpc_enabled)
}

View file

@ -3,6 +3,7 @@ use tauri::State;
use crate::auth::AuthState;
use crate::config::ApiConfig;
use crate::push_log;
#[tauri::command]
pub async fn get_projects(
@ -50,6 +51,16 @@ pub async fn get_projects(
.await
.map_err(|e| format!("Failed to parse projects response: {}", e))?;
push_log(
"info",
"backend",
format!(
"RAW PROJECTS API RESPONSE: {}",
serde_json::to_string_pretty(&projects_response)
.unwrap_or_else(|_| "Failed to serialize".to_string())
),
);
Ok(projects_response)
}

View file

@ -45,15 +45,32 @@ fn get_expected_config_content(api_key: &str, api_url: &str) -> String {
}
}
fn normalize_config_content(content: &str) -> String {
fn check_config_has_required_values(content: &str, api_key: &str, api_url: &str) -> bool {
let normalized = content.replace("\r\n", "\n");
let mut found_api_url = false;
let mut found_api_key = false;
content
.replace("\r\n", "\n")
.lines()
.map(|line| line.trim())
.filter(|line| !line.is_empty())
.collect::<Vec<_>>()
.join("\n")
for line in normalized.lines() {
let trimmed = line.trim();
if trimmed.starts_with("api_url") {
if let Some(value) = trimmed.split('=').nth(1) {
let value = value.trim();
if value == api_url {
found_api_url = true;
}
}
} else if trimmed.starts_with("api_key") {
if let Some(value) = trimmed.split('=').nth(1) {
let value = value.trim();
if value == api_key {
found_api_key = true;
}
}
}
}
found_api_url && found_api_key
}
#[tauri::command]
@ -72,7 +89,7 @@ pub async fn check_wakatime_config(api_key: String, api_url: String) -> Result<W
};
let matches = if let Some(ref actual) = actual_content {
normalize_config_content(actual) == normalize_config_content(&expected_content)
check_config_has_required_values(actual, &api_key, &api_url)
} else {
false
};
@ -136,23 +153,8 @@ pub async fn setup_hackatime_macos_linux(api_key: String, api_url: String) -> Re
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());
if !check_config_has_required_values(&config_content, &api_key, &api_url) {
return Err("Config file is missing required api_url and api_key values".to_string());
}
Ok(format!(
@ -191,23 +193,8 @@ pub async fn setup_hackatime_windows(api_key: String, api_url: String) -> Result
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());
if !check_config_has_required_values(&config_content, &api_key, &api_url) {
return Err("Config file is missing required api_url and api_key values".to_string());
}
Ok(format!(

View file

@ -54,7 +54,12 @@ pub fn setup_tray(app: &AppHandle) -> Result<(), Box<dyn std::error::Error>> {
if let Some(window) = app.get_webview_window("main") {
if window.is_visible().unwrap_or(false) {
let _ = window.hide();
#[cfg(target_os = "macos")]
let _ = app.set_activation_policy(tauri::ActivationPolicy::Accessory);
} else {
#[cfg(target_os = "macos")]
let _ = app.set_activation_policy(tauri::ActivationPolicy::Regular);
let _ = window.show();
let _ = window.set_focus();
}
@ -65,6 +70,10 @@ pub fn setup_tray(app: &AppHandle) -> Result<(), Box<dyn std::error::Error>> {
..
} => {
let app = tray.app_handle();
#[cfg(target_os = "macos")]
let _ = app.set_activation_policy(tauri::ActivationPolicy::Regular);
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();

View file

@ -2,6 +2,9 @@ use tauri::Manager;
#[tauri::command]
pub async fn show_window(app: tauri::AppHandle) -> Result<(), String> {
#[cfg(target_os = "macos")]
let _ = app.set_activation_policy(tauri::ActivationPolicy::Regular);
if let Some(window) = app.get_webview_window("main") {
window
.show()
@ -20,6 +23,10 @@ pub async fn hide_window(app: tauri::AppHandle) -> Result<(), String> {
.hide()
.map_err(|e| format!("Failed to hide window: {}", e))?;
}
#[cfg(target_os = "macos")]
let _ = app.set_activation_policy(tauri::ActivationPolicy::Accessory);
Ok(())
}
@ -30,7 +37,13 @@ pub async fn toggle_window(app: tauri::AppHandle) -> Result<(), String> {
window
.hide()
.map_err(|e| format!("Failed to hide window: {}", e))?;
#[cfg(target_os = "macos")]
let _ = app.set_activation_policy(tauri::ActivationPolicy::Accessory);
} else {
#[cfg(target_os = "macos")]
let _ = app.set_activation_policy(tauri::ActivationPolicy::Regular);
window
.show()
.map_err(|e| format!("Failed to show window: {}", e))?;

View file

@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Hackatime Desktop",
"version": "1.6.0",
"version": "1.7.5",
"identifier": "com.hackclub.hackatime",
"build": {
"beforeDevCommand": "pnpm dev",
@ -29,8 +29,9 @@
}
],
"security": {
"csp": null
}
"csp": "default-src 'self' ipc: http://ipc.localhost; connect-src 'self' ipc: http://ipc.localhost https://hackatime.hackclub.com https://desktop.hackatime.hackclub-assets.com wss://*.ingest.us.sentry.io https://us.i.posthog.com https://fonts.googleapis.com https://fonts.gstatic.com; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https:; font-src 'self' data: https://fonts.gstatic.com; worker-src 'self' blob:;"
},
"withGlobalTauri": true
},
"bundle": {
"active": true,
@ -63,7 +64,7 @@
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDdERjg4QTFCNTJFMDk0MUQKUldRZGxPQlNHNHI0ZlRkMDN0MGI1MnllY1dUVStZalV3dVdhcTFuREx5SGtBc0txQ2xnTWs3WU4K",
"endpoints": [
"https://pub-d35fbe65a5b5426bb6d62ff02a8c7d03.r2.dev/update-manifest.json"
"https://desktop.hackatime.hackclub-assets.com/update-manifest.json"
]
}
}

View file

@ -28,6 +28,26 @@ if (!(window as any).__hackatimeConsoleWrapped) {
console.info('[CONSOLE] Console wrapper initialized - logs will be captured');
}
if (!(window as any).__hackatimeErrorHandlerSet) {
(window as any).__hackatimeErrorHandlerSet = true;
window.addEventListener('unhandledrejection', (event) => {
console.error('[UNHANDLED REJECTION]', event.reason);
const errorMessage = event.reason?.message || String(event.reason);
if (errorMessage.includes('callbackId') ||
errorMessage.includes('IPC') ||
errorMessage.includes('Load failed')) {
event.preventDefault();
console.warn('[IPC] Suppressed IPC-related error that was already logged');
}
});
window.addEventListener('error', (event) => {
console.error('[UNHANDLED ERROR]', event.error);
});
}
interface AuthState {
is_authenticated: boolean;
access_token: string | null;
@ -61,9 +81,11 @@ const sessionStats = ref<any>(null);
const presenceData = ref<any>(null);
const presenceRefreshInterval = ref<ReturnType<typeof setInterval> | null>(null);
const presenceFetchInProgress = ref(false);
const updateCheckInterval = ref<ReturnType<typeof setInterval> | null>(null);
const oauthUrl = ref<string | null>(null);
const nextPresenceFetchAllowedAt = ref<number>(0);
const lastPresenceFetchAt = ref<number>(0);
const currentOs = ref<string | null>(null);
const currentPage = ref<'home' | 'projects' | 'statistics' | 'settings'>('home');
@ -77,6 +99,8 @@ const updateData = ref<any>(null);
const showUpdateModal = ref(false);
const isInstallingUpdate = ref(false);
const currentVersion = ref<string>('1.5.1');
const lastUpdateCheckTime = ref<number>(0);
const updateCheckInProgress = ref(false);
const weeklyChartData = computed(() => {
@ -111,6 +135,7 @@ onMounted(async () => {
await loadAuthState();
await loadApiConfig();
await loadHackatimeInfo();
await loadCurrentOs();
try {
const appVersion = await invoke("get_app_version") as string;
@ -119,10 +144,9 @@ onMounted(async () => {
console.warn("Failed to get app version:", error);
}
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';
isDevMode.value = window.location.hostname === 'localhost' ||
window.location.hostname === '127.0.0.1' ||
window.location.protocol === 'http:';
try {
const startUrls = await getCurrent();
@ -163,11 +187,17 @@ onMounted(async () => {
window.addEventListener('focus', async () => {
await loadAuthState();
if (authState.value.is_authenticated) {
checkForUpdatesAndInstall();
}
});
document.addEventListener('visibilitychange', async () => {
if (!document.hidden) {
await loadAuthState();
if (authState.value.is_authenticated) {
checkForUpdatesAndInstall();
}
}
});
@ -177,10 +207,12 @@ onMounted(async () => {
}
checkForUpdatesAndInstall();
startUpdateChecks();
});
onUnmounted(() => {
stopPresenceRefresh();
stopUpdateChecks();
});
async function loadAuthState() {
@ -313,6 +345,16 @@ async function loadHackatimeInfo() {
}
}
async function loadCurrentOs() {
try {
currentOs.value = await invoke("get_current_os") as string;
console.log("Current OS detected:", currentOs.value);
} catch (error) {
console.error("Failed to detect current OS:", error);
currentOs.value = null;
}
}
async function loadPresenceData() {
if (presenceFetchInProgress.value) {
return;
@ -358,6 +400,24 @@ function stopPresenceRefresh() {
}
}
function startUpdateChecks() {
if (updateCheckInterval.value) {
clearInterval(updateCheckInterval.value);
updateCheckInterval.value = null;
}
updateCheckInterval.value = setInterval(() => {
checkForUpdatesAndInstall();
}, 60 * 60 * 1000); // Check every hour
console.log("Started periodic update checks (every 60 minutes)");
}
function stopUpdateChecks() {
if (updateCheckInterval.value) {
clearInterval(updateCheckInterval.value);
updateCheckInterval.value = null;
}
}
async function authenticate() {
isLoading.value = true;
@ -470,12 +530,25 @@ async function handleDirectOAuthAuth(token?: string) {
}
}
async function checkForUpdatesAndInstall() {
if (isDevMode.value) {
console.info('[AUTO-UPDATE] Skipping auto-update check in development mode');
async function checkForUpdatesAndInstall(retryCount = 0) {
if (updateCheckInProgress.value) {
console.info('[AUTO-UPDATE] Update check already in progress, skipping');
return;
}
const now = Date.now();
const timeSinceLastCheck = now - lastUpdateCheckTime.value;
const minInterval = 5 * 60 * 1000; // 5 minutes minimum between checks
if (timeSinceLastCheck < minInterval && lastUpdateCheckTime.value > 0) {
console.info(`[AUTO-UPDATE] Skipping update check, last check was ${Math.round(timeSinceLastCheck / 1000)}s ago`);
return;
}
updateCheckInProgress.value = true;
lastUpdateCheckTime.value = now;
try {
console.info('[AUTO-UPDATE] Checking for updates...');
const update = await check();
@ -490,6 +563,19 @@ async function checkForUpdatesAndInstall() {
}
} catch (error) {
console.error('[AUTO-UPDATE] Auto-update check failed:', error);
if (retryCount < 2) {
console.info(`[AUTO-UPDATE] Retrying update check in 10 seconds (attempt ${retryCount + 1}/3)`);
setTimeout(() => {
updateCheckInProgress.value = false;
checkForUpdatesAndInstall(retryCount + 1);
}, 10000);
return;
} else {
console.error('[AUTO-UPDATE] Failed to check for updates after 3 attempts');
}
} finally {
updateCheckInProgress.value = false;
}
}
@ -554,6 +640,7 @@ async function handleInstallNow() {
:isLoading="isLoading"
:isDevMode="isDevMode"
:oauthUrl="oauthUrl"
:currentOs="currentOs"
@authenticate="authenticate"
@handleDirectOAuthAuth="handleDirectOAuthAuth"
@openOAuthUrlManually="openOAuthUrlManually"
@ -674,17 +761,21 @@ async function handleInstallNow() {
<div v-if="authState.is_authenticated && userStats" class="w-64 min-w-64 flex flex-col responsive-full-width">
<div class="card-3d-app h-full">
<div class="rounded-[8px] border border-black p-4 card-3d-app-front h-full flex flex-col" style="background-color: #3D2C3E;">
<div class="flex items-center justify-between mb-4">
<h2 class="text-white text-[16px] font-bold italic m-0" style="font-family: 'Outfit', sans-serif;">
leaderboard
</h2>
<div class="flex gap-2 text-[10px]" style="font-family: 'Outfit', sans-serif;">
<span class="text-white underline cursor-pointer">friends</span>
<span class="text-white cursor-pointer">global</span>
<div class="flex items-center justify-between mb-4">
<h2 class="text-white text-[16px] font-bold italic m-0" style="font-family: 'Outfit', sans-serif;">
leaderboard
</h2>
<div class="flex gap-2 text-[10px]" style="font-family: 'Outfit', sans-serif;">
<span class="text-white underline cursor-pointer">friends</span>
<span class="text-white cursor-pointer">global</span>
</div>
</div>
<div class="flex items-center justify-center h-full">
<p class="text-white text-[18px] font-semibold opacity-60" style="font-family: 'Outfit', sans-serif;">
Coming Soon...
</p>
</div>
</div>
<!-- Leaderboard content would go here -->
</div>
</div>
</div>
</div>

View file

@ -1,13 +1,13 @@
<template>
<div class="card-3d">
<div class="rounded-[8px] border border-black p-6 card-3d-front h-full flex flex-col" style="background-color: #3D2C3E;">
<div class="flex items-start space-x-4 flex-1">
<div class="text-3xl">{{ icon }}</div>
<div class="flex-1 flex flex-col">
<div class="flex items-start space-x-4">
<div class="text-3xl flex-shrink-0">{{ icon }}</div>
<div class="flex-1 flex flex-col min-w-0">
<h3 class="text-lg font-semibold text-text-primary mb-2">{{ title }}</h3>
<p class="text-text-secondary mb-3 flex-1">{{ description }}</p>
<div class="flex items-center justify-between">
<div class="text-2xl font-bold" :style="{ color: color }">{{ value }}</div>
<p class="text-text-secondary mb-4 text-sm line-clamp-2">{{ description }}</p>
<div class="mt-auto">
<div class="text-2xl font-bold mb-1" :style="{ color: color }">{{ value }}</div>
<div class="text-sm text-text-secondary">{{ trend }}</div>
</div>
</div>

View file

@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, computed } from 'vue';
import { ref } from 'vue';
const props = defineProps<{
defineProps<{
version: string;
}>();

View file

@ -38,7 +38,55 @@ Sentry.init({
profilesSampleRate: __SENTRY_ENVIRONMENT__ === 'production' ? 0.1 : 1.0,
enableLogs: true
enableLogs: true,
beforeSend(event, hint) {
const error = hint.originalException as Error | undefined
const errorMessage = (error as any)?.message || error?.toString() || ''
if (
errorMessage.includes('callbackId') ||
errorMessage.includes('IPC custom protocol failed') ||
errorMessage.includes('Tauri will now use the postMessage interface') ||
errorMessage.includes('Load failed') && errorMessage.includes('localhost') ||
errorMessage.includes('ipc://localhost') ||
errorMessage.includes('project.editors.some') ||
errorMessage.includes('project.total_heartbeats.toLocaleString') ||
errorMessage.includes('el.__vnode') ||
errorMessage.includes('patchElement') && errorMessage.includes('null') ||
event.exception?.values?.some(value =>
value.value?.includes('callbackId') ||
value.value?.includes('[callbackId, data]') ||
value.value?.includes('project.editors.some') ||
value.value?.includes('project.total_heartbeats') ||
value.value?.includes('el.__vnode') ||
(value.value?.includes('patchElement') && value.value?.includes('null'))
)
) {
console.log('[SENTRY] Filtered out known benign error:', errorMessage)
return null
}
return event
},
ignoreErrors: [
'IPC custom protocol failed',
'callbackId',
'Load failed',
'ipc://localhost',
'undefined is not an object (evaluating \'[callbackId, data]\')',
'undefined is not an object (evaluating \'project.editors.some\')',
'undefined is not an object (evaluating \'project.total_heartbeats.toLocaleString\')',
'null is not an object (evaluating \'el.__vnode = n2\')',
'null is not an object (evaluating \'el.__vnode\')',
/IPC custom protocol/,
/callbackId/,
/project\.editors\.some/,
/project\.total_heartbeats/,
/el\.__vnode/,
/patchElement/,
]
})
app.mount('#app')

View file

@ -83,6 +83,28 @@
</span>
</button>
<!-- Linux-specific OAuth URL copy section -->
<div v-if="currentOs === 'linux' && oauthUrl" class="mt-6 mb-6 p-4 bg-[#2A1F2B] border border-white/20 rounded-lg">
<p class="text-white/70 text-sm mb-3" style="font-family: 'Outfit', sans-serif;">
<strong>Linux:</strong> Copy the link to open in your browser manually
</p>
<div class="flex gap-2">
<input
:value="oauthUrl"
readonly
class="flex-1 p-3 bg-[#3D2C3E] border border-white/20 rounded-lg text-white font-mono text-xs focus:outline-none focus:border-[#E99682] transition-colors select-all"
@click="($event.target as HTMLInputElement)?.select()"
/>
<button
@click="copyOAuthUrl"
class="px-4 py-3 rounded-lg border-2 border-[rgba(0,0,0,0.35)] font-bold text-sm transition-all bg-[#E99682] text-white hover:bg-[#d88672]"
style="font-family: 'Outfit', sans-serif;"
>
Copy
</button>
</div>
</div>
<button
@click="cancelAuth"
class="text-white/60 text-base hover:text-white transition-colors font-medium"
@ -108,12 +130,6 @@ const emit = defineEmits<{
openOAuthUrlManually: [];
}>();
defineProps<{
isLoading: boolean;
isDevMode: boolean;
oauthUrl: string | null;
}>();
const authInProgress = ref(false);
const directToken = ref('');
@ -138,6 +154,25 @@ function handleDirectAuth() {
directToken.value = '';
}
}
const props = defineProps<{
isLoading: boolean;
isDevMode: boolean;
oauthUrl: string | null;
currentOs: string | null;
}>();
async function copyOAuthUrl() {
if (!props.oauthUrl) return;
try {
await navigator.clipboard.writeText(props.oauthUrl);
alert("OAuth URL copied to clipboard!");
} catch (error) {
console.error("Failed to copy OAuth URL:", error);
alert("Failed to copy OAuth URL to clipboard");
}
}
</script>
<style scoped>

View file

@ -128,56 +128,45 @@
<div class="flex-1 overflow-y-auto min-h-0" ref="scrollContainer">
<div class="grid gap-4 pt-2 pb-4">
<div
v-for="project in paginatedProjects"
:key="project.name"
v-for="(project, index) in paginatedProjects"
:key="`${project?.name || 'unnamed'}-${index}`"
class="card-3d"
@click="selectProject(project)"
>
<div class="rounded-[8px] border border-black p-4 card-3d-front cursor-pointer hover:bg-[#4a3a4b] transition-colors" style="background-color: #3D2C3E;">
<div class="flex justify-between items-start mb-3">
<div class="flex-1 min-w-0">
<h4 class="text-white font-semibold text-lg mb-1 truncate" style="font-family: 'Outfit', sans-serif;">{{ project.name }}</h4>
<h4 class="text-white font-semibold text-lg mb-1 truncate" style="font-family: 'Outfit', sans-serif;">{{ project?.name || 'Unnamed' }}</h4>
<div class="flex items-center gap-4 text-sm text-white/60 flex-wrap" style="font-family: 'Outfit', sans-serif;">
<span>{{ (project.total_heartbeats || 0).toLocaleString() }} heartbeats</span>
<span>{{ formatDuration(project.total_seconds || 0) }}</span>
<span v-if="project.recent_activity_seconds && project.recent_activity_seconds > 0" class="text-[#E99682] font-medium">
Active recently
<span>{{ formatDuration(project?.total_seconds ?? 0) }}</span>
<span v-if="project?.most_recent_heartbeat" class="text-white/40">
Last active: {{ formatDate(project.most_recent_heartbeat) }}
</span>
</div>
</div>
<div class="text-right flex-shrink-0">
<div class="text-xl font-bold text-[#E99682]" style="font-family: 'Outfit', sans-serif;">
{{ ((project.total_seconds || 0) / 3600).toFixed(1) }}h
{{ ((project?.total_seconds ?? 0) / 3600).toFixed(1) }}h
</div>
</div>
</div>
<!-- Languages and Editors -->
<div class="flex flex-wrap gap-2 mb-3">
<div class="flex flex-wrap gap-2">
<span
v-for="language in (project.languages || []).slice(0, 3)"
:key="language"
v-for="(language, langIndex) in (project?.languages || []).slice(0, 3)"
:key="`${project?.name}-lang-${langIndex}-${language}`"
class="px-2 py-1 bg-[rgba(233,150,130,0.15)] text-[#E99682] text-xs rounded-md font-medium"
style="font-family: 'Outfit', sans-serif;"
>
{{ language }}
</span>
<span
v-if="(project.languages || []).length > 3"
v-if="(project?.languages || []).length > 3"
class="px-2 py-1 bg-[rgba(50,36,51,0.15)] text-white/60 text-xs rounded-md"
style="font-family: 'Outfit', sans-serif;"
>
+{{ (project.languages || []).length - 3 }} more
</span>
</div>
<!-- Time Range -->
<div class="text-xs text-white/50" style="font-family: 'Outfit', sans-serif;">
<span v-if="project.first_heartbeat">
First: {{ formatDate(project.first_heartbeat) }}
</span>
<span v-if="project.last_heartbeat" class="ml-4">
Last: {{ formatDate(project.last_heartbeat) }}
+{{ (project?.languages || []).length - 3 }} more
</span>
</div>
</div>
@ -225,12 +214,12 @@
<div class="flex items-start justify-between mb-3">
<div class="flex-1 min-w-0">
<h2 class="text-3xl font-bold text-white m-0 mb-2 truncate" style="font-family: 'Outfit', sans-serif;">
{{ selectedProject.name }}
{{ selectedProject?.name || 'Unnamed' }}
</h2>
<div class="flex items-center gap-4 text-white/60 flex-wrap" style="font-family: 'Outfit', sans-serif;">
<span class="text-base">{{ (selectedProject.total_heartbeats || 0).toLocaleString() }} heartbeats</span>
<span v-if="selectedProject.recent_activity_seconds && selectedProject.recent_activity_seconds > 0" class="px-2 py-1 bg-[rgba(233,150,130,0.2)] text-[#E99682] text-sm rounded-md font-medium">
Active recently
<span class="text-base">{{ formatDuration(selectedProject?.total_seconds ?? 0) }}</span>
<span v-if="selectedProject?.most_recent_heartbeat" class="text-sm text-white/40">
Last active: {{ formatDate(selectedProject.most_recent_heartbeat) }}
</span>
</div>
</div>
@ -248,35 +237,23 @@
<!-- Modal Content -->
<div class="flex-1 overflow-y-auto p-6 space-y-6 min-h-0">
<!-- Stats Grid -->
<div class="grid grid-cols-2 gap-4">
<div class="bg-[rgba(42,31,43,0.5)] border-2 border-[rgba(0,0,0,0.3)] rounded-lg p-4">
<div class="text-white/60 text-sm mb-1" style="font-family: 'Outfit', sans-serif;">Total Time</div>
<div class="text-3xl font-bold text-white" style="font-family: 'Outfit', sans-serif;">
{{ ((selectedProject.total_seconds || 0) / 3600).toFixed(1) }}h
</div>
<div class="text-white/40 text-xs mt-1" style="font-family: 'Outfit', sans-serif;">
{{ formatDuration(selectedProject.total_seconds || 0) }}
</div>
<div class="bg-[rgba(42,31,43,0.5)] border-2 border-[rgba(0,0,0,0.3)] rounded-lg p-6">
<div class="text-white/60 text-sm mb-1" style="font-family: 'Outfit', sans-serif;">Total Time</div>
<div class="text-4xl font-bold text-white mb-2" style="font-family: 'Outfit', sans-serif;">
{{ ((selectedProject?.total_seconds ?? 0) / 3600).toFixed(1) }}h
</div>
<div class="bg-[rgba(42,31,43,0.5)] border-2 border-[rgba(0,0,0,0.3)] rounded-lg p-4">
<div class="text-white/60 text-sm mb-1" style="font-family: 'Outfit', sans-serif;">Heartbeats</div>
<div class="text-3xl font-bold text-white" style="font-family: 'Outfit', sans-serif;">
{{ (selectedProject.total_heartbeats || 0).toLocaleString() }}
</div>
<div class="text-white/40 text-xs mt-1" style="font-family: 'Outfit', sans-serif;">
Activity events
</div>
<div class="text-white/40 text-base" style="font-family: 'Outfit', sans-serif;">
{{ formatDuration(selectedProject?.total_seconds ?? 0) }}
</div>
</div>
<!-- Languages Section -->
<div v-if="selectedProject.languages && selectedProject.languages.length > 0">
<div v-if="selectedProject?.languages && selectedProject.languages.length > 0">
<h3 class="text-white text-lg font-bold mb-3" style="font-family: 'Outfit', sans-serif;">Languages</h3>
<div class="flex flex-wrap gap-2">
<span
v-for="language in selectedProject.languages"
:key="language"
v-for="(language, langIndex) in selectedProject.languages"
:key="`modal-lang-${langIndex}-${language}`"
class="px-3 py-2 bg-[rgba(233,150,130,0.15)] text-[#E99682] text-sm rounded-lg font-medium border-2 border-[rgba(233,150,130,0.3)]"
style="font-family: 'Outfit', sans-serif;"
>
@ -284,39 +261,6 @@
</span>
</div>
</div>
<!-- Editors Section -->
<div v-if="selectedProject.editors && selectedProject.editors.length > 0">
<h3 class="text-white text-lg font-bold mb-3" style="font-family: 'Outfit', sans-serif;">Editors</h3>
<div class="flex flex-wrap gap-2">
<span
v-for="editor in selectedProject.editors"
:key="editor"
class="px-3 py-2 bg-[rgba(232,133,146,0.15)] text-[#E88592] text-sm rounded-lg font-medium border-2 border-[rgba(232,133,146,0.3)]"
style="font-family: 'Outfit', sans-serif;"
>
{{ editor }}
</span>
</div>
</div>
<!-- Repository Link -->
<div v-if="selectedProject.repo_url">
<a
:href="selectedProject.repo_url"
target="_blank"
rel="noopener noreferrer"
class="pushable pushable-active w-full block"
style="font-family: 'Outfit', sans-serif;"
>
<span class="front w-full py-3 px-4 rounded-lg border-2 border-[rgba(0,0,0,0.35)] font-bold flex items-center justify-center gap-2" style="background: linear-gradient(135deg, #E99682 0%, #EB9182 33%, #E88592 66%, #E883AE 100%); color: white;">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
</svg>
View Repository
</span>
</a>
</div>
</div>
</div>
</div>
@ -332,14 +276,8 @@ import RandomLoader from "../components/RandomLoader.vue";
interface Project {
name: string;
total_seconds: number;
total_heartbeats: number;
languages: string[];
editors: string[];
first_heartbeat: string | null;
last_heartbeat: string | null;
repo_url: string | null;
recent_activity_seconds: number;
recent_activity_formatted: string;
most_recent_heartbeat: string | null;
}
interface ProjectsResponse {
@ -368,8 +306,7 @@ const languageDropdownRef = ref<HTMLElement | null>(null);
const sortOptions = [
{ value: 'recent', label: 'Most Recent' },
{ value: 'time', label: 'Most Time' },
{ value: 'name', label: 'Name (A-Z)' },
{ value: 'heartbeats', label: 'Most Active' }
{ value: 'name', label: 'Name (A-Z)' }
];
const itemsPerPage = ref(20);
@ -390,47 +327,52 @@ const sortByLabel = computed(() => {
const allLanguages = computed(() => {
const languages = new Set<string>();
allProjects.value.forEach(project => {
if (project.languages && Array.isArray(project.languages)) {
project.languages.forEach(lang => languages.add(lang));
if (project && project.languages && Array.isArray(project.languages)) {
project.languages.forEach(lang => {
if (lang && typeof lang === 'string') {
languages.add(lang);
}
});
}
});
return Array.from(languages).sort();
});
const filteredProjects = computed(() => {
let filtered = [...allProjects.value];
let filtered = [...allProjects.value].filter(project => project && typeof project === 'object');
if (searchQuery.value.trim()) {
const query = searchQuery.value.toLowerCase();
filtered = filtered.filter(project =>
project.name?.toLowerCase().includes(query) ||
(project.languages || []).some(lang => lang.toLowerCase().includes(query)) ||
(project.editors || []).some(editor => editor.toLowerCase().includes(query))
);
filtered = filtered.filter(project => {
if (!project) return false;
const nameMatch = project.name?.toLowerCase().includes(query);
const languageMatch = Array.isArray(project.languages) &&
project.languages.some(lang => lang && typeof lang === 'string' && lang.toLowerCase().includes(query));
return nameMatch || languageMatch;
});
}
if (filterLanguage.value) {
filtered = filtered.filter(project =>
(project.languages || []).includes(filterLanguage.value)
project && Array.isArray(project.languages) && project.languages.includes(filterLanguage.value)
);
}
switch (sortBy.value) {
case "recent":
filtered.sort((a, b) => {
const dateA = a.last_heartbeat ? new Date(a.last_heartbeat).getTime() : 0;
const dateB = b.last_heartbeat ? new Date(b.last_heartbeat).getTime() : 0;
const dateA = a?.most_recent_heartbeat ? new Date(a.most_recent_heartbeat).getTime() : 0;
const dateB = b?.most_recent_heartbeat ? new Date(b.most_recent_heartbeat).getTime() : 0;
return dateB - dateA;
});
break;
case "time":
filtered.sort((a, b) => (b.total_seconds || 0) - (a.total_seconds || 0));
filtered.sort((a, b) => (b?.total_seconds || 0) - (a?.total_seconds || 0));
break;
case "name":
filtered.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
break;
case "heartbeats":
filtered.sort((a, b) => (b.total_heartbeats || 0) - (a.total_heartbeats || 0));
filtered.sort((a, b) => (a?.name || '').localeCompare(b?.name || ''));
break;
}
@ -446,6 +388,15 @@ const hasMoreProjects = computed(() => {
return paginatedProjects.value.length < filteredProjects.value.length;
});
function normalizeProject(project: any): Project {
return {
name: project?.name || 'Unnamed Project',
total_seconds: Number(project?.total_seconds) || 0,
languages: Array.isArray(project?.languages) ? project.languages : [],
most_recent_heartbeat: project?.most_recent_heartbeat || null
};
}
async function loadProjects() {
isLoading.value = true;
error.value = null;
@ -464,7 +415,9 @@ async function loadProjects() {
apiConfig: props.apiConfig
}) as ProjectsResponse;
console.log("Projects loaded:", response);
allProjects.value = response.projects || [];
const projects = response?.projects || [];
allProjects.value = projects.map(normalizeProject).filter(p => p.name && p.name !== 'Unnamed Project');
} catch (err) {
console.error("Failed to load projects:", err);
error.value = err instanceof Error ? err.message : String(err);

View file

@ -11,22 +11,23 @@
</div>
<!-- Content Grid -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 min-h-0">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Left Column -->
<div class="flex flex-col gap-6 min-h-0">
<div class="flex flex-col gap-6">
<!-- Preferences -->
<div class="card-3d flex-1 min-h-0">
<div class="rounded-[8px] border border-black p-6 card-3d-front h-full" style="background-color: #3D2C3E;">
<div class="card-3d">
<div class="rounded-[8px] border border-black p-5 card-3d-front" style="background-color: #3D2C3E;">
<h3 class="text-white text-[16px] font-bold m-0 mb-4" style="font-family: 'Outfit', sans-serif;">Preferences</h3>
<div class="space-y-5">
<div class="flex items-center justify-between">
<div>
<h4 class="font-medium text-text-primary mb-1">Auto-start</h4>
<p class="text-sm text-text-secondary">Start with system</p>
</div>
<label class="switch">
<input type="checkbox">
<span class="slider"></span>
<label class="switch" :class="{ 'opacity-50 cursor-not-allowed': isLoading }">
<input type="checkbox" :checked="autostartEnabled" :disabled="isLoading" @change="toggleAutostart">
<span class="slider" :class="{ 'animate-pulse': isLoading }"></span>
</label>
</div>
<div class="flex items-center justify-between">
@ -44,21 +45,54 @@
<h4 class="font-medium text-text-primary mb-1">Notifications</h4>
<p class="text-sm text-text-secondary">Show desktop notifications</p>
</div>
<label class="switch">
<input type="checkbox" checked>
<span class="slider"></span>
<label class="switch" :class="{ 'opacity-50 cursor-not-allowed': isLoading }">
<input type="checkbox" :checked="notificationsEnabled" :disabled="isLoading" @change="toggleNotifications">
<span class="slider" :class="{ 'animate-pulse': isLoading }"></span>
</label>
</div>
</div>
</div>
</div>
<!-- API Access -->
<div v-if="apiKey" class="card-3d ph-no-capture">
<div class="rounded-[8px] border border-black p-5 card-3d-front" style="background-color: #3D2C3E;">
<h3 class="text-white text-[16px] font-bold m-0 mb-4" style="font-family: 'Outfit', sans-serif;">Your API Key</h3>
<p class="text-sm text-text-secondary mb-4">Use this key to authenticate with the KubeTime API</p>
<div class="flex flex-col sm:flex-row gap-3 items-stretch sm:items-center">
<input
:type="showApiKey ? 'text' : 'password'"
:value="apiKey"
readonly
class="flex-1 p-3 bg-[rgba(20,15,21,0.3)] border border-[rgba(50,36,51,0.4)] rounded-xl text-text-primary font-mono text-sm min-w-0 break-all"
/>
<div class="flex gap-2 flex-shrink-0">
<button @click="$emit('update:showApiKey', !showApiKey)" class="p-3 border border-[rgba(50,36,51,0.4)] rounded-xl cursor-pointer text-sm min-w-11 transition-all duration-200 bg-[rgba(20,15,21,0.3)] text-text-secondary hover:bg-bg-primary hover:text-text-primary hover:border-accent-primary">
<svg v-if="showApiKey" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L8.464 8.464M9.878 9.878L12 12m-2.122-2.122l1.415 1.415M12 12l2.122 2.122m-2.122-2.122L12 12m2.122 2.122l-1.415-1.415M12 12l-2.122-2.122"></path>
</svg>
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
</svg>
</button>
<button @click="copyApiKey" class="p-3 border border-accent-info rounded-xl cursor-pointer text-sm min-w-11 transition-all duration-200 bg-accent-info text-white hover:bg-blue-400">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
</svg>
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Right Column -->
<div class="flex flex-col gap-6 min-h-0">
<div class="flex flex-col gap-6">
<!-- About -->
<div class="card-3d">
<div class="rounded-[8px] border border-black p-6 card-3d-front" style="background-color: #3D2C3E;">
<div class="rounded-[8px] border border-black p-5 card-3d-front" style="background-color: #3D2C3E;">
<h3 class="text-white text-[16px] font-bold m-0 mb-4" style="font-family: 'Outfit', sans-serif;">About</h3>
<div class="space-y-3">
<div class="flex justify-between">
<span class="text-text-secondary">Version</span>
@ -90,7 +124,7 @@
</button>
</div>
</div>
<div v-if="updateInfo" class="mt-4 p-4 rounded-lg border border-black" style="background-color: #2A1F2B;">
<div v-if="updateInfo" class="mt-4 p-4 rounded-lg border border-[rgba(50,36,51,0.4)]" style="background-color: #2A1F2B;">
<div class="space-y-2">
<div class="flex justify-between">
<span class="text-text-secondary text-sm">New Version</span>
@ -121,45 +155,9 @@
</div>
</div>
<!-- API Access -->
<div v-if="apiKey" class="card-3d ph-no-capture">
<div class="rounded-[8px] border border-black p-6 card-3d-front" style="background-color: #3D2C3E;">
<div class="space-y-4">
<div>
<h4 class="font-medium text-text-primary mb-2">Your API Key</h4>
<p class="text-sm text-text-secondary mb-4">Use this key to authenticate with the KubeTime API</p>
<div class="flex flex-col sm:flex-row gap-3 items-stretch sm:items-center">
<input
:type="showApiKey ? 'text' : 'password'"
:value="apiKey"
readonly
class="flex-1 p-3 bg-[rgba(50,36,51,0.15)] border border-[rgba(50,36,51,0.25)] rounded-xl text-text-primary font-mono text-sm min-w-0 break-all"
/>
<div class="flex gap-2 flex-shrink-0">
<button @click="$emit('update:showApiKey', !showApiKey)" class="p-3 border border-[rgba(50,36,51,0.25)] rounded-xl cursor-pointer text-sm min-w-11 transition-all duration-200 bg-[rgba(50,36,51,0.15)] text-text-secondary hover:bg-bg-primary hover:text-text-primary hover:border-accent-primary">
<svg v-if="showApiKey" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L8.464 8.464M9.878 9.878L12 12m-2.122-2.122l1.415 1.415M12 12l2.122 2.122m-2.122-2.122L12 12m2.122 2.122l-1.415-1.415M12 12l-2.122-2.122"></path>
</svg>
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
</svg>
</button>
<button @click="copyApiKey" class="p-3 border border-accent-info rounded-xl cursor-pointer text-sm min-w-11 transition-all duration-200 bg-accent-info text-white hover:bg-blue-400">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
</svg>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- WakaTime Configuration -->
<div class="card-3d">
<div class="rounded-[8px] border border-black p-6 card-3d-front" style="background-color: #3D2C3E;">
<div class="rounded-[8px] border border-black p-5 card-3d-front" style="background-color: #3D2C3E;">
<div class="flex items-center justify-between">
<div>
<h4 class="font-medium text-text-primary mb-1">Setup</h4>
@ -177,7 +175,7 @@
<!-- Cache Management -->
<div class="card-3d">
<div class="rounded-[8px] border border-black p-6 card-3d-front" style="background-color: #3D2C3E;">
<div class="rounded-[8px] border border-black p-5 card-3d-front" style="background-color: #3D2C3E;">
<div class="flex items-center justify-between">
<div>
<h4 class="font-medium text-text-primary mb-1">Statistics Cache</h4>
@ -196,7 +194,7 @@
<!-- Account -->
<div class="card-3d">
<div class="rounded-[8px] border border-black p-6 card-3d-front" style="background-color: #3D2C3E;">
<div class="rounded-[8px] border border-black p-5 card-3d-front" style="background-color: #3D2C3E;">
<div class="flex items-center justify-between">
<div>
<h4 class="font-medium text-text-primary mb-1">Sign Out</h4>
@ -292,6 +290,8 @@ const emit = defineEmits<{
}>()
const discordRpcEnabled = ref(false);
const autostartEnabled = ref(false);
const notificationsEnabled = ref(false);
const isLoading = ref(false);
const appVersion = ref('...');
const isClearingCache = ref(false);
@ -558,6 +558,39 @@ async function loadDiscordRpcState() {
}
}
async function loadAutostartState() {
try {
autostartEnabled.value = await invoke("get_autostart_enabled");
} catch (error) {
console.error("Failed to load autostart state:", error);
}
}
async function loadNotificationsState() {
try {
notificationsEnabled.value = await invoke("get_notifications_enabled");
} catch (error) {
console.error("Failed to load notifications state:", error);
}
}
async function toggleAutostart() {
if (isLoading.value) return;
isLoading.value = true;
try {
const newState = !autostartEnabled.value;
await invoke("set_autostart_enabled", { enabled: newState });
autostartEnabled.value = newState;
} catch (error) {
console.error("Failed to toggle autostart:", error);
autostartEnabled.value = !autostartEnabled.value;
} finally {
isLoading.value = false;
}
}
async function toggleDiscordRpc() {
if (isLoading.value) return;
@ -575,6 +608,23 @@ async function toggleDiscordRpc() {
}
}
async function toggleNotifications() {
if (isLoading.value) return;
isLoading.value = true;
try {
const newState = !notificationsEnabled.value;
await invoke("set_notifications_enabled", { enabled: newState });
notificationsEnabled.value = newState;
} catch (error) {
console.error("Failed to toggle notifications:", error);
notificationsEnabled.value = !notificationsEnabled.value;
} finally {
isLoading.value = false;
}
}
function copyApiKey() {
emit('copyApiKey')
}
@ -664,6 +714,8 @@ async function downloadAndInstallUpdate() {
onMounted(async () => {
loadDiscordRpcState();
loadAutostartState();
loadNotificationsState();
try {
appVersion.value = await getVersion();
} catch (error) {