From 130d0e2ca603107c5883307bc572cbffccd42c0c Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 27 Feb 2026 13:25:53 +0200 Subject: [PATCH] feat: linux appimage build with docker, egl fallback, and webkitgtk fixes --- .dockerignore | 8 + .gitignore | 40 -- Dockerfile.appimage | 81 +++ README.md | 59 +- build-appimage.sh | 21 + package-lock.json | 4 +- src-tauri/Cargo.lock | 3 +- src-tauri/Cargo.toml | 12 +- src-tauri/src/commands.rs | 147 ++++- src-tauri/src/lib.rs | 122 +++- src-tauri/src/main.rs | 160 ++++++ src-tauri/src/os_detection_linux.rs | 537 ++++++++++++++++++ ...s_detection.rs => os_detection_windows.rs} | 0 src-tauri/tauri.conf.json | 5 + src/components/TitleBar.vue | 7 +- src/directives/tooltip.ts | 58 +- src/styles/main.css | 2 + src/utils/audio.ts | 64 ++- src/views/Settings.vue | 16 +- 19 files changed, 1260 insertions(+), 86 deletions(-) create mode 100644 .dockerignore delete mode 100644 .gitignore create mode 100644 Dockerfile.appimage create mode 100644 build-appimage.sh create mode 100644 src-tauri/src/os_detection_linux.rs rename src-tauri/src/{os_detection.rs => os_detection_windows.rs} (100%) diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..bbf6ed7 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +node_modules +src-tauri/target +dist +dist-appimage +.claude +.git +docs +trash diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 78165d2..0000000 --- a/.gitignore +++ /dev/null @@ -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/ diff --git a/Dockerfile.appimage b/Dockerfile.appimage new file mode 100644 index 0000000..346dd4d --- /dev/null +++ b/Dockerfile.appimage @@ -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/ diff --git a/README.md b/README.md index 052e894..e6ce24a 100644 --- a/README.md +++ b/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. --- diff --git a/build-appimage.sh b/build-appimage.sh new file mode 100644 index 0000000..4c272d5 --- /dev/null +++ b/build-appimage.sh @@ -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)" diff --git a/package-lock.json b/package-lock.json index 91a73aa..4c0f250 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 2c1d64d..249e3dd 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5743,10 +5743,11 @@ dependencies = [ [[package]] name = "zeroclock" -version = "1.0.1" +version = "1.0.2" dependencies = [ "chrono", "env_logger", + "gtk", "log", "png", "rusqlite", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5c19806..cb8c508 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 0898c0c..9455429 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -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, } +// 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 { @@ -3754,3 +3765,137 @@ fn get_default_templates() -> Vec { ] } +#[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, volume: f64) { + std::thread::spawn(move || { + play_sound_inner(&tones, volume); + }); +} + +#[cfg(not(target_os = "linux"))] +#[tauri::command] +pub fn play_sound(_tones: Vec, _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, + #[serde(default)] + pub detune: Option, +} + +#[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; + } + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c06b0a8..270f611 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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}; diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index f127082..a3009fa 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -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(); } diff --git a/src-tauri/src/os_detection_linux.rs b/src-tauri/src/os_detection_linux.rs new file mode 100644 index 0000000..937a5b9 --- /dev/null +++ b/src-tauri/src/os_detection_linux.rs @@ -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, +} + +/// 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 { + 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::() { + 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 { + 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::() { + is_our_uid = uid == my_uid; + } + } + } + if let Some(rest) = line.strip_prefix("PPid:") { + if let Ok(p) = rest.trim().parse::() { + 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 { + 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 { + 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) { + 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 { + // 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 { + 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::>() + .join(" ") +} diff --git a/src-tauri/src/os_detection.rs b/src-tauri/src/os_detection_windows.rs similarity index 100% rename from src-tauri/src/os_detection.rs rename to src-tauri/src/os_detection_windows.rs diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index e8fd636..53156df 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -53,6 +53,11 @@ "webviewInstallMode": { "type": "embedBootstrapper" } + }, + "linux": { + "appimage": { + "bundleMediaFramework": false + } } }, "plugins": { diff --git a/src/components/TitleBar.vue b/src/components/TitleBar.vue index 2725783..d2a89ff 100644 --- a/src/components/TitleBar.vue +++ b/src/components/TitleBar.vue @@ -1,5 +1,6 @@