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
//! The module includes the setup utilities of Deskulpt.

use crate::{states::CanvasClickThroughState, utils::toggle_click_through_state};
use std::{
    thread::{sleep, spawn},
    time::Duration,
};
use tauri::{
    menu::{MenuBuilder, MenuItemBuilder},
    tray::{MouseButton, MouseButtonState, TrayIconEvent},
    App, AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder, Window,
    WindowEvent,
};

#[cfg(target_os = "macos")]
use objc::{
    msg_send,
    runtime::{Object, NO},
    sel, sel_impl,
};

/// Create the canvas window.
pub(crate) fn create_canvas(app: &mut App) -> Result<(), Box<dyn std::error::Error>> {
    let canvas = WebviewWindowBuilder::new(
        app,
        "canvas",
        WebviewUrl::App("views/canvas.html".into()),
    )
    .maximized(true)
    .transparent(true)
    .decorations(false)
    .always_on_bottom(true)
    .visible(false) // TODO: https://github.com/tauri-apps/tauri/issues/9597
    .skip_taskbar(true) // Windows and Linux; macOS see below for hiding from dock
    .build()?;

    #[cfg(target_os = "macos")]
    // Disable the window shadow on macOS; there will be shadows left on movement for
    // transparent and undecorated windows that we are using; it seems that disabling
    // shadows does not have significant visual impacts
    unsafe {
        let ns_window = canvas.ns_window()? as *mut Object;
        let () = msg_send![ns_window, setHasShadow:NO];
    }

    canvas.show()?; // TODO: remove when `visible` is fixed

    // Be consistent with the default of `CanvasClickThroughState`
    canvas.set_ignore_cursor_events(true)?;

    Ok(())
}

/// Listen to window events.
///
/// This is to be initialized with `builder.on_window_event(listen_to_windows)` on the
/// application builder instance. It prevents the manager window from closing when the
/// close button is clicked, but only hide it instead.
pub(crate) fn listen_to_windows(window: &Window, event: &WindowEvent) {
    if window.label() == "manager" {
        if let WindowEvent::CloseRequested { api, .. } = event {
            api.prevent_close();
            window.hide().unwrap();
        }
    }
}

/// Initialize the Deskulpt system tray.
///
/// This binds the menu and event handlers to the system tray with ID "deskulpt-tray",
/// see `tauri.conf.json`. Note that the cnavas click-through state is managed in this
/// function as well! This tray would be intialized with the following features:
///
/// - When left-clicking the tray icon or clicking the "toggle" menu item, toggle the
///   click-through state of the canvas window. Note that left-clicking is unsupported
///   on Linux, so the "toggle" menu item is present as a workaround.
/// - When clicking the "manage" menu item, show the manager window.
/// - When clicking the "exit" menu item, exit the application (with cleanup). This
///   should, in production, be the only normal way to exit the application.
pub(crate) fn init_system_tray(app: &App) -> Result<(), Box<dyn std::error::Error>> {
    let deskulpt_tray = app.tray_by_id("deskulpt-tray").unwrap();

    // Be consistent with the default of `CanvasClickThroughState`
    let item_toggle = MenuItemBuilder::with_id("toggle", "Float").build(app)?;
    app.manage(CanvasClickThroughState::init(true, item_toggle.clone()));

    // Set up the tray menu
    let tray_menu = MenuBuilder::new(app)
        .items(&[
            &item_toggle,
            &MenuItemBuilder::with_id("manage", "Manage").build(app)?,
            &MenuItemBuilder::with_id("exit", "Exit").build(app)?,
        ])
        .build()?;
    deskulpt_tray.set_menu(Some(tray_menu))?;

    // Register event handler for the tray menu
    deskulpt_tray.on_menu_event(move |app_handle, event| match event.id().as_ref() {
        "toggle" => {
            let _ = toggle_click_through_state(app_handle); // Consume potential error
        },
        "manage" => show_manager_window(app_handle),
        "exit" => on_app_exit(app_handle),
        _ => {},
    });

    // Register event handler for the tray icon itself
    deskulpt_tray.on_tray_icon_event(|tray, event| {
        if let TrayIconEvent::Click { button, button_state, .. } = event {
            if button == MouseButton::Left && button_state == MouseButtonState::Down {
                let _ = toggle_click_through_state(tray.app_handle()); // Consume error
            }
        }
    });

    Ok(())
}

/// Attempt to show the manager window.
///
/// This will make the manager visible if it is not already, and set focus if it is not
/// already focused. If the manager window does not exist, create the window. There is
/// no guarantee that this operation will succeed, but it will try to do so.
fn show_manager_window(app_handle: &AppHandle) {
    let inner = || -> Result<(), Box<dyn std::error::Error>> {
        if let Some(manager) = app_handle.get_webview_window("manager") {
            manager.show()?;
            manager.set_focus()?;
            return Ok(());
        }

        // Failed to get the manager window; we create a new one from the existing
        // configuration instead; note that the manager window is the first item in the
        // window list in `tauri.conf.json`
        let config = app_handle.config().app.windows.first().unwrap();
        let manager = WebviewWindowBuilder::from_config(app_handle, config)?.build()?;
        manager.show()?;
        manager.set_focus()?;
        Ok(())
    };

    let _ = inner(); // Consume any error
}

/// The cleanup function to be called on application exit.
fn on_app_exit(app_handle: &AppHandle) {
    if app_handle.get_webview_window("manager").is_none() {
        app_handle.exit(0); // Manager window does not exist; should not happen
    };

    // Emit the "exit-app" event to the manager window so that it can save the global
    // settings to a file before the application exits; it will then be in charge of
    // exiting the application
    if app_handle.emit_to("manager", "exit-app", ()).is_err() {
        app_handle.exit(0); // Event fails to be emitted
    }

    // This is a safeguard to ensure that the application exits in case the manager
    // window fails to do so; we give it a 5-second timeout
    let app_handle = app_handle.clone();
    spawn(move || {
        sleep(Duration::from_secs(5));
        app_handle.exit(0);
    });
}