柚子/使用 Tauri 2 和 Rust 插件定制 macOS 窗口控件

Created Tue, 17 Dec 2024 00:00:00 +0000 Modified Tue, 17 Dec 2024 04:58:23 +0000
By 柚子 3162 Words 14 min Edit

前言

在现代桌面应用开发中,跨平台框架提供了开发和部署的便利,而定制化要求通常需要与平台特有的 API 交互。Tauri 框架利用 Rust 和 Web 技术实现高效且安全的跨平台桌面应用开发。在 Tauri 2.x 中,插件系统的引入极大地增强了灵活性,允许开发者注入和定制原生功能。

本文将深入探讨如何在 Tauri 2.x 中创建和使用插件来定制 macOS 窗口控件(交通灯按钮)。我们将详细分析如何利用 Tauri 插件机制、Rust 与 macOS 的 Cocoa 框架交互,以及事件处理的设计和实现细节。

目标功能

在这段代码的实现中,我们的目标是:

  1. 定制 macOS 窗口交通灯按钮的位置
  2. 在窗口大小调整或状态变化时,动态更新按钮的位置
  3. 使用 Tauri 插件机制,使功能可重用且易于维护

1. Tauri 插件机制概述

Tauri 的插件系统允许开发者通过 Rust 插件向主应用添加额外的功能。插件可以通过 TauriPlugin 类型定义,并通过 Builder API 注册到 Tauri 应用中。插件不仅可以扩展 Tauri 的功能,还能与原生平台的特性紧密集成。

基本的插件创建流程:

  • TauriPlugin 定义:插件在 Rust 代码中定义,提供入口函数如 init()
  • 事件注册:使用 .on_* 方法(例如 .on_window_ready())来响应特定的窗口事件。
  • 插件集成:通过 builder.plugin() 方法将插件添加到 Tauri 应用中。

2. 获取 macOS 窗口句柄:Cocoa 与 Rust 的交互

macOS 的窗口控制(如关闭、最小化、全屏按钮)是由 NSWindow 管理的,这些控件的行为和位置都由 Cocoa 框架控制。在 Rust 中,我们可以通过 cocoa crate 来调用 Cocoa API,获取和操作这些控件。

获取 NSWindow 句柄

Tauri 为每个创建的窗口提供了 ns_window() 方法,这个方法返回一个原生的 NSWindow 句柄,我们可以利用它来控制 macOS 上的窗口。为了操作交通灯按钮,我们需要获取到窗口的 NSWindow 对象。

use cocoa::appkit::NSWindow;
use cocoa::base::id;
use tauri::Window;

fn get_ns_window(window: &Window) -> id {
    window.ns_window().expect("Failed to get NSWindow handle") as id
}

这行代码获取了 Tauri 窗口对应的 NSWindow 对象,并将其转换为 Cocoa 框架的原生 id 类型。之后,我们可以使用 Cocoa 的 API 操作该对象。

操作交通灯按钮的位置

macOS 上的窗口控制按钮(交通灯按钮)被封装为 NSWindowButton,这些按钮的位置和大小由 NSWindow 控制。我们可以通过 standardWindowButton_ 方法获取各个按钮,并通过 setFrameOrigin 调整它们的位置。

use cocoa::appkit::{NSWindowButton, NSWindow};
use cocoa::base::id;
use cocoa::foundation::NSRect;
use objc::runtime::msg_send;

fn position_traffic_lights(ns_window: id, x: f64, y: f64) {
    let close_button = unsafe { msg_send![ns_window, standardWindowButton: NSWindowButton::NSWindowCloseButton] };
    let miniaturize_button = unsafe { msg_send![ns_window, standardWindowButton: NSWindowButton::NSWindowMiniaturizeButton] };
    let zoom_button = unsafe { msg_send![ns_window, standardWindowButton: NSWindowButton::NSWindowZoomButton] };

    let buttons = vec![close_button, miniaturize_button, zoom_button];
    let space_between = unsafe {
        let close_rect: NSRect = msg_send![close_button, frame];
        let miniaturize_rect: NSRect = msg_send![miniaturize_button, frame];
        miniaturize_rect.origin.x - close_rect.origin.x
    };

    for (i, button) in buttons.into_iter().enumerate() {
        let mut rect: NSRect = unsafe { msg_send![button, frame] };
        rect.origin.x = x + (i as f64 * space_between);
        unsafe {
            msg_send![button, setFrameOrigin: rect.origin];
        }
    }
}

此时,我们已获得 closeminiaturizezoom 按钮的句柄。接下来,我们需要调整它们的位置。首先,我们获取按钮的尺寸(如高度),然后计算新的按钮位置。

use cocoa::foundation::NSRect;
use objc::runtime::msg_send;

fn adjust_traffic_lights_position(close_button: id, miniaturize_button: id, zoom_button: id, x: f64, y: f64) {
    let close_rect: NSRect = unsafe { msg_send![close_button, frame] };
    let button_height = close_rect.size.height;
    let space_between = unsafe {
        let mini_rect: NSRect = msg_send![miniaturize_button, frame];
        mini_rect.origin.x - close_rect.origin.x
    };
    let new_rect = NSRect {
        origin: NSPoint { x, y },
        size: NSSize { width: 100.0, height: button_height },
    };
    unsafe {
        msg_send![close_button, setFrame: new_rect];
        msg_send![miniaturize_button, setFrameOrigin: new_rect.origin];
    }
}

3. 使用 Delegate 监听窗口事件:保持按钮位置同步

为了在窗口调整大小或其他状态变化时,保持按钮位置的一致性,我们需要监听窗口的相关事件,如 resizemove。这可以通过设置 NSWindowDelegate 来实现。

NSWindowDelegate 与事件处理

在 Cocoa 中,NSWindowDelegate 负责接收窗口的事件,包括窗口大小变化、全屏状态改变等。我们通过设置自定义 delegate 来响应这些事件,并在窗口调整时重新计算按钮的位置。

extern "C" fn on_window_did_resize(this: &Object, _cmd: Sel, notification: id) {
    unsafe {
        let window_state = &mut *(this.get_ivar("app_box") as *mut WindowState);
        let ns_window = window_state.window.ns_window().unwrap();
        position_traffic_lights(ns_window, WINDOW_CONTROL_PAD_X, WINDOW_CONTROL_PAD_Y);
    }
}

在此示例中,我们定义了 on_window_did_resize 函数,当窗口大小变化时,它会通过 with_window_state 函数获取当前窗口状态,并重新调整交通灯按钮的位置。

with_window_stateunsafe 操作

with_window_state 函数是一个帮助函数,它允许我们获取窗口的状态并将其传递给事件处理函数。Rust 的 unsafe 代码块在这里是必要的,因为我们需要访问一些通过 get_ivar 存储的原始指针:

unsafe {
    let state = &mut *(this.get_ivar("app_box") as *mut WindowState);
    // 其他逻辑
}

这种 unsafe 代码块允许我们绕过 Rust 的严格借用检查,直接操作内存。需要注意的是,这种方式非常高效,但需要开发者小心内存管理,避免出现悬垂指针或内存泄漏。在进行 unsafe 操作时,务必确保内存安全,避免发生内存访问错误。

注册 Delegate

NSWindowDelegate 是一个 Objective-C 类,因此我们需要将 Rust 函数映射为 Objective-C 方法。通过 objc::runtime::Objectobjc::runtime::Sel,我们可以将 Rust 函数绑定到 macOS 的窗口代理方法上:

let delegate_name = format!("windowDelegate_{}", random_str);
ns_win.setDelegate_(delegate!(&delegate_name, {
    window: id = ns_win,
    app_box: *mut c_void = app_box,
    super_delegate: id = current_delegate,
    (windowDidResize:) => on_window_did_resize as extern fn(&Object, Sel, id),
    // 其他方法
}));

这里的 on_window_did_resize 函数会在窗口大小调整时被调用。通过 msg_send! 宏,我们将事件传递给原生的 super_delegate,从而保持 Cocoa 的正常事件处理链。

4. 插件集成与事件绑定

Tauri 插件机制允许我们将功能集成到主应用中。通过插件的注册机制,我们可以在应用初始化时自动加载并配置窗口行为。init 函数会在插件加载时被调用,我们可以在这里处理窗口控件的初始化和事件绑定。

pub fn init<R: Runtime>() -> TauriPlugin<R> {
    Builder::new("traffic_light_positioner")
        .on_window_ready(|window| {
            #[cfg(target_os = "macos")]
            setup_traffic_light_positioner(window);
        })
        .build()
}

on_window_ready 事件中,我们调用 setup_traffic_light_positioner 函数进行窗口控件位置调整的初始化。

5. 使用插件

通过 Tauri 插件系统,我们可以将上述插件注入到主应用中,使其自动生效。通过 builder.plugin(mac::window::init()),我们将窗口定制功能注入到应用中。

#[cfg(target_os = "macos")]
let builder = builder.plugin(mac::window::init());

这样,插件会在窗口创建时自动运行,并根据需要调整窗口的交通灯按钮。

6. 完整代码

完整代码修改自 https://github.com/victoralvesf/aonsoku/blob/main/src-tauri/src/mac/window.rs

use objc::{msg_send, sel, sel_impl};
use rand::{distributions::Alphanumeric, Rng};
use tauri::{
    plugin::{Builder, TauriPlugin},
    Runtime, Window,
}; // 0.8

const WINDOW_CONTROL_PAD_X: f64 = 14.0;
const WINDOW_CONTROL_PAD_Y: f64 = 16.0;

struct UnsafeWindowHandle(*mut std::ffi::c_void);
unsafe impl Send for UnsafeWindowHandle {}
unsafe impl Sync for UnsafeWindowHandle {}

pub fn init<R: Runtime>() -> TauriPlugin<R> {
    Builder::new("traffic_light_positioner")
        .on_window_ready(|window| {
            #[cfg(target_os = "macos")]
            setup_traffic_light_positioner(window);
            return;
        })
        .build()
}

#[cfg(target_os = "macos")]
fn position_traffic_lights(ns_window_handle: UnsafeWindowHandle, x: f64, y: f64) {
    use cocoa::appkit::{NSView, NSWindow, NSWindowButton, NSWindowTitleVisibility};
    use cocoa::foundation::NSRect;
    let ns_window = ns_window_handle.0 as cocoa::base::id;
    unsafe {
        // Hide the title bar
        ns_window.setTitleVisibility_(NSWindowTitleVisibility::NSWindowTitleHidden);
        // Hide the title bar background
        ns_window.setTitlebarAppearsTransparent_(cocoa::base::YES);

        let close = ns_window.standardWindowButton_(NSWindowButton::NSWindowCloseButton);
        let miniaturize =
            ns_window.standardWindowButton_(NSWindowButton::NSWindowMiniaturizeButton);
        let zoom = ns_window.standardWindowButton_(NSWindowButton::NSWindowZoomButton);

        let title_bar_container_view = close.superview().superview();

        let close_rect: NSRect = msg_send![close, frame];
        let button_height = close_rect.size.height;

        let title_bar_frame_height = button_height + y;
        let mut title_bar_rect = NSView::frame(title_bar_container_view);
        title_bar_rect.size.height = title_bar_frame_height;
        title_bar_rect.origin.y = NSView::frame(ns_window).size.height - title_bar_frame_height;
        let _: () = msg_send![title_bar_container_view, setFrame: title_bar_rect];

        let window_buttons = vec![close, miniaturize, zoom];
        let space_between = NSView::frame(miniaturize).origin.x - NSView::frame(close).origin.x;

        for (i, button) in window_buttons.into_iter().enumerate() {
            let mut rect: NSRect = NSView::frame(button);
            rect.origin.x = x + (i as f64 * space_between);
            button.setFrameOrigin(rect.origin);
        }
    }
}

#[cfg(target_os = "macos")]
#[derive(Debug)]
struct WindowState<R: Runtime> {
    window: Window<R>,
}

#[cfg(target_os = "macos")]
pub fn setup_traffic_light_positioner<R: Runtime>(window: Window<R>) {
    use cocoa::appkit::NSWindow;
    use cocoa::base::{id, BOOL};
    use cocoa::delegate;
    use cocoa::foundation::NSUInteger;
    use objc::runtime::{Object, Sel};
    use std::ffi::c_void;
    use tauri::Emitter;

    // Do the initial positioning
    position_traffic_lights(
        UnsafeWindowHandle(window.ns_window().expect("Failed to create window handle")),
        WINDOW_CONTROL_PAD_X,
        WINDOW_CONTROL_PAD_Y,
    );

    // Ensure they stay in place while resizing the window.
    fn with_window_state<R: Runtime, F: FnOnce(&mut WindowState<R>) -> T, T>(
        this: &Object,
        func: F,
    ) {
        let ptr = unsafe {
            let x: *mut c_void = *this.get_ivar("app_box");
            &mut *(x as *mut WindowState<R>)
        };
        func(ptr);
    }

    unsafe {
        let ns_win = window
            .ns_window()
            .expect("NS Window should exist to mount traffic light delegate.")
            as id;

        let current_delegate: id = ns_win.delegate();

        extern "C" fn on_window_should_close(this: &Object, _cmd: Sel, sender: id) -> BOOL {
            unsafe {
                let super_del: id = *this.get_ivar("super_delegate");
                msg_send![super_del, windowShouldClose: sender]
            }
        }
        extern "C" fn on_window_will_close(this: &Object, _cmd: Sel, notification: id) {
            unsafe {
                let super_del: id = *this.get_ivar("super_delegate");
                let _: () = msg_send![super_del, windowWillClose: notification];
            }
        }
        extern "C" fn on_window_did_resize<R: Runtime>(this: &Object, _cmd: Sel, notification: id) {
            unsafe {
                with_window_state(&*this, |state: &mut WindowState<R>| {
                    let id = state
                        .window
                        .ns_window()
                        .expect("NS window should exist on state to handle resize")
                        as id;

                    #[cfg(target_os = "macos")]
                    position_traffic_lights(
                        UnsafeWindowHandle(id as *mut std::ffi::c_void),
                        WINDOW_CONTROL_PAD_X,
                        WINDOW_CONTROL_PAD_Y,
                    );
                });

                let super_del: id = *this.get_ivar("super_delegate");
                let _: () = msg_send![super_del, windowDidResize: notification];
            }
        }
        extern "C" fn on_window_did_move(this: &Object, _cmd: Sel, notification: id) {
            unsafe {
                let super_del: id = *this.get_ivar("super_delegate");
                let _: () = msg_send![super_del, windowDidMove: notification];
            }
        }
        extern "C" fn on_window_did_change_backing_properties(
            this: &Object,
            _cmd: Sel,
            notification: id,
        ) {
            unsafe {
                let super_del: id = *this.get_ivar("super_delegate");
                let _: () = msg_send![super_del, windowDidChangeBackingProperties: notification];
            }
        }
        extern "C" fn on_window_did_become_key(this: &Object, _cmd: Sel, notification: id) {
            unsafe {
                let super_del: id = *this.get_ivar("super_delegate");
                let _: () = msg_send![super_del, windowDidBecomeKey: notification];
            }
        }
        extern "C" fn on_window_did_resign_key(this: &Object, _cmd: Sel, notification: id) {
            unsafe {
                let super_del: id = *this.get_ivar("super_delegate");
                let _: () = msg_send![super_del, windowDidResignKey: notification];
            }
        }
        extern "C" fn on_dragging_entered(this: &Object, _cmd: Sel, notification: id) -> BOOL {
            unsafe {
                let super_del: id = *this.get_ivar("super_delegate");
                msg_send![super_del, draggingEntered: notification]
            }
        }
        extern "C" fn on_prepare_for_drag_operation(
            this: &Object,
            _cmd: Sel,
            notification: id,
        ) -> BOOL {
            unsafe {
                let super_del: id = *this.get_ivar("super_delegate");
                msg_send![super_del, prepareForDragOperation: notification]
            }
        }
        extern "C" fn on_perform_drag_operation(this: &Object, _cmd: Sel, sender: id) -> BOOL {
            unsafe {
                let super_del: id = *this.get_ivar("super_delegate");
                msg_send![super_del, performDragOperation: sender]
            }
        }
        extern "C" fn on_conclude_drag_operation(this: &Object, _cmd: Sel, notification: id) {
            unsafe {
                let super_del: id = *this.get_ivar("super_delegate");
                let _: () = msg_send![super_del, concludeDragOperation: notification];
            }
        }
        extern "C" fn on_dragging_exited(this: &Object, _cmd: Sel, notification: id) {
            unsafe {
                let super_del: id = *this.get_ivar("super_delegate");
                let _: () = msg_send![super_del, draggingExited: notification];
            }
        }
        extern "C" fn on_window_will_use_full_screen_presentation_options(
            this: &Object,
            _cmd: Sel,
            window: id,
            proposed_options: NSUInteger,
        ) -> NSUInteger {
            unsafe {
                let super_del: id = *this.get_ivar("super_delegate");
                msg_send![super_del, window: window willUseFullScreenPresentationOptions: proposed_options]
            }
        }
        extern "C" fn on_window_did_enter_full_screen<R: Runtime>(
            this: &Object,
            _cmd: Sel,
            notification: id,
        ) {
            unsafe {
                with_window_state(&*this, |state: &mut WindowState<R>| {
                    state
                        .window
                        .emit("did-enter-fullscreen", ())
                        .expect("Failed to emit event");
                });

                let super_del: id = *this.get_ivar("super_delegate");
                let _: () = msg_send![super_del, windowDidEnterFullScreen: notification];
            }
        }
        extern "C" fn on_window_will_enter_full_screen<R: Runtime>(
            this: &Object,
            _cmd: Sel,
            notification: id,
        ) {
            unsafe {
                with_window_state(&*this, |state: &mut WindowState<R>| {
                    state
                        .window
                        .emit("will-enter-fullscreen", ())
                        .expect("Failed to emit event");
                });

                let super_del: id = *this.get_ivar("super_delegate");
                let _: () = msg_send![super_del, windowWillEnterFullScreen: notification];
            }
        }
        extern "C" fn on_window_did_exit_full_screen<R: Runtime>(
            this: &Object,
            _cmd: Sel,
            notification: id,
        ) {
            unsafe {
                with_window_state(&*this, |state: &mut WindowState<R>| {
                    state
                        .window
                        .emit("did-exit-fullscreen", ())
                        .expect("Failed to emit event");

                    let id = state.window.ns_window().expect("Failed to emit event") as id;
                    position_traffic_lights(
                        UnsafeWindowHandle(id as *mut std::ffi::c_void),
                        WINDOW_CONTROL_PAD_X,
                        WINDOW_CONTROL_PAD_Y,
                    );
                });

                let super_del: id = *this.get_ivar("super_delegate");
                let _: () = msg_send![super_del, windowDidExitFullScreen: notification];
            }
        }
        extern "C" fn on_window_will_exit_full_screen<R: Runtime>(
            this: &Object,
            _cmd: Sel,
            notification: id,
        ) {
            unsafe {
                with_window_state(&*this, |state: &mut WindowState<R>| {
                    state
                        .window
                        .emit("will-exit-fullscreen", ())
                        .expect("Failed to emit event");
                });

                let super_del: id = *this.get_ivar("super_delegate");
                let _: () = msg_send![super_del, windowWillExitFullScreen: notification];
            }
        }
        extern "C" fn on_window_did_fail_to_enter_full_screen(
            this: &Object,
            _cmd: Sel,
            window: id,
        ) {
            unsafe {
                let super_del: id = *this.get_ivar("super_delegate");
                let _: () = msg_send![super_del, windowDidFailToEnterFullScreen: window];
            }
        }
        extern "C" fn on_effective_appearance_did_change(
            this: &Object,
            _cmd: Sel,
            notification: id,
        ) {
            unsafe {
                let super_del: id = *this.get_ivar("super_delegate");
                let _: () = msg_send![super_del, effectiveAppearanceDidChange: notification];
            }
        }
        extern "C" fn on_effective_appearance_did_changed_on_main_thread(
            this: &Object,
            _cmd: Sel,
            notification: id,
        ) {
            unsafe {
                let super_del: id = *this.get_ivar("super_delegate");
                let _: () = msg_send![
                    super_del,
                    effectiveAppearanceDidChangedOnMainThread: notification
                ];
            }
        }

        // Are we deallocing this properly ? (I miss safe Rust :(  )
        let window_label = window.label().to_string();

        let app_state = WindowState { window };
        let app_box = Box::into_raw(Box::new(app_state)) as *mut c_void;
        let random_str: String = rand::thread_rng()
            .sample_iter(&Alphanumeric)
            .take(20)
            .map(char::from)
            .collect();
        let delegate_name = format!("windowDelegate_{}_{}", window_label, random_str);

        ns_win.setDelegate_(delegate!(&delegate_name, {
            window: id = ns_win,
            app_box: *mut c_void = app_box,
            toolbar: id = cocoa::base::nil,
            super_delegate: id = current_delegate,
            (windowShouldClose:) => on_window_should_close as extern fn(&Object, Sel, id) -> BOOL,
            (windowWillClose:) => on_window_will_close as extern fn(&Object, Sel, id),
            (windowDidResize:) => on_window_did_resize::<R> as extern fn(&Object, Sel, id),
            (windowDidMove:) => on_window_did_move as extern fn(&Object, Sel, id),
            (windowDidChangeBackingProperties:) => on_window_did_change_backing_properties as extern fn(&Object, Sel, id),
            (windowDidBecomeKey:) => on_window_did_become_key as extern fn(&Object, Sel, id),
            (windowDidResignKey:) => on_window_did_resign_key as extern fn(&Object, Sel, id),
            (draggingEntered:) => on_dragging_entered as extern fn(&Object, Sel, id) -> BOOL,
            (prepareForDragOperation:) => on_prepare_for_drag_operation as extern fn(&Object, Sel, id) -> BOOL,
            (performDragOperation:) => on_perform_drag_operation as extern fn(&Object, Sel, id) -> BOOL,
            (concludeDragOperation:) => on_conclude_drag_operation as extern fn(&Object, Sel, id),
            (draggingExited:) => on_dragging_exited as extern fn(&Object, Sel, id),
            (window:willUseFullScreenPresentationOptions:) => on_window_will_use_full_screen_presentation_options as extern fn(&Object, Sel, id, NSUInteger) -> NSUInteger,
            (windowDidEnterFullScreen:) => on_window_did_enter_full_screen::<R> as extern fn(&Object, Sel, id),
            (windowWillEnterFullScreen:) => on_window_will_enter_full_screen::<R> as extern fn(&Object, Sel, id),
            (windowDidExitFullScreen:) => on_window_did_exit_full_screen::<R> as extern fn(&Object, Sel, id),
            (windowWillExitFullScreen:) => on_window_will_exit_full_screen::<R> as extern fn(&Object, Sel, id),
            (windowDidFailToEnterFullScreen:) => on_window_did_fail_to_enter_full_screen as extern fn(&Object, Sel, id),
            (effectiveAppearanceDidChange:) => on_effective_appearance_did_change as extern fn(&Object, Sel, id),
            (effectiveAppearanceDidChangedOnMainThread:) => on_effective_appearance_did_changed_on_main_thread as extern fn(&Object, Sel, id)
        }))
    }
}

7. 参考资料

[0]https://github.com/victoralvesf/aonsoku
[1]https://gist.github.com/charrondev/43150e940bd2771b1ea88256d491c7a9
[2]https://github.com/itseeleeya/tauri-plugin-trafficlights-positioner/