Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
987 changes: 620 additions & 367 deletions Cargo.lock

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ license = "MIT"
arboard = {version = "3.6.1", features = ["wl-clipboard-rs", "wayland-data-control"]}
ctrlc = "3.5.1"
env_logger = "0.11.8"
ksni = {version = "0.3.2", features = ["blocking"]}
log = "0.4.29"
muda = "0.17.1"
notify-rust = {version = "4.11.7", default-features = false, features = ["d"]}
open = "5.3.3"
resvg = "0.45.1"
Expand All @@ -19,6 +19,10 @@ serde_json = "1.0.145"
shlex = "1.3.0"
tempfile = "3.24.0"
thiserror = "2.0.17"
tray-icon = "0.21.3"
which = "8.0.0"
whoami = "2.0.1"
wl-clipboard-rs = "0.9.2"

[target.'cfg(target_os = "linux")'.dependencies]
gtk = "0.18.2"
24 changes: 21 additions & 3 deletions nix/package.nix
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
{
lib,
dbus,
pkg-config,
rustPlatform,
xorg,
gtk3,
libayatana-appindicator,
libappindicator-gtk3,
makeWrapper,
rev ? "dirty",
}: let
cargoToml = lib.importTOML ../Cargo.toml;
Expand All @@ -24,8 +27,23 @@ in
cargoLock.lockFile = ../Cargo.lock;

strictDeps = true;
nativeBuildInputs = [pkg-config];
buildInputs = [dbus xorg.libxcb];
nativeBuildInputs = [pkg-config makeWrapper];
buildInputs = [
xorg.libxcb
gtk3
libayatana-appindicator
libappindicator-gtk3
];

# Wrap the binary to set LD_LIBRARY_PATH for runtime library loading
postFixup = ''
wrapProgram $out/bin/tailray \
--prefix LD_LIBRARY_PATH : ${lib.makeLibraryPath [
libayatana-appindicator
libappindicator-gtk3
gtk3
]}
'';

meta = {
description = "Rust implementation of tailscale-systray";
Expand Down
25 changes: 21 additions & 4 deletions nix/shell.nix
Original file line number Diff line number Diff line change
@@ -1,21 +1,32 @@
{
lib,
mkShell,
dbus,
python3,
pkg-config,
rust-analyzer-unwrapped,
rustfmt,
clippy,
cargo,
rustc,
lld,
rustPlatform,
# Build libs
xorg,
gtk3,
libayatana-appindicator,
libappindicator-gtk3,
xdotool,
}:
mkShell {
env."RUST_SRC_PATH" = "${rustPlatform.rustLibSrc}";

strictDeps = true;
buildInputs = [dbus xorg.libxcb];
buildInputs = [
xorg.libxcb
xdotool
gtk3
libayatana-appindicator
libappindicator-gtk3
];

nativeBuildInputs = [
pkg-config
python3
Expand All @@ -25,5 +36,11 @@ mkShell {
rust-analyzer-unwrapped
(rustfmt.override {asNightly = true;})
clippy
lld
];

env = {
RUST_SRC_PATH = "${rustPlatform.rustLibSrc}";
LD_LIBRARY_PATH = "${lib.makeLibraryPath [libayatana-appindicator libappindicator-gtk3 gtk3]}";
};
}
10 changes: 0 additions & 10 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ pub enum AppError {
Io(std::io::Error),
SerdeJson(serde_json::Error),
Notify(notify_rust::error::Error),
Ksni(ksni::Error),

// Generic string error
Message(String),
Expand All @@ -36,7 +35,6 @@ impl fmt::Display for AppError {
AppError::Io(e) => write!(f, "IO error: {e}"),
AppError::SerdeJson(e) => write!(f, "JSON error: {e}"),
AppError::Notify(e) => write!(f, "Notification error: {e}"),
AppError::Ksni(e) => write!(f, "Ksni error: {e}"),
AppError::Message(msg) => write!(f, "{msg}"),
}
}
Expand All @@ -53,7 +51,6 @@ impl Error for AppError {
AppError::Io(e) => Some(e),
AppError::SerdeJson(e) => Some(e),
AppError::Notify(e) => Some(e),
AppError::Ksni(e) => Some(e),
AppError::Message(_) => None,
}
}
Expand Down Expand Up @@ -121,10 +118,3 @@ impl From<Box<dyn std::error::Error>> for AppError {
AppError::Message(e.to_string())
}
}

// From conversion for ksni::Error
impl From<ksni::Error> for AppError {
fn from(e: ksni::Error) -> Self {
AppError::Ksni(e)
}
}
108 changes: 61 additions & 47 deletions src/svg/renderer.rs
Original file line number Diff line number Diff line change
@@ -1,19 +1,60 @@
use std::{error::Error, fmt};
use std::{error::Error, fmt, sync::OnceLock};

use ksni::Icon;
use log::{debug, error};
use resvg::{
self,
tiny_skia::{Pixmap, Transform},
usvg::{Options, Tree},
};
use tray_icon::Icon;

const SVG_DATA_LIGHT: &str = include_str!("assets/tailscale-light.svg");
const SVG_DATA_DARK: &str = include_str!("assets/tailscale-dark.svg");

const DISABLED_OPACITY: &str = "0.4";
const ENABLED_OPACITY: &str = "1.0";

// Icon cache to avoid repeated SVG parsing
static ICON_CACHE: OnceLock<IconCache> = OnceLock::new();

struct IconCache {
light_enabled: Option<Icon>,
light_disabled: Option<Icon>,
dark_enabled: Option<Icon>,
dark_disabled: Option<Icon>,
}

impl IconCache {
fn new() -> Self {
let renderer = Resvg::default();

let light_enabled = renderer.to_icon(SVG_DATA_LIGHT).ok();
let light_disabled = renderer
.to_icon(&SVG_DATA_LIGHT.replace(ENABLED_OPACITY, DISABLED_OPACITY))
.ok();
let dark_enabled = renderer.to_icon(SVG_DATA_DARK).ok();
let dark_disabled = renderer
.to_icon(&SVG_DATA_DARK.replace(ENABLED_OPACITY, DISABLED_OPACITY))
.ok();

Self {
light_enabled,
light_disabled,
dark_enabled,
dark_disabled,
}
}

fn get(&self, theme: Theme, enabled: bool) -> Option<&Icon> {
match (theme, enabled) {
(Theme::Light, true) => self.light_enabled.as_ref(),
(Theme::Light, false) => self.light_disabled.as_ref(),
(Theme::Dark, true) => self.dark_enabled.as_ref(),
(Theme::Dark, false) => self.dark_disabled.as_ref(),
}
}
}

/// Icon theme variant
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Theme {
Expand All @@ -39,14 +80,6 @@ impl Theme {
})
.unwrap_or_default()
}

/// Get the SVG data for this theme
const fn svg_data(&self) -> &'static str {
match self {
Self::Light => SVG_DATA_LIGHT,
Self::Dark => SVG_DATA_DARK,
}
}
}

#[derive(Debug)]
Expand Down Expand Up @@ -74,7 +107,7 @@ pub struct Resvg<'a> {
}

impl Resvg<'_> {
/// Convert an SVG string to a KDE Systray Icon
/// Convert an SVG string to a tray icon
#[allow(clippy::cast_sign_loss)]
#[allow(clippy::cast_possible_truncation)]
pub fn to_icon(&self, svg_str: &str) -> Result<Icon, RenderError> {
Expand All @@ -95,46 +128,27 @@ impl Resvg<'_> {
// Render the SVG to the pixmap
resvg::render(&tree, self.transform, &mut pixmap.as_mut());

// Convert from RGBA to ARGB format for KDE system tray
let argb_data: Vec<u8> = pixmap
.take()
.chunks_exact(4)
.flat_map(|rgba| [rgba[3], rgba[0], rgba[1], rgba[2]])
.collect();

// Create the Icon
Ok(Icon {
width: size.width() as i32,
height: size.height() as i32,
data: argb_data,
})
// Get RGBA data
let rgba_data = pixmap.take();

// Create the Icon using from_rgba
Icon::from_rgba(rgba_data, width, height)
.map_err(|e| RenderError::PixmapCreation(e.to_string()))
}

/// Load appropriate icon based on connection state and theme
pub fn load_icon(theme: Theme, enabled: bool) -> Vec<Icon> {
let renderer = Self::default();
let svg_data = theme.svg_data();

if enabled {
debug!("Loading enabled Tailscale icon (theme: {theme:?})");
match renderer.to_icon(svg_data) {
Ok(icon) => vec![icon],
Err(e) => {
error!("Failed to load enabled icon: {e}");
Vec::new()
},
}
pub fn load_icon(theme: Theme, enabled: bool) -> Option<Icon> {
let cache = ICON_CACHE.get_or_init(IconCache::new);

if let Some(icon) = cache.get(theme, enabled) {
debug!(
"Loading {} Tailscale icon (theme: {theme:?})",
if enabled { "enabled" } else { "disabled" }
);
Some(icon.clone())
} else {
debug!("Loading disabled Tailscale icon (theme: {theme:?})");
// Replace opacity in SVG
let disabled_svg = svg_data.replace(ENABLED_OPACITY, DISABLED_OPACITY);
match renderer.to_icon(&disabled_svg) {
Ok(icon) => vec![icon],
Err(e) => {
error!("Failed to load disabled icon: {e}");
Vec::new()
},
}
error!("Failed to load icon from cache");
None
}
}
}
Loading