feat(stage-tamagotchi): add click through support (previous macOS only, now both supported) (#187)

---------

Co-authored-by: DWCarrot <23579844+DWCarrot@users.noreply.github.com>
Co-authored-by: Makito <5277268+sumimakito@users.noreply.github.com>
Co-authored-by: Midori Kochiya <1381736+kmod-midori@users.noreply.github.com>
This commit is contained in:
Neko 2025-05-31 01:45:47 +08:00 committed by GitHub
parent da4da0aeb6
commit 5c521c41a8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 379 additions and 284 deletions

View file

@ -1,9 +1,37 @@
root = true root = true
[*.toml]
indent_size = 4
indent_style = space
max_line_length = 100
trim_trailing_whitespace = true
[*.rs]
indent_size = 2
indent_style = space
max_line_length = 100
trim_trailing_whitespace = true
[*.md]
# double whitespace at end of line
# denotes a line break in Markdown
trim_trailing_whitespace = false
[*.{js,ts,vue,tsx,jsx,html,css,json,yaml,yml}]
indent_size = 2
indent_style = space
trim_trailing_whitespace = true
[*.go]
indent_size = 4
indent_style = tab
trim_trailing_whitespace = true
[*.proto]
indent_size = 2
indent_style = space
[*] [*]
charset = utf-8 charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf end_of_line = lf
insert_final_newline = true insert_final_newline = true
trim_trailing_whitespace = true

View file

@ -9,6 +9,13 @@ on:
branches: branches:
- main - main
env:
# Rust
CARGO_TERM_COLOR: always
# https://github.com/Mozilla-Actions/sccache-action#rust-code
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: 'true'
jobs: jobs:
lint: lint:
name: Lint name: Lint
@ -16,15 +23,16 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable # # Rust
with: # - uses: dtolnay/rust-toolchain@nightly
components: rustfmt,clippy # - uses: mozilla-actions/sccache-action@v0.0.3
- name: Install system dependencies - name: Install system dependencies
run: | run: |
sudo apt update sudo apt update
sudo apt install -y libwebkit2gtk-4.1-dev sudo apt install -y libwebkit2gtk-4.1-dev
# Node.js
- uses: pnpm/action-setup@v3 - uses: pnpm/action-setup@v3
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
@ -39,12 +47,20 @@ jobs:
pnpm run lint pnpm run lint
pnpm run lint:rust pnpm run lint:rust
# - name: Lint Rust
# run: |
# cargo +nightly fmt --all --check
# cargo clippy -- -W clippy::pedantic -W clippy::nursery -A clippy::missing-errors-doc -A clippy::module_name_repetitions
build-test: build-test:
name: Build Test name: Build Test
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
# # Rust
# - uses: dtolnay/rust-toolchain@nightly
# - uses: mozilla-actions/sccache-action@v0.0.3
- name: Install system dependencies - name: Install system dependencies
run: | run: |
@ -64,13 +80,18 @@ jobs:
run: | run: |
pnpm run build:packages pnpm run build:packages
pnpm run build:apps pnpm run build:apps
pnpm run build:crates
# - name: Build Rust
# run: |
# pnpm run build:crates
typecheck: typecheck:
name: Type Check name: Type Check
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
# Node.js
- uses: pnpm/action-setup@v3 - uses: pnpm/action-setup@v3
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:

1
.tool-versions Normal file
View file

@ -0,0 +1 @@
nodejs 24.1.0

View file

@ -12,6 +12,10 @@ Hello! Thank you for your interest in contributing to this project. This guide w
<details> <details>
<summary>Windows setup</summary> <summary>Windows setup</summary>
0. Download [Visual Studio](https://visualstudio.microsoft.com/downloads/), and follow the instructions here: https://rust-lang.github.io/rustup/installation/windows-msvc.html#walkthrough-installing-visual-studio-2022
> Make sure to install Windows SDK and C++ build tools when installing Visual Studio.
1. Open PowerShell 1. Open PowerShell
2. Install [`scoop`](https://scoop.sh/) 2. Install [`scoop`](https://scoop.sh/)
@ -20,12 +24,21 @@ Hello! Thank you for your interest in contributing to this project. This guide w
Invoke-RestMethod -Uri https://get.scoop.sh | Invoke-Expression Invoke-RestMethod -Uri https://get.scoop.sh | Invoke-Expression
``` ```
3. Install `git`, `node` through `scoop` 3. Install `git`, Node.js, `rustup`, `msvc` through `scoop`
```powershell ```powershell
scoop install git nodejs scoop install git nodejs rustup
# For Rust dependencies
# Not required if you are not going to develop on either crates or apps/tamagotchi
scoop install main/rust-msvc
# Rust & Windows specific
rustup toolchain install stable-x86_64-pc-windows-msvc
rustup default stable-x86_64-pc-windows-msvc
``` ```
> https://stackoverflow.com/a/64121601
4. Install `pnpm` through `corepack` 4. Install `pnpm` through `corepack`
```powershell ```powershell
@ -112,6 +125,10 @@ git checkout -b <your-branch-name>
```shell ```shell
corepack enable corepack enable
pnpm install pnpm install
# For Rust dependencies
# Not required if you are not going to develop on either crates or apps/tamagotchi
cargo fetch
``` ```
> [!NOTE] > [!NOTE]

View file

@ -1,3 +1,3 @@
fn main() { fn main() {
tauri_build::build() tauri_build::build();
} }

View file

@ -0,0 +1,3 @@
pub mod native_macos;
pub mod native_windows;
pub mod state;

View file

@ -0,0 +1,72 @@
#[cfg(target_os = "macos")]
use objc2::{class, msg_send};
/// Get cursor position relative to the window
#[cfg(target_os = "macos")]
pub async fn is_cursor_in_window(window: &tauri::Window) -> bool {
use objc2_foundation::{NSPoint, NSRect};
unsafe {
// Get cursor position in screen coordinates (macOS coordinates - origin at bottom left)
let mouse_location: NSPoint = msg_send![class!(NSEvent), mouseLocation];
// Get all screens
//
// We need screens count because for multiple-display users,
// in macOS, the Native API returns the mouse coordinates relative to the primary
// display, for example, if we say the primary display is 1920x1080, another two lies
// on both sides of the primary display with the size of 1920x1080 too, mouse coordinates
// will be 0 at the left edge of p-display, and 1920 at the right edge of the p-display,
// -1080 at the left edge of the left-side display, and 2160 at the right edge of the right-side
// display.
let screens: *const objc2::runtime::AnyObject = msg_send![class!(NSScreen), screens];
let screens_count: usize = msg_send![screens, count];
// Get window position and size from Tauri
// Get the NSWindow from Tauri window to access native properties
//
// While Tauri does provide us window.outer_position() and window.outer_size()
// it's not enough to determine if the cursor is inside the window.
// The value of those two functions returns the in-compatible coordinates (top-left origin)
// with OBJ-C Native API values. This produces a lot of issues when calculating the collision
// of the cursor relative to the window.
//
// We need to get the window's frame in macOS coordinates (bottom-left origin)
// and check if the cursor is inside that frame.
let ns_window: *mut objc2::runtime::AnyObject = window.ns_window().unwrap().cast();
let window_frame: NSRect = msg_send![ns_window, frame];
// Log all screens information
for _ in 0..screens_count {
// For debugging purpose, screen object, frame size of the screen, visible frame size of the screen,
// and the scale factor of the screen can be obtained as follows:
//
// let screen: *const objc2::runtime::AnyObject = msg_send![screens, objectAtIndex: i];
// let frame: NSRect = msg_send![screen, frame];
// let visible_frame: NSRect = msg_send![screen, visibleFrame];
// let scale_factor: f64 = msg_send![screen, backingScaleFactor];
// Check if mouse is inside our window's frame
let is_inside = mouse_location.x >= window_frame.origin.x && mouse_location.x <= (window_frame.origin.x + window_frame.size.width) && mouse_location.y >= window_frame.origin.y && mouse_location.y <= (window_frame.origin.y + window_frame.size.height);
if is_inside {
return true;
}
}
}
false
}
/// Check if modifier key is pressed (Option/Alt or Control)
#[cfg(target_os = "macos")]
pub fn is_modifier_pressed() -> bool {
unsafe {
let event_class: &'static objc2::runtime::AnyClass = class!(NSEvent);
let flags: u64 = msg_send![event_class, modifierFlags];
// Check for Alt/Option (1 << 19) or Control (1 << 18)
// let ctrl_pressed = (flags & (1 << 18)) != 0;
// alt_pressed || ctrl_pressed
(flags & (1 << 19)) != 0
}
}

View file

@ -0,0 +1,35 @@
/// Get cursor position relative to the window
#[cfg(target_os = "windows")]
pub async fn is_cursor_in_window(window: &tauri::Window) -> bool {
use windows::Win32::{
Foundation::{POINT, RECT},
UI::WindowsAndMessaging::{GetCursorPos, GetWindowRect},
};
unsafe {
let hwnd = window.hwnd().unwrap();
let mut cursor_pos = POINT::default();
let mut window_rect = RECT::default();
// Get cursor position in screen coordinates
if GetCursorPos(&mut cursor_pos).is_ok() {
// Get window rectangle
if GetWindowRect(hwnd, &mut window_rect).is_ok() {
// Check if cursor is inside window bounds
return cursor_pos.x >= window_rect.left && cursor_pos.x <= window_rect.right && cursor_pos.y >= window_rect.top && cursor_pos.y <= window_rect.bottom;
}
}
false
}
}
/// Check if modifier key is pressed (Alt key)
#[cfg(target_os = "windows")]
pub fn is_modifier_pressed() -> bool {
use windows::Win32::UI::Input::KeyboardAndMouse::{GetAsyncKeyState, VK_MENU};
unsafe {
// Check if Alt key is pressed (VK_MENU is the virtual key code for Alt)
GetAsyncKeyState(VK_MENU.0 as i32) < 0
}
}

View file

@ -0,0 +1,37 @@
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
use tauri::{Emitter, Manager};
#[derive(Default)]
pub struct WindowClickThroughState {
pub monitoring_enabled: Arc<AtomicBool>,
pub enabled: Arc<AtomicBool>,
pub cursor_inside: Arc<AtomicBool>,
}
pub fn set_cursor_inside(window: &tauri::Window, is_inside: bool) -> Result<(), String> {
let state = window.state::<WindowClickThroughState>();
state.cursor_inside.store(is_inside, Ordering::Relaxed);
window.set_ignore_cursor_events(is_inside).map_err(|e| format!("Failed to set click-through state: {e}"))?;
let _ = window.emit("tauri-app:window-click-through:is-inside", is_inside);
Ok(())
}
pub fn set_click_through_enabled(window: &tauri::Window, enabled: bool) -> Result<(), String> {
let state = window.state::<WindowClickThroughState>();
state.enabled.store(enabled, Ordering::Relaxed);
window.set_ignore_cursor_events(enabled).map_err(|e| format!("Failed to set click-through state: {e}"))?;
let _ = window.emit("tauri-app:window-click-through:enabled", enabled);
Ok(())
}

View file

@ -1,20 +1,16 @@
use std::path::Path; use std::path::Path;
use tauri::{WebviewUrl, WebviewWindowBuilder};
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
use tauri::TitleBarStyle; use tauri::TitleBarStyle;
use tauri::{WebviewUrl, WebviewWindowBuilder};
pub fn new_chat_window(app: &tauri::AppHandle) -> Result<(), tauri::Error> { pub fn new_chat_window(app: &tauri::AppHandle) -> Result<(), tauri::Error> {
let mut builder = WebviewWindowBuilder::new( let mut builder = WebviewWindowBuilder::new(app, "chat", WebviewUrl::App(Path::new("#/chat").to_path_buf()))
app, .title("Chat")
"chat", .inner_size(600.0, 800.0)
WebviewUrl::App(Path::new("#/chat").to_path_buf()), .shadow(true)
) .transparent(false)
.title("Chat") .accept_first_mouse(true);
.inner_size(600.0, 800.0)
.shadow(true)
.transparent(false)
.accept_first_mouse(true);
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
{ {
@ -29,5 +25,5 @@ pub fn new_chat_window(app: &tauri::AppHandle) -> Result<(), tauri::Error> {
} }
builder.build()?; builder.build()?;
return Ok(()); Ok(())
} }

View file

@ -1,20 +1,16 @@
use std::path::Path; use std::path::Path;
use tauri::{WebviewUrl, WebviewWindowBuilder};
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
use tauri::TitleBarStyle; use tauri::TitleBarStyle;
use tauri::{WebviewUrl, WebviewWindowBuilder};
pub fn new_settings_window(app: &tauri::AppHandle) -> Result<(), tauri::Error> { pub fn new_settings_window(app: &tauri::AppHandle) -> Result<(), tauri::Error> {
let mut builder = WebviewWindowBuilder::new( let mut builder = WebviewWindowBuilder::new(app, "settings", WebviewUrl::App(Path::new("#/settings").to_path_buf()))
app, .title("Settings")
"settings", .inner_size(450.0, 800.0)
WebviewUrl::App(Path::new("#/settings").to_path_buf()), .shadow(true)
) .transparent(false)
.title("Settings") .accept_first_mouse(true);
.inner_size(450.0, 800.0)
.shadow(true)
.transparent(false)
.accept_first_mouse(true);
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
{ {
@ -29,5 +25,5 @@ pub fn new_settings_window(app: &tauri::AppHandle) -> Result<(), tauri::Error> {
} }
builder.build()?; builder.build()?;
return Ok(()); Ok(())
} }

View file

@ -1,6 +1,6 @@
use tauri::Manager; use tauri::Manager;
use crate::windows; use crate::app_windows;
#[tauri::command] #[tauri::command]
pub async fn open_settings_window(app: tauri::AppHandle) -> Result<(), tauri::Error> { pub async fn open_settings_window(app: tauri::AppHandle) -> Result<(), tauri::Error> {
@ -10,7 +10,7 @@ pub async fn open_settings_window(app: tauri::AppHandle) -> Result<(), tauri::Er
return Ok(()); return Ok(());
} }
windows::settings::new_settings_window(&app)?; app_windows::settings::new_settings_window(&app)?;
Ok(()) Ok(())
} }
@ -22,6 +22,6 @@ pub async fn open_chat_window(app: tauri::AppHandle) -> Result<(), tauri::Error>
return Ok(()); return Ok(());
} }
windows::chat::new_chat_window(&app)?; app_windows::chat::new_chat_window(&app)?;
Ok(()) Ok(())
} }

View file

@ -1,177 +1,54 @@
use tauri::menu::{Menu, MenuItem}; use std::{sync::atomic::Ordering, time::Duration};
use tauri::tray::TrayIconBuilder;
use tauri::Emitter;
use tauri::RunEvent;
use std::sync::atomic::{AtomicBool, Ordering}; use tauri::{
use std::sync::Arc; menu::{Menu, MenuItem},
use std::time::Duration; tray::TrayIconBuilder,
Emitter,
Manager,
RunEvent,
WebviewUrl,
WebviewWindowBuilder,
};
use tauri_plugin_prevent_default::Flags;
use tokio::time::sleep; use tokio::time::sleep;
#[cfg(target_os = "macos")] mod app_click_through;
use objc2::{class, msg_send}; mod app_windows;
#[cfg(target_os = "macos")]
use objc2_foundation::{NSPoint, NSRect};
#[cfg(target_os = "macos")]
use tauri::{ActivationPolicy, TitleBarStyle};
use tauri::{Manager, WebviewUrl, WebviewWindowBuilder};
use tauri_plugin_prevent_default::Flags;
#[cfg(target_os = "windows")]
use windows::Win32::UI::WindowsAndMessaging::{
GetWindowLongW, SetWindowLongW, GWL_EXSTYLE, WS_EX_TRANSPARENT,
};
mod commands; mod commands;
mod windows;
#[derive(Default)]
struct WindowClickThroughState {
is_click_through_monitoring_enabled: Arc<AtomicBool>,
is_click_through_enabled: Arc<AtomicBool>,
is_click_through_cursor_inside: Arc<AtomicBool>,
}
fn set_cursor_inside(window: &tauri::Window, is_inside: bool) -> Result<(), String> {
let state = window.state::<WindowClickThroughState>();
state
.is_click_through_cursor_inside
.store(is_inside, Ordering::Relaxed);
window
.set_ignore_cursor_events(is_inside)
.map_err(|e| format!("Failed to set click-through state: {}", e))?;
let _ = window.emit("tauri-app:window-click-through:is-inside", is_inside);
Ok(())
}
fn set_click_through_enabled(window: &tauri::Window, enabled: bool) -> Result<(), String> {
let state = window.state::<WindowClickThroughState>();
state
.is_click_through_enabled
.store(enabled, Ordering::Relaxed);
window
.set_ignore_cursor_events(enabled)
.map_err(|e| format!("Failed to set click-through state: {}", e))?;
let _ = window.emit("tauri-app:window-click-through:enabled", enabled);
Ok(())
}
/// Get cursor position relative to the window
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
async fn is_cursor_in_window(window: &tauri::Window) -> bool { use app_click_through::native_macos::{is_cursor_in_window, is_modifier_pressed};
unsafe { #[cfg(target_os = "windows")]
// Get cursor position in screen coordinates (macOS coordinates - origin at bottom left) use app_click_through::native_windows::{is_cursor_in_window, is_modifier_pressed};
let mouse_location: NSPoint = msg_send![class!(NSEvent), mouseLocation]; use app_click_through::state::{set_click_through_enabled, set_cursor_inside, WindowClickThroughState};
// Get all screens
//
// We need screens count because for multiple-display users,
// in macOS, the Native API returns the mouse coordinates relative to the primary
// display, for example, if we say the primary display is 1920x1080, another two lies
// on both sides of the primary display with the size of 1920x1080 too, mouse coordinates
// will be 0 at the left edge of p-display, and 1920 at the right edge of the p-display,
// -1080 at the left edge of the left-side display, and 2160 at the right edge of the right-side
// display.
let screens: *const objc2::runtime::AnyObject = msg_send![class!(NSScreen), screens];
let screens_count: usize = msg_send![screens, count];
// Get window position and size from Tauri
// Get the NSWindow from Tauri window to access native properties
//
// While Tauri does provide us window.outer_position() and window.outer_size()
// it's not enough to determine if the cursor is inside the window.
// The value of those two functions returns the in-compatible coordinates (top-left origin)
// with OBJ-C Native API values. This produces a lot of issues when calculating the collision
// of the cursor relative to the window.
//
// We need to get the window's frame in macOS coordinates (bottom-left origin)
// and check if the cursor is inside that frame.
let ns_window: *mut objc2::runtime::AnyObject = window.ns_window().unwrap() as *mut _;
let window_frame: NSRect = msg_send![ns_window, frame];
// Log all screens information
for _ in 0..screens_count {
// For debugging purpose, screen object, frame size of the screen, visible frame size of the screen,
// and the scale factor of the screen can be obtained as follows:
//
// let screen: *const objc2::runtime::AnyObject = msg_send![screens, objectAtIndex: i];
// let frame: NSRect = msg_send![screen, frame];
// let visible_frame: NSRect = msg_send![screen, visibleFrame];
// let scale_factor: f64 = msg_send![screen, backingScaleFactor];
// Check if mouse is inside our window's frame
let is_inside = mouse_location.x >= window_frame.origin.x
&& mouse_location.x <= (window_frame.origin.x + window_frame.size.width)
&& mouse_location.y >= window_frame.origin.y
&& mouse_location.y <= (window_frame.origin.y + window_frame.size.height);
if is_inside {
return true;
}
}
}
false
}
/// Check if modifier key is pressed (Option/Alt or Control)
#[cfg(target_os = "macos")]
fn is_modifier_pressed() -> bool {
unsafe {
let event_class: &'static objc2::runtime::AnyClass = class!(NSEvent);
let flags: u64 = msg_send![event_class, modifierFlags];
// Check for Alt/Option (1 << 19) or Control (1 << 18)
let alt_pressed = (flags & (1 << 19)) != 0;
// let ctrl_pressed = (flags & (1 << 18)) != 0;
// alt_pressed || ctrl_pressed
alt_pressed
}
}
#[tauri::command] #[tauri::command]
async fn start_monitor_for_clicking_through(window: tauri::Window) -> Result<(), String> { async fn start_monitor_for_clicking_through(window: tauri::Window) -> Result<(), String> {
log::info!("Starting monitor for clicking through"); let window = window;
let window = window.clone();
let state = window.state::<WindowClickThroughState>(); let state = window.state::<WindowClickThroughState>();
let is_click_through_enabled = state.is_click_through_enabled.clone(); let enabled = state.enabled.clone();
let is_click_through_monitoring_enabled = state.is_click_through_monitoring_enabled.clone(); let monitoring_enabled = state.monitoring_enabled.clone();
// Already monitoring? // Already monitoring?
if is_click_through_monitoring_enabled.load(Ordering::Relaxed) { if monitoring_enabled.load(Ordering::Relaxed) {
return Ok(()); return Ok(());
} else {
// Set to true
state
.is_click_through_monitoring_enabled
.store(true, Ordering::Relaxed);
} }
// Set to true
state.monitoring_enabled.store(true, Ordering::Relaxed);
// Then start interval timer for monitoring // Then start interval timer for monitoring
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
loop { loop {
sleep(Duration::from_millis(32)).await; // ~30FPS check rate sleep(Duration::from_millis(32)).await; // ~30FPS check rate
// If monitoring is already stopped, break the loop // If monitoring is already stopped, break the loop
if !is_click_through_monitoring_enabled.load(Ordering::Relaxed) { if !monitoring_enabled.load(Ordering::Relaxed) {
log::info!("Monitoring is stopped, breaking loop");
break; break;
} }
// If is disabled already, skip until next check // If is disabled already, skip until next check
if !is_click_through_enabled.load(Ordering::Relaxed) { if !enabled.load(Ordering::Relaxed) {
log::info!("Click-through is disabled, skipping check");
continue; continue;
} }
@ -180,41 +57,36 @@ async fn start_monitor_for_clicking_through(window: tauri::Window) -> Result<(),
let cursor_inside = is_cursor_in_window(&window).await; let cursor_inside = is_cursor_in_window(&window).await;
let modifier_pressed = is_modifier_pressed(); let modifier_pressed = is_modifier_pressed();
log::info!( // Only allow disabling click-through when:
"Cursor inside: {}, Modifier pressed: {}", // 1. Cursor is OUTSIDE the window AND
cursor_inside, // 2. Modifier key is pressed
modifier_pressed let _ = set_cursor_inside(&window, cursor_inside && !modifier_pressed);
); }
#[cfg(target_os = "windows")]
{
let cursor_inside = is_cursor_in_window(&window).await;
let modifier_pressed = is_modifier_pressed();
// Only allow disabling click-through when: // Only allow disabling click-through when:
// 1. Cursor is OUTSIDE the window AND // 1. Cursor is OUTSIDE the window AND
// 2. Modifier key is pressed // 2. Modifier key is pressed
let should_be_click_through = cursor_inside && !modifier_pressed; let _ = set_cursor_inside(&window, cursor_inside && !modifier_pressed);
if should_be_click_through {
let _ = set_cursor_inside(&window, true);
} else {
let _ = set_cursor_inside(&window, false);
}
} }
} }
}); });
return Ok(()); Ok(())
} }
#[tauri::command] #[tauri::command]
async fn stop_monitor_for_clicking_through(window: tauri::Window) -> Result<(), String> { async fn stop_monitor_for_clicking_through(window: tauri::Window) -> Result<(), String> {
log::info!("Stopping monitor for clicking through"); let window = window;
let window = window.clone();
let state = window.state::<WindowClickThroughState>(); let state = window.state::<WindowClickThroughState>();
// Set to false // Set to false
// Termination will be triggered in the next interval check (tick) // Termination will be triggered in the next interval check (tick)
state state.monitoring_enabled.store(false, Ordering::Relaxed);
.is_click_through_monitoring_enabled
.store(false, Ordering::Relaxed);
Ok(()) Ok(())
} }
@ -232,11 +104,11 @@ async fn stop_click_through(window: tauri::Window) -> Result<(), String> {
} }
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
#[allow(clippy::missing_panics_doc)]
pub fn run() { pub fn run() {
let prevent_default_plugin = tauri_plugin_prevent_default::Builder::new() let prevent_default_plugin = tauri_plugin_prevent_default::Builder::new().with_flags(Flags::RELOAD).build();
.with_flags(Flags::RELOAD)
.build();
#[allow(clippy::missing_panics_doc)]
tauri::Builder::default() tauri::Builder::default()
.plugin(prevent_default_plugin) .plugin(prevent_default_plugin)
.plugin(tauri_plugin_mcp::Builder.build()) .plugin(tauri_plugin_mcp::Builder.build())
@ -245,30 +117,22 @@ pub fn run() {
.setup(|app| { .setup(|app| {
let mut builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default()); let mut builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default());
builder = builder builder = builder.title("AIRI").decorations(false).inner_size(450.0, 600.0).shadow(false).transparent(true).always_on_top(true);
.title("AIRI")
.decorations(false)
.inner_size(450.0, 600.0)
.shadow(false)
.transparent(true)
.always_on_top(true);
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
{ {
builder = builder.title_bar_style(TitleBarStyle::Transparent); builder = builder.title_bar_style(tauri::TitleBarStyle::Transparent);
} }
let _ = builder.build().unwrap(); let _ = builder.build().unwrap();
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
app.set_activation_policy(ActivationPolicy::Accessory); // hide dock icon {
app.set_activation_policy(tauri::ActivationPolicy::Accessory); // hide dock icon
}
if cfg!(debug_assertions) { if cfg!(debug_assertions) {
app.handle().plugin( app.handle().plugin(tauri_plugin_log::Builder::default().level(log::LevelFilter::Info).build())?;
tauri_plugin_log::Builder::default()
.level(log::LevelFilter::Info)
.build(),
)?;
} }
// TODO: i18n // TODO: i18n
@ -295,7 +159,7 @@ pub fn run() {
return; return;
} }
windows::settings::new_settings_window(app).unwrap(); app_windows::settings::new_settings_window(app).unwrap();
} }
"hide" => { "hide" => {
let window = app.get_webview_window("settings"); let window = app.get_webview_window("settings");
@ -326,11 +190,10 @@ pub fn run() {
]) ])
.build(tauri::generate_context!()) .build(tauri::generate_context!())
.expect("error while building tauri application") .expect("error while building tauri application")
.run(|_, event| match event { .run(|_, event| {
RunEvent::ExitRequested { .. } => { if let RunEvent::ExitRequested { .. } = event {
println!("Exiting app"); println!("Exiting app");
println!("Exited app"); println!("Exited app");
} }
_ => {}
}); });
} }

View file

@ -1,9 +1,4 @@
const COMMANDS: &[&str] = &[ const COMMANDS: &[&str] = &["connect_server", "disconnect_server", "list_tools", "call_tool"];
"connect_server",
"disconnect_server",
"list_tools",
"call_tool",
];
fn main() { fn main() {
tauri_plugin::Builder::new(COMMANDS).build(); tauri_plugin::Builder::new(COMMANDS).build();

View file

@ -1,29 +1,33 @@
use std::process::Stdio; use std::process::Stdio;
use rmcp::model::CallToolRequestParam;
use rmcp::{ use rmcp::{
model::{CallToolResult, Tool}, model::{CallToolRequestParam, CallToolResult, Tool},
service::RunningService, service::RunningService,
transport::TokioChildProcess, transport::TokioChildProcess,
RoleClient, ServiceExt, RoleClient,
ServiceExt,
}; };
use serde_json::{Map, Value}; use serde_json::{Map, Value};
use tauri::{ use tauri::{
plugin::{self, TauriPlugin}, plugin::{self, TauriPlugin},
Manager, Runtime, AppHandle,
Manager,
Runtime,
State,
}; };
use tauri::{AppHandle, State}; use tokio::{process::Command, sync::Mutex};
use tokio::process::Command;
use tokio::sync::Mutex;
pub struct McpState { pub struct McpState {
pub client: Option<RunningService<RoleClient, ()>>, pub client: Option<RunningService<RoleClient, ()>>,
} }
#[allow(clippy::missing_panics_doc)]
pub fn destroy<R: Runtime>(app_handle: &AppHandle<R>) { pub fn destroy<R: Runtime>(app_handle: &AppHandle<R>) {
println!("Destroying MCP plugin"); println!("Destroying MCP plugin");
tokio::runtime::Runtime::new().unwrap().block_on(async { tokio::runtime::Runtime::new().unwrap().block_on(async {
let state = app_handle.state::<Mutex<McpState>>(); let state = app_handle.state::<Mutex<McpState>>();
let mut state = state.lock().await; let mut state = state.lock().await;
if state.client.is_none() { if state.client.is_none() {
println!("MCP plugin not connected, no need to disconnect"); println!("MCP plugin not connected, no need to disconnect");
@ -31,36 +35,29 @@ pub fn destroy<R: Runtime>(app_handle: &AppHandle<R>) {
} }
let client = state.client.take().unwrap(); let client = state.client.take().unwrap();
drop(state);
client.cancel().await.unwrap(); client.cancel().await.unwrap();
// client.waiting().await.unwrap(); // client.waiting().await.unwrap();
state.client = None;
}); });
println!("MCP plugin destroyed"); println!("MCP plugin destroyed");
} }
#[tauri::command] #[tauri::command]
async fn connect_server( async fn connect_server(state: State<'_, Mutex<McpState>>, command: String, args: Vec<String>) -> Result<(), String> {
state: State<'_, Mutex<McpState>>,
command: String,
args: Vec<String>,
) -> Result<(), String> {
let mut state = state.lock().await; let mut state = state.lock().await;
if state.client.is_some() { if state.client.is_some() {
return Err("Client already connected".to_string()); return Err("Client already connected".to_string());
} }
let child_process = TokioChildProcess::new( let child_process = TokioChildProcess::new(Command::new(command).args(args).stderr(Stdio::inherit()).stdout(Stdio::inherit())).unwrap();
Command::new(command)
.args(args)
.stderr(Stdio::inherit())
.stdout(Stdio::inherit()),
)
.unwrap();
let service: RunningService<RoleClient, ()> = ().serve(child_process).await.unwrap(); let service: RunningService<RoleClient, ()> = ().serve(child_process).await.unwrap();
state.client = Some(service); state.client = Some(service);
drop(state);
Ok(()) Ok(())
} }
@ -73,9 +70,9 @@ async fn disconnect_server(state: State<'_, Mutex<McpState>>) -> Result<(), Stri
} }
let cancel_result = state.client.take().unwrap().cancel().await; let cancel_result = state.client.take().unwrap().cancel().await;
println!("Cancel result: {:?}", cancel_result); println!("Cancel result: {cancel_result:?}");
// state.client.take().unwrap().waiting().await.unwrap(); // state.client.take().unwrap().waiting().await.unwrap();
state.client = None; drop(state);
println!("Disconnected from MCP server"); println!("Disconnected from MCP server");
@ -90,24 +87,17 @@ async fn list_tools(state: State<'_, Mutex<McpState>>) -> Result<Vec<Tool>, Stri
return Err("Client not connected".to_string()); return Err("Client not connected".to_string());
} }
let list_tools_result = client let list_tools_result = client.unwrap().list_tools(Option::default()).await.unwrap(); // TODO: handle error
.unwrap()
.list_tools(Default::default())
.await
.unwrap(); // TODO: handle error
let tools = list_tools_result.tools; let tools = list_tools_result.tools;
drop(state);
Ok(tools) Ok(tools)
} }
#[tauri::command] #[tauri::command]
async fn call_tool( async fn call_tool(state: State<'_, Mutex<McpState>>, name: String, args: Option<Map<String, Value>>) -> Result<CallToolResult, String> {
state: State<'_, Mutex<McpState>>, println!("Calling tool: {name:?}");
name: String, println!("Arguments: {args:?}");
args: Option<Map<String, Value>>,
) -> Result<CallToolResult, String> {
println!("Calling tool: {:?}", name);
println!("Arguments: {:?}", args);
let state = state.lock().await; let state = state.lock().await;
let client = state.client.as_ref(); let client = state.client.as_ref();
@ -115,16 +105,10 @@ async fn call_tool(
return Err("Client not connected".to_string()); return Err("Client not connected".to_string());
} }
let call_tool_result = client let call_tool_result = client.unwrap().call_tool(CallToolRequestParam { name: name.into(), arguments: args }).await.unwrap();
.unwrap() drop(state);
.call_tool(CallToolRequestParam {
name: name.into(),
arguments: args,
})
.await
.unwrap();
println!("Tool result: {:?}", call_tool_result); println!("Tool result: {call_tool_result:?}");
Ok(call_tool_result) Ok(call_tool_result)
} }
@ -133,16 +117,12 @@ async fn call_tool(
pub struct Builder; pub struct Builder;
impl Builder { impl Builder {
#[must_use]
pub fn build<R: Runtime>(self) -> TauriPlugin<R> { pub fn build<R: Runtime>(self) -> TauriPlugin<R> {
println!("Building MCP plugin"); println!("Building MCP plugin");
plugin::Builder::new("mcp") plugin::Builder::new("mcp")
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![connect_server, disconnect_server, list_tools, call_tool])
connect_server,
disconnect_server,
list_tools,
call_tool
])
.setup(|app_handle, _| { .setup(|app_handle, _| {
app_handle.manage(Mutex::new(McpState { client: None })); app_handle.manage(Mutex::new(McpState { client: None }));
Ok(()) Ok(())

View file

@ -15,6 +15,10 @@ Hello! Thank you for your interest in contributing to this project. This guide w
<details> <details>
<summary>Windows setup</summary> <summary>Windows setup</summary>
0. Download [Visual Studio](https://visualstudio.microsoft.com/downloads/), and follow the instructions here: https://rust-lang.github.io/rustup/installation/windows-msvc.html#walkthrough-installing-visual-studio-2022
> Make sure to install Windows SDK and C++ build tools when installing Visual Studio.
1. Open PowerShell 1. Open PowerShell
2. Install [`scoop`](https://scoop.sh/) 2. Install [`scoop`](https://scoop.sh/)
@ -23,10 +27,17 @@ Hello! Thank you for your interest in contributing to this project. This guide w
Invoke-RestMethod -Uri https://get.scoop.sh | Invoke-Expression Invoke-RestMethod -Uri https://get.scoop.sh | Invoke-Expression
``` ```
3. Install `git`, `node` through `scoop` 3. Install `git`, Node.js, `rustup`, `msvc` through `scoop`
```powershell ```powershell
scoop install git nodejs scoop install git nodejs rustup
# For Rust dependencies
# Not required if you are not going to develop on either crates or apps/tamagotchi
scoop install main/rust-msvc
# Rust & Windows specific
rustup toolchain install stable-x86_64-pc-windows-msvc
rustup default stable-x86_64-pc-windows-msvc
``` ```
4. Install `pnpm` through `corepack` 4. Install `pnpm` through `corepack`
@ -115,6 +126,10 @@ git checkout -b <your-branch-name>
```shell ```shell
corepack enable corepack enable
pnpm install pnpm install
# For Rust dependencies
# Not required if you are not going to develop on either crates or apps/tamagotchi
cargo fetch
``` ```
:::note :::note

13
rust-toolchain.toml Normal file
View file

@ -0,0 +1,13 @@
# https://rust-lang.github.io/rustup/overrides.html#the-toolchain-file
[toolchain]
channel = "nightly"
# https://rust-lang.github.io/rustup/concepts/components.html
components = [
"rustc",
"cargo",
"rustfmt",
"rust-analyzer",
"clippy"
]
profile = "minimal"

View file

@ -1 +1,24 @@
# https://github.com/rust-lang/rustfmt/blob/master/Configurations.md
edition = "2024"
tab_spaces = 2 tab_spaces = 2
newline_style = "Unix"
imports_indent = "Block" # nightly
match_arm_leading_pipes = "Preserve"
# empty_item_single_line = true # nightly
group_imports = "StdExternalCrate" # nightly
imports_granularity = "Crate" # nightly
imports_layout = "HorizontalVertical" # nightly
# match_arm_blocks = false # nightly
match_block_trailing_comma = true
# normalize_comments = true # nightly
# normalize_doc_attributes = true # nightly
# overflow_delimited_expr = true # nightly
# reorder_impl_items = true # nightly
# spaces_around_ranges = true # nightly
# unstable_features = true # nightly
use_field_init_shorthand = true
use_try_shorthand = true
struct_field_align_threshold = 999
enum_discrim_align_threshold = 999
max_width = 256