Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
130d0e2ca6 | ||
|
|
507fa33be8 | ||
|
|
f4f964140b |
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
src-tauri/target
|
||||||
|
dist
|
||||||
|
dist-appimage
|
||||||
|
.claude
|
||||||
|
.git
|
||||||
|
docs
|
||||||
|
trash
|
||||||
40
.gitignore
vendored
40
.gitignore
vendored
@@ -1,40 +0,0 @@
|
|||||||
node_modules
|
|
||||||
dist
|
|
||||||
docs
|
|
||||||
trash
|
|
||||||
|
|
||||||
# Rust/Tauri build artifacts
|
|
||||||
src-tauri/target
|
|
||||||
src-tauri/gen
|
|
||||||
|
|
||||||
# AI/LLM tools
|
|
||||||
.claude
|
|
||||||
.claude/*
|
|
||||||
CLAUDE.md
|
|
||||||
.cursorrules
|
|
||||||
.cursor
|
|
||||||
.cursor/
|
|
||||||
.copilot
|
|
||||||
.copilot/
|
|
||||||
.github/copilot
|
|
||||||
.aider*
|
|
||||||
.aiderignore
|
|
||||||
.continue
|
|
||||||
.continue/
|
|
||||||
.ai
|
|
||||||
.ai/
|
|
||||||
.llm
|
|
||||||
.llm/
|
|
||||||
.windsurf
|
|
||||||
.windsurf/
|
|
||||||
.codeium
|
|
||||||
.codeium/
|
|
||||||
.tabnine
|
|
||||||
.tabnine/
|
|
||||||
.sourcery
|
|
||||||
.sourcery/
|
|
||||||
cursor.rules
|
|
||||||
.bolt
|
|
||||||
.bolt/
|
|
||||||
.v0
|
|
||||||
.v0/
|
|
||||||
81
Dockerfile.appimage
Normal file
81
Dockerfile.appimage
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
FROM ubuntu:24.04
|
||||||
|
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
# Tauri v2 build dependencies for Ubuntu 24.04 (glibc 2.39, WebKitGTK 2.44+)
|
||||||
|
# Targets any distro from mid-2024 onwards while supporting modern Wayland compositors
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
build-essential \
|
||||||
|
curl \
|
||||||
|
wget \
|
||||||
|
file \
|
||||||
|
libgtk-3-dev \
|
||||||
|
libwebkit2gtk-4.1-dev \
|
||||||
|
librsvg2-dev \
|
||||||
|
patchelf \
|
||||||
|
libssl-dev \
|
||||||
|
libayatana-appindicator3-dev \
|
||||||
|
libglib2.0-dev \
|
||||||
|
libgdk-pixbuf2.0-dev \
|
||||||
|
libsoup-3.0-dev \
|
||||||
|
libjavascriptcoregtk-4.1-dev \
|
||||||
|
xdg-utils \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Node.js 22 LTS
|
||||||
|
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
||||||
|
&& apt-get install -y nodejs \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Rust stable
|
||||||
|
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||||
|
ENV PATH="/root/.cargo/bin:${PATH}"
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Cache Rust dependencies first
|
||||||
|
COPY src-tauri/Cargo.toml src-tauri/Cargo.lock src-tauri/
|
||||||
|
COPY src-tauri/build.rs src-tauri/
|
||||||
|
# Create stub lib/main so cargo can resolve the crate
|
||||||
|
RUN mkdir -p src-tauri/src && \
|
||||||
|
echo 'fn main() {}' > src-tauri/src/main.rs && \
|
||||||
|
echo '' > src-tauri/src/lib.rs && \
|
||||||
|
cd src-tauri && cargo fetch && \
|
||||||
|
rm -rf src
|
||||||
|
|
||||||
|
# Cache npm dependencies
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Copy the full source
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build the AppImage
|
||||||
|
RUN npx tauri build --bundles appimage
|
||||||
|
|
||||||
|
# Strip GPU/GL libraries that linuxdeploy bundles from the build system.
|
||||||
|
# These MUST come from the host's GPU driver at runtime - bundling them causes
|
||||||
|
# black windows, EGL failures, and driver mismatches on any system with a
|
||||||
|
# different Mesa version or proprietary NVIDIA drivers.
|
||||||
|
# See: https://github.com/AppImage/pkg2appimage/blob/master/excludelist
|
||||||
|
RUN cd src-tauri/target/release/bundle/appimage && \
|
||||||
|
chmod +x *.AppImage && \
|
||||||
|
./*.AppImage --appimage-extract && \
|
||||||
|
find squashfs-root \( \
|
||||||
|
-name "libEGL.so*" -o \
|
||||||
|
-name "libGL.so*" -o \
|
||||||
|
-name "libGLX.so*" -o \
|
||||||
|
-name "libGLdispatch.so*" -o \
|
||||||
|
-name "libOpenGL.so*" -o \
|
||||||
|
-name "libgbm.so*" -o \
|
||||||
|
-name "libdrm.so*" -o \
|
||||||
|
-name "libglapi.so*" -o \
|
||||||
|
-name "libGLESv2.so*" \
|
||||||
|
\) -delete && \
|
||||||
|
rm -f *.AppImage && \
|
||||||
|
wget -q https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage -O /tmp/appimagetool && \
|
||||||
|
chmod +x /tmp/appimagetool && \
|
||||||
|
ARCH=x86_64 /tmp/appimagetool --appimage-extract-and-run --no-appstream squashfs-root/ && \
|
||||||
|
rm -rf squashfs-root /tmp/appimagetool
|
||||||
|
|
||||||
|
# The AppImage will be in src-tauri/target/release/bundle/appimage/
|
||||||
59
README.md
59
README.md
@@ -181,7 +181,52 @@ Accessibility is not a feature. It is a baseline.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🚀 Getting started
|
## 📦 Downloads
|
||||||
|
|
||||||
|
Grab the latest release from the [releases page](https://git.lashman.live/lashman/zeroclock/releases).
|
||||||
|
|
||||||
|
| Platform | File | Notes |
|
||||||
|
|----------|------|-------|
|
||||||
|
| Windows x64 | `zeroclock-v*.exe` | Portable executable, no installer needed |
|
||||||
|
| Linux x86_64 | `ZeroClock-x86_64.AppImage` | Portable AppImage, no installation needed |
|
||||||
|
|
||||||
|
### Linux AppImage
|
||||||
|
|
||||||
|
Download, make executable, and run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod +x ZeroClock-x86_64.AppImage
|
||||||
|
./ZeroClock-x86_64.AppImage
|
||||||
|
```
|
||||||
|
|
||||||
|
- Built on Ubuntu 24.04 (glibc 2.39) - compatible with most distros from mid-2024 onwards
|
||||||
|
- Portable - data is stored next to the AppImage file
|
||||||
|
- Automatically installs a `.desktop` file and icon on first run
|
||||||
|
- Wayland and X11 supported
|
||||||
|
|
||||||
|
**Troubleshooting:**
|
||||||
|
|
||||||
|
If you see a black or blank window, try:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
WEBKIT_DISABLE_DMABUF_RENDERER=0 ./ZeroClock-x86_64.AppImage
|
||||||
|
```
|
||||||
|
|
||||||
|
If that does not help:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
WEBKIT_DISABLE_COMPOSITING_MODE=1 ./ZeroClock-x86_64.AppImage
|
||||||
|
```
|
||||||
|
|
||||||
|
NixOS users should run via nixGL:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nixGL appimage-run ./ZeroClock-x86_64.AppImage
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Building from source
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
@@ -193,7 +238,7 @@ Accessibility is not a feature. It is a baseline.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone the repository
|
# Clone the repository
|
||||||
git clone https://github.com/your-username/zeroclock.git
|
git clone https://git.lashman.live/lashman/zeroclock.git
|
||||||
cd zeroclock
|
cd zeroclock
|
||||||
|
|
||||||
# Install frontend dependencies
|
# Install frontend dependencies
|
||||||
@@ -206,6 +251,16 @@ npx tauri dev
|
|||||||
npx tauri build
|
npx tauri build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Building the Linux AppImage
|
||||||
|
|
||||||
|
To build a portable AppImage with broad compatibility, use the Docker build script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./build-appimage.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This builds inside an Ubuntu 24.04 container and outputs to `dist-appimage/`. Requires Docker.
|
||||||
|
|
||||||
The database is created automatically on first launch in the same directory as the executable.
|
The database is created automatically on first launch in the same directory as the executable.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
21
build-appimage.sh
Normal file
21
build-appimage.sh
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
IMAGE_NAME="zeroclock-appimage-builder"
|
||||||
|
OUTPUT_DIR="$SCRIPT_DIR/dist-appimage"
|
||||||
|
|
||||||
|
echo "Building Docker image (Ubuntu 24.04 / glibc 2.39)..."
|
||||||
|
docker build -f Dockerfile.appimage -t "$IMAGE_NAME" "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
echo "Extracting AppImage..."
|
||||||
|
rm -rf "$OUTPUT_DIR"
|
||||||
|
mkdir -p "$OUTPUT_DIR"
|
||||||
|
|
||||||
|
CONTAINER_ID=$(docker create "$IMAGE_NAME")
|
||||||
|
docker cp "$CONTAINER_ID:/app/src-tauri/target/release/bundle/appimage/." "$OUTPUT_DIR/"
|
||||||
|
docker rm "$CONTAINER_ID" > /dev/null
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Done! AppImage(s) in: $OUTPUT_DIR/"
|
||||||
|
ls -lh "$OUTPUT_DIR/"*.AppImage 2>/dev/null || echo "(no .AppImage files found - check build output above)"
|
||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "zeroclock",
|
"name": "zeroclock",
|
||||||
"version": "1.0.0",
|
"version": "1.0.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "zeroclock",
|
"name": "zeroclock",
|
||||||
"version": "1.0.0",
|
"version": "1.0.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.2.0",
|
"@tauri-apps/api": "^2.2.0",
|
||||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "zeroclock",
|
"name": "zeroclock",
|
||||||
"version": "1.0.0",
|
"version": "1.0.2",
|
||||||
"description": "Time tracking desktop application",
|
"description": "Time tracking desktop application",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
3
src-tauri/Cargo.lock
generated
3
src-tauri/Cargo.lock
generated
@@ -5743,10 +5743,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zeroclock"
|
name = "zeroclock"
|
||||||
version = "1.0.0"
|
version = "1.0.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
|
"gtk",
|
||||||
"log",
|
"log",
|
||||||
"png",
|
"png",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "zeroclock"
|
name = "zeroclock"
|
||||||
version = "1.0.0"
|
version = "1.0.2"
|
||||||
description = "A local time tracking app with invoicing"
|
description = "A local time tracking app with invoicing"
|
||||||
authors = ["you"]
|
authors = ["you"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
@@ -26,11 +26,13 @@ chrono = { version = "0.4", features = ["serde"] }
|
|||||||
tauri-plugin-window-state = "2"
|
tauri-plugin-window-state = "2"
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
env_logger = "0.11"
|
env_logger = "0.11"
|
||||||
png = "0.17"
|
|
||||||
|
|
||||||
[dependencies.windows]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
version = "0.58"
|
gtk = "0.18"
|
||||||
features = [
|
|
||||||
|
[target.'cfg(windows)'.dependencies]
|
||||||
|
png = "0.17"
|
||||||
|
windows = { version = "0.58", features = [
|
||||||
"Win32_UI_WindowsAndMessaging",
|
"Win32_UI_WindowsAndMessaging",
|
||||||
"Win32_System_Threading",
|
"Win32_System_Threading",
|
||||||
"Win32_System_SystemInformation",
|
"Win32_System_SystemInformation",
|
||||||
@@ -39,7 +41,7 @@ features = [
|
|||||||
"Win32_Graphics_Gdi",
|
"Win32_Graphics_Gdi",
|
||||||
"Win32_Storage_FileSystem",
|
"Win32_Storage_FileSystem",
|
||||||
"Win32_Foundation",
|
"Win32_Foundation",
|
||||||
]
|
] }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
panic = "abort"
|
panic = "abort"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use crate::AppState;
|
|||||||
use crate::os_detection;
|
use crate::os_detection;
|
||||||
use rusqlite::params;
|
use rusqlite::params;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tauri::{Manager, State};
|
use tauri::{AppHandle, Manager, State};
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct Client {
|
pub struct Client {
|
||||||
@@ -1102,6 +1102,17 @@ pub struct TrackedApp {
|
|||||||
pub display_name: Option<String>,
|
pub display_name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Platform detection
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_platform() -> String {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{ "linux".to_string() }
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{ "windows".to_string() }
|
||||||
|
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||||
|
{ "unknown".to_string() }
|
||||||
|
}
|
||||||
|
|
||||||
// OS Detection commands
|
// OS Detection commands
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_idle_seconds() -> Result<u64, String> {
|
pub fn get_idle_seconds() -> Result<u64, String> {
|
||||||
@@ -3754,3 +3765,137 @@ fn get_default_templates() -> Vec<InvoiceTemplate> {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn quit_app(app: AppHandle) {
|
||||||
|
app.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Play a synthesized notification sound on Linux via system audio tools.
|
||||||
|
/// Generates a WAV in memory and pipes it through paplay/pw-play/aplay.
|
||||||
|
/// `tones` is a JSON array of {freq, duration_ms, delay_ms, detune} objects.
|
||||||
|
/// `volume` is 0.0-1.0.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn play_sound(tones: Vec<SoundTone>, volume: f64) {
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
play_sound_inner(&tones, volume);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn play_sound(_tones: Vec<SoundTone>, _volume: f64) {
|
||||||
|
// No-op on non-Linux; frontend uses Web Audio API directly
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct SoundTone {
|
||||||
|
pub freq: f64,
|
||||||
|
pub duration_ms: u32,
|
||||||
|
pub delay_ms: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub freq_end: Option<f64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub detune: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn play_sound_inner(tones: &[SoundTone], volume: f64) {
|
||||||
|
const SAMPLE_RATE: u32 = 44100;
|
||||||
|
let vol = volume.clamp(0.0, 1.0) as f32;
|
||||||
|
|
||||||
|
// Calculate total duration
|
||||||
|
let total_ms: u32 = tones.iter().map(|t| t.delay_ms + t.duration_ms).sum();
|
||||||
|
let total_samples = ((total_ms as f64 / 1000.0) * SAMPLE_RATE as f64) as usize + SAMPLE_RATE as usize / 10; // +100ms padding
|
||||||
|
let mut samples = vec![0i16; total_samples];
|
||||||
|
|
||||||
|
let mut offset_ms: u32 = 0;
|
||||||
|
for tone in tones {
|
||||||
|
offset_ms += tone.delay_ms;
|
||||||
|
let start_sample = ((offset_ms as f64 / 1000.0) * SAMPLE_RATE as f64) as usize;
|
||||||
|
let num_samples = ((tone.duration_ms as f64 / 1000.0) * SAMPLE_RATE as f64) as usize;
|
||||||
|
let attack_samples = (0.010 * SAMPLE_RATE as f64) as usize; // 10ms attack
|
||||||
|
let release_samples = (0.050 * SAMPLE_RATE as f64) as usize; // 50ms release
|
||||||
|
|
||||||
|
let freq_start = tone.freq;
|
||||||
|
let freq_end = tone.freq_end.unwrap_or(tone.freq);
|
||||||
|
|
||||||
|
for i in 0..num_samples {
|
||||||
|
if start_sample + i >= samples.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let t = i as f64 / SAMPLE_RATE as f64;
|
||||||
|
let progress = i as f64 / num_samples as f64;
|
||||||
|
|
||||||
|
// Frequency interpolation (for slides)
|
||||||
|
let freq = freq_start + (freq_end - freq_start) * progress;
|
||||||
|
|
||||||
|
// Envelope
|
||||||
|
let env = if i < attack_samples {
|
||||||
|
i as f32 / attack_samples as f32
|
||||||
|
} else if i > num_samples - release_samples {
|
||||||
|
(num_samples - i) as f32 / release_samples as f32
|
||||||
|
} else {
|
||||||
|
1.0
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut sample = (t * freq * 2.0 * std::f64::consts::PI).sin() as f32;
|
||||||
|
|
||||||
|
// Optional detuned second oscillator for warmth
|
||||||
|
if let Some(detune_cents) = tone.detune {
|
||||||
|
let freq2 = freq * (2.0_f64).powf(detune_cents / 1200.0);
|
||||||
|
sample += (t * freq2 * 2.0 * std::f64::consts::PI).sin() as f32;
|
||||||
|
sample *= 0.5; // normalize
|
||||||
|
}
|
||||||
|
|
||||||
|
sample *= env * vol;
|
||||||
|
let val = (sample * 32000.0) as i16;
|
||||||
|
samples[start_sample + i] = samples[start_sample + i].saturating_add(val);
|
||||||
|
}
|
||||||
|
offset_ms += tone.duration_ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build WAV in memory
|
||||||
|
let data_size = (samples.len() * 2) as u32;
|
||||||
|
let file_size = 36 + data_size;
|
||||||
|
let mut wav = Vec::with_capacity(file_size as usize + 8);
|
||||||
|
|
||||||
|
// RIFF header
|
||||||
|
wav.extend_from_slice(b"RIFF");
|
||||||
|
wav.extend_from_slice(&file_size.to_le_bytes());
|
||||||
|
wav.extend_from_slice(b"WAVE");
|
||||||
|
// fmt chunk
|
||||||
|
wav.extend_from_slice(b"fmt ");
|
||||||
|
wav.extend_from_slice(&16u32.to_le_bytes()); // chunk size
|
||||||
|
wav.extend_from_slice(&1u16.to_le_bytes()); // PCM
|
||||||
|
wav.extend_from_slice(&1u16.to_le_bytes()); // mono
|
||||||
|
wav.extend_from_slice(&SAMPLE_RATE.to_le_bytes());
|
||||||
|
wav.extend_from_slice(&(SAMPLE_RATE * 2).to_le_bytes()); // byte rate
|
||||||
|
wav.extend_from_slice(&2u16.to_le_bytes()); // block align
|
||||||
|
wav.extend_from_slice(&16u16.to_le_bytes()); // bits per sample
|
||||||
|
// data chunk
|
||||||
|
wav.extend_from_slice(b"data");
|
||||||
|
wav.extend_from_slice(&data_size.to_le_bytes());
|
||||||
|
for s in &samples {
|
||||||
|
wav.extend_from_slice(&s.to_le_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try available audio players in order
|
||||||
|
for cmd in &["paplay", "pw-play", "aplay"] {
|
||||||
|
if let Ok(mut child) = std::process::Command::new(cmd)
|
||||||
|
.arg("--")
|
||||||
|
.arg("/dev/stdin")
|
||||||
|
.stdin(std::process::Stdio::piped())
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.spawn()
|
||||||
|
{
|
||||||
|
if let Some(ref mut stdin) = child.stdin {
|
||||||
|
use std::io::Write;
|
||||||
|
let _ = stdin.write_all(&wav);
|
||||||
|
}
|
||||||
|
let _ = child.wait();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,13 @@ use tauri::Manager;
|
|||||||
|
|
||||||
mod database;
|
mod database;
|
||||||
mod commands;
|
mod commands;
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
#[path = "os_detection_windows.rs"]
|
||||||
|
mod os_detection;
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[path = "os_detection_linux.rs"]
|
||||||
mod os_detection;
|
mod os_detection;
|
||||||
|
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
@@ -13,16 +20,73 @@ pub struct AppState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn get_data_dir() -> PathBuf {
|
fn get_data_dir() -> PathBuf {
|
||||||
let exe_path = std::env::current_exe().unwrap();
|
// On Linux AppImage: $APPIMAGE points to the .AppImage file itself.
|
||||||
let data_dir = exe_path.parent().unwrap().join("data");
|
// Store data next to the AppImage so it's fully portable.
|
||||||
|
let base = if let Ok(appimage_path) = std::env::var("APPIMAGE") {
|
||||||
|
PathBuf::from(appimage_path)
|
||||||
|
.parent()
|
||||||
|
.map(|p| p.to_path_buf())
|
||||||
|
.unwrap_or_else(|| std::env::current_exe().unwrap().parent().unwrap().to_path_buf())
|
||||||
|
} else {
|
||||||
|
std::env::current_exe().unwrap().parent().unwrap().to_path_buf()
|
||||||
|
};
|
||||||
|
let data_dir = base.join("data");
|
||||||
std::fs::create_dir_all(&data_dir).ok();
|
std::fs::create_dir_all(&data_dir).ok();
|
||||||
data_dir
|
data_dir
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
|
/// On Linux AppImage, install a .desktop file and icon into the user's local
|
||||||
|
/// XDG directories so GNOME/KDE can show the correct dock icon. Also cleans up
|
||||||
|
/// stale entries if the AppImage has been moved or deleted.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn install_desktop_entry() {
|
||||||
|
let appimage_path = match std::env::var("APPIMAGE") {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(_) => return, // Not running as AppImage
|
||||||
|
};
|
||||||
|
let appdir = std::env::var("APPDIR").unwrap_or_default();
|
||||||
|
let home = match std::env::var("HOME") {
|
||||||
|
Ok(h) => h,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Install .desktop file
|
||||||
|
let apps_dir = format!("{home}/.local/share/applications");
|
||||||
|
std::fs::create_dir_all(&apps_dir).ok();
|
||||||
|
|
||||||
|
let desktop_content = format!(
|
||||||
|
"[Desktop Entry]\n\
|
||||||
|
Name=ZeroClock\n\
|
||||||
|
Comment=Local time tracking with invoicing\n\
|
||||||
|
Exec={appimage_path}\n\
|
||||||
|
Icon=zeroclock\n\
|
||||||
|
Type=Application\n\
|
||||||
|
Terminal=false\n\
|
||||||
|
StartupWMClass=zeroclock\n\
|
||||||
|
Categories=Office;ProjectManagement;\n"
|
||||||
|
);
|
||||||
|
std::fs::write(format!("{apps_dir}/zeroclock.desktop"), &desktop_content).ok();
|
||||||
|
|
||||||
|
// Install icons from the AppImage's bundled hicolor theme
|
||||||
|
if !appdir.is_empty() {
|
||||||
|
for size in &["32x32", "128x128", "256x256@2"] {
|
||||||
|
let src = format!("{appdir}/usr/share/icons/hicolor/{size}/apps/zeroclock.png");
|
||||||
|
if std::path::Path::new(&src).exists() {
|
||||||
|
let dest_dir = format!("{home}/.local/share/icons/hicolor/{size}/apps");
|
||||||
|
std::fs::create_dir_all(&dest_dir).ok();
|
||||||
|
std::fs::copy(&src, format!("{dest_dir}/zeroclock.png")).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
env_logger::init();
|
env_logger::init();
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
install_desktop_entry();
|
||||||
|
|
||||||
let data_dir = get_data_dir();
|
let data_dir = get_data_dir();
|
||||||
let db_path = data_dir.join("timetracker.db");
|
let db_path = data_dir.join("timetracker.db");
|
||||||
|
|
||||||
@@ -154,8 +218,62 @@ pub fn run() {
|
|||||||
commands::update_recurring_invoice,
|
commands::update_recurring_invoice,
|
||||||
commands::delete_recurring_invoice,
|
commands::delete_recurring_invoice,
|
||||||
commands::check_recurring_invoices,
|
commands::check_recurring_invoices,
|
||||||
|
commands::quit_app,
|
||||||
|
commands::get_platform,
|
||||||
|
commands::play_sound,
|
||||||
])
|
])
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
|
// On Wayland, `decorations: false` in tauri.conf.json is ignored due to a
|
||||||
|
// GTK bug. We set an empty CSD titlebar so the compositor doesn't add
|
||||||
|
// rectangular SSD, then strip GTK's default CSD shadow/border via CSS.
|
||||||
|
// See: https://github.com/tauri-apps/tauri/issues/6562
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
use gtk::prelude::{CssProviderExt, GtkWindowExt, WidgetExt};
|
||||||
|
|
||||||
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
if let Ok(gtk_window) = window.gtk_window() {
|
||||||
|
// Strip GTK's CSD shadow and border from the decoration node
|
||||||
|
let provider = gtk::CssProvider::new();
|
||||||
|
provider.load_from_data(b"\
|
||||||
|
decoration { \
|
||||||
|
box-shadow: none; \
|
||||||
|
margin: 0; \
|
||||||
|
border: none; \
|
||||||
|
} \
|
||||||
|
").ok();
|
||||||
|
if let Some(screen) = WidgetExt::screen(>k_window) {
|
||||||
|
gtk::StyleContext::add_provider_for_screen(
|
||||||
|
&screen,
|
||||||
|
&provider,
|
||||||
|
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION + 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set an empty zero-height CSD titlebar so the compositor
|
||||||
|
// doesn't add rectangular server-side decorations.
|
||||||
|
// Suppress the harmless "called on a realized window" GTK warning
|
||||||
|
// by briefly redirecting stderr to /dev/null.
|
||||||
|
let empty = gtk::Box::new(gtk::Orientation::Horizontal, 0);
|
||||||
|
empty.set_size_request(-1, 0);
|
||||||
|
empty.set_visible(true);
|
||||||
|
unsafe {
|
||||||
|
extern "C" { fn dup(fd: i32) -> i32; fn dup2(fd: i32, fd2: i32) -> i32; fn close(fd: i32) -> i32; }
|
||||||
|
let saved = dup(2);
|
||||||
|
if let Ok(devnull) = std::fs::File::open("/dev/null") {
|
||||||
|
use std::os::unix::io::AsRawFd;
|
||||||
|
dup2(devnull.as_raw_fd(), 2);
|
||||||
|
gtk_window.set_titlebar(Some(&empty));
|
||||||
|
dup2(saved, 2);
|
||||||
|
} else {
|
||||||
|
gtk_window.set_titlebar(Some(&empty));
|
||||||
|
}
|
||||||
|
if saved >= 0 { close(saved); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(desktop)]
|
#[cfg(desktop)]
|
||||||
{
|
{
|
||||||
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
|
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
|
||||||
|
|||||||
@@ -3,6 +3,166 @@
|
|||||||
windows_subsystem = "windows"
|
windows_subsystem = "windows"
|
||||||
)]
|
)]
|
||||||
|
|
||||||
|
/// Probe EGL: load libEGL, get a display, initialize it, and verify that at
|
||||||
|
/// least one usable RGBA config exists. Returns true only if the full
|
||||||
|
/// sequence succeeds - this catches missing drivers, Mesa/NVIDIA mismatches,
|
||||||
|
/// and broken configs that would give WebKitGTK a black window.
|
||||||
|
/// Uses dlopen so there's no link-time dependency on EGL.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn egl_display_available() -> bool {
|
||||||
|
extern "C" {
|
||||||
|
fn dlopen(filename: *const std::ffi::c_char, flags: i32) -> *mut std::ffi::c_void;
|
||||||
|
fn dlsym(handle: *mut std::ffi::c_void, symbol: *const std::ffi::c_char) -> *mut std::ffi::c_void;
|
||||||
|
fn dlclose(handle: *mut std::ffi::c_void) -> i32;
|
||||||
|
}
|
||||||
|
const RTLD_LAZY: i32 = 1;
|
||||||
|
|
||||||
|
macro_rules! sym {
|
||||||
|
($handle:expr, $name:literal) => {{
|
||||||
|
let s = dlsym($handle, concat!($name, "\0").as_ptr() as *const _);
|
||||||
|
if s.is_null() { dlclose($handle); return false; }
|
||||||
|
s
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
let handle = dlopen(b"libEGL.so.1\0".as_ptr() as *const _, RTLD_LAZY);
|
||||||
|
if handle.is_null() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the three functions we need
|
||||||
|
let get_display: extern "C" fn(usize) -> usize =
|
||||||
|
std::mem::transmute(sym!(handle, "eglGetDisplay"));
|
||||||
|
let initialize: extern "C" fn(usize, *mut i32, *mut i32) -> u32 =
|
||||||
|
std::mem::transmute(sym!(handle, "eglInitialize"));
|
||||||
|
let choose_config: extern "C" fn(usize, *const i32, *mut usize, i32, *mut i32) -> u32 =
|
||||||
|
std::mem::transmute(sym!(handle, "eglChooseConfig"));
|
||||||
|
let terminate: extern "C" fn(usize) -> u32 =
|
||||||
|
std::mem::transmute(sym!(handle, "eglTerminate"));
|
||||||
|
|
||||||
|
// Step 1: Get display
|
||||||
|
let display = get_display(0); // EGL_DEFAULT_DISPLAY
|
||||||
|
if display == 0 {
|
||||||
|
dlclose(handle);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Initialize
|
||||||
|
let mut major: i32 = 0;
|
||||||
|
let mut minor: i32 = 0;
|
||||||
|
if initialize(display, &mut major, &mut minor) == 0 {
|
||||||
|
dlclose(handle);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Check for a usable RGBA config
|
||||||
|
// EGL_RED_SIZE=0x3024 EGL_GREEN_SIZE=0x3023 EGL_BLUE_SIZE=0x3022
|
||||||
|
// EGL_ALPHA_SIZE=0x3021 EGL_SURFACE_TYPE=0x3033 EGL_WINDOW_BIT=0x0004
|
||||||
|
// EGL_RENDERABLE_TYPE=0x3040 EGL_OPENGL_ES2_BIT=0x0004 EGL_NONE=0x3038
|
||||||
|
let attribs: [i32; 15] = [
|
||||||
|
0x3024, 8, // RED 8
|
||||||
|
0x3023, 8, // GREEN 8
|
||||||
|
0x3022, 8, // BLUE 8
|
||||||
|
0x3021, 8, // ALPHA 8
|
||||||
|
0x3033, 0x0004, // SURFACE_TYPE = WINDOW
|
||||||
|
0x3040, 0x0004, // RENDERABLE_TYPE = ES2
|
||||||
|
0x3038, // NONE
|
||||||
|
0, 0, // padding
|
||||||
|
];
|
||||||
|
let mut config: usize = 0;
|
||||||
|
let mut num_configs: i32 = 0;
|
||||||
|
let ok = choose_config(
|
||||||
|
display,
|
||||||
|
attribs.as_ptr(),
|
||||||
|
&mut config,
|
||||||
|
1,
|
||||||
|
&mut num_configs,
|
||||||
|
);
|
||||||
|
|
||||||
|
terminate(display);
|
||||||
|
dlclose(handle);
|
||||||
|
|
||||||
|
ok != 0 && num_configs > 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
|
// The AppImage's linuxdeploy-plugin-gtk.sh forces GDK_BACKEND=x11 as a
|
||||||
|
// safety default, but XWayland on NVIDIA breaks WebKitGTK input events
|
||||||
|
// (no clicks, no scroll) and client-side decorations.
|
||||||
|
// Override to native Wayland which works correctly on modern NVIDIA drivers.
|
||||||
|
// See: https://github.com/tauri-apps/tauri/issues/11790
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
if std::env::var("WAYLAND_DISPLAY").is_ok() || std::env::var("XDG_SESSION_TYPE").map(|v| v == "wayland").unwrap_or(false) {
|
||||||
|
std::env::set_var("GDK_BACKEND", "wayland");
|
||||||
|
}
|
||||||
|
|
||||||
|
// When running as an AppImage, prevent GTK from loading the host's
|
||||||
|
// GIO modules (gvfs, dconf, libproxy). These are compiled against the
|
||||||
|
// host's newer glib and fail with "undefined symbol" errors when loaded
|
||||||
|
// into the AppImage's bundled older glib.
|
||||||
|
// These modules are optional - gvfs (virtual filesystems), dconf
|
||||||
|
// (desktop settings), libproxy (proxy config) - none are needed here.
|
||||||
|
if std::env::var("APPIMAGE").is_ok() {
|
||||||
|
if let Ok(appdir) = std::env::var("APPDIR") {
|
||||||
|
let gio_path = format!("{}/usr/lib/x86_64-linux-gnu/gio/modules", appdir);
|
||||||
|
if std::path::Path::new(&gio_path).exists() {
|
||||||
|
std::env::set_var("GIO_MODULE_DIR", &gio_path);
|
||||||
|
} else {
|
||||||
|
// No bundled GIO modules - point to empty dir to skip system ones
|
||||||
|
std::env::set_var("GIO_MODULE_DIR", &appdir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable the DMA-BUF renderer in AppImage builds.
|
||||||
|
// WebKitGTK 2.42+ defaults to DMA-BUF for framebuffers, but inside an
|
||||||
|
// AppImage the bundled Mesa often can't construct them against the host's
|
||||||
|
// GPU drivers - producing a completely black window with zero errors.
|
||||||
|
// This falls back to shared-memory buffers; GPU compositing still works,
|
||||||
|
// visual/performance difference is negligible for a desktop app.
|
||||||
|
// Override: WEBKIT_DISABLE_DMABUF_RENDERER=0
|
||||||
|
// See: https://github.com/tauri-apps/tauri/issues/13183
|
||||||
|
if std::env::var("APPIMAGE").is_ok()
|
||||||
|
&& std::env::var("WEBKIT_DISABLE_DMABUF_RENDERER").is_err()
|
||||||
|
{
|
||||||
|
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-detect EGL failures for AppImage builds.
|
||||||
|
// If EGL can't even initialize or find a usable config, also disable
|
||||||
|
// GPU compositing entirely (more aggressive than the DMABUF fix above).
|
||||||
|
// Override: WEBKIT_DISABLE_COMPOSITING_MODE=0
|
||||||
|
if std::env::var("APPIMAGE").is_ok()
|
||||||
|
&& std::env::var("WEBKIT_DISABLE_COMPOSITING_MODE").is_err()
|
||||||
|
&& !egl_display_available()
|
||||||
|
{
|
||||||
|
std::env::set_var("WEBKIT_DISABLE_COMPOSITING_MODE", "1");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure GStreamer can find system plugins for WebKitGTK Web Audio.
|
||||||
|
// The AppImage doesn't bundle GStreamer (bundleMediaFramework: false),
|
||||||
|
// so point it at the host's plugin directories.
|
||||||
|
if std::env::var("GST_PLUGIN_SYSTEM_PATH").is_err()
|
||||||
|
&& std::env::var("GST_PLUGIN_SYSTEM_PATH_1_0").is_err()
|
||||||
|
{
|
||||||
|
let paths = [
|
||||||
|
"/usr/lib/x86_64-linux-gnu/gstreamer-1.0",
|
||||||
|
"/usr/lib64/gstreamer-1.0",
|
||||||
|
"/usr/lib/gstreamer-1.0",
|
||||||
|
];
|
||||||
|
let existing: Vec<&str> = paths
|
||||||
|
.iter()
|
||||||
|
.filter(|p| std::path::Path::new(p).exists())
|
||||||
|
.copied()
|
||||||
|
.collect();
|
||||||
|
if !existing.is_empty() {
|
||||||
|
std::env::set_var("GST_PLUGIN_SYSTEM_PATH_1_0", existing.join(":"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
zeroclock_lib::run();
|
zeroclock_lib::run();
|
||||||
}
|
}
|
||||||
|
|||||||
537
src-tauri/src/os_detection_linux.rs
Normal file
537
src-tauri/src/os_detection_linux.rs
Normal file
@@ -0,0 +1,537 @@
|
|||||||
|
use serde::Serialize;
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Clone)]
|
||||||
|
pub struct WindowInfo {
|
||||||
|
pub exe_name: String,
|
||||||
|
pub exe_path: String,
|
||||||
|
pub title: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub icon: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get system idle time in seconds via D-Bus.
|
||||||
|
///
|
||||||
|
/// Tries (in order):
|
||||||
|
/// 1. org.gnome.Mutter.IdleMonitor.GetIdletime (returns milliseconds)
|
||||||
|
/// 2. org.freedesktop.ScreenSaver.GetSessionIdleTime (returns seconds)
|
||||||
|
/// 3. Falls back to 0
|
||||||
|
pub fn get_system_idle_seconds() -> u64 {
|
||||||
|
// Try GNOME Mutter IdleMonitor (returns milliseconds)
|
||||||
|
if let Some(ms) = gdbus_call_u64(
|
||||||
|
"org.gnome.Mutter.IdleMonitor",
|
||||||
|
"/org/gnome/Mutter/IdleMonitor/Core",
|
||||||
|
"org.gnome.Mutter.IdleMonitor",
|
||||||
|
"GetIdletime",
|
||||||
|
) {
|
||||||
|
return ms / 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try freedesktop ScreenSaver (returns seconds)
|
||||||
|
if let Some(secs) = gdbus_call_u64(
|
||||||
|
"org.freedesktop.ScreenSaver",
|
||||||
|
"/org/freedesktop/ScreenSaver",
|
||||||
|
"org.freedesktop.ScreenSaver",
|
||||||
|
"GetSessionIdleTime",
|
||||||
|
) {
|
||||||
|
return secs;
|
||||||
|
}
|
||||||
|
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Call a D-Bus method that returns a single uint64/uint32 value via gdbus.
|
||||||
|
/// Parses the `(uint64 12345,)` or `(uint32 12345,)` output format.
|
||||||
|
fn gdbus_call_u64(dest: &str, object: &str, interface: &str, method: &str) -> Option<u64> {
|
||||||
|
let output = Command::new("gdbus")
|
||||||
|
.args([
|
||||||
|
"call",
|
||||||
|
"--session",
|
||||||
|
"--dest",
|
||||||
|
dest,
|
||||||
|
"--object-path",
|
||||||
|
object,
|
||||||
|
"--method",
|
||||||
|
&format!("{}.{}", interface, method),
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
// Format: "(uint64 12345,)" or "(uint32 12345,)"
|
||||||
|
// Extract the number after "uint64 " or "uint32 "
|
||||||
|
let s = stdout.trim();
|
||||||
|
for prefix in &["uint64 ", "uint32 "] {
|
||||||
|
if let Some(pos) = s.find(prefix) {
|
||||||
|
let after = &s[pos + prefix.len()..];
|
||||||
|
let num_str: String = after.chars().take_while(|c| c.is_ascii_digit()).collect();
|
||||||
|
return num_str.parse().ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the current user's UID from /proc/self/status.
|
||||||
|
fn get_current_uid() -> u32 {
|
||||||
|
if let Ok(status) = fs::read_to_string("/proc/self/status") {
|
||||||
|
for line in status.lines() {
|
||||||
|
if let Some(rest) = line.strip_prefix("Uid:") {
|
||||||
|
if let Some(uid_str) = rest.split_whitespace().next() {
|
||||||
|
if let Ok(uid) = uid_str.parse::<u32>() {
|
||||||
|
return uid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
u32::MAX // Unlikely fallback - won't match any process
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enumerate running processes from /proc.
|
||||||
|
///
|
||||||
|
/// Reads /proc/[pid]/exe, /proc/[pid]/comm, and /proc/[pid]/status to build
|
||||||
|
/// a list of user-space processes. Filters out kernel threads, zombies, and
|
||||||
|
/// common system daemons. Deduplicates by exe path.
|
||||||
|
pub fn enumerate_running_processes() -> Vec<WindowInfo> {
|
||||||
|
let my_uid = get_current_uid();
|
||||||
|
let mut seen_paths = HashSet::new();
|
||||||
|
let mut results = Vec::new();
|
||||||
|
|
||||||
|
let Ok(entries) = fs::read_dir("/proc") else {
|
||||||
|
return results;
|
||||||
|
};
|
||||||
|
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let name = entry.file_name();
|
||||||
|
let name_str = name.to_string_lossy();
|
||||||
|
|
||||||
|
// Only numeric directories (PIDs)
|
||||||
|
if !name_str.chars().all(|c| c.is_ascii_digit()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let pid_path = entry.path();
|
||||||
|
|
||||||
|
// Check process belongs to current user
|
||||||
|
if let Ok(status) = fs::read_to_string(pid_path.join("status")) {
|
||||||
|
let mut is_our_uid = false;
|
||||||
|
let mut ppid: u32 = 1;
|
||||||
|
|
||||||
|
for line in status.lines() {
|
||||||
|
if let Some(rest) = line.strip_prefix("Uid:") {
|
||||||
|
if let Some(uid_str) = rest.split_whitespace().next() {
|
||||||
|
if let Ok(uid) = uid_str.parse::<u32>() {
|
||||||
|
is_our_uid = uid == my_uid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(rest) = line.strip_prefix("PPid:") {
|
||||||
|
if let Ok(p) = rest.trim().parse::<u32>() {
|
||||||
|
ppid = p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !is_our_uid {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip kernel threads (ppid 0 or 2)
|
||||||
|
if ppid == 0 || ppid == 2 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip zombies (empty cmdline)
|
||||||
|
if let Ok(cmdline) = fs::read(pid_path.join("cmdline")) {
|
||||||
|
if cmdline.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get exe path via symlink
|
||||||
|
let exe_path = match fs::read_link(pid_path.join("exe")) {
|
||||||
|
Ok(p) => {
|
||||||
|
let ps = p.to_string_lossy().to_string();
|
||||||
|
// Skip deleted executables
|
||||||
|
if ps.contains(" (deleted)") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ps
|
||||||
|
}
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Deduplicate by exe path
|
||||||
|
if !seen_paths.insert(exe_path.clone()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get comm (short process name)
|
||||||
|
let comm = fs::read_to_string(pid_path.join("comm"))
|
||||||
|
.unwrap_or_default()
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Skip common system daemons / background services
|
||||||
|
if is_system_daemon(&comm, &exe_path) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let exe_name = std::path::Path::new(&exe_path)
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_else(|| comm.clone());
|
||||||
|
|
||||||
|
let display_name = prettify_name(&exe_name);
|
||||||
|
|
||||||
|
results.push(WindowInfo {
|
||||||
|
exe_name,
|
||||||
|
exe_path,
|
||||||
|
title: String::new(),
|
||||||
|
display_name,
|
||||||
|
icon: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve icons from .desktop files
|
||||||
|
let icon_map = build_desktop_icon_map();
|
||||||
|
for info in &mut results {
|
||||||
|
if let Some(icon_name) = icon_map.get(&info.exe_name) {
|
||||||
|
if let Some(data_url) = resolve_icon_to_data_url(icon_name) {
|
||||||
|
info.icon = Some(data_url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results.sort_by(|a, b| a.display_name.to_lowercase().cmp(&b.display_name.to_lowercase()));
|
||||||
|
results
|
||||||
|
}
|
||||||
|
|
||||||
|
/// On Linux we cannot detect window visibility (Wayland security model).
|
||||||
|
/// Return running processes filtered to likely-GUI apps as a best-effort
|
||||||
|
/// approximation - the timer will pause only when the tracked process exits.
|
||||||
|
pub fn enumerate_visible_windows() -> Vec<WindowInfo> {
|
||||||
|
enumerate_running_processes()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|w| is_likely_gui_app(&w.exe_path, &w.exe_name))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Heuristic: returns true for processes that are likely background daemons.
|
||||||
|
fn is_system_daemon(comm: &str, exe_path: &str) -> bool {
|
||||||
|
const DAEMON_NAMES: &[&str] = &[
|
||||||
|
"systemd",
|
||||||
|
"dbus-daemon",
|
||||||
|
"dbus-broker",
|
||||||
|
"pipewire",
|
||||||
|
"pipewire-pulse",
|
||||||
|
"wireplumber",
|
||||||
|
"pulseaudio",
|
||||||
|
"xdg-desktop-portal",
|
||||||
|
"xdg-document-portal",
|
||||||
|
"xdg-permission-store",
|
||||||
|
"gvfsd",
|
||||||
|
"gvfs-udisks2-volume-monitor",
|
||||||
|
"at-spi-bus-launcher",
|
||||||
|
"at-spi2-registryd",
|
||||||
|
"gnome-keyring-daemon",
|
||||||
|
"ssh-agent",
|
||||||
|
"gpg-agent",
|
||||||
|
"polkitd",
|
||||||
|
"gsd-",
|
||||||
|
"evolution-data-server",
|
||||||
|
"evolution-calendar-factory",
|
||||||
|
"evolution-addressbook-factory",
|
||||||
|
"tracker-miner-fs",
|
||||||
|
"tracker-extract",
|
||||||
|
"ibus-daemon",
|
||||||
|
"ibus-x11",
|
||||||
|
"ibus-portal",
|
||||||
|
"ibus-extension-gtk3",
|
||||||
|
"fcitx5",
|
||||||
|
"goa-daemon",
|
||||||
|
"goa-identity-service",
|
||||||
|
"xdg-desktop-portal-gnome",
|
||||||
|
"xdg-desktop-portal-gtk",
|
||||||
|
"xdg-desktop-portal-kde",
|
||||||
|
"xdg-desktop-portal-wlr",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Exact match
|
||||||
|
if DAEMON_NAMES.contains(&comm) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefix match (e.g. gsd-*)
|
||||||
|
for prefix in &["gsd-", "gvfs", "xdg-desktop-portal"] {
|
||||||
|
if comm.starts_with(prefix) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// System paths
|
||||||
|
if exe_path.starts_with("/usr/libexec/")
|
||||||
|
|| exe_path.starts_with("/usr/lib/systemd/")
|
||||||
|
|| exe_path.starts_with("/usr/lib/polkit")
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Heuristic: returns true for processes that are likely GUI applications.
|
||||||
|
fn is_likely_gui_app(exe_path: &str, exe_name: &str) -> bool {
|
||||||
|
// Common GUI app locations
|
||||||
|
let gui_paths = ["/usr/bin/", "/usr/local/bin/", "/opt/", "/snap/", "/flatpak/"];
|
||||||
|
let in_gui_path = gui_paths.iter().any(|p| exe_path.starts_with(p))
|
||||||
|
|| exe_path.contains("/AppRun")
|
||||||
|
|| exe_path.contains(".AppImage");
|
||||||
|
|
||||||
|
// Home directory apps (Electron, AppImage, etc.)
|
||||||
|
let in_home = exe_path.contains("/.local/") || exe_path.contains("/home/");
|
||||||
|
|
||||||
|
if !in_gui_path && !in_home {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exclude known CLI-only tools
|
||||||
|
const CLI_TOOLS: &[&str] = &[
|
||||||
|
"bash", "sh", "zsh", "fish", "dash", "csh", "tcsh",
|
||||||
|
"cat", "ls", "grep", "find", "sed", "awk", "sort", "cut",
|
||||||
|
"curl", "wget", "ssh", "scp", "rsync",
|
||||||
|
"git", "make", "cmake", "cargo", "npm", "node", "python", "python3",
|
||||||
|
"ruby", "perl", "java", "javac",
|
||||||
|
"top", "htop", "btop", "tmux", "screen",
|
||||||
|
"sudo", "su", "pkexec",
|
||||||
|
"journalctl", "systemctl",
|
||||||
|
];
|
||||||
|
|
||||||
|
if CLI_TOOLS.contains(&exe_name) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a map from exe_name → icon_name by scanning .desktop files.
|
||||||
|
fn build_desktop_icon_map() -> HashMap<String, String> {
|
||||||
|
let mut map = HashMap::new();
|
||||||
|
let dirs = [
|
||||||
|
"/usr/share/applications",
|
||||||
|
"/usr/local/share/applications",
|
||||||
|
"/var/lib/flatpak/exports/share/applications",
|
||||||
|
"/var/lib/snapd/desktop/applications",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Also check ~/.local/share/applications
|
||||||
|
let home_apps = std::env::var("HOME")
|
||||||
|
.map(|h| format!("{h}/.local/share/applications"))
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
for dir in dirs.iter().chain(std::iter::once(&home_apps.as_str())) {
|
||||||
|
if dir.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Ok(entries) = fs::read_dir(dir) else { continue };
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.extension().and_then(|e| e.to_str()) != Some("desktop") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Ok(content) = fs::read_to_string(&path) {
|
||||||
|
parse_desktop_entry(&content, &mut map);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
map
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a .desktop file and extract exe_name → icon_name mappings.
|
||||||
|
fn parse_desktop_entry(content: &str, map: &mut HashMap<String, String>) {
|
||||||
|
let mut icon = None;
|
||||||
|
let mut exec = None;
|
||||||
|
let mut in_desktop_entry = false;
|
||||||
|
|
||||||
|
for line in content.lines() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed.starts_with('[') {
|
||||||
|
in_desktop_entry = trimmed == "[Desktop Entry]";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !in_desktop_entry {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(val) = trimmed.strip_prefix("Icon=") {
|
||||||
|
icon = Some(val.trim().to_string());
|
||||||
|
} else if let Some(val) = trimmed.strip_prefix("Exec=") {
|
||||||
|
// Extract the binary name from the Exec line
|
||||||
|
// Exec can be: /usr/bin/foo, env VAR=val foo, flatpak run ..., etc.
|
||||||
|
let val = val.trim();
|
||||||
|
// Strip env prefix
|
||||||
|
let cmd_part = if val.starts_with("env ") {
|
||||||
|
// Skip env KEY=VAL pairs
|
||||||
|
val.split_whitespace()
|
||||||
|
.skip(1)
|
||||||
|
.find(|s| !s.contains('='))
|
||||||
|
.unwrap_or("")
|
||||||
|
} else {
|
||||||
|
val.split_whitespace().next().unwrap_or("")
|
||||||
|
};
|
||||||
|
// Get just the filename
|
||||||
|
if let Some(name) = Path::new(cmd_part).file_name() {
|
||||||
|
exec = Some(name.to_string_lossy().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let (Some(exe_name), Some(icon_name)) = (exec, icon) {
|
||||||
|
if !exe_name.is_empty() && !icon_name.is_empty() {
|
||||||
|
map.entry(exe_name).or_insert(icon_name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve an icon name to a base64 data URL.
|
||||||
|
/// Handles both absolute paths and theme icon names.
|
||||||
|
fn resolve_icon_to_data_url(icon_name: &str) -> Option<String> {
|
||||||
|
// If it's an absolute path, read directly
|
||||||
|
if icon_name.starts_with('/') {
|
||||||
|
return read_icon_file(Path::new(icon_name));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search hicolor theme at standard sizes (prefer smaller for 20x20 display)
|
||||||
|
let sizes = ["32x32", "48x48", "24x24", "64x64", "scalable", "128x128", "256x256"];
|
||||||
|
let theme_dirs = [
|
||||||
|
"/usr/share/icons/hicolor",
|
||||||
|
"/usr/share/pixmaps",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Also check user theme
|
||||||
|
let home_icons = std::env::var("HOME")
|
||||||
|
.map(|h| format!("{h}/.local/share/icons/hicolor"))
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// Try hicolor theme with size variants
|
||||||
|
for base in theme_dirs.iter().map(|s| s.to_string()).chain(
|
||||||
|
if home_icons.is_empty() { None } else { Some(home_icons.clone()) }
|
||||||
|
) {
|
||||||
|
if base.ends_with("pixmaps") {
|
||||||
|
// /usr/share/pixmaps has flat layout
|
||||||
|
for ext in &["png", "svg", "xpm"] {
|
||||||
|
let path = format!("{base}/{icon_name}.{ext}");
|
||||||
|
if let Some(url) = read_icon_file(Path::new(&path)) {
|
||||||
|
return Some(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for size in &sizes {
|
||||||
|
for ext in &["png", "svg"] {
|
||||||
|
let path = format!("{base}/{size}/apps/{icon_name}.{ext}");
|
||||||
|
if let Some(url) = read_icon_file(Path::new(&path)) {
|
||||||
|
return Some(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read an icon file and return it as a base64 data URL.
|
||||||
|
/// Supports PNG and SVG.
|
||||||
|
fn read_icon_file(path: &Path) -> Option<String> {
|
||||||
|
if !path.exists() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let ext = path.extension()?.to_str()?;
|
||||||
|
let data = fs::read(path).ok()?;
|
||||||
|
if data.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mime = match ext {
|
||||||
|
"png" => "image/png",
|
||||||
|
"svg" => "image/svg+xml",
|
||||||
|
"xpm" => return None, // XPM isn't useful as a data URL
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut b64 = String::new();
|
||||||
|
base64_encode(&data, &mut b64);
|
||||||
|
|
||||||
|
Some(format!("data:{mime};base64,{b64}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Base64 encode bytes into a string (no padding variants, standard alphabet).
|
||||||
|
fn base64_encode(input: &[u8], output: &mut String) {
|
||||||
|
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||||
|
|
||||||
|
let mut i = 0;
|
||||||
|
let len = input.len();
|
||||||
|
output.reserve((len + 2) / 3 * 4);
|
||||||
|
|
||||||
|
while i + 2 < len {
|
||||||
|
let n = (input[i] as u32) << 16 | (input[i + 1] as u32) << 8 | input[i + 2] as u32;
|
||||||
|
output.push(ALPHABET[((n >> 18) & 0x3F) as usize] as char);
|
||||||
|
output.push(ALPHABET[((n >> 12) & 0x3F) as usize] as char);
|
||||||
|
output.push(ALPHABET[((n >> 6) & 0x3F) as usize] as char);
|
||||||
|
output.push(ALPHABET[(n & 0x3F) as usize] as char);
|
||||||
|
i += 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
match len - i {
|
||||||
|
1 => {
|
||||||
|
let n = (input[i] as u32) << 16;
|
||||||
|
output.push(ALPHABET[((n >> 18) & 0x3F) as usize] as char);
|
||||||
|
output.push(ALPHABET[((n >> 12) & 0x3F) as usize] as char);
|
||||||
|
output.push('=');
|
||||||
|
output.push('=');
|
||||||
|
}
|
||||||
|
2 => {
|
||||||
|
let n = (input[i] as u32) << 16 | (input[i + 1] as u32) << 8;
|
||||||
|
output.push(ALPHABET[((n >> 18) & 0x3F) as usize] as char);
|
||||||
|
output.push(ALPHABET[((n >> 12) & 0x3F) as usize] as char);
|
||||||
|
output.push(ALPHABET[((n >> 6) & 0x3F) as usize] as char);
|
||||||
|
output.push('=');
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert an exe name like "gnome-terminal-server" into "Gnome Terminal Server".
|
||||||
|
fn prettify_name(name: &str) -> String {
|
||||||
|
// Strip common suffixes
|
||||||
|
let stripped = name
|
||||||
|
.strip_suffix("-bin")
|
||||||
|
.or_else(|| name.strip_suffix(".bin"))
|
||||||
|
.unwrap_or(name);
|
||||||
|
|
||||||
|
stripped
|
||||||
|
.split(|c: char| c == '-' || c == '_')
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(|word| {
|
||||||
|
let mut chars = word.chars();
|
||||||
|
match chars.next() {
|
||||||
|
Some(first) => {
|
||||||
|
let mut s = first.to_uppercase().to_string();
|
||||||
|
s.extend(chars);
|
||||||
|
s
|
||||||
|
}
|
||||||
|
None => String::new(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "ZeroClock",
|
"productName": "ZeroClock",
|
||||||
"version": "1.0.0",
|
"version": "1.0.2",
|
||||||
"identifier": "com.localtimetracker.app",
|
"identifier": "com.localtimetracker.app",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
@@ -53,6 +53,11 @@
|
|||||||
"webviewInstallMode": {
|
"webviewInstallMode": {
|
||||||
"type": "embedBootstrapper"
|
"type": "embedBootstrapper"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"linux": {
|
||||||
|
"appimage": {
|
||||||
|
"bundleMediaFramework": false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
|
|||||||
@@ -324,6 +324,8 @@ onMounted(async () => {
|
|||||||
if (settingsStore.settings.close_to_tray === 'true') {
|
if (settingsStore.settings.close_to_tray === 'true') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
await win.hide()
|
await win.hide()
|
||||||
|
} else {
|
||||||
|
await win.destroy()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { getCurrentWindow } from '@tauri-apps/api/window'
|
import { getCurrentWindow } from '@tauri-apps/api/window'
|
||||||
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useTimerStore } from '../stores/timer'
|
import { useTimerStore } from '../stores/timer'
|
||||||
import { useProjectsStore } from '../stores/projects'
|
import { useProjectsStore } from '../stores/projects'
|
||||||
@@ -35,7 +36,11 @@ async function toggleMaximize() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function close() {
|
async function close() {
|
||||||
await appWindow.close()
|
if (settingsStore.settings.minimize_to_tray === 'true') {
|
||||||
|
await appWindow.hide()
|
||||||
|
} else {
|
||||||
|
await invoke('quit_app')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startDrag() {
|
async function startDrag() {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Directive, DirectiveBinding } from 'vue'
|
import type { Directive, DirectiveBinding } from 'vue'
|
||||||
import { getZoomFactor } from '../utils/dropdown'
|
import { getFixedPositionMapping } from '../utils/dropdown'
|
||||||
|
|
||||||
interface TooltipState {
|
interface TooltipState {
|
||||||
el: HTMLElement
|
el: HTMLElement
|
||||||
@@ -48,32 +48,29 @@ function positionTooltip(state: TooltipState) {
|
|||||||
if (!tip) return
|
if (!tip) return
|
||||||
|
|
||||||
const rect = state.el.getBoundingClientRect()
|
const rect = state.el.getBoundingClientRect()
|
||||||
const zoom = getZoomFactor()
|
const { scaleX, scaleY, offsetX, offsetY } = getFixedPositionMapping()
|
||||||
const margin = 6
|
const margin = 6
|
||||||
const arrowSize = 4
|
const arrowSize = 4
|
||||||
|
|
||||||
// Measure tooltip
|
// Measure tooltip (in viewport pixels)
|
||||||
tip.style.left = '-9999px'
|
tip.style.left = '-9999px'
|
||||||
tip.style.top = '-9999px'
|
tip.style.top = '-9999px'
|
||||||
tip.style.opacity = '0'
|
tip.style.opacity = '0'
|
||||||
const tipRect = tip.getBoundingClientRect()
|
const tipRect = tip.getBoundingClientRect()
|
||||||
const tipW = tipRect.width / zoom
|
const tipW = tipRect.width
|
||||||
const tipH = tipRect.height / zoom
|
const tipH = tipRect.height
|
||||||
|
|
||||||
const elTop = rect.top / zoom
|
// All in viewport pixels for placement decisions
|
||||||
const elLeft = rect.left / zoom
|
const vpW = window.innerWidth
|
||||||
const elW = rect.width / zoom
|
const vpH = window.innerHeight
|
||||||
const elH = rect.height / zoom
|
|
||||||
const vpW = window.innerWidth / zoom
|
|
||||||
const vpH = window.innerHeight / zoom
|
|
||||||
|
|
||||||
// Determine placement
|
// Determine placement
|
||||||
let placement = state.placement
|
let placement = state.placement
|
||||||
if (placement === 'auto') {
|
if (placement === 'auto') {
|
||||||
const spaceAbove = elTop
|
const spaceAbove = rect.top
|
||||||
const spaceBelow = vpH - elTop - elH
|
const spaceBelow = vpH - rect.bottom
|
||||||
const spaceRight = vpW - elLeft - elW
|
const spaceRight = vpW - rect.right
|
||||||
const spaceLeft = elLeft
|
const spaceLeft = rect.left
|
||||||
|
|
||||||
// Prefer top, then bottom, then right, then left
|
// Prefer top, then bottom, then right, then left
|
||||||
if (spaceAbove >= tipH + margin + arrowSize) placement = 'top'
|
if (spaceAbove >= tipH + margin + arrowSize) placement = 'top'
|
||||||
@@ -83,8 +80,9 @@ function positionTooltip(state: TooltipState) {
|
|||||||
else placement = 'top'
|
else placement = 'top'
|
||||||
}
|
}
|
||||||
|
|
||||||
let top = 0
|
// Calculate position in viewport pixels
|
||||||
let left = 0
|
let topVP = 0
|
||||||
|
let leftVP = 0
|
||||||
const arrow = tip.querySelector('[data-arrow]') as HTMLElement
|
const arrow = tip.querySelector('[data-arrow]') as HTMLElement
|
||||||
|
|
||||||
// Reset arrow classes
|
// Reset arrow classes
|
||||||
@@ -92,37 +90,39 @@ function positionTooltip(state: TooltipState) {
|
|||||||
|
|
||||||
switch (placement) {
|
switch (placement) {
|
||||||
case 'top':
|
case 'top':
|
||||||
top = elTop - tipH - margin
|
topVP = rect.top - tipH - margin
|
||||||
left = elLeft + elW / 2 - tipW / 2
|
leftVP = rect.left + rect.width / 2 - tipW / 2
|
||||||
arrow.className = 'absolute w-0 h-0 border-x-4 border-x-transparent border-t-4 left-1/2 -translate-x-1/2'
|
arrow.className = 'absolute w-0 h-0 border-x-4 border-x-transparent border-t-4 left-1/2 -translate-x-1/2'
|
||||||
arrow.style.cssText = 'bottom: -4px; border-top-color: var(--color-bg-elevated);'
|
arrow.style.cssText = 'bottom: -4px; border-top-color: var(--color-bg-elevated);'
|
||||||
break
|
break
|
||||||
case 'bottom':
|
case 'bottom':
|
||||||
top = elTop + elH + margin
|
topVP = rect.bottom + margin
|
||||||
left = elLeft + elW / 2 - tipW / 2
|
leftVP = rect.left + rect.width / 2 - tipW / 2
|
||||||
arrow.className = 'absolute w-0 h-0 border-x-4 border-x-transparent border-b-4 left-1/2 -translate-x-1/2'
|
arrow.className = 'absolute w-0 h-0 border-x-4 border-x-transparent border-b-4 left-1/2 -translate-x-1/2'
|
||||||
arrow.style.cssText = 'top: -4px; border-bottom-color: var(--color-bg-elevated);'
|
arrow.style.cssText = 'top: -4px; border-bottom-color: var(--color-bg-elevated);'
|
||||||
break
|
break
|
||||||
case 'right':
|
case 'right':
|
||||||
top = elTop + elH / 2 - tipH / 2
|
topVP = rect.top + rect.height / 2 - tipH / 2
|
||||||
left = elLeft + elW + margin
|
leftVP = rect.right + margin
|
||||||
arrow.className = 'absolute w-0 h-0 border-y-4 border-y-transparent border-r-4 top-1/2 -translate-y-1/2'
|
arrow.className = 'absolute w-0 h-0 border-y-4 border-y-transparent border-r-4 top-1/2 -translate-y-1/2'
|
||||||
arrow.style.cssText = 'left: -4px; border-right-color: var(--color-bg-elevated);'
|
arrow.style.cssText = 'left: -4px; border-right-color: var(--color-bg-elevated);'
|
||||||
break
|
break
|
||||||
case 'left':
|
case 'left':
|
||||||
top = elTop + elH / 2 - tipH / 2
|
topVP = rect.top + rect.height / 2 - tipH / 2
|
||||||
left = elLeft - tipW - margin
|
leftVP = rect.left - tipW - margin
|
||||||
arrow.className = 'absolute w-0 h-0 border-y-4 border-y-transparent border-l-4 top-1/2 -translate-y-1/2'
|
arrow.className = 'absolute w-0 h-0 border-y-4 border-y-transparent border-l-4 top-1/2 -translate-y-1/2'
|
||||||
arrow.style.cssText = 'right: -4px; border-left-color: var(--color-bg-elevated);'
|
arrow.style.cssText = 'right: -4px; border-left-color: var(--color-bg-elevated);'
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clamp to viewport
|
// Clamp to viewport
|
||||||
left = Math.max(4, Math.min(left, vpW - tipW - 4))
|
leftVP = Math.max(4, Math.min(leftVP, vpW - tipW - 4))
|
||||||
top = Math.max(4, Math.min(top, vpH - tipH - 4))
|
topVP = Math.max(4, Math.min(topVP, vpH - tipH - 4))
|
||||||
|
|
||||||
tip.style.left = `${left}px`
|
// Convert viewport pixels to CSS pixels for position:fixed inside #app
|
||||||
tip.style.top = `${top}px`
|
// (handles coordinate mapping differences between Chromium and WebKitGTK)
|
||||||
|
tip.style.left = `${(leftVP - offsetX) / scaleX}px`
|
||||||
|
tip.style.top = `${(topVP - offsetY) / scaleY}px`
|
||||||
tip.style.opacity = '1'
|
tip.style.opacity = '1'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -111,6 +111,8 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
background-color: var(--color-bg-base);
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
|
|
||||||
export type SoundEvent =
|
export type SoundEvent =
|
||||||
| 'timer_start'
|
| 'timer_start'
|
||||||
| 'timer_stop'
|
| 'timer_stop'
|
||||||
@@ -41,9 +43,59 @@ const DEFAULT_SETTINGS: AudioSettings = {
|
|||||||
events: { ...DEFAULT_EVENTS },
|
events: { ...DEFAULT_EVENTS },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tone description for the Rust backend
|
||||||
|
interface SoundTone {
|
||||||
|
freq: number
|
||||||
|
duration_ms: number
|
||||||
|
delay_ms: number
|
||||||
|
freq_end?: number
|
||||||
|
detune?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map each sound event to its tone sequence (mirrors the Web Audio synthesis)
|
||||||
|
const TONE_MAP: Record<SoundEvent, SoundTone[]> = {
|
||||||
|
timer_start: [
|
||||||
|
{ freq: 523, duration_ms: 100, delay_ms: 0, detune: 3 },
|
||||||
|
{ freq: 659, duration_ms: 150, delay_ms: 10, detune: 3 },
|
||||||
|
],
|
||||||
|
timer_stop: [
|
||||||
|
{ freq: 784, duration_ms: 250, delay_ms: 0, freq_end: 523 },
|
||||||
|
],
|
||||||
|
timer_pause: [
|
||||||
|
{ freq: 440, duration_ms: 120, delay_ms: 0 },
|
||||||
|
],
|
||||||
|
timer_resume: [
|
||||||
|
{ freq: 523, duration_ms: 120, delay_ms: 0 },
|
||||||
|
],
|
||||||
|
idle_alert: [
|
||||||
|
{ freq: 880, duration_ms: 80, delay_ms: 0 },
|
||||||
|
{ freq: 880, duration_ms: 80, delay_ms: 60 },
|
||||||
|
],
|
||||||
|
goal_reached: [
|
||||||
|
{ freq: 523, duration_ms: 120, delay_ms: 0, detune: 3 },
|
||||||
|
{ freq: 659, duration_ms: 120, delay_ms: 10, detune: 3 },
|
||||||
|
{ freq: 784, duration_ms: 120, delay_ms: 10, detune: 3 },
|
||||||
|
],
|
||||||
|
break_reminder: [
|
||||||
|
{ freq: 659, duration_ms: 200, delay_ms: 0 },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
class AudioEngine {
|
class AudioEngine {
|
||||||
private ctx: AudioContext | null = null
|
private ctx: AudioContext | null = null
|
||||||
private settings: AudioSettings = { ...DEFAULT_SETTINGS, events: { ...DEFAULT_SETTINGS.events } }
|
private settings: AudioSettings = { ...DEFAULT_SETTINGS, events: { ...DEFAULT_SETTINGS.events } }
|
||||||
|
private _isLinux: boolean | null = null
|
||||||
|
|
||||||
|
private async isLinux(): Promise<boolean> {
|
||||||
|
if (this._isLinux === null) {
|
||||||
|
try {
|
||||||
|
this._isLinux = (await invoke('get_platform')) === 'linux'
|
||||||
|
} catch {
|
||||||
|
this._isLinux = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this._isLinux
|
||||||
|
}
|
||||||
|
|
||||||
private ensureContext(): AudioContext {
|
private ensureContext(): AudioContext {
|
||||||
if (!this.ctx) {
|
if (!this.ctx) {
|
||||||
@@ -81,7 +133,17 @@ class AudioEngine {
|
|||||||
this.synthesize(event)
|
this.synthesize(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
private synthesize(event: SoundEvent) {
|
private async synthesize(event: SoundEvent) {
|
||||||
|
// On Linux, use the Rust backend which plays via paplay/pw-play/aplay
|
||||||
|
if (await this.isLinux()) {
|
||||||
|
const tones = TONE_MAP[event]
|
||||||
|
if (tones) {
|
||||||
|
invoke('play_sound', { tones, volume: this.gain }).catch(() => {})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// On other platforms, use Web Audio API
|
||||||
switch (event) {
|
switch (event) {
|
||||||
case 'timer_start':
|
case 'timer_start':
|
||||||
this.playTimerStart()
|
this.playTimerStart()
|
||||||
|
|||||||
@@ -9,6 +9,23 @@ export interface ImportEntry {
|
|||||||
tags?: string[]
|
tags?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DateFormat = 'DMY' | 'MDY'
|
||||||
|
export type DateDetectResult = DateFormat | 'ambiguous'
|
||||||
|
|
||||||
|
export function detectDateFormat(rows: string[][], dateColIndex: number): DateDetectResult {
|
||||||
|
if (dateColIndex < 0) return 'ambiguous'
|
||||||
|
for (let i = 1; i < rows.length; i++) {
|
||||||
|
const val = (rows[i]?.[dateColIndex] || '').trim()
|
||||||
|
const match = val.match(/^(\d{1,2})[\/.](\d{1,2})[\/.](\d{2,4})$/)
|
||||||
|
if (!match) continue
|
||||||
|
const a = parseInt(match[1])
|
||||||
|
const b = parseInt(match[2])
|
||||||
|
if (a > 12) return 'DMY'
|
||||||
|
if (b > 12) return 'MDY'
|
||||||
|
}
|
||||||
|
return 'ambiguous'
|
||||||
|
}
|
||||||
|
|
||||||
export function parseCSV(text: string): string[][] {
|
export function parseCSV(text: string): string[][] {
|
||||||
const lines = text.split('\n').filter(l => l.trim())
|
const lines = text.split('\n').filter(l => l.trim())
|
||||||
return lines.map(line => {
|
return lines.map(line => {
|
||||||
@@ -31,6 +48,7 @@ export function parseCSV(text: string): string[][] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function parseDurationString(dur: string): number {
|
export function parseDurationString(dur: string): number {
|
||||||
|
if (!dur) return 0
|
||||||
// Handle HH:MM:SS, HH:MM, or decimal hours
|
// Handle HH:MM:SS, HH:MM, or decimal hours
|
||||||
if (dur.includes(':')) {
|
if (dur.includes(':')) {
|
||||||
const parts = dur.split(':').map(Number)
|
const parts = dur.split(':').map(Number)
|
||||||
@@ -42,26 +60,160 @@ export function parseDurationString(dur: string): number {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapTogglCSV(rows: string[][]): ImportEntry[] {
|
// Parse locale-variable date strings into YYYY-MM-DD
|
||||||
// Toggl CSV format: Description, Project, Client, Task, Tag, Start date, Start time, End date, End time, Duration
|
function parseFlexDate(dateStr: string, formatHint?: DateFormat): string {
|
||||||
const header = rows[0].map(h => h.toLowerCase())
|
if (!dateStr) return new Date().toISOString().split('T')[0]
|
||||||
const descIdx = header.findIndex(h => h.includes('description'))
|
const s = dateStr.trim()
|
||||||
const projIdx = header.findIndex(h => h.includes('project'))
|
|
||||||
const clientIdx = header.findIndex(h => h.includes('client'))
|
// YYYY-MM-DD (ISO)
|
||||||
const taskIdx = header.findIndex(h => h.includes('task'))
|
if (/^\d{4}-\d{1,2}-\d{1,2}$/.test(s)) {
|
||||||
const tagIdx = header.findIndex(h => h.includes('tag'))
|
const [y, m, d] = s.split('-')
|
||||||
const startDateIdx = header.findIndex(h => h.includes('start date'))
|
return `${y}-${m.padStart(2, '0')}-${d.padStart(2, '0')}`
|
||||||
const startTimeIdx = header.findIndex(h => h.includes('start time'))
|
}
|
||||||
const durationIdx = header.findIndex(h => h.includes('duration'))
|
|
||||||
|
// Slash or dot separated: could be MM/DD/YYYY, DD/MM/YYYY, DD.MM.YYYY
|
||||||
|
const match = s.match(/^(\d{1,2})[\/.](\d{1,2})[\/.](\d{2,4})$/)
|
||||||
|
if (match) {
|
||||||
|
let [, a, b, year] = match
|
||||||
|
if (year.length === 2) year = '20' + year
|
||||||
|
// If first part > 12, must be day-first (DD/MM/YYYY)
|
||||||
|
if (parseInt(a) > 12) return `${year}-${b.padStart(2, '0')}-${a.padStart(2, '0')}`
|
||||||
|
// If second part > 12, must be month-first (MM/DD/YYYY)
|
||||||
|
if (parseInt(b) > 12) return `${year}-${a.padStart(2, '0')}-${b.padStart(2, '0')}`
|
||||||
|
// Use format hint if provided
|
||||||
|
if (formatHint === 'DMY') return `${year}-${b.padStart(2, '0')}-${a.padStart(2, '0')}`
|
||||||
|
// Default to MDY
|
||||||
|
return `${year}-${a.padStart(2, '0')}-${b.padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort: try Date constructor
|
||||||
|
const d = new Date(s)
|
||||||
|
if (!isNaN(d.getTime())) return d.toISOString().split('T')[0]
|
||||||
|
return new Date().toISOString().split('T')[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse 12h or 24h time strings into HH:MM:SS
|
||||||
|
function parseFlexTime(timeStr: string): string {
|
||||||
|
if (!timeStr) return '00:00:00'
|
||||||
|
const s = timeStr.trim()
|
||||||
|
|
||||||
|
// 12-hour: "1:00 PM", "11:30:00 AM"
|
||||||
|
const ampm = s.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(AM|PM)$/i)
|
||||||
|
if (ampm) {
|
||||||
|
let hour = parseInt(ampm[1])
|
||||||
|
const min = ampm[2]
|
||||||
|
const sec = ampm[3] || '00'
|
||||||
|
const period = ampm[4].toUpperCase()
|
||||||
|
if (period === 'PM' && hour !== 12) hour += 12
|
||||||
|
if (period === 'AM' && hour === 12) hour = 0
|
||||||
|
return `${hour.toString().padStart(2, '0')}:${min}:${sec}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 24-hour: "13:00", "09:30:00"
|
||||||
|
const h24 = s.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/)
|
||||||
|
if (h24) {
|
||||||
|
return `${h24[1].padStart(2, '0')}:${h24[2]}:${h24[3] || '00'}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return '00:00:00'
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeDateTime(date: string, time?: string, dateFormat?: DateFormat): string {
|
||||||
|
const d = parseFlexDate(date, dateFormat)
|
||||||
|
const t = time ? parseFlexTime(time) : '00:00:00'
|
||||||
|
return `${d}T${t}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findCol(header: string[], ...terms: string[]): number {
|
||||||
|
for (const term of terms) {
|
||||||
|
const idx = header.findIndex(h => h === term)
|
||||||
|
if (idx >= 0) return idx
|
||||||
|
}
|
||||||
|
for (const term of terms) {
|
||||||
|
const idx = header.findIndex(h => h.includes(term))
|
||||||
|
if (idx >= 0) return idx
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
function col(row: string[], idx: number): string {
|
||||||
|
return idx >= 0 ? (row[idx] || '') : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapTogglCSV(rows: string[][], dateFormat?: DateFormat): ImportEntry[] {
|
||||||
|
const header = rows[0].map(h => h.toLowerCase().trim())
|
||||||
|
const descIdx = findCol(header, 'description')
|
||||||
|
const projIdx = findCol(header, 'project')
|
||||||
|
const clientIdx = findCol(header, 'client')
|
||||||
|
const taskIdx = findCol(header, 'task')
|
||||||
|
const tagIdx = findCol(header, 'tags', 'tag')
|
||||||
|
const startDateIdx = findCol(header, 'start date')
|
||||||
|
const startTimeIdx = findCol(header, 'start time')
|
||||||
|
const durationIdx = findCol(header, 'duration')
|
||||||
|
|
||||||
return rows.slice(1).map(row => ({
|
return rows.slice(1).map(row => ({
|
||||||
project_name: row[projIdx] || 'Imported',
|
project_name: col(row, projIdx) || 'Imported',
|
||||||
client_name: clientIdx >= 0 ? row[clientIdx] : undefined,
|
client_name: col(row, clientIdx) || undefined,
|
||||||
task_name: taskIdx >= 0 ? row[taskIdx] : undefined,
|
task_name: col(row, taskIdx) || undefined,
|
||||||
description: descIdx >= 0 ? row[descIdx] : undefined,
|
description: col(row, descIdx) || undefined,
|
||||||
start_time: combineDateTime(row[startDateIdx], row[startTimeIdx]),
|
start_time: safeDateTime(col(row, startDateIdx), col(row, startTimeIdx) || undefined, dateFormat),
|
||||||
duration: durationIdx >= 0 ? parseDurationString(row[durationIdx]) : 0,
|
duration: parseDurationString(col(row, durationIdx)),
|
||||||
tags: tagIdx >= 0 && row[tagIdx] ? row[tagIdx].split(',').map(t => t.trim()) : undefined,
|
tags: tagIdx >= 0 && row[tagIdx] ? row[tagIdx].split(',').map(t => t.trim()).filter(Boolean) : undefined,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapClockifyCSV(rows: string[][], dateFormat?: DateFormat): ImportEntry[] {
|
||||||
|
const header = rows[0].map(h => h.toLowerCase().trim())
|
||||||
|
const descIdx = findCol(header, 'description')
|
||||||
|
const projIdx = findCol(header, 'project')
|
||||||
|
const clientIdx = findCol(header, 'client')
|
||||||
|
const taskIdx = findCol(header, 'task')
|
||||||
|
const tagIdx = findCol(header, 'tags', 'tag')
|
||||||
|
const startDateIdx = findCol(header, 'start date')
|
||||||
|
const startTimeIdx = findCol(header, 'start time')
|
||||||
|
// Prefer decimal duration - always a simple number, no format ambiguity
|
||||||
|
const durDecIdx = header.findIndex(h => h.includes('duration (decimal)'))
|
||||||
|
const durHIdx = header.findIndex(h => h.includes('duration (h)'))
|
||||||
|
const durPlainIdx = header.findIndex(h => h === 'duration')
|
||||||
|
|
||||||
|
return rows.slice(1).map(row => {
|
||||||
|
let duration = 0
|
||||||
|
if (durDecIdx >= 0 && row[durDecIdx]) {
|
||||||
|
duration = Math.round(parseFloat(row[durDecIdx]) * 3600)
|
||||||
|
} else if (durHIdx >= 0 && row[durHIdx]) {
|
||||||
|
duration = parseDurationString(row[durHIdx])
|
||||||
|
} else if (durPlainIdx >= 0 && row[durPlainIdx]) {
|
||||||
|
duration = parseDurationString(row[durPlainIdx])
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
project_name: col(row, projIdx) || 'Imported',
|
||||||
|
client_name: col(row, clientIdx) || undefined,
|
||||||
|
task_name: col(row, taskIdx) || undefined,
|
||||||
|
description: col(row, descIdx) || undefined,
|
||||||
|
start_time: safeDateTime(col(row, startDateIdx), col(row, startTimeIdx) || undefined, dateFormat),
|
||||||
|
duration,
|
||||||
|
tags: tagIdx >= 0 && row[tagIdx] ? row[tagIdx].split(',').map(t => t.trim()).filter(Boolean) : undefined,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapHarvestCSV(rows: string[][], dateFormat?: DateFormat): ImportEntry[] {
|
||||||
|
const header = rows[0].map(h => h.toLowerCase().trim())
|
||||||
|
const dateIdx = findCol(header, 'date')
|
||||||
|
const projIdx = header.findIndex(h => h === 'project')
|
||||||
|
const clientIdx = findCol(header, 'client')
|
||||||
|
const taskIdx = findCol(header, 'task')
|
||||||
|
const notesIdx = findCol(header, 'notes')
|
||||||
|
const hoursIdx = header.findIndex(h => h === 'hours')
|
||||||
|
|
||||||
|
return rows.slice(1).map(row => ({
|
||||||
|
project_name: col(row, projIdx) || 'Imported',
|
||||||
|
client_name: col(row, clientIdx) || undefined,
|
||||||
|
task_name: col(row, taskIdx) || undefined,
|
||||||
|
description: col(row, notesIdx) || undefined,
|
||||||
|
start_time: safeDateTime(col(row, dateIdx), undefined, dateFormat),
|
||||||
|
duration: hoursIdx >= 0 && row[hoursIdx] ? Math.round(parseFloat(row[hoursIdx]) * 3600) : 0,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,9 +227,3 @@ export function mapGenericCSV(rows: string[][], mapping: Record<string, number>)
|
|||||||
duration: mapping.duration >= 0 ? parseDurationString(row[mapping.duration]) : 0,
|
duration: mapping.duration >= 0 ? parseDurationString(row[mapping.duration]) : 0,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
function combineDateTime(date: string, time: string): string {
|
|
||||||
if (!date) return new Date().toISOString()
|
|
||||||
if (!time) return new Date(date).toISOString()
|
|
||||||
return new Date(`${date} ${time}`).toISOString()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -526,6 +526,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Linux app tracking notice -->
|
||||||
|
<p v-if="platform === 'linux'" class="text-[0.6875rem] text-text-tertiary -mt-1">
|
||||||
|
On Linux, window visibility cannot be detected. The timer will only pause when the tracked app's process exits entirely.
|
||||||
|
</p>
|
||||||
|
|
||||||
<!-- Check Interval -->
|
<!-- Check Interval -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -547,7 +552,7 @@
|
|||||||
<div class="border-t border-border-subtle" />
|
<div class="border-t border-border-subtle" />
|
||||||
|
|
||||||
<!-- Timeline Recording -->
|
<!-- Timeline Recording -->
|
||||||
<div class="flex items-center justify-between">
|
<div :class="['flex items-center justify-between', platform === 'linux' && 'opacity-50 pointer-events-none']">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-[0.8125rem] text-text-primary">Record app timeline</p>
|
<p class="text-[0.8125rem] text-text-primary">Record app timeline</p>
|
||||||
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Capture which apps and windows are active while the timer runs. Data stays local.</p>
|
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Capture which apps and windows are active while the timer runs. Data stays local.</p>
|
||||||
@@ -556,6 +561,7 @@
|
|||||||
@click="toggleTimelineRecording"
|
@click="toggleTimelineRecording"
|
||||||
role="switch"
|
role="switch"
|
||||||
:aria-checked="timelineRecording"
|
:aria-checked="timelineRecording"
|
||||||
|
:disabled="platform === 'linux'"
|
||||||
aria-label="Record app timeline"
|
aria-label="Record app timeline"
|
||||||
:class="[
|
:class="[
|
||||||
'relative inline-flex h-5 w-9 items-center rounded-full transition-colors duration-150',
|
'relative inline-flex h-5 w-9 items-center rounded-full transition-colors duration-150',
|
||||||
@@ -570,6 +576,10 @@
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Linux timeline notice -->
|
||||||
|
<p v-if="platform === 'linux'" class="text-[0.6875rem] text-text-tertiary -mt-1">
|
||||||
|
Timeline recording is not available on Linux - Wayland's security model prevents detecting the foreground window.
|
||||||
|
</p>
|
||||||
|
|
||||||
<!-- Divider -->
|
<!-- Divider -->
|
||||||
<div class="border-t border-border-subtle" />
|
<div class="border-t border-border-subtle" />
|
||||||
@@ -1445,6 +1455,32 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Date format detection -->
|
||||||
|
<div v-if="importFormat !== 'json' && importFormat !== 'generic'" class="flex items-center gap-3 mb-3">
|
||||||
|
<template v-if="importDateDetected !== 'ambiguous'">
|
||||||
|
<span class="text-[0.6875rem] text-text-tertiary">
|
||||||
|
Date format detected: {{ importDateFormat === 'DMY' ? 'DD/MM/YYYY' : 'MM/DD/YYYY' }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
@click="importDateDetected = 'ambiguous'"
|
||||||
|
class="text-[0.6875rem] text-accent hover:text-accent-hover transition-colors"
|
||||||
|
>
|
||||||
|
Change
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<label class="text-[0.6875rem] text-text-tertiary">Date format:</label>
|
||||||
|
<div class="w-36">
|
||||||
|
<AppSelect
|
||||||
|
v-model="importDateFormat"
|
||||||
|
:options="dateFormatOptions"
|
||||||
|
label-key="label"
|
||||||
|
value-key="value"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@click="executeImport"
|
@click="executeImport"
|
||||||
:disabled="isImporting"
|
:disabled="isImporting"
|
||||||
@@ -1541,7 +1577,7 @@ import AppSelect from '../components/AppSelect.vue'
|
|||||||
import AppShortcutRecorder from '../components/AppShortcutRecorder.vue'
|
import AppShortcutRecorder from '../components/AppShortcutRecorder.vue'
|
||||||
import AppTimePicker from '../components/AppTimePicker.vue'
|
import AppTimePicker from '../components/AppTimePicker.vue'
|
||||||
import { LOCALES, getCurrencies, getCurrencySymbol } from '../utils/locale'
|
import { LOCALES, getCurrencies, getCurrencySymbol } from '../utils/locale'
|
||||||
import { parseCSV, mapTogglCSV, mapGenericCSV, type ImportEntry } from '../utils/import'
|
import { parseCSV, mapTogglCSV, mapClockifyCSV, mapHarvestCSV, mapGenericCSV, detectDateFormat, findCol, type ImportEntry, type DateFormat, type DateDetectResult } from '../utils/import'
|
||||||
import { TIMER_FONTS, loadGoogleFont, loadAndApplyTimerFont } from '../utils/fonts'
|
import { TIMER_FONTS, loadGoogleFont, loadAndApplyTimerFont } from '../utils/fonts'
|
||||||
import { UI_FONTS, loadUIFont } from '../utils/uiFonts'
|
import { UI_FONTS, loadUIFont } from '../utils/uiFonts'
|
||||||
import { useFocusTrap } from '../utils/focusTrap'
|
import { useFocusTrap } from '../utils/focusTrap'
|
||||||
@@ -1648,6 +1684,7 @@ const shortcutToggleTimer = ref('CmdOrCtrl+Shift+T')
|
|||||||
const shortcutShowApp = ref('CmdOrCtrl+Shift+Z')
|
const shortcutShowApp = ref('CmdOrCtrl+Shift+Z')
|
||||||
const shortcutQuickEntry = ref('CmdOrCtrl+Shift+N')
|
const shortcutQuickEntry = ref('CmdOrCtrl+Shift+N')
|
||||||
const timelineRecording = ref(false)
|
const timelineRecording = ref(false)
|
||||||
|
const platform = ref('')
|
||||||
const timerFont = ref('JetBrains Mono')
|
const timerFont = ref('JetBrains Mono')
|
||||||
const timerFontOptions = TIMER_FONTS
|
const timerFontOptions = TIMER_FONTS
|
||||||
const reduceMotion = ref('system')
|
const reduceMotion = ref('system')
|
||||||
@@ -1978,10 +2015,18 @@ const importFileName = ref('')
|
|||||||
const importPreview = ref<string[][]>([])
|
const importPreview = ref<string[][]>([])
|
||||||
const importStatus = ref('')
|
const importStatus = ref('')
|
||||||
const isImporting = ref(false)
|
const isImporting = ref(false)
|
||||||
|
const importDateDetected = ref<DateDetectResult>('ambiguous')
|
||||||
|
const importDateFormat = ref<DateFormat>('MDY')
|
||||||
|
|
||||||
|
const dateFormatOptions = [
|
||||||
|
{ label: 'MM/DD/YYYY', value: 'MDY' },
|
||||||
|
{ label: 'DD/MM/YYYY', value: 'DMY' },
|
||||||
|
]
|
||||||
|
|
||||||
const importFormats = [
|
const importFormats = [
|
||||||
{ label: 'Toggl CSV', value: 'toggl' },
|
{ label: 'Toggl CSV', value: 'toggl' },
|
||||||
{ label: 'Clockify CSV', value: 'clockify' },
|
{ label: 'Clockify CSV', value: 'clockify' },
|
||||||
|
{ label: 'Harvest CSV', value: 'harvest' },
|
||||||
{ label: 'Generic CSV', value: 'generic' },
|
{ label: 'Generic CSV', value: 'generic' },
|
||||||
{ label: 'ZeroClock JSON', value: 'json' },
|
{ label: 'ZeroClock JSON', value: 'json' },
|
||||||
]
|
]
|
||||||
@@ -2357,8 +2402,23 @@ async function handleImportFile() {
|
|||||||
|
|
||||||
if (importFormat.value === 'json') {
|
if (importFormat.value === 'json') {
|
||||||
importPreview.value = []
|
importPreview.value = []
|
||||||
|
importDateDetected.value = 'ambiguous'
|
||||||
|
importDateFormat.value = 'MDY'
|
||||||
} else {
|
} else {
|
||||||
importPreview.value = parseCSV(content).slice(0, 6)
|
const allRows = parseCSV(content)
|
||||||
|
importPreview.value = allRows.slice(0, 6)
|
||||||
|
|
||||||
|
// Detect date format from CSV data
|
||||||
|
const header = allRows[0]?.map(h => h.toLowerCase().trim()) || []
|
||||||
|
let dateColIdx = -1
|
||||||
|
if (importFormat.value === 'harvest') {
|
||||||
|
dateColIdx = findCol(header, 'date')
|
||||||
|
} else {
|
||||||
|
dateColIdx = findCol(header, 'start date')
|
||||||
|
}
|
||||||
|
const detected = detectDateFormat(allRows, dateColIdx)
|
||||||
|
importDateDetected.value = detected
|
||||||
|
importDateFormat.value = detected === 'ambiguous' ? 'MDY' : detected
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to read file:', e)
|
console.error('Failed to read file:', e)
|
||||||
@@ -2379,8 +2439,13 @@ async function executeImport() {
|
|||||||
const rows = parseCSV(importFileContent.value)
|
const rows = parseCSV(importFileContent.value)
|
||||||
let entries: ImportEntry[]
|
let entries: ImportEntry[]
|
||||||
|
|
||||||
if (importFormat.value === 'toggl' || importFormat.value === 'clockify') {
|
const df = importDateFormat.value
|
||||||
entries = mapTogglCSV(rows)
|
if (importFormat.value === 'toggl') {
|
||||||
|
entries = mapTogglCSV(rows, df)
|
||||||
|
} else if (importFormat.value === 'clockify') {
|
||||||
|
entries = mapClockifyCSV(rows, df)
|
||||||
|
} else if (importFormat.value === 'harvest') {
|
||||||
|
entries = mapHarvestCSV(rows, df)
|
||||||
} else {
|
} else {
|
||||||
entries = mapGenericCSV(rows, { project: 0, description: 1, start_time: 2, duration: 3, client: -1, task: -1 })
|
entries = mapGenericCSV(rows, { project: 0, description: 1, start_time: 2, duration: 3, client: -1, task: -1 })
|
||||||
}
|
}
|
||||||
@@ -2392,6 +2457,8 @@ async function executeImport() {
|
|||||||
importFileContent.value = ''
|
importFileContent.value = ''
|
||||||
importFileName.value = ''
|
importFileName.value = ''
|
||||||
importPreview.value = []
|
importPreview.value = []
|
||||||
|
importDateDetected.value = 'ambiguous'
|
||||||
|
importDateFormat.value = 'MDY'
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
importStatus.value = `Error: ${e}`
|
importStatus.value = `Error: ${e}`
|
||||||
} finally {
|
} finally {
|
||||||
@@ -2439,6 +2506,7 @@ async function clearAllData() {
|
|||||||
|
|
||||||
// Load settings on mount
|
// Load settings on mount
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
try { platform.value = await invoke('get_platform') } catch { /* non-critical */ }
|
||||||
await settingsStore.fetchSettings()
|
await settingsStore.fetchSettings()
|
||||||
|
|
||||||
hourlyRate.value = parseFloat(settingsStore.settings.hourly_rate) || 0
|
hourlyRate.value = parseFloat(settingsStore.settings.hourly_rate) || 0
|
||||||
@@ -2468,7 +2536,7 @@ onMounted(async () => {
|
|||||||
businessEmail.value = settingsStore.settings.business_email || ''
|
businessEmail.value = settingsStore.settings.business_email || ''
|
||||||
businessPhone.value = settingsStore.settings.business_phone || ''
|
businessPhone.value = settingsStore.settings.business_phone || ''
|
||||||
businessLogo.value = settingsStore.settings.business_logo || ''
|
businessLogo.value = settingsStore.settings.business_logo || ''
|
||||||
timelineRecording.value = settingsStore.settings.timeline_recording === 'on'
|
timelineRecording.value = platform.value !== 'linux' && settingsStore.settings.timeline_recording === 'on'
|
||||||
timerFont.value = settingsStore.settings.timer_font || 'JetBrains Mono'
|
timerFont.value = settingsStore.settings.timer_font || 'JetBrains Mono'
|
||||||
reduceMotion.value = settingsStore.settings.reduce_motion || 'system'
|
reduceMotion.value = settingsStore.settings.reduce_motion || 'system'
|
||||||
dyslexiaMode.value = settingsStore.settings.dyslexia_mode === 'true'
|
dyslexiaMode.value = settingsStore.settings.dyslexia_mode === 'true'
|
||||||
|
|||||||
Reference in New Issue
Block a user