feat: linux appimage build with docker, egl fallback, and webkitgtk fixes

This commit is contained in:
Your Name
2026-02-27 13:25:53 +02:00
parent 507fa33be8
commit 130d0e2ca6
19 changed files with 1260 additions and 86 deletions

3
src-tauri/Cargo.lock generated
View File

@@ -5743,10 +5743,11 @@ dependencies = [
[[package]]
name = "zeroclock"
version = "1.0.1"
version = "1.0.2"
dependencies = [
"chrono",
"env_logger",
"gtk",
"log",
"png",
"rusqlite",

View File

@@ -26,11 +26,13 @@ chrono = { version = "0.4", features = ["serde"] }
tauri-plugin-window-state = "2"
log = "0.4"
env_logger = "0.11"
png = "0.17"
[dependencies.windows]
version = "0.58"
features = [
[target.'cfg(target_os = "linux")'.dependencies]
gtk = "0.18"
[target.'cfg(windows)'.dependencies]
png = "0.17"
windows = { version = "0.58", features = [
"Win32_UI_WindowsAndMessaging",
"Win32_System_Threading",
"Win32_System_SystemInformation",
@@ -39,7 +41,7 @@ features = [
"Win32_Graphics_Gdi",
"Win32_Storage_FileSystem",
"Win32_Foundation",
]
] }
[profile.release]
panic = "abort"

View File

@@ -2,7 +2,7 @@ use crate::AppState;
use crate::os_detection;
use rusqlite::params;
use serde::{Deserialize, Serialize};
use tauri::{Manager, State};
use tauri::{AppHandle, Manager, State};
#[derive(Debug, Serialize, Deserialize)]
pub struct Client {
@@ -1102,6 +1102,17 @@ pub struct TrackedApp {
pub display_name: Option<String>,
}
// Platform detection
#[tauri::command]
pub fn get_platform() -> String {
#[cfg(target_os = "linux")]
{ "linux".to_string() }
#[cfg(target_os = "windows")]
{ "windows".to_string() }
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
{ "unknown".to_string() }
}
// OS Detection commands
#[tauri::command]
pub fn get_idle_seconds() -> Result<u64, String> {
@@ -3754,3 +3765,137 @@ fn get_default_templates() -> Vec<InvoiceTemplate> {
]
}
#[tauri::command]
pub fn quit_app(app: AppHandle) {
app.exit(0);
}
/// Play a synthesized notification sound on Linux via system audio tools.
/// Generates a WAV in memory and pipes it through paplay/pw-play/aplay.
/// `tones` is a JSON array of {freq, duration_ms, delay_ms, detune} objects.
/// `volume` is 0.0-1.0.
#[cfg(target_os = "linux")]
#[tauri::command]
pub fn play_sound(tones: Vec<SoundTone>, volume: f64) {
std::thread::spawn(move || {
play_sound_inner(&tones, volume);
});
}
#[cfg(not(target_os = "linux"))]
#[tauri::command]
pub fn play_sound(_tones: Vec<SoundTone>, _volume: f64) {
// No-op on non-Linux; frontend uses Web Audio API directly
}
#[derive(Debug, Deserialize)]
pub struct SoundTone {
pub freq: f64,
pub duration_ms: u32,
pub delay_ms: u32,
#[serde(default)]
pub freq_end: Option<f64>,
#[serde(default)]
pub detune: Option<f64>,
}
#[cfg(target_os = "linux")]
fn play_sound_inner(tones: &[SoundTone], volume: f64) {
const SAMPLE_RATE: u32 = 44100;
let vol = volume.clamp(0.0, 1.0) as f32;
// Calculate total duration
let total_ms: u32 = tones.iter().map(|t| t.delay_ms + t.duration_ms).sum();
let total_samples = ((total_ms as f64 / 1000.0) * SAMPLE_RATE as f64) as usize + SAMPLE_RATE as usize / 10; // +100ms padding
let mut samples = vec![0i16; total_samples];
let mut offset_ms: u32 = 0;
for tone in tones {
offset_ms += tone.delay_ms;
let start_sample = ((offset_ms as f64 / 1000.0) * SAMPLE_RATE as f64) as usize;
let num_samples = ((tone.duration_ms as f64 / 1000.0) * SAMPLE_RATE as f64) as usize;
let attack_samples = (0.010 * SAMPLE_RATE as f64) as usize; // 10ms attack
let release_samples = (0.050 * SAMPLE_RATE as f64) as usize; // 50ms release
let freq_start = tone.freq;
let freq_end = tone.freq_end.unwrap_or(tone.freq);
for i in 0..num_samples {
if start_sample + i >= samples.len() {
break;
}
let t = i as f64 / SAMPLE_RATE as f64;
let progress = i as f64 / num_samples as f64;
// Frequency interpolation (for slides)
let freq = freq_start + (freq_end - freq_start) * progress;
// Envelope
let env = if i < attack_samples {
i as f32 / attack_samples as f32
} else if i > num_samples - release_samples {
(num_samples - i) as f32 / release_samples as f32
} else {
1.0
};
let mut sample = (t * freq * 2.0 * std::f64::consts::PI).sin() as f32;
// Optional detuned second oscillator for warmth
if let Some(detune_cents) = tone.detune {
let freq2 = freq * (2.0_f64).powf(detune_cents / 1200.0);
sample += (t * freq2 * 2.0 * std::f64::consts::PI).sin() as f32;
sample *= 0.5; // normalize
}
sample *= env * vol;
let val = (sample * 32000.0) as i16;
samples[start_sample + i] = samples[start_sample + i].saturating_add(val);
}
offset_ms += tone.duration_ms;
}
// Build WAV in memory
let data_size = (samples.len() * 2) as u32;
let file_size = 36 + data_size;
let mut wav = Vec::with_capacity(file_size as usize + 8);
// RIFF header
wav.extend_from_slice(b"RIFF");
wav.extend_from_slice(&file_size.to_le_bytes());
wav.extend_from_slice(b"WAVE");
// fmt chunk
wav.extend_from_slice(b"fmt ");
wav.extend_from_slice(&16u32.to_le_bytes()); // chunk size
wav.extend_from_slice(&1u16.to_le_bytes()); // PCM
wav.extend_from_slice(&1u16.to_le_bytes()); // mono
wav.extend_from_slice(&SAMPLE_RATE.to_le_bytes());
wav.extend_from_slice(&(SAMPLE_RATE * 2).to_le_bytes()); // byte rate
wav.extend_from_slice(&2u16.to_le_bytes()); // block align
wav.extend_from_slice(&16u16.to_le_bytes()); // bits per sample
// data chunk
wav.extend_from_slice(b"data");
wav.extend_from_slice(&data_size.to_le_bytes());
for s in &samples {
wav.extend_from_slice(&s.to_le_bytes());
}
// Try available audio players in order
for cmd in &["paplay", "pw-play", "aplay"] {
if let Ok(mut child) = std::process::Command::new(cmd)
.arg("--")
.arg("/dev/stdin")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
{
if let Some(ref mut stdin) = child.stdin {
use std::io::Write;
let _ = stdin.write_all(&wav);
}
let _ = child.wait();
return;
}
}
}

View File

@@ -5,6 +5,13 @@ use tauri::Manager;
mod database;
mod commands;
#[cfg(target_os = "windows")]
#[path = "os_detection_windows.rs"]
mod os_detection;
#[cfg(target_os = "linux")]
#[path = "os_detection_linux.rs"]
mod os_detection;
pub struct AppState {
@@ -13,16 +20,73 @@ pub struct AppState {
}
fn get_data_dir() -> PathBuf {
let exe_path = std::env::current_exe().unwrap();
let data_dir = exe_path.parent().unwrap().join("data");
// On Linux AppImage: $APPIMAGE points to the .AppImage file itself.
// Store data next to the AppImage so it's fully portable.
let base = if let Ok(appimage_path) = std::env::var("APPIMAGE") {
PathBuf::from(appimage_path)
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| std::env::current_exe().unwrap().parent().unwrap().to_path_buf())
} else {
std::env::current_exe().unwrap().parent().unwrap().to_path_buf()
};
let data_dir = base.join("data");
std::fs::create_dir_all(&data_dir).ok();
data_dir
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
/// On Linux AppImage, install a .desktop file and icon into the user's local
/// XDG directories so GNOME/KDE can show the correct dock icon. Also cleans up
/// stale entries if the AppImage has been moved or deleted.
#[cfg(target_os = "linux")]
fn install_desktop_entry() {
let appimage_path = match std::env::var("APPIMAGE") {
Ok(p) => p,
Err(_) => return, // Not running as AppImage
};
let appdir = std::env::var("APPDIR").unwrap_or_default();
let home = match std::env::var("HOME") {
Ok(h) => h,
Err(_) => return,
};
// Install .desktop file
let apps_dir = format!("{home}/.local/share/applications");
std::fs::create_dir_all(&apps_dir).ok();
let desktop_content = format!(
"[Desktop Entry]\n\
Name=ZeroClock\n\
Comment=Local time tracking with invoicing\n\
Exec={appimage_path}\n\
Icon=zeroclock\n\
Type=Application\n\
Terminal=false\n\
StartupWMClass=zeroclock\n\
Categories=Office;ProjectManagement;\n"
);
std::fs::write(format!("{apps_dir}/zeroclock.desktop"), &desktop_content).ok();
// Install icons from the AppImage's bundled hicolor theme
if !appdir.is_empty() {
for size in &["32x32", "128x128", "256x256@2"] {
let src = format!("{appdir}/usr/share/icons/hicolor/{size}/apps/zeroclock.png");
if std::path::Path::new(&src).exists() {
let dest_dir = format!("{home}/.local/share/icons/hicolor/{size}/apps");
std::fs::create_dir_all(&dest_dir).ok();
std::fs::copy(&src, format!("{dest_dir}/zeroclock.png")).ok();
}
}
}
}
pub fn run() {
env_logger::init();
#[cfg(target_os = "linux")]
install_desktop_entry();
let data_dir = get_data_dir();
let db_path = data_dir.join("timetracker.db");
@@ -154,8 +218,62 @@ pub fn run() {
commands::update_recurring_invoice,
commands::delete_recurring_invoice,
commands::check_recurring_invoices,
commands::quit_app,
commands::get_platform,
commands::play_sound,
])
.setup(|app| {
// On Wayland, `decorations: false` in tauri.conf.json is ignored due to a
// GTK bug. We set an empty CSD titlebar so the compositor doesn't add
// rectangular SSD, then strip GTK's default CSD shadow/border via CSS.
// See: https://github.com/tauri-apps/tauri/issues/6562
#[cfg(target_os = "linux")]
{
use gtk::prelude::{CssProviderExt, GtkWindowExt, WidgetExt};
if let Some(window) = app.get_webview_window("main") {
if let Ok(gtk_window) = window.gtk_window() {
// Strip GTK's CSD shadow and border from the decoration node
let provider = gtk::CssProvider::new();
provider.load_from_data(b"\
decoration { \
box-shadow: none; \
margin: 0; \
border: none; \
} \
").ok();
if let Some(screen) = WidgetExt::screen(&gtk_window) {
gtk::StyleContext::add_provider_for_screen(
&screen,
&provider,
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION + 1,
);
}
// Set an empty zero-height CSD titlebar so the compositor
// doesn't add rectangular server-side decorations.
// Suppress the harmless "called on a realized window" GTK warning
// by briefly redirecting stderr to /dev/null.
let empty = gtk::Box::new(gtk::Orientation::Horizontal, 0);
empty.set_size_request(-1, 0);
empty.set_visible(true);
unsafe {
extern "C" { fn dup(fd: i32) -> i32; fn dup2(fd: i32, fd2: i32) -> i32; fn close(fd: i32) -> i32; }
let saved = dup(2);
if let Ok(devnull) = std::fs::File::open("/dev/null") {
use std::os::unix::io::AsRawFd;
dup2(devnull.as_raw_fd(), 2);
gtk_window.set_titlebar(Some(&empty));
dup2(saved, 2);
} else {
gtk_window.set_titlebar(Some(&empty));
}
if saved >= 0 { close(saved); }
}
}
}
}
#[cfg(desktop)]
{
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};

View File

@@ -3,6 +3,166 @@
windows_subsystem = "windows"
)]
/// Probe EGL: load libEGL, get a display, initialize it, and verify that at
/// least one usable RGBA config exists. Returns true only if the full
/// sequence succeeds - this catches missing drivers, Mesa/NVIDIA mismatches,
/// and broken configs that would give WebKitGTK a black window.
/// Uses dlopen so there's no link-time dependency on EGL.
#[cfg(target_os = "linux")]
fn egl_display_available() -> bool {
extern "C" {
fn dlopen(filename: *const std::ffi::c_char, flags: i32) -> *mut std::ffi::c_void;
fn dlsym(handle: *mut std::ffi::c_void, symbol: *const std::ffi::c_char) -> *mut std::ffi::c_void;
fn dlclose(handle: *mut std::ffi::c_void) -> i32;
}
const RTLD_LAZY: i32 = 1;
macro_rules! sym {
($handle:expr, $name:literal) => {{
let s = dlsym($handle, concat!($name, "\0").as_ptr() as *const _);
if s.is_null() { dlclose($handle); return false; }
s
}};
}
unsafe {
let handle = dlopen(b"libEGL.so.1\0".as_ptr() as *const _, RTLD_LAZY);
if handle.is_null() {
return false;
}
// Resolve the three functions we need
let get_display: extern "C" fn(usize) -> usize =
std::mem::transmute(sym!(handle, "eglGetDisplay"));
let initialize: extern "C" fn(usize, *mut i32, *mut i32) -> u32 =
std::mem::transmute(sym!(handle, "eglInitialize"));
let choose_config: extern "C" fn(usize, *const i32, *mut usize, i32, *mut i32) -> u32 =
std::mem::transmute(sym!(handle, "eglChooseConfig"));
let terminate: extern "C" fn(usize) -> u32 =
std::mem::transmute(sym!(handle, "eglTerminate"));
// Step 1: Get display
let display = get_display(0); // EGL_DEFAULT_DISPLAY
if display == 0 {
dlclose(handle);
return false;
}
// Step 2: Initialize
let mut major: i32 = 0;
let mut minor: i32 = 0;
if initialize(display, &mut major, &mut minor) == 0 {
dlclose(handle);
return false;
}
// Step 3: Check for a usable RGBA config
// EGL_RED_SIZE=0x3024 EGL_GREEN_SIZE=0x3023 EGL_BLUE_SIZE=0x3022
// EGL_ALPHA_SIZE=0x3021 EGL_SURFACE_TYPE=0x3033 EGL_WINDOW_BIT=0x0004
// EGL_RENDERABLE_TYPE=0x3040 EGL_OPENGL_ES2_BIT=0x0004 EGL_NONE=0x3038
let attribs: [i32; 15] = [
0x3024, 8, // RED 8
0x3023, 8, // GREEN 8
0x3022, 8, // BLUE 8
0x3021, 8, // ALPHA 8
0x3033, 0x0004, // SURFACE_TYPE = WINDOW
0x3040, 0x0004, // RENDERABLE_TYPE = ES2
0x3038, // NONE
0, 0, // padding
];
let mut config: usize = 0;
let mut num_configs: i32 = 0;
let ok = choose_config(
display,
attribs.as_ptr(),
&mut config,
1,
&mut num_configs,
);
terminate(display);
dlclose(handle);
ok != 0 && num_configs > 0
}
}
fn main() {
// The AppImage's linuxdeploy-plugin-gtk.sh forces GDK_BACKEND=x11 as a
// safety default, but XWayland on NVIDIA breaks WebKitGTK input events
// (no clicks, no scroll) and client-side decorations.
// Override to native Wayland which works correctly on modern NVIDIA drivers.
// See: https://github.com/tauri-apps/tauri/issues/11790
#[cfg(target_os = "linux")]
{
if std::env::var("WAYLAND_DISPLAY").is_ok() || std::env::var("XDG_SESSION_TYPE").map(|v| v == "wayland").unwrap_or(false) {
std::env::set_var("GDK_BACKEND", "wayland");
}
// When running as an AppImage, prevent GTK from loading the host's
// GIO modules (gvfs, dconf, libproxy). These are compiled against the
// host's newer glib and fail with "undefined symbol" errors when loaded
// into the AppImage's bundled older glib.
// These modules are optional - gvfs (virtual filesystems), dconf
// (desktop settings), libproxy (proxy config) - none are needed here.
if std::env::var("APPIMAGE").is_ok() {
if let Ok(appdir) = std::env::var("APPDIR") {
let gio_path = format!("{}/usr/lib/x86_64-linux-gnu/gio/modules", appdir);
if std::path::Path::new(&gio_path).exists() {
std::env::set_var("GIO_MODULE_DIR", &gio_path);
} else {
// No bundled GIO modules - point to empty dir to skip system ones
std::env::set_var("GIO_MODULE_DIR", &appdir);
}
}
}
// Disable the DMA-BUF renderer in AppImage builds.
// WebKitGTK 2.42+ defaults to DMA-BUF for framebuffers, but inside an
// AppImage the bundled Mesa often can't construct them against the host's
// GPU drivers - producing a completely black window with zero errors.
// This falls back to shared-memory buffers; GPU compositing still works,
// visual/performance difference is negligible for a desktop app.
// Override: WEBKIT_DISABLE_DMABUF_RENDERER=0
// See: https://github.com/tauri-apps/tauri/issues/13183
if std::env::var("APPIMAGE").is_ok()
&& std::env::var("WEBKIT_DISABLE_DMABUF_RENDERER").is_err()
{
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
}
// Auto-detect EGL failures for AppImage builds.
// If EGL can't even initialize or find a usable config, also disable
// GPU compositing entirely (more aggressive than the DMABUF fix above).
// Override: WEBKIT_DISABLE_COMPOSITING_MODE=0
if std::env::var("APPIMAGE").is_ok()
&& std::env::var("WEBKIT_DISABLE_COMPOSITING_MODE").is_err()
&& !egl_display_available()
{
std::env::set_var("WEBKIT_DISABLE_COMPOSITING_MODE", "1");
}
// Ensure GStreamer can find system plugins for WebKitGTK Web Audio.
// The AppImage doesn't bundle GStreamer (bundleMediaFramework: false),
// so point it at the host's plugin directories.
if std::env::var("GST_PLUGIN_SYSTEM_PATH").is_err()
&& std::env::var("GST_PLUGIN_SYSTEM_PATH_1_0").is_err()
{
let paths = [
"/usr/lib/x86_64-linux-gnu/gstreamer-1.0",
"/usr/lib64/gstreamer-1.0",
"/usr/lib/gstreamer-1.0",
];
let existing: Vec<&str> = paths
.iter()
.filter(|p| std::path::Path::new(p).exists())
.copied()
.collect();
if !existing.is_empty() {
std::env::set_var("GST_PLUGIN_SYSTEM_PATH_1_0", existing.join(":"));
}
}
}
zeroclock_lib::run();
}

View File

@@ -0,0 +1,537 @@
use serde::Serialize;
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::Path;
use std::process::Command;
#[derive(Debug, Serialize, Clone)]
pub struct WindowInfo {
pub exe_name: String,
pub exe_path: String,
pub title: String,
pub display_name: String,
pub icon: Option<String>,
}
/// Get system idle time in seconds via D-Bus.
///
/// Tries (in order):
/// 1. org.gnome.Mutter.IdleMonitor.GetIdletime (returns milliseconds)
/// 2. org.freedesktop.ScreenSaver.GetSessionIdleTime (returns seconds)
/// 3. Falls back to 0
pub fn get_system_idle_seconds() -> u64 {
// Try GNOME Mutter IdleMonitor (returns milliseconds)
if let Some(ms) = gdbus_call_u64(
"org.gnome.Mutter.IdleMonitor",
"/org/gnome/Mutter/IdleMonitor/Core",
"org.gnome.Mutter.IdleMonitor",
"GetIdletime",
) {
return ms / 1000;
}
// Try freedesktop ScreenSaver (returns seconds)
if let Some(secs) = gdbus_call_u64(
"org.freedesktop.ScreenSaver",
"/org/freedesktop/ScreenSaver",
"org.freedesktop.ScreenSaver",
"GetSessionIdleTime",
) {
return secs;
}
0
}
/// Call a D-Bus method that returns a single uint64/uint32 value via gdbus.
/// Parses the `(uint64 12345,)` or `(uint32 12345,)` output format.
fn gdbus_call_u64(dest: &str, object: &str, interface: &str, method: &str) -> Option<u64> {
let output = Command::new("gdbus")
.args([
"call",
"--session",
"--dest",
dest,
"--object-path",
object,
"--method",
&format!("{}.{}", interface, method),
])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
// Format: "(uint64 12345,)" or "(uint32 12345,)"
// Extract the number after "uint64 " or "uint32 "
let s = stdout.trim();
for prefix in &["uint64 ", "uint32 "] {
if let Some(pos) = s.find(prefix) {
let after = &s[pos + prefix.len()..];
let num_str: String = after.chars().take_while(|c| c.is_ascii_digit()).collect();
return num_str.parse().ok();
}
}
None
}
/// Get the current user's UID from /proc/self/status.
fn get_current_uid() -> u32 {
if let Ok(status) = fs::read_to_string("/proc/self/status") {
for line in status.lines() {
if let Some(rest) = line.strip_prefix("Uid:") {
if let Some(uid_str) = rest.split_whitespace().next() {
if let Ok(uid) = uid_str.parse::<u32>() {
return uid;
}
}
}
}
}
u32::MAX // Unlikely fallback - won't match any process
}
/// Enumerate running processes from /proc.
///
/// Reads /proc/[pid]/exe, /proc/[pid]/comm, and /proc/[pid]/status to build
/// a list of user-space processes. Filters out kernel threads, zombies, and
/// common system daemons. Deduplicates by exe path.
pub fn enumerate_running_processes() -> Vec<WindowInfo> {
let my_uid = get_current_uid();
let mut seen_paths = HashSet::new();
let mut results = Vec::new();
let Ok(entries) = fs::read_dir("/proc") else {
return results;
};
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
// Only numeric directories (PIDs)
if !name_str.chars().all(|c| c.is_ascii_digit()) {
continue;
}
let pid_path = entry.path();
// Check process belongs to current user
if let Ok(status) = fs::read_to_string(pid_path.join("status")) {
let mut is_our_uid = false;
let mut ppid: u32 = 1;
for line in status.lines() {
if let Some(rest) = line.strip_prefix("Uid:") {
if let Some(uid_str) = rest.split_whitespace().next() {
if let Ok(uid) = uid_str.parse::<u32>() {
is_our_uid = uid == my_uid;
}
}
}
if let Some(rest) = line.strip_prefix("PPid:") {
if let Ok(p) = rest.trim().parse::<u32>() {
ppid = p;
}
}
}
if !is_our_uid {
continue;
}
// Skip kernel threads (ppid 0 or 2)
if ppid == 0 || ppid == 2 {
continue;
}
} else {
continue;
}
// Skip zombies (empty cmdline)
if let Ok(cmdline) = fs::read(pid_path.join("cmdline")) {
if cmdline.is_empty() {
continue;
}
}
// Get exe path via symlink
let exe_path = match fs::read_link(pid_path.join("exe")) {
Ok(p) => {
let ps = p.to_string_lossy().to_string();
// Skip deleted executables
if ps.contains(" (deleted)") {
continue;
}
ps
}
Err(_) => continue,
};
// Deduplicate by exe path
if !seen_paths.insert(exe_path.clone()) {
continue;
}
// Get comm (short process name)
let comm = fs::read_to_string(pid_path.join("comm"))
.unwrap_or_default()
.trim()
.to_string();
// Skip common system daemons / background services
if is_system_daemon(&comm, &exe_path) {
continue;
}
let exe_name = std::path::Path::new(&exe_path)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| comm.clone());
let display_name = prettify_name(&exe_name);
results.push(WindowInfo {
exe_name,
exe_path,
title: String::new(),
display_name,
icon: None,
});
}
// Resolve icons from .desktop files
let icon_map = build_desktop_icon_map();
for info in &mut results {
if let Some(icon_name) = icon_map.get(&info.exe_name) {
if let Some(data_url) = resolve_icon_to_data_url(icon_name) {
info.icon = Some(data_url);
}
}
}
results.sort_by(|a, b| a.display_name.to_lowercase().cmp(&b.display_name.to_lowercase()));
results
}
/// On Linux we cannot detect window visibility (Wayland security model).
/// Return running processes filtered to likely-GUI apps as a best-effort
/// approximation - the timer will pause only when the tracked process exits.
pub fn enumerate_visible_windows() -> Vec<WindowInfo> {
enumerate_running_processes()
.into_iter()
.filter(|w| is_likely_gui_app(&w.exe_path, &w.exe_name))
.collect()
}
/// Heuristic: returns true for processes that are likely background daemons.
fn is_system_daemon(comm: &str, exe_path: &str) -> bool {
const DAEMON_NAMES: &[&str] = &[
"systemd",
"dbus-daemon",
"dbus-broker",
"pipewire",
"pipewire-pulse",
"wireplumber",
"pulseaudio",
"xdg-desktop-portal",
"xdg-document-portal",
"xdg-permission-store",
"gvfsd",
"gvfs-udisks2-volume-monitor",
"at-spi-bus-launcher",
"at-spi2-registryd",
"gnome-keyring-daemon",
"ssh-agent",
"gpg-agent",
"polkitd",
"gsd-",
"evolution-data-server",
"evolution-calendar-factory",
"evolution-addressbook-factory",
"tracker-miner-fs",
"tracker-extract",
"ibus-daemon",
"ibus-x11",
"ibus-portal",
"ibus-extension-gtk3",
"fcitx5",
"goa-daemon",
"goa-identity-service",
"xdg-desktop-portal-gnome",
"xdg-desktop-portal-gtk",
"xdg-desktop-portal-kde",
"xdg-desktop-portal-wlr",
];
// Exact match
if DAEMON_NAMES.contains(&comm) {
return true;
}
// Prefix match (e.g. gsd-*)
for prefix in &["gsd-", "gvfs", "xdg-desktop-portal"] {
if comm.starts_with(prefix) {
return true;
}
}
// System paths
if exe_path.starts_with("/usr/libexec/")
|| exe_path.starts_with("/usr/lib/systemd/")
|| exe_path.starts_with("/usr/lib/polkit")
{
return true;
}
false
}
/// Heuristic: returns true for processes that are likely GUI applications.
fn is_likely_gui_app(exe_path: &str, exe_name: &str) -> bool {
// Common GUI app locations
let gui_paths = ["/usr/bin/", "/usr/local/bin/", "/opt/", "/snap/", "/flatpak/"];
let in_gui_path = gui_paths.iter().any(|p| exe_path.starts_with(p))
|| exe_path.contains("/AppRun")
|| exe_path.contains(".AppImage");
// Home directory apps (Electron, AppImage, etc.)
let in_home = exe_path.contains("/.local/") || exe_path.contains("/home/");
if !in_gui_path && !in_home {
return false;
}
// Exclude known CLI-only tools
const CLI_TOOLS: &[&str] = &[
"bash", "sh", "zsh", "fish", "dash", "csh", "tcsh",
"cat", "ls", "grep", "find", "sed", "awk", "sort", "cut",
"curl", "wget", "ssh", "scp", "rsync",
"git", "make", "cmake", "cargo", "npm", "node", "python", "python3",
"ruby", "perl", "java", "javac",
"top", "htop", "btop", "tmux", "screen",
"sudo", "su", "pkexec",
"journalctl", "systemctl",
];
if CLI_TOOLS.contains(&exe_name) {
return false;
}
true
}
/// Build a map from exe_name → icon_name by scanning .desktop files.
fn build_desktop_icon_map() -> HashMap<String, String> {
let mut map = HashMap::new();
let dirs = [
"/usr/share/applications",
"/usr/local/share/applications",
"/var/lib/flatpak/exports/share/applications",
"/var/lib/snapd/desktop/applications",
];
// Also check ~/.local/share/applications
let home_apps = std::env::var("HOME")
.map(|h| format!("{h}/.local/share/applications"))
.unwrap_or_default();
for dir in dirs.iter().chain(std::iter::once(&home_apps.as_str())) {
if dir.is_empty() {
continue;
}
let Ok(entries) = fs::read_dir(dir) else { continue };
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("desktop") {
continue;
}
if let Ok(content) = fs::read_to_string(&path) {
parse_desktop_entry(&content, &mut map);
}
}
}
map
}
/// Parse a .desktop file and extract exe_name → icon_name mappings.
fn parse_desktop_entry(content: &str, map: &mut HashMap<String, String>) {
let mut icon = None;
let mut exec = None;
let mut in_desktop_entry = false;
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with('[') {
in_desktop_entry = trimmed == "[Desktop Entry]";
continue;
}
if !in_desktop_entry {
continue;
}
if let Some(val) = trimmed.strip_prefix("Icon=") {
icon = Some(val.trim().to_string());
} else if let Some(val) = trimmed.strip_prefix("Exec=") {
// Extract the binary name from the Exec line
// Exec can be: /usr/bin/foo, env VAR=val foo, flatpak run ..., etc.
let val = val.trim();
// Strip env prefix
let cmd_part = if val.starts_with("env ") {
// Skip env KEY=VAL pairs
val.split_whitespace()
.skip(1)
.find(|s| !s.contains('='))
.unwrap_or("")
} else {
val.split_whitespace().next().unwrap_or("")
};
// Get just the filename
if let Some(name) = Path::new(cmd_part).file_name() {
exec = Some(name.to_string_lossy().to_string());
}
}
}
if let (Some(exe_name), Some(icon_name)) = (exec, icon) {
if !exe_name.is_empty() && !icon_name.is_empty() {
map.entry(exe_name).or_insert(icon_name);
}
}
}
/// Resolve an icon name to a base64 data URL.
/// Handles both absolute paths and theme icon names.
fn resolve_icon_to_data_url(icon_name: &str) -> Option<String> {
// If it's an absolute path, read directly
if icon_name.starts_with('/') {
return read_icon_file(Path::new(icon_name));
}
// Search hicolor theme at standard sizes (prefer smaller for 20x20 display)
let sizes = ["32x32", "48x48", "24x24", "64x64", "scalable", "128x128", "256x256"];
let theme_dirs = [
"/usr/share/icons/hicolor",
"/usr/share/pixmaps",
];
// Also check user theme
let home_icons = std::env::var("HOME")
.map(|h| format!("{h}/.local/share/icons/hicolor"))
.unwrap_or_default();
// Try hicolor theme with size variants
for base in theme_dirs.iter().map(|s| s.to_string()).chain(
if home_icons.is_empty() { None } else { Some(home_icons.clone()) }
) {
if base.ends_with("pixmaps") {
// /usr/share/pixmaps has flat layout
for ext in &["png", "svg", "xpm"] {
let path = format!("{base}/{icon_name}.{ext}");
if let Some(url) = read_icon_file(Path::new(&path)) {
return Some(url);
}
}
} else {
for size in &sizes {
for ext in &["png", "svg"] {
let path = format!("{base}/{size}/apps/{icon_name}.{ext}");
if let Some(url) = read_icon_file(Path::new(&path)) {
return Some(url);
}
}
}
}
}
None
}
/// Read an icon file and return it as a base64 data URL.
/// Supports PNG and SVG.
fn read_icon_file(path: &Path) -> Option<String> {
if !path.exists() {
return None;
}
let ext = path.extension()?.to_str()?;
let data = fs::read(path).ok()?;
if data.is_empty() {
return None;
}
let mime = match ext {
"png" => "image/png",
"svg" => "image/svg+xml",
"xpm" => return None, // XPM isn't useful as a data URL
_ => return None,
};
let mut b64 = String::new();
base64_encode(&data, &mut b64);
Some(format!("data:{mime};base64,{b64}"))
}
/// Base64 encode bytes into a string (no padding variants, standard alphabet).
fn base64_encode(input: &[u8], output: &mut String) {
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut i = 0;
let len = input.len();
output.reserve((len + 2) / 3 * 4);
while i + 2 < len {
let n = (input[i] as u32) << 16 | (input[i + 1] as u32) << 8 | input[i + 2] as u32;
output.push(ALPHABET[((n >> 18) & 0x3F) as usize] as char);
output.push(ALPHABET[((n >> 12) & 0x3F) as usize] as char);
output.push(ALPHABET[((n >> 6) & 0x3F) as usize] as char);
output.push(ALPHABET[(n & 0x3F) as usize] as char);
i += 3;
}
match len - i {
1 => {
let n = (input[i] as u32) << 16;
output.push(ALPHABET[((n >> 18) & 0x3F) as usize] as char);
output.push(ALPHABET[((n >> 12) & 0x3F) as usize] as char);
output.push('=');
output.push('=');
}
2 => {
let n = (input[i] as u32) << 16 | (input[i + 1] as u32) << 8;
output.push(ALPHABET[((n >> 18) & 0x3F) as usize] as char);
output.push(ALPHABET[((n >> 12) & 0x3F) as usize] as char);
output.push(ALPHABET[((n >> 6) & 0x3F) as usize] as char);
output.push('=');
}
_ => {}
}
}
/// Convert an exe name like "gnome-terminal-server" into "Gnome Terminal Server".
fn prettify_name(name: &str) -> String {
// Strip common suffixes
let stripped = name
.strip_suffix("-bin")
.or_else(|| name.strip_suffix(".bin"))
.unwrap_or(name);
stripped
.split(|c: char| c == '-' || c == '_')
.filter(|s| !s.is_empty())
.map(|word| {
let mut chars = word.chars();
match chars.next() {
Some(first) => {
let mut s = first.to_uppercase().to_string();
s.extend(chars);
s
}
None => String::new(),
}
})
.collect::<Vec<_>>()
.join(" ")
}

View File

@@ -53,6 +53,11 @@
"webviewInstallMode": {
"type": "embedBootstrapper"
}
},
"linux": {
"appimage": {
"bundleMediaFramework": false
}
}
},
"plugins": {