use objc2::rc::Retained;
use objc2::runtime::NSObject;
use objc2::{define_class, msg_send, sel, DefinedClass, MainThreadOnly};
use objc2_app_kit::{
NSBezierPath, NSButton, NSColor, NSView, NSViewFrameDidChangeNotification, NSWindow,
NSWindowButton, NSWindowDidBecomeKeyNotification, NSWindowDidResignKeyNotification,
NSWindowDidUpdateNotification, NSWindowOrderingMode,
};
use objc2_foundation::{
MainThreadMarker, NSNotification, NSNotificationCenter, NSObjectProtocol, NSPoint, NSRect, NSSize,
};
use std::cell::{Cell, RefCell};
use std::collections::{HashMap, HashSet};
use std::sync::{Mutex, OnceLock};
type Rgba = (f64, f64, f64, f64);
#[derive(Clone, Default)]
struct TintConfig {
close: Option<Rgba>,
minimize: Option<Rgba>,
zoom: Option<Rgba>,
diameter: f64,
}
static CONFIG: Mutex<Option<TintConfig>> = Mutex::new(None);
static OBSERVER_APP: OnceLock<tauri::AppHandle> = OnceLock::new();
#[derive(Default)]
struct TintIvars {
color: RefCell<Option<Retained<NSColor>>>,
diameter: Cell<f64>,
}
define_class!(
#[unsafe(super(NSView))]
#[name = "TishTrafficTintView"]
#[thread_kind = MainThreadOnly]
#[ivars = TintIvars]
struct TintView;
impl TintView {
#[unsafe(method(drawRect:))]
fn draw_rect(&self, _dirty_rect: NSRect) {
let is_key = self.window().map(|w| w.isKeyWindow()).unwrap_or(false);
if !is_key {
return;
}
let ivars = self.ivars();
let borrowed = ivars.color.borrow();
let Some(color) = borrowed.as_ref() else {
return;
};
let bounds = self.bounds();
let raw = ivars.diameter.get();
let d = if raw > 0.0 {
raw
} else {
bounds.size.width.min(bounds.size.height)
};
let rect = NSRect::new(
NSPoint::new(
bounds.origin.x + (bounds.size.width - d) / 2.0,
bounds.origin.y + (bounds.size.height - d) / 2.0,
),
NSSize::new(d, d),
);
color.setFill();
NSBezierPath::bezierPathWithOvalInRect(rect).fill();
}
#[unsafe(method(hitTest:))]
fn hit_test(&self, _point: NSPoint) -> *mut NSView {
std::ptr::null_mut()
}
}
);
impl TintView {
fn new(mtm: MainThreadMarker, frame: NSRect) -> Retained<Self> {
let this = mtm.alloc().set_ivars(TintIvars::default());
unsafe { msg_send![super(this), initWithFrame: frame] }
}
}
thread_local! {
static OVERLAYS: RefCell<HashMap<usize, Retained<TintView>>> = RefCell::new(HashMap::new());
static APPLYING: Cell<bool> = const { Cell::new(false) };
}
fn standard_buttons(window: &NSWindow) -> Option<[Retained<NSButton>; 3]> {
Some([
window.standardWindowButton(NSWindowButton::CloseButton)?,
window.standardWindowButton(NSWindowButton::MiniaturizeButton)?,
window.standardWindowButton(NSWindowButton::ZoomButton)?,
])
}
fn apply_button(
mtm: MainThreadMarker,
button: &NSButton,
color: Option<Rgba>,
diameter: f64,
is_key: bool,
) {
let key = button as *const NSButton as usize;
match color {
Some((r, g, b, a)) => {
let frame = button.frame();
if frame.size.width < 1.0 || frame.size.height < 1.0 {
return; }
let Some(superview) = (unsafe { button.superview() }) else {
return;
};
let overlay = OVERLAYS.with(|m| m.borrow().get(&key).cloned());
let overlay = overlay.unwrap_or_else(|| {
let v = TintView::new(mtm, frame);
OVERLAYS.with(|m| m.borrow_mut().insert(key, v.clone()));
v
});
overlay.setFrame(frame);
superview.addSubview_positioned_relativeTo(&overlay, NSWindowOrderingMode::Above, None);
let nscolor = NSColor::colorWithSRGBRed_green_blue_alpha(r, g, b, a);
*overlay.ivars().color.borrow_mut() = Some(nscolor);
overlay.ivars().diameter.set(diameter);
overlay.setHidden(!is_key);
overlay.setNeedsDisplay(true);
}
None => {
if let Some(overlay) = OVERLAYS.with(|m| m.borrow_mut().remove(&key)) {
overlay.removeFromSuperview();
}
}
}
}
fn with_ns_window<F: FnOnce(&NSWindow)>(window: &tauri::WebviewWindow, f: F) {
let Ok(ns_window_ptr) = window.ns_window() else {
return;
};
if ns_window_ptr.is_null() {
return;
}
let ns_window: &NSWindow = unsafe { &*(ns_window_ptr as *const NSWindow) };
f(ns_window);
}
pub fn apply(window: &tauri::WebviewWindow) {
if APPLYING.with(|f| f.get()) {
return;
}
APPLYING.with(|f| f.set(true));
if let Some(mtm) = MainThreadMarker::new() {
let config = CONFIG.lock().unwrap().clone();
with_ns_window(window, |ns_window| {
install_observers(ns_window);
let is_key = ns_window.isKeyWindow();
if let Some(buttons) = standard_buttons(ns_window) {
match &config {
Some(cfg) => {
apply_button(mtm, &buttons[0], cfg.close, cfg.diameter, is_key);
apply_button(mtm, &buttons[1], cfg.minimize, cfg.diameter, is_key);
apply_button(mtm, &buttons[2], cfg.zoom, cfg.diameter, is_key);
}
None => {
for button in &buttons {
apply_button(mtm, button, None, 0.0, is_key);
}
}
}
}
});
}
APPLYING.with(|f| f.set(false));
}
thread_local! {
static RELAYOUT_OBSERVER: RefCell<Option<Retained<TintObserver>>> = const { RefCell::new(None) };
static OBSERVED_BUTTONS: RefCell<HashSet<usize>> = RefCell::new(HashSet::new());
}
define_class!(
#[unsafe(super(NSObject))]
#[name = "TishTrafficTintObserver"]
#[thread_kind = MainThreadOnly]
#[ivars = ()]
struct TintObserver;
unsafe impl NSObjectProtocol for TintObserver {}
impl TintObserver {
#[unsafe(method(onRelayout:))]
fn on_relayout(&self, _note: Option<&NSNotification>) {
reapply_all();
}
}
);
impl TintObserver {
fn new(mtm: MainThreadMarker) -> Retained<Self> {
let this = mtm.alloc().set_ivars(());
unsafe { msg_send![super(this), init] }
}
}
fn reapply_all() {
let Some(app) = OBSERVER_APP.get() else {
return;
};
use tauri::Manager;
for (_, window) in app.webview_windows() {
apply(&window);
}
}
fn observer(mtm: MainThreadMarker) -> Retained<TintObserver> {
RELAYOUT_OBSERVER.with(|cell| {
cell.borrow_mut()
.get_or_insert_with(|| TintObserver::new(mtm))
.clone()
})
}
fn install_observers(ns_window: &NSWindow) {
let Some(mtm) = MainThreadMarker::new() else {
return;
};
if OBSERVER_APP.get().is_none() {
return;
}
let center = NSNotificationCenter::defaultCenter();
let observer = observer(mtm);
static WINDOW_OBSERVER_INSTALLED: OnceLock<()> = OnceLock::new();
if WINDOW_OBSERVER_INSTALLED.set(()).is_ok() {
unsafe {
center.addObserver_selector_name_object(
&observer,
sel!(onRelayout:),
Some(NSWindowDidUpdateNotification),
None,
);
center.addObserver_selector_name_object(
&observer,
sel!(onRelayout:),
Some(NSWindowDidBecomeKeyNotification),
None,
);
center.addObserver_selector_name_object(
&observer,
sel!(onRelayout:),
Some(NSWindowDidResignKeyNotification),
None,
);
}
}
let Some(buttons) = standard_buttons(ns_window) else {
return;
};
for button in &buttons {
let key = Retained::as_ptr(button) as usize;
if OBSERVED_BUTTONS.with(|set| set.borrow().contains(&key)) {
continue;
}
button.setPostsFrameChangedNotifications(true);
unsafe {
center.addObserver_selector_name_object(
&observer,
sel!(onRelayout:),
Some(NSViewFrameDidChangeNotification),
Some(button),
);
}
OBSERVED_BUTTONS.with(|set| set.borrow_mut().insert(key));
}
}
fn apply_all_scheduled(app: &tauri::AppHandle) {
for delay_ms in [0u64, 120, 300, 700, 1400] {
let handle = app.clone();
std::thread::spawn(move || {
if delay_ms > 0 {
std::thread::sleep(std::time::Duration::from_millis(delay_ms));
}
let inner = handle.clone();
let _ = handle.run_on_main_thread(move || {
use tauri::Manager;
for (_, window) in inner.webview_windows() {
apply(&window);
}
});
});
}
}
pub fn init(app: &tauri::AppHandle) {
let _ = OBSERVER_APP.set(app.clone());
}
fn parse_hex(s: &str) -> Option<Rgba> {
let h = s.trim().trim_start_matches('#');
let hx = |slice: &str| u8::from_str_radix(slice, 16).ok();
let dup = |slice: &str| u8::from_str_radix(slice, 16).ok().map(|v| v * 16 + v);
let (r, g, b, a) = match h.len() {
3 => (dup(&h[0..1])?, dup(&h[1..2])?, dup(&h[2..3])?, 255u8),
6 => (hx(&h[0..2])?, hx(&h[2..4])?, hx(&h[4..6])?, 255u8),
8 => (hx(&h[0..2])?, hx(&h[2..4])?, hx(&h[4..6])?, hx(&h[6..8])?),
_ => return None,
};
Some((
r as f64 / 255.0,
g as f64 / 255.0,
b as f64 / 255.0,
a as f64 / 255.0,
))
}
pub fn set_tint(
app: &tauri::AppHandle,
close: Option<String>,
minimize: Option<String>,
zoom: Option<String>,
diameter: Option<f64>,
opacity: Option<f64>,
) {
let op = opacity.unwrap_or(1.0).clamp(0.0, 1.0);
let parse = |s: Option<String>| {
s.as_deref()
.and_then(parse_hex)
.map(|(r, g, b, a)| (r, g, b, a * op))
};
let cfg = TintConfig {
close: parse(close),
minimize: parse(minimize),
zoom: parse(zoom),
diameter: diameter.unwrap_or(0.0).max(0.0),
};
let any = cfg.close.is_some() || cfg.minimize.is_some() || cfg.zoom.is_some();
*CONFIG.lock().unwrap() = if any { Some(cfg) } else { None };
apply_all_scheduled(app);
}
pub fn reapply(app: &tauri::AppHandle) {
apply_all_scheduled(app);
}