137 Commits

Author SHA1 Message Date
Your Name 58892361dd fix window controls not responding to clicks 2026-03-13 00:35:04 +02:00
Your Name e677faed33 add disclosure note to readme 2026-03-13 00:07:02 +02:00
Your Name 9ab3536942 linux appimage build with docker, egl fallback, and webkitgtk fixes 2026-02-27 13:26:04 +02:00
Your Name c0405ceda3 auto-detect date format (DD/MM vs MM/DD) in CSV imports
Scans all date values in imported CSVs to determine whether the file
uses DD/MM/YYYY or MM/DD/YYYY format. When the format is ambiguous
(all day and month values are <= 12), shows an inline dropdown for the
user to choose. Bump version to 1.0.2.
2026-02-21 16:56:27 +02:00
Your Name 7c7e1e8207 close button and CSV import parsing for Clockify/Harvest
Close button did nothing when "close to tray" was disabled - the
onCloseRequested handler lacked an explicit destroy call for the
non-tray path.

Clockify CSV import threw RangeError because locale-dependent date
formats (MM/DD/YYYY, DD.MM.YYYY, 12h time) were passed straight
to the Date constructor. Added flexible date/time parsers that
handle all Clockify export variants without relying on Date parsing.

Added dedicated Clockify mapper that prefers Duration (decimal)
column and a new Harvest CSV importer (date + decimal hours, no
start/end times).

Bump version to 1.0.1.
2026-02-21 14:56:53 +02:00
Your Name 55941a4d41 complete export/import cycle and remove sample data
Export now includes invoice_payments and recurring_invoices tables.
Import restored to use ID-based lookups and all fields for clients,
projects, tasks, and time entries. Added missing import support for
timeline_events, calendar_sources, calendar_events, invoice_payments,
and recurring_invoices. Export uses native save dialog instead of blob
download. Removed sample data seeding (seed.rs, UI, command).
2026-02-21 01:34:26 +02:00
Your Name d4dba6cfd8 add CC0 license file and update readme badge 2026-02-21 01:20:20 +02:00
Your Name c4703dfe98 tooltips, two-column timer, font selector, tray behavior, icons, readme
- Custom tooltip directive (WCAG AAA) on every button in the app
- Two-column timer layout with sticky hero and recent entries sidebar
- Timer font selector with 16 monospace Google Fonts and live preview
- UI font selector with 15+ Google Fonts
- Close-to-tray and minimize-to-tray settings
- New app icons (no-glow variants), platform icon set
- Mini timer pop-out window
- Favorites strip with drag-reorder and inline actions
- README with feature documentation
- Remove tracked files that belong in gitignore
2026-02-21 01:15:57 +02:00
Your Name 66d12e8c5c boost text-tertiary contrast for WCAG AAA (7:1) 2026-02-20 18:40:19 +02:00
Your Name 4617c7bb7f auto-backup UI and window close hook 2026-02-20 15:41:38 +02:00
Your Name 5146d9e2a0 export with all tables and auto-backup command 2026-02-20 15:40:02 +02:00
Your Name e0558eb93c rounding visibility in invoices and reports 2026-02-20 15:37:20 +02:00
Your Name 84b2ca54c8 rounding visibility indicators on entry rows 2026-02-20 15:36:07 +02:00
Your Name 76b139e792 time-of-day heatmap in reports patterns tab 2026-02-20 15:32:20 +02:00
Your Name 8567f94e3e project health badges and attention section 2026-02-20 15:32:14 +02:00
Your Name 361a1308b8 weekly comparison indicators and sparklines on dashboard 2026-02-20 15:32:07 +02:00
Your Name 81171bec1b receipt thumbnails, lightbox, and file picker for expenses 2026-02-20 15:25:18 +02:00
Your Name 2b8f9415b3 receipt lightbox component with zoom and focus trap 2026-02-20 15:23:11 +02:00
Your Name 218277e899 global shortcut for quick entry dialog 2026-02-20 15:20:27 +02:00
Your Name 45764795b3 global quick entry dialog component 2026-02-20 15:18:34 +02:00
Your Name 44803a7f6d timesheet row persistence and copy last week 2026-02-20 15:17:01 +02:00
Your Name 18d88bee6c timesheet row persistence backend 2026-02-20 15:15:50 +02:00
Your Name 5c44327500 entry template management in settings 2026-02-20 15:10:48 +02:00
Your Name 304483e4f9 entry template picker and save-as-template in entries view 2026-02-20 15:09:37 +02:00
Your Name eb8595f512 entry templates pinia store 2026-02-20 15:07:18 +02:00
Your Name 786085901f entry templates CRUD backend 2026-02-20 15:06:50 +02:00
Your Name 15c858a2fb cascade delete dialog for clients with dependency counts 2026-02-20 15:02:39 +02:00
Your Name c60246413b client cascade delete with dependency counts 2026-02-20 15:01:33 +02:00
Your Name 36b9cbcc54 smart timer safety net - save dialog on stop without project 2026-02-20 14:58:02 +02:00
Your Name a9e6984009 timer save dialog for no-project and long-timer scenarios 2026-02-20 14:56:17 +02:00
Your Name 8f86a7e5b6 use batch save for invoice items 2026-02-20 14:55:17 +02:00
Your Name f059dd0a92 batch invoice items save with transaction 2026-02-20 14:54:37 +02:00
Your Name 8ed76a1249 independent try/catch per onboarding detection call 2026-02-20 14:47:26 +02:00
Your Name ece4abde97 standardize error handling across all stores 2026-02-20 14:46:56 +02:00
Your Name 342579a78a use unified error handler in entries store 2026-02-20 14:43:10 +02:00
Your Name 1a7b361a16 unified error handler with retry for transient errors 2026-02-20 14:42:30 +02:00
Your Name 525a93e218 persistent notifications toggle in settings 2026-02-20 14:40:50 +02:00
Your Name a391c539f4 toast undo button and hover/focus pause 2026-02-20 14:38:34 +02:00
Your Name b42c3d49d7 toast auto-dismiss with undo and pause support 2026-02-20 14:38:08 +02:00
Your Name 9677f99337 enhancement round 2 implementation plan - 34 tasks
Phase 1: Toast auto-dismiss/undo, unified error handling, onboarding
resilience, invoice batch save, smart timer safety net.
Phase 2: Client cascade delete, entry templates, timesheet persistence,
global quick entry, receipt management.
Phase 3: Dashboard comparison, project health, heatmap, rounding
visibility, complete export with auto-backup.
2026-02-20 14:29:25 +02:00
Your Name 7c69abd135 enhancement round 2 design - 15 feature proposals
Covers smart timer safety net, toast undo system, unified error
handling, onboarding resilience, invoice save reliability, global
quick entry, entry templates, timesheet persistence, client cascade,
receipt management, weekly comparison, project health cards, time
heatmap, rounding preview, and export scheduling. All features
designed for WCAG 2.2 AAA compliance.
2026-02-20 14:22:01 +02:00
Your Name 8330da5608 add tour store for guided walkthrough state 2026-02-20 09:36:26 +02:00
Your Name 5e313f3cea tidy up project structure and normalize formatting 2026-02-19 22:43:14 +02:00
Your Name 81f9386136 mini timer renders via window label instead of hash routing
The mini timer window was blank because hash-based routing
(createWebHashHistory) doesn't work with Tauri's WebviewUrl path.
Now App.vue detects the mini timer by checking getCurrentWindow().label
=== 'mini-timer' and renders the MiniTimer component directly,
bypassing the router entirely.
2026-02-18 15:26:44 +02:00
Your Name 4f44ce48d2 mini timer window blank due to hash routing mismatch
The app uses createWebHashHistory but the mini timer window was
opened with WebviewUrl::App("/mini-timer") which sets the URL path,
not the hash fragment. Vue Router never matched the route, so the
Dashboard rendered in a 300x64 window (appearing blank). Now loads
root URL and sets window.location.hash via eval. Also shows/focuses
the main window when closing the mini timer.
2026-02-18 15:23:20 +02:00
Your Name f78bfa81ec load invoice templates from JSON files via backend
Templates are now loaded dynamically from data/templates/*.json
via the get_invoice_templates Tauri command instead of being
hardcoded in TypeScript. Preview and PDF renderer switch on
template.layout instead of template.id, allowing custom templates
to reuse built-in layouts with different colors.
2026-02-18 15:17:54 +02:00
Your Name fd2b24ebc2 load invoice templates from JSON files in data/templates directory 2026-02-18 15:12:30 +02:00
Your Name 1a7554e390 delete invoice_items before invoice to prevent FK constraint failure 2026-02-18 15:07:43 +02:00
Your Name f329e20aa0 make template picker full-screen with fixed positioning so buttons are visible 2026-02-18 15:05:02 +02:00
Your Name 960fa5c791 rewrite InvoicePreview with 15 unique typographic layouts 2026-02-18 14:50:49 +02:00
Your Name 7de92d122e rewrite PDF renderer with 15 unique typographic layouts 2026-02-18 14:45:38 +02:00
Your Name 68a575fc3f add two-step invoice flow with full-screen template picker 2026-02-18 14:43:55 +02:00
Your Name 53f2449438 update invoicePdf wrapper with new default template ID 2026-02-18 14:41:23 +02:00
Your Name 9373d207a2 rewrite invoice template configs with design-doc IDs and colors 2026-02-18 14:39:01 +02:00
Your Name f3b6bb0b21 add template_id to Invoice interface and updateInvoiceTemplate action 2026-02-18 14:38:14 +02:00
Your Name b09e6d0fb6 add template_id column to invoices table and update_invoice_template command 2026-02-18 14:37:26 +02:00
Your Name 3b1e9a2004 add invoice templates v2 implementation plan 2026-02-18 14:32:38 +02:00
Your Name cc3598f407 add invoice templates v2 complete redesign design doc 2026-02-18 14:28:41 +02:00
Your Name 41ddba5d13 integrate template picker into invoice create and preview views 2026-02-18 13:35:11 +02:00
Your Name 1eadc82db7 add business identity settings for invoice branding 2026-02-18 13:34:44 +02:00
Your Name 5308006578 add InvoicePreview.vue with all 7 header styles and 5 table styles 2026-02-18 13:30:27 +02:00
Your Name efeba209cf add InvoiceTemplatePicker split-pane component 2026-02-18 13:28:40 +02:00
Your Name 978b3baec3 add config-driven jsPDF invoice renderer with all header and table styles 2026-02-18 13:26:11 +02:00
Your Name 6ea47e534f add 15 invoice template configs and registry 2026-02-18 13:16:36 +02:00
Your Name 873876a7ed add invoice templates implementation plan
9-task plan covering template config types, jsPDF renderer,
HTML preview component, template picker UI, Invoices.vue
integration, business identity settings, and polish passes.
2026-02-18 13:12:37 +02:00
Your Name a57bdc9867 add invoice templates design document
15 visually distinct templates across 4 tiers (Professional
Essentials, Creative & Modern, Warm & Distinctive, Premium &
Specialized) with template config schema, picker UI design,
shared renderer architecture, and business identity support.
2026-02-18 13:07:39 +02:00
Your Name 1bb6619a17 migrate remaining dialogs to Vue Transition, remove old keyframes
Convert Settings, Invoices, IdlePrompt, AppTrackingPrompt, and
AppDiscard dialogs from animate-modal-enter CSS class to proper
<Transition name="modal"> wrappers for enter/leave animations.
Remove unused animate-modal-enter and animate-dropdown-enter keyframes.
2026-02-18 11:36:35 +02:00
Your Name 85ef68c461 add transitions and micro-interactions across all views
- Page transitions with slide-up/fade on route changes (App.vue)
- NavRail sliding active indicator with spring-like easing
- List enter/leave/move animations on Entries, Projects, Clients, Timer
- Modal enter/leave transitions with scale+fade on all dialogs
- Dropdown transitions with overshoot on all select/picker components
- Button feedback (scale on hover/active), card hover lift effects
- Timer pulse on start, glow on stop, floating empty state icons
- Content fade-in on Dashboard, Reports, Calendar, Timesheet
- Tag chip enter/leave animations in AppTagInput
- Progress bar smooth width transitions
- Implementation plan document
2026-02-18 11:33:58 +02:00
Your Name 1bb1debad0 add animation CSS classes, keyframes, and reduced-motion support 2026-02-18 11:22:32 +02:00
Your Name 76f0c3cfd2 install @vueuse/motion and create spring presets 2026-02-18 11:19:52 +02:00
Your Name 6d17e488c3 add motion system design for animations and micro-interactions 2026-02-18 11:07:57 +02:00
Your Name dff9ca60ff add daily/weekly goals, streaks, and time rounding
Settings Timer tab now has daily/weekly goal hour inputs. Dashboard
shows goal progress bars and streak counter. Settings Billing tab
has rounding toggle with increment and method selectors. New
rounding.ts utility for nearest/up/down time rounding.
2026-02-18 10:51:56 +02:00
Your Name 16c46d4c42 add budget progress indicators to Projects and Dashboard
Project edit dialog includes budget hours and amount fields. Project
cards show progress bars with color-coded status. Dashboard displays
budget alerts section for projects exceeding 75% of budget.
2026-02-18 10:51:47 +02:00
Your Name 7bef8f8723 integrate tags in Timer and Entries views
Timer shows tag selector below description, saves tags on stop.
Entries table displays tag chips per row with color coding.
Tags loaded from store on mount.
2026-02-18 10:51:39 +02:00
Your Name 17b6913677 add data import from CSV and JSON
Import utility with CSV parser, Toggl/Clockify format mapping, and
generic CSV column mapping. Settings Data tab has import UI with
file picker, format selector, preview table, and import execution.
2026-02-18 10:46:33 +02:00
Your Name 278dac1f69 enhance floating mini timer with controls and pop-out button
MiniTimer shows project color dot, name, elapsed time, stop button,
and expand-to-main button. Timer.vue has pop-out button when running.
2026-02-18 10:46:25 +02:00
Your Name 3792f38420 add global keyboard shortcuts for timer toggle and show app
Register CmdOrCtrl+Shift+T (toggle timer) and CmdOrCtrl+Shift+Z
(show app) via tauri-plugin-global-shortcut. Shortcut keys are
configurable in Settings Timer tab. Shortcuts re-register on change.
2026-02-18 10:46:18 +02:00
Your Name e1f8f93b23 add profitability tab and favorites strip
Reports view now has Hours/Profitability tabs with per-project revenue
table. Timer view shows favorites strip for quick project selection
and a Save as Favorite button next to the description input.
2026-02-18 10:46:10 +02:00
Your Name 255a1486e8 add Calendar, Timesheet, and MiniTimer views
Calendar shows weekly time-block layout with hour rows, entry positioning,
current time indicator, and week navigation. Timesheet provides a weekly
grid with project/task rows, day columns, totals, and add-row functionality.
MiniTimer is a minimal always-on-top timer display for the floating window.
2026-02-18 10:39:08 +02:00
Your Name 35bfdfab5f add AppTagInput multi-select tag component 2026-02-18 10:35:18 +02:00
Your Name 3a80ba3496 add markdown rendering for entry descriptions 2026-02-18 10:35:12 +02:00
Your Name 3501d1e7d8 add duplicate, copy previous day/week, and repeat entry 2026-02-18 10:35:06 +02:00
Your Name f4d8dcd9c7 add theme customization with accent colors and light mode 2026-02-18 10:34:59 +02:00
Your Name b3f310d8e4 add global-shortcut plugin and mini timer window commands 2026-02-18 02:06:07 +02:00
Your Name 006d2028c1 add goals, profitability, timesheet, and import commands 2026-02-18 02:04:10 +02:00
Your Name 6ca526b348 add favorites table, CRUD commands, and Pinia store 2026-02-18 02:02:57 +02:00
Your Name 4ebfe3a442 add project budgets and rounding override columns 2026-02-18 02:02:13 +02:00
Your Name f5e2aec698 add tags table, CRUD commands, and Pinia store 2026-02-18 02:01:04 +02:00
Your Name 5fcc87da81 dynamic currency symbols and integrated datetime picker
- Replace all hardcoded prefix="$" with :prefix="getCurrencySymbol()"
  in Settings, Projects, and Invoices views
- Replace hardcoded ($) labels with dynamic currency symbol
- Extend AppDatePicker with showTime prop + hour/minute v-models
  for integrated date+time selection
- Simplify Entries.vue to use single AppDatePicker with showTime
  instead of separate hour/minute inputs
2026-02-17 23:53:45 +02:00
Your Name f7dcb1e403 add viewport margin to all modal dialogs 2026-02-17 23:41:59 +02:00
Your Name 5e67949d6d replace native datetime-local with custom date picker + time inputs 2026-02-17 23:41:24 +02:00
Your Name d1c7ce406a replace all hardcoded en-US and $ formatting with locale-aware helpers 2026-02-17 23:39:31 +02:00
Your Name 313466ea15 replace native number inputs with AppNumberInput across all views 2026-02-17 23:36:02 +02:00
Your Name a639ee067c add locale and currency settings with searchable dropdowns 2026-02-17 23:35:27 +02:00
Your Name 06ab77fa9b apply default hourly rate from settings when creating new projects 2026-02-17 23:35:24 +02:00
Your Name 61729baa29 add AppNumberInput component with press-and-hold repeat 2026-02-17 23:33:13 +02:00
Your Name d37a827cb2 add searchable prop to AppSelect for filtering long option lists 2026-02-17 23:33:11 +02:00
Your Name 55de2a557a add locale utility with 140+ locales and 120+ currencies 2026-02-17 23:31:04 +02:00
Your Name 00817c8cdc add UI improvements batch implementation plan 2026-02-17 23:22:40 +02:00
Your Name 328d5638ef add UI improvements batch design (locale, datetime picker, number input, etc.) 2026-02-17 23:17:00 +02:00
Your Name 197d1708e1 add Clients view with card grid, dialogs, and billing details 2026-02-17 22:57:08 +02:00
Your Name 18d4d0edf9 add Client billing fields to store, /clients route, and reorder NavRail 2026-02-17 22:54:31 +02:00
Your Name c1984b7ec4 add client billing fields to database and Rust backend 2026-02-17 22:52:51 +02:00
Your Name e385aac789 add Clients view and NavRail reorg implementation plan 2026-02-17 22:48:22 +02:00
Your Name fad168da6c add Clients view and NavRail reorg design 2026-02-17 22:44:50 +02:00
Your Name f35b332a79 make custom dropdowns and date pickers respect UI zoom setting
Teleported panels read zoom from #app and apply it to their own style,
with position coordinates divided by the zoom factor so they align
correctly with the zoomed trigger elements.
2026-02-17 22:35:42 +02:00
Your Name dd281a48bc replace all native selects and date inputs with custom components 2026-02-17 22:27:51 +02:00
Your Name dc03b9459b add AppDatePicker custom calendar component 2026-02-17 22:24:47 +02:00
Your Name 689d053dbb add AppSelect custom dropdown component 2026-02-17 22:22:43 +02:00
Your Name 32f5c6fec3 add custom dropdowns and date pickers implementation plan 2026-02-17 22:17:42 +02:00
Your Name d00b8e6588 add custom dropdowns and date pickers design 2026-02-17 22:15:10 +02:00
Your Name 8643eb23f2 upgrade typography - Plus Jakarta Sans headings, JetBrains Mono data, 14px base
Heading font: Plus Jakarta Sans (500/600/700) for all h1-h3, stat values, dialog titles, timer display, and wordmark.
Body font: Inter (400/500/600/700) unchanged but base bumped from 13px to 14px.
Mono font: JetBrains Mono replaces IBM Plex Mono for code and tabular data.
2026-02-17 22:06:48 +02:00
Your Name 3e514322ff bump border-radius globally - rounded to rounded-lg, rounded-lg to rounded-xl 2026-02-17 21:56:48 +02:00
Your Name 908e9c440b redesign Settings with left sidebar tabs per Apple HIG
Four tabs (General, Timer, Billing, Data) with icon + label sidebar,
amber active indicator, auto-save on change, progressive disclosure
for timer settings, and danger zone isolated within Data tab.
2026-02-17 21:49:48 +02:00
Your Name 6b519386ce add Settings sidebar tabs design 2026-02-17 21:48:28 +02:00
Your Name 2ef0937446 reorganize Settings per Apple HIG - auto-save, progressive disclosure, danger zone 2026-02-17 21:43:04 +02:00
Your Name 5072f96948 window dragging - use startDragging() API instead of data attribute 2026-02-17 21:36:30 +02:00
Your Name 4d02b88ebe persist window position and size between runs 2026-02-17 21:33:32 +02:00
Your Name 1b00cf294a portable storage - data directory next to exe 2026-02-17 21:33:26 +02:00
Your Name 24aed3330c zoom initialization and toast container in App.vue 2026-02-17 21:32:15 +02:00
Your Name 43c51dabf8 redesign Settings - amber save, UI zoom, toasts 2026-02-17 21:31:54 +02:00
Your Name 6dea9cfaf6 redesign Invoices - amber tabs and totals, rich empty state 2026-02-17 21:31:05 +02:00
Your Name 2b88555c94 redesign Reports - amber actions and stats, toast notifications 2026-02-17 21:29:53 +02:00
Your Name 3ab7840614 redesign Entries - filter container, amber actions, rich empty state 2026-02-17 21:28:42 +02:00
Your Name 7fc242d18d redesign Projects - amber button, color presets, rich empty state 2026-02-17 21:27:49 +02:00
Your Name 49e9bfc201 redesign Timer - amber Start, colon pulse, toast 2026-02-17 21:26:33 +02:00
Your Name c6bf56c473 redesign Dashboard - greeting, amber stats, rich empty state 2026-02-17 21:25:52 +02:00
Your Name 27ed4b458e amber wordmark and NavRail active indicator 2026-02-17 21:25:08 +02:00
Your Name 98c266fd9e add toast notification system 2026-02-17 21:25:02 +02:00
Your Name 58b1187fc6 overhaul design tokens - charcoal palette + amber accent 2026-02-17 21:24:15 +02:00
Your Name 75084a1bd0 add UI polish implementation plan (14 tasks) 2026-02-17 21:21:16 +02:00
Your Name 3e1b97fe6f add UI polish & UX upgrade design doc
Covers amber accent system, charcoal background lift, button hierarchy,
toast notifications, rich empty states, UI zoom, portable storage,
and window state persistence.
2026-02-17 21:15:41 +02:00
Your Name 8c868ba256 syntax error in Invoices.vue 2026-02-17 18:44:02 +02:00
Your Name 61c4db1713 add Dashboard view 2026-02-17 18:30:01 +02:00
Your Name 8b1dc78132 initialize Tauri backend with SQLite database 2026-02-17 18:03:12 +02:00
Your Name e570f4af8e initialize Tauri v2 + Vue 3 project 2026-02-17 17:46:39 +02:00
Your Name e3070461c7 Initial commit 2026-02-17 17:37:20 +02:00
20 changed files with 1272 additions and 101 deletions
+8
View File
@@ -0,0 +1,8 @@
node_modules
src-tauri/target
dist
dist-appimage
.claude
.git
docs
trash
-40
View File
@@ -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
View 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/
+61 -2
View File
@@ -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
@@ -193,7 +238,7 @@ Accessibility is not a feature. It is a baseline.
```bash
# Clone the repository
git clone https://github.com/your-username/zeroclock.git
git clone https://git.lashman.live/lashman/zeroclock.git
cd zeroclock
# Install frontend dependencies
@@ -206,6 +251,16 @@ npx tauri dev
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.
---
@@ -262,3 +317,7 @@ ZeroClock is released under [CC0 1.0 Universal](LICENSE) - dedicated to the publ
*Just a tool that respects your time.*
</div>
---
This project is developed with the help of a locally-run LLM via opencode for scaffolding, planning, and routine code tasks. Architecture and design decisions are my own.
+21
View 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)"
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "zeroclock",
"version": "1.0.0",
"version": "1.0.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "zeroclock",
"version": "1.0.0",
"version": "1.0.2",
"dependencies": {
"@tauri-apps/api": "^2.2.0",
"@tauri-apps/plugin-dialog": "^2.6.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "zeroclock",
"version": "1.0.2",
"version": "1.0.3",
"description": "Time tracking desktop application",
"type": "module",
"scripts": {
+2 -1
View File
@@ -5743,10 +5743,11 @@ dependencies = [
[[package]]
name = "zeroclock"
version = "1.0.1"
version = "1.0.2"
dependencies = [
"chrono",
"env_logger",
"gtk",
"log",
"png",
"rusqlite",
+7 -5
View File
@@ -26,11 +26,13 @@ chrono = { version = "0.4", features = ["serde"] }
tauri-plugin-window-state = "2"
log = "0.4"
env_logger = "0.11"
png = "0.17"
[dependencies.windows]
version = "0.58"
features = [
[target.'cfg(target_os = "linux")'.dependencies]
gtk = "0.18"
[target.'cfg(windows)'.dependencies]
png = "0.17"
windows = { version = "0.58", features = [
"Win32_UI_WindowsAndMessaging",
"Win32_System_Threading",
"Win32_System_SystemInformation",
@@ -39,7 +41,7 @@ features = [
"Win32_Graphics_Gdi",
"Win32_Storage_FileSystem",
"Win32_Foundation",
]
] }
[profile.release]
panic = "abort"
+146 -1
View File
@@ -2,7 +2,7 @@ use crate::AppState;
use crate::os_detection;
use rusqlite::params;
use serde::{Deserialize, Serialize};
use tauri::{Manager, State};
use tauri::{AppHandle, Manager, State};
#[derive(Debug, Serialize, Deserialize)]
pub struct Client {
@@ -1102,6 +1102,17 @@ pub struct TrackedApp {
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
#[tauri::command]
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;
}
}
}
+120 -2
View File
@@ -5,6 +5,13 @@ use tauri::Manager;
mod database;
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;
pub struct AppState {
@@ -13,16 +20,73 @@ pub struct AppState {
}
fn get_data_dir() -> PathBuf {
let exe_path = std::env::current_exe().unwrap();
let data_dir = exe_path.parent().unwrap().join("data");
// On Linux AppImage: $APPIMAGE points to the .AppImage file itself.
// 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();
data_dir
}
#[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() {
env_logger::init();
#[cfg(target_os = "linux")]
install_desktop_entry();
let data_dir = get_data_dir();
let db_path = data_dir.join("timetracker.db");
@@ -154,8 +218,62 @@ pub fn run() {
commands::update_recurring_invoice,
commands::delete_recurring_invoice,
commands::check_recurring_invoices,
commands::quit_app,
commands::get_platform,
commands::play_sound,
])
.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(&gtk_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)]
{
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
+160
View File
@@ -3,6 +3,166 @@
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() {
// 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();
}
+537
View 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(" ")
}
+6 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ZeroClock",
"version": "1.0.2",
"version": "1.0.3",
"identifier": "com.localtimetracker.app",
"build": {
"beforeDevCommand": "npm run dev",
@@ -53,6 +53,11 @@
"webviewInstallMode": {
"type": "embedBootstrapper"
}
},
"linux": {
"appimage": {
"bundleMediaFramework": false
}
}
},
"plugins": {
+12 -14
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { getCurrentWindow } from '@tauri-apps/api/window'
import { invoke } from '@tauri-apps/api/core'
import { ref, onMounted } from 'vue'
import { useTimerStore } from '../stores/timer'
import { useProjectsStore } from '../stores/projects'
@@ -13,6 +14,9 @@ const settingsStore = useSettingsStore()
onMounted(async () => {
isMaximized.value = await appWindow.isMaximized()
appWindow.onResized(async () => {
isMaximized.value = await appWindow.isMaximized()
})
})
function getProjectName(projectId: number | null): string {
@@ -35,23 +39,19 @@ async function toggleMaximize() {
}
async function close() {
await appWindow.close()
if (settingsStore.settings.minimize_to_tray === 'true') {
await appWindow.hide()
} else {
await invoke('quit_app')
}
}
async function startDrag() {
await appWindow.startDragging()
}
async function handleDoubleClick() {
await toggleMaximize()
}
</script>
<template>
<header
class="h-11 flex items-center justify-between px-4 bg-bg-surface border-b border-border-subtle select-none shrink-0"
@mousedown.left="startDrag"
@dblclick="handleDoubleClick"
style="-webkit-app-region: drag"
>
<!-- Left: App name -->
<div class="flex items-center">
@@ -66,6 +66,7 @@ async function handleDoubleClick() {
<div
role="status"
aria-live="off"
style="-webkit-app-region: no-drag"
class="flex items-center gap-3 transition-opacity duration-150"
:class="timerStore.isRunning || timerStore.isPaused ? 'opacity-100' : 'opacity-0 pointer-events-none'"
>
@@ -92,7 +93,6 @@ async function handleDoubleClick() {
v-if="timerStore.isRunning"
v-tooltip.bottom="'Pause timer'"
@click="timerStore.pauseManual()"
@mousedown.stop
class="w-11 h-11 flex items-center justify-center text-text-tertiary hover:text-status-warning transition-colors duration-150"
aria-label="Pause timer"
>
@@ -105,7 +105,6 @@ async function handleDoubleClick() {
v-else-if="timerStore.timerState === 'PAUSED_MANUAL'"
v-tooltip.bottom="'Resume timer'"
@click="timerStore.resumeFromPause()"
@mousedown.stop
class="w-11 h-11 flex items-center justify-center text-text-tertiary hover:text-accent-text transition-colors duration-150"
aria-label="Resume timer"
>
@@ -118,7 +117,6 @@ async function handleDoubleClick() {
<button
v-tooltip.bottom="'Stop timer'"
@click="timerStore.stop()"
@mousedown.stop
class="w-11 h-11 flex items-center justify-center text-text-tertiary hover:text-status-error transition-colors duration-150"
aria-label="Stop timer"
>
@@ -129,7 +127,7 @@ async function handleDoubleClick() {
</div>
<!-- Right: Window controls -->
<div class="flex items-center" @mousedown.stop>
<div class="flex items-center" style="-webkit-app-region: no-drag">
<button
v-tooltip.bottom="'Minimize'"
@click="minimize"
+29 -29
View File
@@ -1,5 +1,5 @@
import type { Directive, DirectiveBinding } from 'vue'
import { getZoomFactor } from '../utils/dropdown'
import { getFixedPositionMapping } from '../utils/dropdown'
interface TooltipState {
el: HTMLElement
@@ -48,32 +48,29 @@ function positionTooltip(state: TooltipState) {
if (!tip) return
const rect = state.el.getBoundingClientRect()
const zoom = getZoomFactor()
const { scaleX, scaleY, offsetX, offsetY } = getFixedPositionMapping()
const margin = 6
const arrowSize = 4
// Measure tooltip
// Measure tooltip (in viewport pixels)
tip.style.left = '-9999px'
tip.style.top = '-9999px'
tip.style.opacity = '0'
const tipRect = tip.getBoundingClientRect()
const tipW = tipRect.width / zoom
const tipH = tipRect.height / zoom
const tipW = tipRect.width
const tipH = tipRect.height
const elTop = rect.top / zoom
const elLeft = rect.left / zoom
const elW = rect.width / zoom
const elH = rect.height / zoom
const vpW = window.innerWidth / zoom
const vpH = window.innerHeight / zoom
// All in viewport pixels for placement decisions
const vpW = window.innerWidth
const vpH = window.innerHeight
// Determine placement
let placement = state.placement
if (placement === 'auto') {
const spaceAbove = elTop
const spaceBelow = vpH - elTop - elH
const spaceRight = vpW - elLeft - elW
const spaceLeft = elLeft
const spaceAbove = rect.top
const spaceBelow = vpH - rect.bottom
const spaceRight = vpW - rect.right
const spaceLeft = rect.left
// Prefer top, then bottom, then right, then left
if (spaceAbove >= tipH + margin + arrowSize) placement = 'top'
@@ -83,8 +80,9 @@ function positionTooltip(state: TooltipState) {
else placement = 'top'
}
let top = 0
let left = 0
// Calculate position in viewport pixels
let topVP = 0
let leftVP = 0
const arrow = tip.querySelector('[data-arrow]') as HTMLElement
// Reset arrow classes
@@ -92,37 +90,39 @@ function positionTooltip(state: TooltipState) {
switch (placement) {
case 'top':
top = elTop - tipH - margin
left = elLeft + elW / 2 - tipW / 2
topVP = rect.top - tipH - margin
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.style.cssText = 'bottom: -4px; border-top-color: var(--color-bg-elevated);'
break
case 'bottom':
top = elTop + elH + margin
left = elLeft + elW / 2 - tipW / 2
topVP = rect.bottom + margin
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.style.cssText = 'top: -4px; border-bottom-color: var(--color-bg-elevated);'
break
case 'right':
top = elTop + elH / 2 - tipH / 2
left = elLeft + elW + margin
topVP = rect.top + rect.height / 2 - tipH / 2
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.style.cssText = 'left: -4px; border-right-color: var(--color-bg-elevated);'
break
case 'left':
top = elTop + elH / 2 - tipH / 2
left = elLeft - tipW - margin
topVP = rect.top + rect.height / 2 - tipH / 2
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.style.cssText = 'right: -4px; border-left-color: var(--color-bg-elevated);'
break
}
// Clamp to viewport
left = Math.max(4, Math.min(left, vpW - tipW - 4))
top = Math.max(4, Math.min(top, vpH - tipH - 4))
leftVP = Math.max(4, Math.min(leftVP, vpW - tipW - 4))
topVP = Math.max(4, Math.min(topVP, vpH - tipH - 4))
tip.style.left = `${left}px`
tip.style.top = `${top}px`
// Convert viewport pixels to CSS pixels for position:fixed inside #app
// (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'
}
+2
View File
@@ -111,6 +111,8 @@
height: 100%;
width: 100%;
overflow: auto;
background-color: var(--color-bg-base);
overflow: hidden;
}
body {
+63 -1
View File
@@ -1,3 +1,5 @@
import { invoke } from '@tauri-apps/api/core'
export type SoundEvent =
| 'timer_start'
| 'timer_stop'
@@ -41,9 +43,59 @@ const DEFAULT_SETTINGS: AudioSettings = {
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 {
private ctx: AudioContext | null = null
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 {
if (!this.ctx) {
@@ -81,7 +133,17 @@ class AudioEngine {
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) {
case 'timer_start':
this.playTimerStart()
+14 -2
View File
@@ -526,6 +526,11 @@
</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 -->
<div class="flex items-center justify-between">
<div>
@@ -547,7 +552,7 @@
<div class="border-t border-border-subtle" />
<!-- Timeline Recording -->
<div class="flex items-center justify-between">
<div :class="['flex items-center justify-between', platform === 'linux' && 'opacity-50 pointer-events-none']">
<div>
<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>
@@ -556,6 +561,7 @@
@click="toggleTimelineRecording"
role="switch"
:aria-checked="timelineRecording"
:disabled="platform === 'linux'"
aria-label="Record app timeline"
:class="[
'relative inline-flex h-5 w-9 items-center rounded-full transition-colors duration-150',
@@ -570,6 +576,10 @@
/>
</button>
</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 -->
<div class="border-t border-border-subtle" />
@@ -1674,6 +1684,7 @@ const shortcutToggleTimer = ref('CmdOrCtrl+Shift+T')
const shortcutShowApp = ref('CmdOrCtrl+Shift+Z')
const shortcutQuickEntry = ref('CmdOrCtrl+Shift+N')
const timelineRecording = ref(false)
const platform = ref('')
const timerFont = ref('JetBrains Mono')
const timerFontOptions = TIMER_FONTS
const reduceMotion = ref('system')
@@ -2495,6 +2506,7 @@ async function clearAllData() {
// Load settings on mount
onMounted(async () => {
try { platform.value = await invoke('get_platform') } catch { /* non-critical */ }
await settingsStore.fetchSettings()
hourlyRate.value = parseFloat(settingsStore.settings.hourly_rate) || 0
@@ -2524,7 +2536,7 @@ onMounted(async () => {
businessEmail.value = settingsStore.settings.business_email || ''
businessPhone.value = settingsStore.settings.business_phone || ''
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'
reduceMotion.value = settingsStore.settings.reduce_motion || 'system'
dyslexiaMode.value = settingsStore.settings.dyslexia_mode === 'true'