hotkey to activate neko interactions

This commit is contained in:
2026-01-05 16:40:27 +08:00
parent 96ba44613d
commit ca995899fe
5 changed files with 196 additions and 31 deletions

View File

@@ -1,10 +1,125 @@
use crate::get_app_handle; use std::sync::atomic::{AtomicBool, Ordering};
use tauri::Manager; use std::sync::Arc;
use std::thread;
use device_query::{DeviceQuery, DeviceState, Keycode};
use once_cell::sync::OnceCell;
use tauri::{Emitter, Manager};
use tauri_plugin_positioner::WindowExt; use tauri_plugin_positioner::WindowExt;
use tracing::{error, info}; use tracing::{error, info, warn};
use crate::get_app_handle;
pub static SCENE_WINDOW_LABEL: &str = "scene"; pub static SCENE_WINDOW_LABEL: &str = "scene";
pub static SPLASH_WINDOW_LABEL: &str = "splash"; pub static SPLASH_WINDOW_LABEL: &str = "splash";
static SCENE_INTERACTIVE_STATE: OnceCell<Arc<AtomicBool>> = OnceCell::new();
static MODIFIER_LISTENER_INIT: OnceCell<()> = OnceCell::new();
fn scene_interactive_state() -> Arc<AtomicBool> {
SCENE_INTERACTIVE_STATE
.get_or_init(|| Arc::new(AtomicBool::new(false)))
.clone()
}
fn update_scene_interactive(interactive: bool) {
let app_handle = get_app_handle();
if let Some(window) = app_handle.get_window(SCENE_WINDOW_LABEL) {
if let Err(e) = window.set_ignore_cursor_events(!interactive) {
error!("Failed to toggle scene cursor events: {}", e);
}
if let Err(e) = window.emit("scene-interactive", &interactive) {
error!("Failed to emit scene interactive event: {}", e);
}
} else {
warn!("Scene window not available for interactive update");
}
}
#[cfg(target_os = "macos")]
#[link(name = "ApplicationServices", kind = "framework")]
extern "C" {
fn AXIsProcessTrusted() -> bool;
}
fn start_scene_modifier_listener() {
MODIFIER_LISTENER_INIT.get_or_init(|| {
let state = scene_interactive_state();
update_scene_interactive(false);
let app_handle = get_app_handle().clone();
#[cfg(target_os = "macos")]
unsafe {
info!("Accessibility status: {}", AXIsProcessTrusted());
if !AXIsProcessTrusted() {
// Warning only - polling might work without explicit permissions for just key state in some contexts,
// or we just want to avoid the crash. We'll show the dialog but not return early if we want to try anyway.
// However, usually global key monitoring requires it.
// Let's show the dialog but NOT return, to try polling.
// Or better, let's keep the return if we think it won't work at all,
// but since the crash was the main issue, let's try to proceed safely.
// For now, I will keep the dialog and the return to encourage users to enable it,
// as it's likely needed for global input monitoring.
// On second thought, let's keep the return to be safe and clear to the user.
error!("Accessibility permissions not granted. Global modifier listener will NOT start.");
use tauri_plugin_dialog::DialogExt;
use tauri_plugin_dialog::MessageDialogBuilder;
use tauri_plugin_dialog::MessageDialogKind;
MessageDialogBuilder::new(
app_handle.dialog().clone(),
"Missing Permissions",
"Friendolls needs Accessibility permissions to detect the Alt key for interactivity. Please grant permissions in System Settings -> Privacy & Security -> Accessibility and restart the app.",
)
.kind(MessageDialogKind::Warning)
.show(|_| {});
return;
}
}
// Spawn a thread for polling key state
thread::spawn(move || {
let device_state = DeviceState::new();
let mut last_interactive = false;
loop {
let keys = device_state.get_keys();
// Check for Alt key (Option on Mac)
let interactive = (keys.contains(&Keycode::LAlt) || keys.contains(&Keycode::RAlt)) || keys.contains(&Keycode::Command);
if interactive != last_interactive {
// State changed
info!("Key down state chanegd!");
let previous = state.swap(interactive, Ordering::SeqCst);
if previous != interactive {
if let Some(window) = app_handle.get_window(SCENE_WINDOW_LABEL) {
if let Err(err) = window.set_ignore_cursor_events(!interactive) {
error!("Failed to toggle scene cursor events: {}", err);
}
if let Err(err) = window.emit("scene-interactive", &interactive) {
error!("Failed to emit scene interactive event: {}", err);
}
} else {
warn!("Scene window not available for interactive update");
}
}
last_interactive = interactive;
}
// Poll every 100ms
thread::sleep(std::time::Duration::from_millis(100));
}
});
});
}
pub fn overlay_fullscreen(window: &tauri::Window) -> Result<(), tauri::Error> { pub fn overlay_fullscreen(window: &tauri::Window) -> Result<(), tauri::Error> {
// Get the primary monitor // Get the primary monitor
let monitor = get_app_handle().primary_monitor()?.unwrap(); let monitor = get_app_handle().primary_monitor()?.unwrap();
@@ -142,6 +257,9 @@ pub fn open_scene_window() {
return; return;
} }
// Start global modifier listener once scene window exists
start_scene_modifier_listener();
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
webview_window.open_devtools(); webview_window.open_devtools();

View File

@@ -0,0 +1,37 @@
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { writable } from "svelte/store";
export const sceneInteractive = writable<boolean>(false);
let unlisten: UnlistenFn | null = null;
let isListening = false;
export async function initSceneInteractiveListener() {
if (isListening) return;
try {
// ensure initial default matches backend default
sceneInteractive.set(false);
unlisten = await listen<boolean>("scene-interactive", (event) => {
sceneInteractive.set(Boolean(event.payload));
});
isListening = true;
} catch (error) {
console.error("Failed to initialize scene interactive listener:", error);
throw error;
}
}
export function stopSceneInteractiveListener() {
if (unlisten) {
unlisten();
unlisten = null;
isListening = false;
}
}
if (import.meta.hot) {
import.meta.hot.dispose(() => {
stopSceneInteractiveListener();
});
}

View File

@@ -3,6 +3,10 @@
import { onMount, onDestroy } from "svelte"; import { onMount, onDestroy } from "svelte";
import { initCursorTracking, stopCursorTracking } from "../events/cursor"; import { initCursorTracking, stopCursorTracking } from "../events/cursor";
import { initAppDataListener } from "../events/app-data"; import { initAppDataListener } from "../events/app-data";
import {
initSceneInteractiveListener,
stopSceneInteractiveListener,
} from "../events/scene-interactive";
let { children } = $props(); let { children } = $props();
if (browser) { if (browser) {
@@ -10,6 +14,7 @@
try { try {
await initCursorTracking(); await initCursorTracking();
await initAppDataListener(); await initAppDataListener();
await initSceneInteractiveListener();
} catch (err) { } catch (err) {
console.error("Failed to initialize event listeners:", err); console.error("Failed to initialize event listeners:", err);
} }
@@ -17,6 +22,7 @@
onDestroy(() => { onDestroy(() => {
stopCursorTracking(); stopCursorTracking();
stopSceneInteractiveListener();
}); });
} }
</script> </script>

View File

@@ -5,12 +5,15 @@
friendsActiveDolls, friendsActiveDolls,
} from "../../events/cursor"; } from "../../events/cursor";
import { appData } from "../../events/app-data"; import { appData } from "../../events/app-data";
import { sceneInteractive } from "../../events/scene-interactive";
import DesktopPet from "./DesktopPet.svelte"; import DesktopPet from "./DesktopPet.svelte";
let innerWidth = 0; let innerWidth = 0;
let innerHeight = 0; let innerHeight = 0;
$: isInteractive = $sceneInteractive;
function getFriendName(userId: string) { function getFriendName(userId: string) {
const friend = $appData?.friends?.find((f) => f.friend.id === userId); const friend = $appData?.friends?.find((f) => f.friend.id === userId);
return friend ? friend.friend.name : userId.slice(0, 8) + "..."; return friend ? friend.friend.name : userId.slice(0, 8) + "...";
@@ -30,39 +33,34 @@
<div class="w-svw h-svh p-4 relative overflow-hidden"> <div class="w-svw h-svh p-4 relative overflow-hidden">
<div <div
class="size-max mx-auto bg-base-100 border-base-200 border px-4 py-3 rounded-xl" class="size-max mx-auto bg-base-100 border-base-200 border p-1 rounded-lg shadow-md"
> >
<div class="flex flex-col text-center"> <div class="flex flex-row gap-1 items-center text-center">
<p class="text-xl">Friendolls</p> <div>
<p class="text-sm opacity-50">Scene Screen</p> <span class="py-3 text-xs items-center gap-2 badge">
<div class="mt-4 flex flex-col gap-1"> <span
<span class="font-mono text-sm"> class={`size-2 rounded-full ${isInteractive ? "bg-success" : "bg-base-300"}`}
Raw: ({$cursorPositionOnScreen.raw.x}, {$cursorPositionOnScreen.raw ></span>
.y}) Intercepting cursor events
</span>
<span class="font-mono text-sm">
Mapped: ({$cursorPositionOnScreen.mapped.x.toFixed(3)}, {$cursorPositionOnScreen.mapped.y.toFixed(
3,
)})
</span> </span>
</div> </div>
<span class="font-mono text-xs badge py-3">
({$cursorPositionOnScreen.mapped.x.toFixed(3)}, {$cursorPositionOnScreen.mapped.y.toFixed(
3,
)})
</span>
{#if Object.keys($friendsCursorPositions).length > 0} {#if Object.keys($friendsCursorPositions).length > 0}
<div class="mt-4 flex flex-col gap-2"> <div class="flex flex-col gap-2">
<p class="text-sm font-semibold opacity-75">Friends Online</p>
<div> <div>
{#each Object.entries($friendsCursorPositions) as [userId, position]} {#each Object.entries($friendsCursorPositions) as [userId, position]}
{@const dollConfig = getFriendDollConfig(userId)} {@const dollConfig = getFriendDollConfig(userId)}
<div <div class="badge py-3 text-xs text-left flex flex-row gap-2">
class="bg-base-200/50 p-2 rounded text-xs text-left flex gap-2 flex-col"
>
<span class="font-bold">{getFriendName(userId)}</span> <span class="font-bold">{getFriendName(userId)}</span>
<div class="flex flex-col font-mono"> <div class="flex flex-col font-mono">
<span> <span>
Raw: ({position.raw.x}, {position.raw.y}) ({position.mapped.x.toFixed(3)}, {position.mapped.y.toFixed(
</span>
<span>
Mapped: ({position.mapped.x.toFixed(3)}, {position.mapped.y.toFixed(
3, 3,
)}) )})
</span> </span>
@@ -74,6 +72,7 @@
{/if} {/if}
</div> </div>
</div> </div>
<div class="absolute inset-0 size-full"> <div class="absolute inset-0 size-full">
{#if Object.keys($friendsCursorPositions).length > 0} {#if Object.keys($friendsCursorPositions).length > 0}
{#each Object.entries($friendsCursorPositions) as [userId, position]} {#each Object.entries($friendsCursorPositions) as [userId, position]}
@@ -85,6 +84,7 @@
name={getFriendName(userId)} name={getFriendName(userId)}
bodyColor={config?.colorScheme?.body} bodyColor={config?.colorScheme?.body}
outlineColor={config?.colorScheme?.outline} outlineColor={config?.colorScheme?.outline}
{isInteractive}
/> />
{/if} {/if}
{/each} {/each}

View File

@@ -8,6 +8,7 @@
export let name = ""; export let name = "";
export let bodyColor: string | undefined = undefined; export let bodyColor: string | undefined = undefined;
export let outlineColor: string | undefined = undefined; export let outlineColor: string | undefined = undefined;
export let isInteractive = false;
let nekoPosX = 32; let nekoPosX = 32;
let nekoPosY = 32; let nekoPosY = 32;
@@ -251,15 +252,18 @@
}); });
</script> </script>
<div <button
class="desktop-pet flex flex-col items-center" onclick={() => {
console.log("clicked on neko");
}}
class="desktop-pet flex flex-col items-center relative"
style=" style="
transform: translate({nekoPosX - 16}px, {nekoPosY - 16}px); transform: translate({nekoPosX - 16}px, {nekoPosY - 16}px);
z-index: 50; z-index: 50;
" "
> >
<div <div
class="pixelated" class="pixelated hover:scale-110 active:scale-95"
style=" style="
width: 32px; width: 32px;
height: 32px; height: 32px;
@@ -268,18 +272,18 @@
" "
></div> ></div>
<span <span
class="text-[10px] bg-black/50 text-white px-1 rounded backdrop-blur-sm mt-1 whitespace-nowrap" class="absolute -bottom-5 width-full text-[10px] bg-black/50 text-white px-1 rounded backdrop-blur-sm mt-1 whitespace-nowrap opacity-0 transition-opacity"
class:opacity-100={isInteractive}
> >
{name} {name}
</span> </span>
</div> </button>
<style> <style>
.desktop-pet { .desktop-pet {
position: fixed; /* Fixed relative to the viewport/container */ position: fixed; /* Fixed relative to the viewport/container */
top: 0; top: 0;
left: 0; left: 0;
pointer-events: none; /* Let clicks pass through */
will-change: transform; will-change: transform;
} }
</style> </style>