tishlang_desktop 1.4.0

cargo:tishlang_desktop / cargo:tishlang_app — cross-device Tish app runtime (Tauri desktop + platform adapters)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
//! macOS traffic-light (window control) inset — theme-driven.
//!
//! With `titleBarStyle: "Overlay"` the close/minimize/zoom buttons are the native macOS controls,
//! and Tauri v2 exposes no config for their position, so we move them natively via the objc2-app-kit
//! dependency already used by dock.rs. The positioning algorithm is a port of tauri-plugin-decorum's
//! `position_traffic_lights` (github.com/clearlysid/tauri-plugin-decorum): grow the title-bar
//! CONTAINER view (the close button's grandparent) so its title-bar subview autoresizes taller, then
//! center each button in that taller bar and inset it from the left.
//!
//! The inset is DRIVEN BY THE HOST (not hard-coded): the webview pushes pad-x/pad-y/spacing via the
//! generic `window.trafficLightInset` command whenever it wants a custom layout. `None` (the host
//! sends nothing) restores the untouched macOS default. What the host derives those values from
//! (e.g. theme tokens) is entirely the host's concern — this module only applies them.
//!
//! macOS re-runs its title-bar layout (resetting the button frames to default) on all sorts of
//! events — show, focus, move, resize, AND webview relayouts like selecting a file — far more than
//! Tauri surfaces as window events. Chasing individual triggers never covered them all, so we install
//! a single `NSWindowDidUpdate` notification observer that re-applies on EVERY relayout. `apply` is
//! idempotent (absolute positions + a "skip if already there" guard), so this can't drift or loop.

use objc2::rc::Retained;
use objc2::runtime::{AnyObject, NSObject};
use objc2::{define_class, msg_send, sel, MainThreadOnly};
use objc2_app_kit::{
    NSApplication, NSButton, NSView, NSViewFrameDidChangeNotification, NSWindow, NSWindowButton,
    NSWindowDidUpdateNotification, NSWindowWillCloseNotification,
};
use objc2_foundation::{
    ns_string, MainThreadMarker, NSDictionary, NSKeyValueChangeKey, NSKeyValueObservingOptions,
    NSNotification, NSNotificationCenter, NSObjectNSKeyValueObserverRegistration, NSObjectProtocol,
    NSPoint, NSRect, NSSize, NSString,
};
use std::cell::RefCell;
use std::collections::HashSet;
use std::ffi::c_void;
use std::ptr::NonNull;
use std::sync::{Mutex, OnceLock};

/// Theme-provided inset. `spacing` is optional — `None` keeps the OS default button spacing.
#[derive(Clone, Copy)]
struct Inset {
    /// Left inset of the close button from the window's left edge.
    pad_x: f64,
    /// Distance from the window's TOP edge down to the TOP of the buttons. We do NOT grow the native
    /// title-bar container to move the buttons down (that bleeds into the content and reads as an
    /// over-tall header) — the container stays at its OS-default height and the buttons are just
    /// placed lower within it, so this is a pure vertical position with no effect on header height.
    pad_y: f64,
    spacing: Option<f64>,
}

/// Active inset, or `None` for the untouched macOS default. Set by `set_traffic_light_inset`.
static CONFIG: Mutex<Option<Inset>> = Mutex::new(None);

/// The untouched-default title-bar container height + button origins, captured ONCE before we first
/// move anything, so switching back to a no-inset theme can restore the exact OS default. The
/// container height is size-independent (title-bar height); button origins are in their (relative)
/// superview space, so both survive window resizes.
struct DefaultSnapshot {
    container_height: f64,
    button_origins: [NSPoint; 3],
}
static DEFAULTS: OnceLock<DefaultSnapshot> = OnceLock::new();

fn standard_buttons(window: &NSWindow) -> Option<[Retained<NSButton>; 3]> {
    let close = window.standardWindowButton(NSWindowButton::CloseButton)?;
    let minimize = window.standardWindowButton(NSWindowButton::MiniaturizeButton)?;
    let zoom = window.standardWindowButton(NSWindowButton::ZoomButton)?;
    Some([close, minimize, zoom])
}

/// Title-bar container = the close button's grandparent (button -> title-bar view -> container).
fn container_of(button: &NSButton) -> Option<Retained<NSView>> {
    // SAFETY: superview() is a plain AppKit accessor; nil is handled by `?`. Main thread only.
    unsafe { button.superview().and_then(|view| view.superview()) }
}

/// Move a button only if it isn't already at `target`. The relayout observer re-applies on every
/// window update, so this both avoids redundant work and prevents a setFrameOrigin→relayout loop.
fn set_origin_if_needed(button: &NSButton, target: NSPoint) {
    let current = button.frame().origin;
    if (current.x - target.x).abs() > 0.5 || (current.y - target.y).abs() > 0.5 {
        button.setFrameOrigin(target);
    }
}

fn capture_defaults(buttons: &[Retained<NSButton>; 3], container: &NSView) -> DefaultSnapshot {
    DefaultSnapshot {
        container_height: container.frame().size.height,
        button_origins: [
            buttons[0].frame().origin,
            buttons[1].frame().origin,
            buttons[2].frame().origin,
        ],
    }
}

fn reposition(ns_window: &NSWindow, inset: Inset) {
    let Some(buttons) = standard_buttons(ns_window) else {
        return;
    };
    let Some(container) = container_of(&buttons[0]) else {
        return;
    };
    // Capture the untouched default the first time we ever move the buttons (they're at OS default
    // here, since a no-inset theme only ever restores, never moves).
    let snapshot = DEFAULTS.get_or_init(|| capture_defaults(&buttons, &container));

    let button_height = buttons[0].frame().size.height;

    // Keep the container at its OS-default height (NO growth — growing it is what pushed the header
    // taller). The buttons' superview fills the container, so a button whose TOP sits `pad_y` below
    // the window top is at superview-y = container_height - pad_y - button_height (AppKit y is
    // bottom-up, container top pinned to the window top).
    let _ = container;
    let button_y = snapshot.container_height - inset.pad_y - button_height;
    let spacing = inset
        .spacing
        .unwrap_or(snapshot.button_origins[1].x - snapshot.button_origins[0].x);
    for (i, button) in buttons.iter().enumerate() {
        set_origin_if_needed(
            button,
            NSPoint {
                x: inset.pad_x + (i as f64) * spacing,
                y: button_y,
            },
        );
    }
}

/// Put the container + buttons back to the captured macOS default (used when the active theme
/// provides no inset). No-op if we've never insetted (buttons are already at the OS default).
fn restore_default(ns_window: &NSWindow) {
    let Some(snapshot) = DEFAULTS.get() else {
        return;
    };
    let Some(buttons) = standard_buttons(ns_window) else {
        return;
    };
    let Some(container) = container_of(&buttons[0]) else {
        return;
    };
    let window_size = ns_window.frame().size;
    // Default container height is size-independent; recompute origin.y/width for the current size.
    container.setFrame(NSRect {
        origin: NSPoint {
            x: 0.0,
            y: window_size.height - snapshot.container_height,
        },
        size: NSSize {
            width: window_size.width,
            height: snapshot.container_height,
        },
    });
    for (i, button) in buttons.iter().enumerate() {
        set_origin_if_needed(button, snapshot.button_origins[i]);
    }
}

// NOTE: there is deliberately no `WebviewWindow` -> `NSWindow` helper here any more. Everything in
// this module runs from an AppKit callback, and `WebviewWindow::ns_window()` dispatches a
// `WindowMessage` that re-enters wry's `handle_user_message`; taking `windows.0.borrow()` there
// while wry's event dispatch holds `borrow_mut()` aborts the process. Windows are reached through
// the notification's own object, or through `NSApplication::windows()`.

thread_local! {
    // Reentrancy guard: our own `setFrameOrigin` synchronously posts NSViewFrameDidChange / KVO,
    // which re-enter `apply` via the observers. Without this, a single external reset cascades into
    // nested re-applies (3 buttons × N window updates), which — layered under boot-time title-bar
    // relayout — was a big part of the main-thread stall. macOS's *real* resets always arrive with the
    // guard clear (they're separate run-loop turns), so those are still caught; only our own
    // move-induced callbacks are suppressed.
    static APPLYING: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}

/// A window this chrome manages: one of THIS process's windows carrying the three standard
/// title-bar buttons. Panels, sheets and borderless windows have no `standardWindowButton`, so they
/// fall out here.
///
/// Deliberately does NOT consult Tauri to decide. Every caller runs inside an AppKit callback, and
/// `WebviewWindow::ns_window()` / `webview_windows()` dispatch a `WindowMessage` that re-enters
/// wry's `handle_user_message` — which takes `windows.0.borrow()` while wry's own event dispatch
/// already holds `borrow_mut()`, aborting the process with `RefCell already mutably borrowed`.
///
/// Gating on prior observation instead would be circular: a window is only ever recorded from
/// `install_observers`, which is only reached through this check.
fn is_managed_window(ns_window: &NSWindow) -> bool {
    standard_buttons(ns_window).is_some()
}

/// Reposition from an AppKit `NSWindow` without calling back into Tauri.
/// Observers MUST use this: `WebviewWindow::ns_window()` re-enters wry's window
/// map, and during close that map is already `borrow_mut`'d → process abort
/// (`RefCell already mutably borrowed`), which takes every window with it.
fn apply_ns(ns_window: &NSWindow) {
    if APPLYING.with(|f| f.get()) {
        return;
    }
    APPLYING.with(|f| f.set(true));
    let config = *CONFIG.lock().unwrap();
    install_observers(ns_window);
    match config {
        Some(inset) => reposition(ns_window, inset),
        None => restore_default(ns_window),
    }
    APPLYING.with(|f| f.set(false));
}

fn apply_from_object(obj: Option<&AnyObject>) {
    let Some(obj) = obj else {
        return;
    };
    if let Some(window) = obj.downcast_ref::<NSWindow>() {
        if is_managed_window(window) {
            apply_ns(window);
        }
        return;
    }
    if let Some(view) = obj.downcast_ref::<NSView>() {
        if let Some(window) = view.window() {
            if is_managed_window(&window) {
                apply_ns(&window);
            }
        }
    }
}

/// Drop every per-window registration made by `install_observers`, on `NSWindowWillClose` — while
/// the buttons are still alive and their observers still removable.
///
/// Without this, the KVO registration outlives the button it observes, which is the documented
/// recipe for AppKit's *"deallocated while key value observers were still registered"* abort, and
/// `OBSERVED_BUTTONS` keeps raw pointers that a later allocation can reuse — silently suppressing
/// observer setup for a new window's buttons.
fn forget_window(ns_window: &NSWindow) {
    let Some(mtm) = MainThreadMarker::new() else {
        return;
    };
    let Some(buttons) = standard_buttons(ns_window) else {
        return;
    };
    let center = NSNotificationCenter::defaultCenter();
    let observer = observer(mtm);
    for button in &buttons {
        let key = Retained::as_ptr(button) as usize;
        if !OBSERVED_BUTTONS.with(|set| set.borrow_mut().remove(&key)) {
            continue;
        }
        unsafe {
            center.removeObserver_name_object(
                &observer,
                Some(NSViewFrameDidChangeNotification),
                Some(button),
            );
            button.removeObserver_forKeyPath(&observer, ns_string!("frame"));
        }
    }
}

// ---- Relayout observers: re-assert the inset the instant macOS resets the buttons ----
//
// Two notifications:
//   * NSViewFrameDidChange on each button — posted SYNCHRONOUSLY when macOS moves a button, so we
//     reposition it before the frame is painted (no flicker). This is what catches the reset from
//     opening a file (which retitles the window / relays the title bar).
//   * NSWindowDidUpdate — a coarse backstop for relayouts that don't move the buttons via setFrame.

static OBSERVER_APP: OnceLock<tauri::AppHandle> = OnceLock::new();

thread_local! {
    static RELAYOUT_OBSERVER: RefCell<Option<Retained<TrafficLightObserver>>> =
        const { RefCell::new(None) };
    // Buttons we've already wired frame-change observers to (by pointer), so we do it once each.
    static OBSERVED_BUTTONS: RefCell<HashSet<usize>> = RefCell::new(HashSet::new());
}

define_class!(
    #[unsafe(super(NSObject))]
    #[name = "TishTrafficLightObserver"]
    #[thread_kind = MainThreadOnly]
    #[ivars = ()]
    struct TrafficLightObserver;

    unsafe impl NSObjectProtocol for TrafficLightObserver {}

    impl TrafficLightObserver {
        #[unsafe(method(onWindowRelayout:))]
        fn on_window_relayout(&self, note: Option<&NSNotification>) {
            // Apply via the notification's NSWindow/NSView — never `webview_windows()` /
            // `ns_window()`, which re-enter wry while a close already holds `borrow_mut`.
            guard_objc_callback("onWindowRelayout:", || {
                apply_from_object(note.and_then(|n| n.object()).as_deref());
            });
        }

        #[unsafe(method(onWindowWillClose:))]
        fn on_window_will_close(&self, note: Option<&NSNotification>) {
            guard_objc_callback("onWindowWillClose:", || {
                let Some(obj) = note.and_then(|n| n.object()) else {
                    return;
                };
                if let Some(window) = obj.downcast_ref::<NSWindow>() {
                    forget_window(window);
                }
            });
        }

        // KVO on each button's `frame`: fires SYNCHRONOUSLY on ANY frame change, including the
        // Auto-Layout-driven reposition of the zoom (green) button during a file load — which posts
        // no NSViewFrameDidChange, so it's the only synchronous way to catch that reset before paint.
        #[unsafe(method(observeValueForKeyPath:ofObject:change:context:))]
        fn observe_value(
            &self,
            _key_path: Option<&NSString>,
            object: Option<&AnyObject>,
            _change: Option<&NSDictionary<NSKeyValueChangeKey, AnyObject>>,
            _context: *mut c_void,
        ) {
            guard_objc_callback("observeValueForKeyPath:", || apply_from_object(object));
        }
    }
);

/// Run an ObjC callback body so a panic can never cross the `extern "C"` boundary.
///
/// Unwinding out of a `define_class!` method is instant `abort()` — that is how the wry `RefCell`
/// re-entrancy took the whole app down. Chrome decoration is cosmetic and must never be fatal, so a
/// panic here degrades to a logged error and default macOS traffic lights.
pub(crate) fn guard_objc_callback(what: &str, f: impl FnOnce()) {
    if std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).is_err() {
        eprintln!("[traffic_lights] panic in {what}; skipping this event's chrome update");
    }
}

fn apply_all_ns() {
    let Some(mtm) = MainThreadMarker::new() else {
        return;
    };
    let app = NSApplication::sharedApplication(mtm);
    for window in app.windows().iter() {
        if is_managed_window(&window) {
            apply_ns(&window);
        }
    }
}

impl TrafficLightObserver {
    fn new(mtm: MainThreadMarker) -> Retained<Self> {
        let this = mtm.alloc().set_ivars(());
        unsafe { msg_send![super(this), init] }
    }
}

fn observer(mtm: MainThreadMarker) -> Retained<TrafficLightObserver> {
    RELAYOUT_OBSERVER.with(|cell| {
        cell.borrow_mut()
            .get_or_insert_with(|| TrafficLightObserver::new(mtm))
            .clone()
    })
}

/// Wire the window-level backstop observer (once) and a per-button synchronous frame observer for
/// this window's buttons (once each). Idempotent; called from `apply_ns` on the main thread.
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);

    // Window-level backstop, once.
    static WINDOW_OBSERVER_INSTALLED: OnceLock<()> = OnceLock::new();
    if WINDOW_OBSERVER_INSTALLED.set(()).is_ok() {
        unsafe {
            center.addObserver_selector_name_object(
                &observer,
                sel!(onWindowRelayout:),
                Some(NSWindowDidUpdateNotification),
                None,
            );
            // Teardown hook: drop this window's per-button observers while they are still valid.
            center.addObserver_selector_name_object(
                &observer,
                sel!(onWindowWillClose:),
                Some(NSWindowWillCloseNotification),
                None,
            );
        }
    }

    // Per-button synchronous frame observers, once per button.
    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!(onWindowRelayout:),
                Some(NSViewFrameDidChangeNotification),
                Some(button),
            );
            // KVO on `frame` too: catches the zoom button's Auto-Layout reposition (which posts no
            // NSViewFrameDidChange) synchronously — the notification path only covers close/minimize.
            button.addObserver_forKeyPath_options_context(
                &observer,
                ns_string!("frame"),
                NSKeyValueObservingOptions::empty(),
                NonNull::dangling().as_ptr(),
            );
        }
        OBSERVED_BUTTONS.with(|set| set.borrow_mut().insert(key));
    }
}

/// Store the app handle the observers use to reach the windows.
pub fn init(app: &tauri::AppHandle) {
    let _ = OBSERVER_APP.set(app.clone());
    // NOTE: there is deliberately NO periodic correction poll. An earlier version re-applied the
    // inset at ~60fps on the main thread to chase the zoom button's reset during a file load, but the
    // real cause of that reset — the window TITLE being rewritten on file-select, which invalidates
    // the title bar — is now fixed at the source (native title reflects the workspace only). A forever
    // main-thread poll only starved the run loop (a multi-second beachball at boot, when macOS is
    // already relaying the title bar hard). Positioning is event-driven: the scheduled sweep the host
    // triggers on every inset/tint push and on `reapply` at window reveal -- which is ALSO what seeds
    // the per-window observers -- plus the relayout observers below. There is no window-create hook
    // and no Resized/Moved/Focused handler for traffic lights; claiming otherwise is what made the
    // seeding regression invisible.
}

/// Apply the current config to every window NOW, then a few more times as the window settles.
/// macOS re-runs its title-bar layout AFTER our apply (during boot, and again right after the window
/// is shown), which resets the buttons to default and leaves them there until the next focus change
/// — so re-applying on a short schedule rides out that settle. Called from `set_inset` (anchored to
/// the theme push) AND from `reapply` (anchored to the window reveal, which is the later reset).
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 _ = handle.run_on_main_thread(apply_all_ns);
        });
    }
}

/// Store the theme-provided inset (`None` for the OS default) and re-apply to every window. Called
/// from the `set_traffic_light_inset` Tauri command.
pub fn set_inset(
    app: &tauri::AppHandle,
    pad_x: Option<f64>,
    pad_y: Option<f64>,
    spacing: Option<f64>,
) {
    let config = match (pad_x, pad_y) {
        (Some(x), Some(y)) => Some(Inset {
            pad_x: x,
            pad_y: y,
            spacing,
        }),
        _ => None,
    };
    *CONFIG.lock().unwrap() = config;
    apply_all_scheduled(app);
}

/// Re-assert the current inset (config unchanged). The frontend calls this right after it reveals the
/// window (`w.show()`), whose title-bar layout is the reset that otherwise left the buttons at default
/// until a manual focus change.
pub fn reapply(app: &tauri::AppHandle) {
    apply_all_scheduled(app);
}