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
[*.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
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true

View file

@ -9,6 +9,13 @@ on:
branches:
- main
env:
# Rust
CARGO_TERM_COLOR: always
# https://github.com/Mozilla-Actions/sccache-action#rust-code
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: 'true'
jobs:
lint:
name: Lint
@ -16,15 +23,16 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt,clippy
# # Rust
# - uses: dtolnay/rust-toolchain@nightly
# - uses: mozilla-actions/sccache-action@v0.0.3
- name: Install system dependencies
run: |
sudo apt update
sudo apt install -y libwebkit2gtk-4.1-dev
# Node.js
- uses: pnpm/action-setup@v3
- uses: actions/setup-node@v4
with:
@ -39,12 +47,20 @@ jobs:
pnpm run lint
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:
name: Build Test
runs-on: ubuntu-latest
steps:
- 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
run: |
@ -64,13 +80,18 @@ jobs:
run: |
pnpm run build:packages
pnpm run build:apps
pnpm run build:crates
# - name: Build Rust
# run: |
# pnpm run build:crates
typecheck:
name: Type Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Node.js
- uses: pnpm/action-setup@v3
- uses: actions/setup-node@v4
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>
<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
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
```
3. Install `git`, `node` through `scoop`
3. Install `git`, Node.js, `rustup`, `msvc` through `scoop`
```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`
```powershell
@ -112,6 +125,10 @@ git checkout -b <your-branch-name>
```shell
corepack enable
pnpm install
# For Rust dependencies
# Not required if you are not going to develop on either crates or apps/tamagotchi
cargo fetch
```
> [!NOTE]

View file

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

View file

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

View file

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

View file

@ -1,177 +1,54 @@
use tauri::menu::{Menu, MenuItem};
use tauri::tray::TrayIconBuilder;
use tauri::Emitter;
use tauri::RunEvent;
use std::{sync::atomic::Ordering, time::Duration};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tauri::{
menu::{Menu, MenuItem},
tray::TrayIconBuilder,
Emitter,
Manager,
RunEvent,
WebviewUrl,
WebviewWindowBuilder,
};
use tauri_plugin_prevent_default::Flags;
use tokio::time::sleep;
#[cfg(target_os = "macos")]
use objc2::{class, msg_send};
#[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 app_click_through;
mod app_windows;
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")]
async fn is_cursor_in_window(window: &tauri::Window) -> bool {
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() 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
}
}
use app_click_through::native_macos::{is_cursor_in_window, is_modifier_pressed};
#[cfg(target_os = "windows")]
use app_click_through::native_windows::{is_cursor_in_window, is_modifier_pressed};
use app_click_through::state::{set_click_through_enabled, set_cursor_inside, WindowClickThroughState};
#[tauri::command]
async fn start_monitor_for_clicking_through(window: tauri::Window) -> Result<(), String> {
log::info!("Starting monitor for clicking through");
let window = window.clone();
let window = window;
let state = window.state::<WindowClickThroughState>();
let is_click_through_enabled = state.is_click_through_enabled.clone();
let is_click_through_monitoring_enabled = state.is_click_through_monitoring_enabled.clone();
let enabled = state.enabled.clone();
let monitoring_enabled = state.monitoring_enabled.clone();
// Already monitoring?
if is_click_through_monitoring_enabled.load(Ordering::Relaxed) {
if monitoring_enabled.load(Ordering::Relaxed) {
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
tauri::async_runtime::spawn(async move {
loop {
sleep(Duration::from_millis(32)).await; // ~30FPS check rate
// If monitoring is already stopped, break the loop
if !is_click_through_monitoring_enabled.load(Ordering::Relaxed) {
log::info!("Monitoring is stopped, breaking loop");
if !monitoring_enabled.load(Ordering::Relaxed) {
break;
}
// If is disabled already, skip until next check
if !is_click_through_enabled.load(Ordering::Relaxed) {
log::info!("Click-through is disabled, skipping check");
if !enabled.load(Ordering::Relaxed) {
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 modifier_pressed = is_modifier_pressed();
log::info!(
"Cursor inside: {}, Modifier pressed: {}",
cursor_inside,
modifier_pressed
);
// Only allow disabling click-through when:
// 1. Cursor is OUTSIDE the window AND
// 2. Modifier key is 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:
// 1. Cursor is OUTSIDE the window AND
// 2. Modifier key is pressed
let should_be_click_through = cursor_inside && !modifier_pressed;
if should_be_click_through {
let _ = set_cursor_inside(&window, true);
} else {
let _ = set_cursor_inside(&window, false);
}
let _ = set_cursor_inside(&window, cursor_inside && !modifier_pressed);
}
}
});
return Ok(());
Ok(())
}
#[tauri::command]
async fn stop_monitor_for_clicking_through(window: tauri::Window) -> Result<(), String> {
log::info!("Stopping monitor for clicking through");
let window = window.clone();
let window = window;
let state = window.state::<WindowClickThroughState>();
// Set to false
// Termination will be triggered in the next interval check (tick)
state
.is_click_through_monitoring_enabled
.store(false, Ordering::Relaxed);
state.monitoring_enabled.store(false, Ordering::Relaxed);
Ok(())
}
@ -232,11 +104,11 @@ async fn stop_click_through(window: tauri::Window) -> Result<(), String> {
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
#[allow(clippy::missing_panics_doc)]
pub fn run() {
let prevent_default_plugin = tauri_plugin_prevent_default::Builder::new()
.with_flags(Flags::RELOAD)
.build();
let prevent_default_plugin = tauri_plugin_prevent_default::Builder::new().with_flags(Flags::RELOAD).build();
#[allow(clippy::missing_panics_doc)]
tauri::Builder::default()
.plugin(prevent_default_plugin)
.plugin(tauri_plugin_mcp::Builder.build())
@ -245,30 +117,22 @@ pub fn run() {
.setup(|app| {
let mut builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default());
builder = builder
.title("AIRI")
.decorations(false)
.inner_size(450.0, 600.0)
.shadow(false)
.transparent(true)
.always_on_top(true);
builder = builder.title("AIRI").decorations(false).inner_size(450.0, 600.0).shadow(false).transparent(true).always_on_top(true);
#[cfg(target_os = "macos")]
{
builder = builder.title_bar_style(TitleBarStyle::Transparent);
builder = builder.title_bar_style(tauri::TitleBarStyle::Transparent);
}
let _ = builder.build().unwrap();
#[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) {
app.handle().plugin(
tauri_plugin_log::Builder::default()
.level(log::LevelFilter::Info)
.build(),
)?;
app.handle().plugin(tauri_plugin_log::Builder::default().level(log::LevelFilter::Info).build())?;
}
// TODO: i18n
@ -295,7 +159,7 @@ pub fn run() {
return;
}
windows::settings::new_settings_window(app).unwrap();
app_windows::settings::new_settings_window(app).unwrap();
}
"hide" => {
let window = app.get_webview_window("settings");
@ -326,11 +190,10 @@ pub fn run() {
])
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(|_, event| match event {
RunEvent::ExitRequested { .. } => {
.run(|_, event| {
if let RunEvent::ExitRequested { .. } = event {
println!("Exiting app");
println!("Exited app");
}
_ => {}
});
}

View file

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

View file

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

View file

@ -15,6 +15,10 @@ Hello! Thank you for your interest in contributing to this project. This guide w
<details>
<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
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
```
3. Install `git`, `node` through `scoop`
3. Install `git`, Node.js, `rustup`, `msvc` through `scoop`
```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`
@ -115,6 +126,10 @@ git checkout -b <your-branch-name>
```shell
corepack enable
pnpm install
# For Rust dependencies
# Not required if you are not going to develop on either crates or apps/tamagotchi
cargo fetch
```
:::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
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