feat: linux appimage build with docker, egl fallback, and webkitgtk fixes
This commit is contained in:
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
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",
|
||||
"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",
|
||||
|
||||
3
src-tauri/Cargo.lock
generated
3
src-tauri/Cargo.lock
generated
@@ -5743,10 +5743,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zeroclock"
|
||||
version = "1.0.1"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"env_logger",
|
||||
"gtk",
|
||||
"log",
|
||||
"png",
|
||||
"rusqlite",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(>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)]
|
||||
{
|
||||
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
|
||||
|
||||
@@ -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
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(" ")
|
||||
}
|
||||
@@ -53,6 +53,11 @@
|
||||
"webviewInstallMode": {
|
||||
"type": "embedBootstrapper"
|
||||
}
|
||||
},
|
||||
"linux": {
|
||||
"appimage": {
|
||||
"bundleMediaFramework": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
|
||||
@@ -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'
|
||||
@@ -35,7 +36,11 @@ 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() {
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
|
||||
@@ -111,6 +111,8 @@
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
background-color: var(--color-bg-base);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user