Compare commits

...

22 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
42 changed files with 815 additions and 262 deletions

View file

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

View file

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

View file

@ -1,5 +1,74 @@
# Changelog # 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) ## [1.6.1](https://github.com/hackclub/hackatime-desktop/compare/app-v1.6.0...app-v1.6.1) (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 # 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) [![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. 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", "name": "desktop",
"private": true, "private": true,
"version": "1.6.1", "version": "1.7.5",
"type": "module", "type": "module",
"packageManager": "pnpm@10.18.0", "packageManager": "pnpm@10.18.0",
"scripts": { "scripts": {
@ -13,6 +13,7 @@
"dependencies": { "dependencies": {
"@sentry/vue": "^10.18.0", "@sentry/vue": "^10.18.0",
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-autostart": "^2",
"@tauri-apps/plugin-deep-link": "^2.4.3", "@tauri-apps/plugin-deep-link": "^2.4.3",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-process": "~2", "@tauri-apps/plugin-process": "~2",

10
pnpm-lock.yaml generated
View file

@ -14,6 +14,9 @@ importers:
'@tauri-apps/api': '@tauri-apps/api':
specifier: ^2 specifier: ^2
version: 2.8.0 version: 2.8.0
'@tauri-apps/plugin-autostart':
specifier: ^2
version: 2.5.0
'@tauri-apps/plugin-deep-link': '@tauri-apps/plugin-deep-link':
specifier: ^2.4.3 specifier: ^2.4.3
version: 2.4.3 version: 2.4.3
@ -579,6 +582,9 @@ packages:
engines: {node: '>= 10'} engines: {node: '>= 10'}
hasBin: true hasBin: true
'@tauri-apps/plugin-autostart@2.5.0':
resolution: {integrity: sha512-smSt0vydfVB950AeYRbO2S/c01SZrgMVg4FOrFLQLom0R0amsu/8zYaxgttriBdxcofjBZuHv4hmROBQIBVXmA==}
'@tauri-apps/plugin-deep-link@2.4.3': '@tauri-apps/plugin-deep-link@2.4.3':
resolution: {integrity: sha512-yVCZpVG1ZrtfCvE7K5LRSrGqlyPlCrqlKgoREJHnfjyYdDtUhFmZqScOXpL8XL2PizJHDsoahEweuTaUPEokPA==} resolution: {integrity: sha512-yVCZpVG1ZrtfCvE7K5LRSrGqlyPlCrqlKgoREJHnfjyYdDtUhFmZqScOXpL8XL2PizJHDsoahEweuTaUPEokPA==}
@ -1290,6 +1296,10 @@ snapshots:
'@tauri-apps/cli-win32-ia32-msvc': 2.8.4 '@tauri-apps/cli-win32-ia32-msvc': 2.8.4
'@tauri-apps/cli-win32-x64-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': '@tauri-apps/plugin-deep-link@2.4.3':
dependencies: dependencies:
'@tauri-apps/api': 2.8.0 '@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" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" 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]] [[package]]
name = "autocfg" name = "autocfg"
version = "1.5.0" version = "1.5.0"
@ -833,6 +844,7 @@ dependencies = [
"sqlx", "sqlx",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-autostart",
"tauri-plugin-deep-link", "tauri-plugin-deep-link",
"tauri-plugin-opener", "tauri-plugin-opener",
"tauri-plugin-process", "tauri-plugin-process",
@ -855,13 +867,33 @@ dependencies = [
"subtle", "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]] [[package]]
name = "dirs" name = "dirs"
version = "6.0.0" version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
dependencies = [ 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]] [[package]]
@ -872,7 +904,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
dependencies = [ dependencies = [
"libc", "libc",
"option-ext", "option-ext",
"redox_users", "redox_users 0.5.2",
"windows-sys 0.61.1", "windows-sys 0.61.1",
] ]
@ -1012,7 +1044,7 @@ dependencies = [
"rustc_version", "rustc_version",
"toml 0.9.7", "toml 0.9.7",
"vswhom", "vswhom",
"winreg", "winreg 0.55.0",
] ]
[[package]] [[package]]
@ -3633,6 +3665,17 @@ dependencies = [
"bitflags 2.9.4", "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]] [[package]]
name = "redox_users" name = "redox_users"
version = "0.5.2" version = "0.5.2"
@ -4740,7 +4783,7 @@ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
"cookie", "cookie",
"dirs", "dirs 6.0.0",
"dunce", "dunce",
"embed_plist", "embed_plist",
"getrandom 0.3.3", "getrandom 0.3.3",
@ -4791,7 +4834,7 @@ checksum = "9c432ccc9ff661803dab74c6cd78de11026a578a9307610bbc39d3c55be7943f"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"cargo_toml", "cargo_toml",
"dirs", "dirs 6.0.0",
"glob", "glob",
"heck 0.5.0", "heck 0.5.0",
"json-patch", "json-patch",
@ -4863,6 +4906,20 @@ dependencies = [
"walkdir", "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]] [[package]]
name = "tauri-plugin-deep-link" name = "tauri-plugin-deep-link"
version = "2.4.3" version = "2.4.3"
@ -4938,7 +4995,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27cbc31740f4d507712550694749572ec0e43bdd66992db7599b89fbfd6b167b" checksum = "27cbc31740f4d507712550694749572ec0e43bdd66992db7599b89fbfd6b167b"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"dirs", "dirs 6.0.0",
"flate2", "flate2",
"futures-util", "futures-util",
"http", "http",
@ -5447,7 +5504,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0d92153331e7d02ec09137538996a7786fe679c629c279e82a6be762b7e6fe2" checksum = "a0d92153331e7d02ec09137538996a7786fe679c629c279e82a6be762b7e6fe2"
dependencies = [ dependencies = [
"crossbeam-channel", "crossbeam-channel",
"dirs", "dirs 6.0.0",
"libappindicator", "libappindicator",
"muda", "muda",
"objc2 0.6.3", "objc2 0.6.3",
@ -6469,6 +6526,15 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "winreg"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
dependencies = [
"winapi",
]
[[package]] [[package]]
name = "winreg" name = "winreg"
version = "0.55.0" version = "0.55.0"
@ -6501,7 +6567,7 @@ dependencies = [
"block2 0.6.2", "block2 0.6.2",
"cookie", "cookie",
"crossbeam-channel", "crossbeam-channel",
"dirs", "dirs 6.0.0",
"dpi", "dpi",
"dunce", "dunce",
"gdkx11", "gdkx11",

View file

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

View file

@ -13,6 +13,9 @@
"opener:default", "opener:default",
"deep-link:default", "deep-link:default",
"updater: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", "opener:default",
"deep-link:default", "deep-link:default",
"updater: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() 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 database;
mod db_commands; mod db_commands;
mod discord_rpc; mod discord_rpc;
mod preferences;
mod projects; mod projects;
mod session; mod session;
mod setup; mod setup;
@ -35,6 +36,11 @@ fn get_app_version(app: tauri::AppHandle) -> String {
app.package_info().version.to_string() app.package_info().version.to_string()
} }
#[tauri::command]
fn get_current_os() -> String {
std::env::consts::OS.to_string()
}
#[derive(Clone, serde::Serialize)] #[derive(Clone, serde::Serialize)]
struct LogEntry { struct LogEntry {
ts: i64, ts: i64,
@ -69,14 +75,15 @@ pub fn run() {
.plugin(tauri_plugin_single_instance::init(|app, args, cwd| { .plugin(tauri_plugin_single_instance::init(|app, args, cwd| {
push_log("info", "backend", format!("Single instance detected. Args: {:?}, CWD: {}", 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") { 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.show();
let _ = window.set_focus(); let _ = window.set_focus();
push_log("info", "backend", "Brought existing window to front".to_string()); push_log("info", "backend", "Brought existing window to front".to_string());
} }
// Process any deep links from the new instance attempt
for arg in args { for arg in args {
if arg.starts_with("hackatime://") { if arg.starts_with("hackatime://") {
push_log("info", "backend", format!("Processing deep link from second instance: {}", arg)); 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_process::init())
.plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
@ -109,6 +117,7 @@ pub fn run() {
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
greet, greet,
get_app_version, get_app_version,
get_current_os,
get_recent_logs, get_recent_logs,
database::get_platform_info, database::get_platform_info,
@ -128,6 +137,14 @@ pub fn run() {
auth::load_auth_state, auth::load_auth_state,
auth::clear_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_macos_linux,
setup::setup_hackatime_windows, setup::setup_hackatime_windows,
setup::test_hackatime_heartbeat, setup::test_hackatime_heartbeat,
@ -153,8 +170,6 @@ pub fn run() {
discord_rpc::discord_rpc_update_from_heartbeat, discord_rpc::discord_rpc_update_from_heartbeat,
discord_rpc::discord_rpc_auto_connect, discord_rpc::discord_rpc_auto_connect,
discord_rpc::discord_rpc_auto_disconnect, discord_rpc::discord_rpc_auto_disconnect,
discord_rpc::get_discord_rpc_enabled,
discord_rpc::set_discord_rpc_enabled,
projects::get_projects, projects::get_projects,
projects::get_project_details, 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)), 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"))] #[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()); push_log("info", "backend", "🪟 Window close requested - hiding to tray".to_string());
api.prevent_close(); 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(); let app_clone = app_handle.clone();
std::thread::spawn(move || { std::thread::spawn(move || {
if let Some(win) = app_clone.get_webview_window("main") { if let Some(win) = app_clone.get_webview_window("main") {
let _ = win.hide(); let _ = win.hide();
push_log("info", "backend", "✅ Window hidden to tray".to_string()); 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(_) => { WindowEvent::Resized(_) => {
// Handle resize events gracefully - no action needed
// This prevents potential crashes on macOS with transparent windows
} }
WindowEvent::Moved(_) => { 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::auth::AuthState;
use crate::config::ApiConfig; use crate::config::ApiConfig;
use crate::push_log;
#[tauri::command] #[tauri::command]
pub async fn get_projects( pub async fn get_projects(
@ -50,6 +51,16 @@ pub async fn get_projects(
.await .await
.map_err(|e| format!("Failed to parse projects response: {}", e))?; .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) 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 for line in normalized.lines() {
.replace("\r\n", "\n") let trimmed = line.trim();
.lines()
.map(|line| line.trim()) if trimmed.starts_with("api_url") {
.filter(|line| !line.is_empty()) if let Some(value) = trimmed.split('=').nth(1) {
.collect::<Vec<_>>() let value = value.trim();
.join("\n") 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] #[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 { 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 { } else {
false 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) let config_content = fs::read_to_string(&config_path)
.map_err(|e| format!("Failed to read config file: {}", e))?; .map_err(|e| format!("Failed to read config file: {}", e))?;
let lines: Vec<&str> = config_content.lines().collect(); if !check_config_has_required_values(&config_content, &api_key, &api_url) {
let mut found_api_url = false; return Err("Config file is missing required api_url and api_key values".to_string());
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!( 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) let config_content = fs::read_to_string(&config_path)
.map_err(|e| format!("Failed to read config file: {}", e))?; .map_err(|e| format!("Failed to read config file: {}", e))?;
let lines: Vec<&str> = config_content.lines().collect(); if !check_config_has_required_values(&config_content, &api_key, &api_url) {
let mut found_api_url = false; return Err("Config file is missing required api_url and api_key values".to_string());
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!( 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 let Some(window) = app.get_webview_window("main") {
if window.is_visible().unwrap_or(false) { if window.is_visible().unwrap_or(false) {
let _ = window.hide(); let _ = window.hide();
#[cfg(target_os = "macos")]
let _ = app.set_activation_policy(tauri::ActivationPolicy::Accessory);
} else { } else {
#[cfg(target_os = "macos")]
let _ = app.set_activation_policy(tauri::ActivationPolicy::Regular);
let _ = window.show(); let _ = window.show();
let _ = window.set_focus(); 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(); 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") { if let Some(window) = app.get_webview_window("main") {
let _ = window.show(); let _ = window.show();
let _ = window.set_focus(); let _ = window.set_focus();

View file

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

View file

@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Hackatime Desktop", "productName": "Hackatime Desktop",
"version": "1.6.1", "version": "1.7.5",
"identifier": "com.hackclub.hackatime", "identifier": "com.hackclub.hackatime",
"build": { "build": {
"beforeDevCommand": "pnpm dev", "beforeDevCommand": "pnpm dev",
@ -29,8 +29,9 @@
} }
], ],
"security": { "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": { "bundle": {
"active": true, "active": true,
@ -63,7 +64,7 @@
"updater": { "updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDdERjg4QTFCNTJFMDk0MUQKUldRZGxPQlNHNHI0ZlRkMDN0MGI1MnllY1dUVStZalV3dVdhcTFuREx5SGtBc0txQ2xnTWs3WU4K", "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDdERjg4QTFCNTJFMDk0MUQKUldRZGxPQlNHNHI0ZlRkMDN0MGI1MnllY1dUVStZalV3dVdhcTFuREx5SGtBc0txQ2xnTWs3WU4K",
"endpoints": [ "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'); 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 { interface AuthState {
is_authenticated: boolean; is_authenticated: boolean;
access_token: string | null; access_token: string | null;
@ -61,9 +81,11 @@ const sessionStats = ref<any>(null);
const presenceData = ref<any>(null); const presenceData = ref<any>(null);
const presenceRefreshInterval = ref<ReturnType<typeof setInterval> | null>(null); const presenceRefreshInterval = ref<ReturnType<typeof setInterval> | null>(null);
const presenceFetchInProgress = ref(false); const presenceFetchInProgress = ref(false);
const updateCheckInterval = ref<ReturnType<typeof setInterval> | null>(null);
const oauthUrl = ref<string | null>(null); const oauthUrl = ref<string | null>(null);
const nextPresenceFetchAllowedAt = ref<number>(0); const nextPresenceFetchAllowedAt = ref<number>(0);
const lastPresenceFetchAt = ref<number>(0); const lastPresenceFetchAt = ref<number>(0);
const currentOs = ref<string | null>(null);
const currentPage = ref<'home' | 'projects' | 'statistics' | 'settings'>('home'); const currentPage = ref<'home' | 'projects' | 'statistics' | 'settings'>('home');
@ -77,6 +99,8 @@ const updateData = ref<any>(null);
const showUpdateModal = ref(false); const showUpdateModal = ref(false);
const isInstallingUpdate = ref(false); const isInstallingUpdate = ref(false);
const currentVersion = ref<string>('1.5.1'); const currentVersion = ref<string>('1.5.1');
const lastUpdateCheckTime = ref<number>(0);
const updateCheckInProgress = ref(false);
const weeklyChartData = computed(() => { const weeklyChartData = computed(() => {
@ -111,6 +135,7 @@ onMounted(async () => {
await loadAuthState(); await loadAuthState();
await loadApiConfig(); await loadApiConfig();
await loadHackatimeInfo(); await loadHackatimeInfo();
await loadCurrentOs();
try { try {
const appVersion = await invoke("get_app_version") as string; const appVersion = await invoke("get_app_version") as string;
@ -119,10 +144,9 @@ onMounted(async () => {
console.warn("Failed to get app version:", error); console.warn("Failed to get app version:", error);
} }
isDevMode.value = apiConfig.value.base_url.includes('localhost') || isDevMode.value = window.location.hostname === 'localhost' ||
apiConfig.value.base_url.includes('127.0.0.1') || window.location.hostname === '127.0.0.1' ||
window.location.hostname === 'localhost' || window.location.protocol === 'http:';
window.location.hostname === '127.0.0.1';
try { try {
const startUrls = await getCurrent(); const startUrls = await getCurrent();
@ -163,11 +187,17 @@ onMounted(async () => {
window.addEventListener('focus', async () => { window.addEventListener('focus', async () => {
await loadAuthState(); await loadAuthState();
if (authState.value.is_authenticated) {
checkForUpdatesAndInstall();
}
}); });
document.addEventListener('visibilitychange', async () => { document.addEventListener('visibilitychange', async () => {
if (!document.hidden) { if (!document.hidden) {
await loadAuthState(); await loadAuthState();
if (authState.value.is_authenticated) {
checkForUpdatesAndInstall();
}
} }
}); });
@ -177,10 +207,12 @@ onMounted(async () => {
} }
checkForUpdatesAndInstall(); checkForUpdatesAndInstall();
startUpdateChecks();
}); });
onUnmounted(() => { onUnmounted(() => {
stopPresenceRefresh(); stopPresenceRefresh();
stopUpdateChecks();
}); });
async function loadAuthState() { 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() { async function loadPresenceData() {
if (presenceFetchInProgress.value) { if (presenceFetchInProgress.value) {
return; 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() { async function authenticate() {
isLoading.value = true; isLoading.value = true;
@ -470,12 +530,25 @@ async function handleDirectOAuthAuth(token?: string) {
} }
} }
async function checkForUpdatesAndInstall() { async function checkForUpdatesAndInstall(retryCount = 0) {
if (isDevMode.value) {
console.info('[AUTO-UPDATE] Skipping auto-update check in development mode'); if (updateCheckInProgress.value) {
console.info('[AUTO-UPDATE] Update check already in progress, skipping');
return; 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 { try {
console.info('[AUTO-UPDATE] Checking for updates...'); console.info('[AUTO-UPDATE] Checking for updates...');
const update = await check(); const update = await check();
@ -490,6 +563,19 @@ async function checkForUpdatesAndInstall() {
} }
} catch (error) { } catch (error) {
console.error('[AUTO-UPDATE] Auto-update check failed:', 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" :isLoading="isLoading"
:isDevMode="isDevMode" :isDevMode="isDevMode"
:oauthUrl="oauthUrl" :oauthUrl="oauthUrl"
:currentOs="currentOs"
@authenticate="authenticate" @authenticate="authenticate"
@handleDirectOAuthAuth="handleDirectOAuthAuth" @handleDirectOAuthAuth="handleDirectOAuthAuth"
@openOAuthUrlManually="openOAuthUrlManually" @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 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="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="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"> <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;"> <h2 class="text-white text-[16px] font-bold italic m-0" style="font-family: 'Outfit', sans-serif;">
leaderboard leaderboard
</h2> </h2>
<div class="flex gap-2 text-[10px]" style="font-family: 'Outfit', sans-serif;"> <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 underline cursor-pointer">friends</span>
<span class="text-white cursor-pointer">global</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>
</div> </div>
<!-- Leaderboard content would go here -->
</div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -1,13 +1,13 @@
<template> <template>
<div class="card-3d"> <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="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="flex items-start space-x-4">
<div class="text-3xl">{{ icon }}</div> <div class="text-3xl flex-shrink-0">{{ icon }}</div>
<div class="flex-1 flex flex-col"> <div class="flex-1 flex flex-col min-w-0">
<h3 class="text-lg font-semibold text-text-primary mb-2">{{ title }}</h3> <h3 class="text-lg font-semibold text-text-primary mb-2">{{ title }}</h3>
<p class="text-text-secondary mb-3 flex-1">{{ description }}</p> <p class="text-text-secondary mb-4 text-sm line-clamp-2">{{ description }}</p>
<div class="flex items-center justify-between"> <div class="mt-auto">
<div class="text-2xl font-bold" :style="{ color: color }">{{ value }}</div> <div class="text-2xl font-bold mb-1" :style="{ color: color }">{{ value }}</div>
<div class="text-sm text-text-secondary">{{ trend }}</div> <div class="text-sm text-text-secondary">{{ trend }}</div>
</div> </div>
</div> </div>

View file

@ -38,7 +38,55 @@ Sentry.init({
profilesSampleRate: __SENTRY_ENVIRONMENT__ === 'production' ? 0.1 : 1.0, 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') app.mount('#app')

View file

@ -83,6 +83,28 @@
</span> </span>
</button> </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 <button
@click="cancelAuth" @click="cancelAuth"
class="text-white/60 text-base hover:text-white transition-colors font-medium" class="text-white/60 text-base hover:text-white transition-colors font-medium"
@ -108,12 +130,6 @@ const emit = defineEmits<{
openOAuthUrlManually: []; openOAuthUrlManually: [];
}>(); }>();
defineProps<{
isLoading: boolean;
isDevMode: boolean;
oauthUrl: string | null;
}>();
const authInProgress = ref(false); const authInProgress = ref(false);
const directToken = ref(''); const directToken = ref('');
@ -138,6 +154,25 @@ function handleDirectAuth() {
directToken.value = ''; 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> </script>
<style scoped> <style scoped>

View file

@ -128,56 +128,45 @@
<div class="flex-1 overflow-y-auto min-h-0" ref="scrollContainer"> <div class="flex-1 overflow-y-auto min-h-0" ref="scrollContainer">
<div class="grid gap-4 pt-2 pb-4"> <div class="grid gap-4 pt-2 pb-4">
<div <div
v-for="project in paginatedProjects" v-for="(project, index) in paginatedProjects"
:key="project.name" :key="`${project?.name || 'unnamed'}-${index}`"
class="card-3d" class="card-3d"
@click="selectProject(project)" @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="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 justify-between items-start mb-3">
<div class="flex-1 min-w-0"> <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;"> <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>{{ formatDuration(project.total_seconds || 0) }}</span> <span v-if="project?.most_recent_heartbeat" class="text-white/40">
<span v-if="project.recent_activity_seconds && project.recent_activity_seconds > 0" class="text-[#E99682] font-medium"> Last active: {{ formatDate(project.most_recent_heartbeat) }}
Active recently
</span> </span>
</div> </div>
</div> </div>
<div class="text-right flex-shrink-0"> <div class="text-right flex-shrink-0">
<div class="text-xl font-bold text-[#E99682]" style="font-family: 'Outfit', sans-serif;"> <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> </div>
</div> </div>
<!-- Languages and Editors --> <!-- Languages and Editors -->
<div class="flex flex-wrap gap-2 mb-3"> <div class="flex flex-wrap gap-2">
<span <span
v-for="language in (project.languages || []).slice(0, 3)" v-for="(language, langIndex) in (project?.languages || []).slice(0, 3)"
:key="language" :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" 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;" style="font-family: 'Outfit', sans-serif;"
> >
{{ language }} {{ language }}
</span> </span>
<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" 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;" style="font-family: 'Outfit', sans-serif;"
> >
+{{ (project.languages || []).length - 3 }} more +{{ (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) }}
</span> </span>
</div> </div>
</div> </div>
@ -225,12 +214,12 @@
<div class="flex items-start justify-between mb-3"> <div class="flex items-start justify-between mb-3">
<div class="flex-1 min-w-0"> <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;"> <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> </h2>
<div class="flex items-center gap-4 text-white/60 flex-wrap" style="font-family: 'Outfit', sans-serif;"> <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 class="text-base">{{ formatDuration(selectedProject?.total_seconds ?? 0) }}</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"> <span v-if="selectedProject?.most_recent_heartbeat" class="text-sm text-white/40">
Active recently Last active: {{ formatDate(selectedProject.most_recent_heartbeat) }}
</span> </span>
</div> </div>
</div> </div>
@ -248,35 +237,23 @@
<!-- Modal Content --> <!-- Modal Content -->
<div class="flex-1 overflow-y-auto p-6 space-y-6 min-h-0"> <div class="flex-1 overflow-y-auto p-6 space-y-6 min-h-0">
<!-- Stats Grid --> <!-- 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-6">
<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-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;">
<div class="text-3xl font-bold text-white" style="font-family: 'Outfit', sans-serif;"> {{ ((selectedProject?.total_seconds ?? 0) / 3600).toFixed(1) }}h
{{ ((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> </div>
<div class="text-white/40 text-base" style="font-family: 'Outfit', sans-serif;">
<div class="bg-[rgba(42,31,43,0.5)] border-2 border-[rgba(0,0,0,0.3)] rounded-lg p-4"> {{ formatDuration(selectedProject?.total_seconds ?? 0) }}
<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> </div>
</div> </div>
<!-- Languages Section --> <!-- 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> <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"> <div class="flex flex-wrap gap-2">
<span <span
v-for="language in selectedProject.languages" v-for="(language, langIndex) in selectedProject.languages"
:key="language" :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)]" 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;" style="font-family: 'Outfit', sans-serif;"
> >
@ -284,39 +261,6 @@
</span> </span>
</div> </div>
</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> </div>
</div> </div>
@ -332,14 +276,8 @@ import RandomLoader from "../components/RandomLoader.vue";
interface Project { interface Project {
name: string; name: string;
total_seconds: number; total_seconds: number;
total_heartbeats: number;
languages: string[]; languages: string[];
editors: string[]; most_recent_heartbeat: string | null;
first_heartbeat: string | null;
last_heartbeat: string | null;
repo_url: string | null;
recent_activity_seconds: number;
recent_activity_formatted: string;
} }
interface ProjectsResponse { interface ProjectsResponse {
@ -368,8 +306,7 @@ const languageDropdownRef = ref<HTMLElement | null>(null);
const sortOptions = [ const sortOptions = [
{ value: 'recent', label: 'Most Recent' }, { value: 'recent', label: 'Most Recent' },
{ value: 'time', label: 'Most Time' }, { value: 'time', label: 'Most Time' },
{ value: 'name', label: 'Name (A-Z)' }, { value: 'name', label: 'Name (A-Z)' }
{ value: 'heartbeats', label: 'Most Active' }
]; ];
const itemsPerPage = ref(20); const itemsPerPage = ref(20);
@ -390,47 +327,52 @@ const sortByLabel = computed(() => {
const allLanguages = computed(() => { const allLanguages = computed(() => {
const languages = new Set<string>(); const languages = new Set<string>();
allProjects.value.forEach(project => { allProjects.value.forEach(project => {
if (project.languages && Array.isArray(project.languages)) { if (project && project.languages && Array.isArray(project.languages)) {
project.languages.forEach(lang => languages.add(lang)); project.languages.forEach(lang => {
if (lang && typeof lang === 'string') {
languages.add(lang);
}
});
} }
}); });
return Array.from(languages).sort(); return Array.from(languages).sort();
}); });
const filteredProjects = computed(() => { const filteredProjects = computed(() => {
let filtered = [...allProjects.value]; let filtered = [...allProjects.value].filter(project => project && typeof project === 'object');
if (searchQuery.value.trim()) { if (searchQuery.value.trim()) {
const query = searchQuery.value.toLowerCase(); const query = searchQuery.value.toLowerCase();
filtered = filtered.filter(project => filtered = filtered.filter(project => {
project.name?.toLowerCase().includes(query) || if (!project) return false;
(project.languages || []).some(lang => lang.toLowerCase().includes(query)) ||
(project.editors || []).some(editor => editor.toLowerCase().includes(query)) 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) { if (filterLanguage.value) {
filtered = filtered.filter(project => filtered = filtered.filter(project =>
(project.languages || []).includes(filterLanguage.value) project && Array.isArray(project.languages) && project.languages.includes(filterLanguage.value)
); );
} }
switch (sortBy.value) { switch (sortBy.value) {
case "recent": case "recent":
filtered.sort((a, b) => { filtered.sort((a, b) => {
const dateA = a.last_heartbeat ? new Date(a.last_heartbeat).getTime() : 0; const dateA = a?.most_recent_heartbeat ? new Date(a.most_recent_heartbeat).getTime() : 0;
const dateB = b.last_heartbeat ? new Date(b.last_heartbeat).getTime() : 0; const dateB = b?.most_recent_heartbeat ? new Date(b.most_recent_heartbeat).getTime() : 0;
return dateB - dateA; return dateB - dateA;
}); });
break; break;
case "time": 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; break;
case "name": case "name":
filtered.sort((a, b) => (a.name || '').localeCompare(b.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));
break; break;
} }
@ -446,6 +388,15 @@ const hasMoreProjects = computed(() => {
return paginatedProjects.value.length < filteredProjects.value.length; 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() { async function loadProjects() {
isLoading.value = true; isLoading.value = true;
error.value = null; error.value = null;
@ -464,7 +415,9 @@ async function loadProjects() {
apiConfig: props.apiConfig apiConfig: props.apiConfig
}) as ProjectsResponse; }) as ProjectsResponse;
console.log("Projects loaded:", response); 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) { } catch (err) {
console.error("Failed to load projects:", err); console.error("Failed to load projects:", err);
error.value = err instanceof Error ? err.message : String(err); error.value = err instanceof Error ? err.message : String(err);

View file

@ -25,9 +25,9 @@
<h4 class="font-medium text-text-primary mb-1">Auto-start</h4> <h4 class="font-medium text-text-primary mb-1">Auto-start</h4>
<p class="text-sm text-text-secondary">Start with system</p> <p class="text-sm text-text-secondary">Start with system</p>
</div> </div>
<label class="switch"> <label class="switch" :class="{ 'opacity-50 cursor-not-allowed': isLoading }">
<input type="checkbox"> <input type="checkbox" :checked="autostartEnabled" :disabled="isLoading" @change="toggleAutostart">
<span class="slider"></span> <span class="slider" :class="{ 'animate-pulse': isLoading }"></span>
</label> </label>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
@ -45,9 +45,9 @@
<h4 class="font-medium text-text-primary mb-1">Notifications</h4> <h4 class="font-medium text-text-primary mb-1">Notifications</h4>
<p class="text-sm text-text-secondary">Show desktop notifications</p> <p class="text-sm text-text-secondary">Show desktop notifications</p>
</div> </div>
<label class="switch"> <label class="switch" :class="{ 'opacity-50 cursor-not-allowed': isLoading }">
<input type="checkbox" checked> <input type="checkbox" :checked="notificationsEnabled" :disabled="isLoading" @change="toggleNotifications">
<span class="slider"></span> <span class="slider" :class="{ 'animate-pulse': isLoading }"></span>
</label> </label>
</div> </div>
</div> </div>
@ -290,6 +290,8 @@ const emit = defineEmits<{
}>() }>()
const discordRpcEnabled = ref(false); const discordRpcEnabled = ref(false);
const autostartEnabled = ref(false);
const notificationsEnabled = ref(false);
const isLoading = ref(false); const isLoading = ref(false);
const appVersion = ref('...'); const appVersion = ref('...');
const isClearingCache = ref(false); const isClearingCache = ref(false);
@ -556,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() { async function toggleDiscordRpc() {
if (isLoading.value) return; if (isLoading.value) return;
@ -573,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() { function copyApiKey() {
emit('copyApiKey') emit('copyApiKey')
} }
@ -662,6 +714,8 @@ async function downloadAndInstallUpdate() {
onMounted(async () => { onMounted(async () => {
loadDiscordRpcState(); loadDiscordRpcState();
loadAutostartState();
loadNotificationsState();
try { try {
appVersion.value = await getVersion(); appVersion.value = await getVersion();
} catch (error) { } catch (error) {