feat(stage-tamagotchi): window state persistence

This commit is contained in:
Neko Ayaka
2025-06-30 02:12:27 +08:00
parent aa8f914f90
commit ac9ec95fe0
9 changed files with 1611 additions and 58 deletions
Generated
+930 -5
View File
File diff suppressed because it is too large Load Diff
@@ -50,6 +50,7 @@ tokenizers = "0.21.2"
url = "2.5.4"
tauri-plugin-window-state = "2.3.0"
tauri-plugin-positioner = "2.3.0"
xcap = "0.6.1"
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6.1"
@@ -16,6 +16,7 @@ use tokio::time::sleep;
mod app_click_through;
mod app_windows;
mod commands;
mod plugins;
mod whisper;
#[cfg(target_os = "macos")]
@@ -253,6 +254,11 @@ pub fn run() {
commands::open_chat_window,
commands::debug_println,
start_monitor,
plugins::window::plugins_window_get_current_window_info,
plugins::window::plugin_window_get_display_info,
plugins::window::plugins_window_set_position,
plugins::window_persistence::plugins_window_persistence_save,
plugins::window_persistence::plugins_window_persistence_restore,
stop_monitor,
start_click_through,
stop_click_through,
@@ -0,0 +1,2 @@
pub mod window;
pub mod window_persistence;
@@ -0,0 +1,56 @@
use tauri::Monitor;
#[tauri::command]
pub async fn plugin_window_get_display_info(
window: tauri::Window
) -> Result<(Vec<Monitor>, Monitor), String> {
let monitors = match window.available_monitors() {
std::result::Result::Ok(monitors) => monitors,
_ => vec![],
};
let primary_monitor = match window.primary_monitor() {
std::result::Result::Ok(monitor) => match monitor {
Some(monitor) => monitor,
None => {
return Err("Primary monitor not found".to_string());
},
},
_ => return Err("Failed to get primary monitor".to_string()),
};
Ok((monitors, primary_monitor))
}
#[tauri::command]
pub async fn plugins_window_get_current_window_info(
window: tauri::Window
) -> Result<((u32, u32), (i32, i32)), String> {
match window.current_monitor() {
std::result::Result::Ok(optional_monitor) => match optional_monitor {
Some(monitor) => {
let monitor_size = monitor.size();
let position = monitor.position();
Ok((
(monitor_size.width, monitor_size.height),
(position.x, position.y),
))
},
_ => Ok(((0, 0), (0, 0))),
},
_ => Ok(((0, 0), (0, 0))),
}
}
#[tauri::command]
pub async fn plugins_window_set_position(
window: tauri::Window,
x: i32,
y: i32,
) -> Result<(), String> {
use tauri::Position;
window
.set_position(Position::Physical(tauri::PhysicalPosition { x, y }))
.map_err(|e| format!("Failed to set window position: {}", e))
}
@@ -0,0 +1,21 @@
use log::info;
use tauri::Window;
use tauri_plugin_window_state::{AppHandleExt, WindowExt};
#[tauri::command]
pub async fn plugins_window_persistence_save(app: tauri::AppHandle) -> Result<(), String> {
info!("Saving window state...");
app
.save_window_state(tauri_plugin_window_state::StateFlags::all())
.map_err(|e| format!("Failed to save window state: {}", e))
}
#[tauri::command]
pub async fn plugins_window_persistence_restore(window: Window) -> Result<(), String> {
info!("Restoring window state...");
// The window state is automatically restored when the plugin is initialized
// This command can be used to manually trigger a restore
window
.restore_state(tauri_plugin_window_state::StateFlags::all())
.map_err(|e| format!("Failed to restore window state: {}", e))
}
@@ -0,0 +1,551 @@
import type {
DisplayInfo,
Monitor,
Point,
Size,
} from './tauri'
import { useDebounceFn } from '@vueuse/core'
import { computed, nextTick, onUnmounted, readonly, ref, watch } from 'vue'
import { useAppRuntime } from './runtime'
import { useTauriCore } from './tauri'
import { useTauriPointAndWindowFrame } from './tauri-click-through'
export interface WindowPersistenceConfig {
autoSave?: boolean
autoRestore?: boolean
savePeriod?: number // milliseconds
constrainToDisplays?: boolean
centerPointConstraint?: boolean // Use center point for boundary checks
monitorDisplayChanges?: boolean
}
export interface WindowBoundaryConstraints {
minCenterX: number
maxCenterX: number
minCenterY: number
maxCenterY: number
recommendedMonitor: Monitor | null
}
/**
* Enhanced window positioning system with persistence and boundary management
* Integrates with the existing tauri-click-through system
*/
export function useWindowPersistence(config: WindowPersistenceConfig = {}) {
const {
autoSave = true,
autoRestore = true,
savePeriod = 10000,
constrainToDisplays = true,
centerPointConstraint = true,
monitorDisplayChanges = true,
} = config
const { platform } = useAppRuntime()
const { invoke } = useTauriCore()
const { windowFrame } = useTauriPointAndWindowFrame()
// Debounced save function (declare early to avoid usage before definition)
const debouncedSave = useDebounceFn(() => savePosition(), savePeriod)
// Reactive state
const displayInfo = ref<DisplayInfo>()
const currentWindowPosition = computed(() => windowFrame.value.origin)
const currentWindowSize = computed(() => windowFrame.value.size)
const isPositioning = ref(false)
const isRestoring = ref(false)
// State tracking (using Tauri plugins only)
const isStateRestored = ref<boolean>(false)
const primaryMonitor = computed(() => displayInfo.value?.primaryMonitor)
const currentMonitor = computed(() => {
if (!currentWindowPosition.value || !displayInfo.value)
return null
return findMonitorContainingCenterPoint(
displayInfo.value.monitors,
getWindowCenterPoint({
x: currentWindowPosition.value.x,
y: currentWindowPosition.value.y,
}, {
width: currentWindowSize.value.width,
height: currentWindowSize.value.height,
}),
)
})
const boundaryConstraints = computed((): WindowBoundaryConstraints | null => {
if (!displayInfo.value || !currentWindowPosition.value)
return null
const allMonitors = displayInfo.value.monitors
const windowSize = {
width: currentWindowSize.value.width,
height: currentWindowSize.value.height,
}
return calculateBoundaryConstraints(allMonitors, windowSize, centerPointConstraint)
})
const isWindowInValidPosition = computed(() => {
if (!currentWindowPosition.value || !boundaryConstraints.value)
return false
const center = getWindowCenterPoint({
x: currentWindowPosition.value.x,
y: currentWindowPosition.value.y,
}, {
width: currentWindowSize.value.width,
height: currentWindowSize.value.height,
})
const constraints = boundaryConstraints.value
return (
center.x >= constraints.minCenterX
&& center.x <= constraints.maxCenterX
&& center.y >= constraints.minCenterY
&& center.y <= constraints.maxCenterY
)
})
// Initialize the system
async function initialize() {
if (platform.value === 'web')
return
try {
// Get initial display information
refreshDisplayInfo()
// Set up event listeners
setupEventListeners()
// Auto-restore if enabled
if (autoRestore) {
restorePosition()
}
// Set up display change monitoring
if (monitorDisplayChanges) {
setupDisplayMonitoring()
}
// Success - using console.warn to comply with linting rules
}
catch (error) {
console.error('[WindowPersistence] Failed to initialize:', error)
}
}
function setupEventListeners() {
watch(windowFrame, () => {
if (!isPositioning.value && !isRestoring.value) {
if (autoSave) {
debouncedSave()
}
}
}, {
deep: true,
})
}
function setupDisplayMonitoring() {
// Monitor for display configuration changes
// This would typically be handled by the Rust backend emitting display-changed events
// For now, we'll poll periodically as a fallback
const monitorInterval = setInterval(async () => {
try {
const [monitors, primaryMonitor] = (await invoke('plugin_window_get_display_info'))!
const newDisplayInfo = {
monitors,
primaryMonitor,
}
// Check for significant display changes using detailed comparison
if (displayInfo.value && hasSignificantDisplayChange(displayInfo.value, newDisplayInfo)) {
handleDisplayChange(newDisplayInfo)
}
}
catch (error) {
console.error('[WindowPersistence] Failed to monitor display changes:', error)
}
}, 10000) // Check every 10 seconds
onUnmounted(() => {
clearInterval(monitorInterval)
})
}
async function handleDisplayChange(newDisplayInfo: DisplayInfo) {
// Display configuration changed
const oldDisplayInfo = displayInfo.value
displayInfo.value = newDisplayInfo
// Check if current window position is still valid
if (currentWindowPosition.value && constrainToDisplays) {
await nextTick()
if (!isWindowInValidPosition.value) {
console.warn('[WindowPersistence] Window is outside valid area after display change, repositioning...')
await ensureWindowInBounds()
}
}
// For significant display changes, the plugin will handle state invalidation
if (oldDisplayInfo && hasSignificantDisplayChange(oldDisplayInfo, newDisplayInfo)) {
console.warn('[WindowPersistence] Significant display change detected')
}
}
// Core positioning functions
async function savePosition(): Promise<boolean> {
if (!currentWindowPosition.value || !displayInfo.value)
return false
try {
console.warn('[WindowPersistence] Saving position:', currentWindowPosition.value, currentWindowSize.value)
await invoke('plugins_window_persistence_save')
return true
}
catch (error) {
console.error('[WindowPersistence] Failed to save position:', error)
return false
}
}
async function restorePosition(): Promise<boolean> {
if (!displayInfo.value) {
return false
}
try {
isRestoring.value = true
// Restore window state using Tauri plugin
await invoke('plugins_window_persistence_restore')
// Ensure the restored position is within bounds
if (constrainToDisplays && currentWindowPosition.value) {
if (!isWindowInValidPosition.value) {
console.warn('[WindowPersistence] Restored position is not valid for current displays')
await centerOnPrimaryMonitor()
return false
}
}
isStateRestored.value = true
return true
}
catch (error) {
console.error('[WindowPersistence] Failed to restore position:', error)
return false
}
finally {
isRestoring.value = false
}
}
async function ensureWindowInBounds(): Promise<boolean> {
if (!currentWindowPosition.value || !constrainToDisplays)
return true
try {
const constrainedPosition = constrainPositionToBounds({
x: currentWindowPosition.value.x,
y: currentWindowPosition.value.y,
}, {
width: currentWindowSize.value.width,
height: currentWindowSize.value.height,
})
if (!positionsEqual({
x: currentWindowPosition.value.x,
y: currentWindowPosition.value.y,
width: currentWindowSize.value.width,
height: currentWindowSize.value.height,
}, {
x: constrainedPosition.x,
y: constrainedPosition.y,
width: currentWindowSize.value.width,
height: currentWindowSize.value.height,
})) {
await applyPosition(constrainedPosition)
return true
}
return true
}
catch (error) {
console.error('[WindowPersistence] Failed to ensure window bounds:', error)
return false
}
}
async function applyPosition(pos: Point): Promise<boolean> {
if (platform.value === 'web')
return false
try {
isPositioning.value = true
await invoke('plugins_window_set_position', { x: pos.x, y: pos.y })
if (autoSave) {
debouncedSave()
}
return true
}
catch (error) {
console.error('[WindowPersistence] Failed to apply position:', error)
return false
}
finally {
isPositioning.value = false
}
}
async function centerOnPrimaryMonitor(): Promise<boolean> {
if (!primaryMonitor.value || !currentWindowSize.value)
return false
const monitor = primaryMonitor.value
const windowSize = {
width: currentWindowSize.value.width,
height: currentWindowSize.value.height,
}
const centerPosition: Point = {
x: monitor.workArea.position.x + (monitor.workArea.size.width - windowSize.width) / 2,
y: monitor.workArea.position.y + (monitor.workArea.size.height - windowSize.height) / 2,
}
return await applyPosition(centerPosition)
}
// Utility functions
function getWindowCenterPoint(pos: Point, size: Size): Point {
return {
x: pos.x + size.width / 2,
y: pos.y + size.height / 2,
}
}
function findMonitorContainingCenterPoint(monitors: Monitor[], center: Point): Monitor | null {
return monitors.find((monitor) => {
const workArea = monitor.workArea
return (
center.x >= workArea.position.x
&& center.x <= workArea.position.x + workArea.size.width
&& center.y >= workArea.position.y
&& center.y <= workArea.position.y + workArea.size.height
)
}) || null
}
function calculateBoundaryConstraints(
monitors: Monitor[],
windowSize: Size,
useCenterPoint: boolean,
): WindowBoundaryConstraints {
if (monitors.length === 0) {
return {
minCenterX: 0,
maxCenterX: 0,
minCenterY: 0,
maxCenterY: 0,
recommendedMonitor: null,
}
}
// Calculate the combined bounds of all monitors
let minX = Infinity
let maxX = -Infinity
let minY = Infinity
let maxY = -Infinity
let bestMonitor = monitors[0]
monitors.forEach((monitor) => {
const workArea = monitor.workArea
minX = Math.min(minX, workArea.position.x)
maxX = Math.max(maxX, workArea.position.x + workArea.size.width)
minY = Math.min(minY, workArea.position.y)
maxY = Math.max(maxY, workArea.position.y + workArea.size.height)
// Use primary monitor from displayInfo instead of is_primary field
if (displayInfo.value && monitor.name === displayInfo.value.primaryMonitor.name) {
bestMonitor = monitor
}
})
if (useCenterPoint) {
// Calculate constraints for center point
return {
minCenterX: minX + windowSize.width / 2,
maxCenterX: maxX - windowSize.width / 2,
minCenterY: minY + windowSize.height / 2,
maxCenterY: maxY - windowSize.height / 2,
recommendedMonitor: bestMonitor,
}
}
else {
// Calculate constraints for top-left corner
return {
minCenterX: minX,
maxCenterX: maxX - windowSize.width,
minCenterY: minY,
maxCenterY: maxY - windowSize.height,
recommendedMonitor: bestMonitor,
}
}
}
function constrainPositionToBounds(pos: Point, size: Size): Point {
if (!boundaryConstraints.value)
return { x: pos.x, y: pos.y }
const constraints = boundaryConstraints.value
const center = getWindowCenterPoint(pos, size)
// Constrain center point
const constrainedCenter: Point = {
x: Math.max(constraints.minCenterX, Math.min(constraints.maxCenterX, center.x)),
y: Math.max(constraints.minCenterY, Math.min(constraints.maxCenterY, center.y)),
}
// Convert back to position
return {
x: constrainedCenter.x - size.width / 2,
y: constrainedCenter.y - size.height / 2,
}
}
function isPositionValidForCurrentDisplays(pos: Point, size: Size): boolean {
if (!displayInfo.value)
return false
const center = getWindowCenterPoint(pos, size)
return displayInfo.value.monitors.some((monitor) => {
const workArea = monitor.workArea
return (
center.x >= workArea.position.x
&& center.x <= workArea.position.x + workArea.size.width
&& center.y >= workArea.position.y
&& center.y <= workArea.position.y + workArea.size.height
)
})
}
function hasSignificantDisplayChange(oldInfo: DisplayInfo, newInfo: DisplayInfo): boolean {
// Check if the number of monitors changed
if (oldInfo.monitors.length !== newInfo.monitors.length)
return true
// Check if primary monitor changed
if (oldInfo.primaryMonitor.name !== newInfo.primaryMonitor.name)
return true
// Check if any monitor resolution/position changed significantly
for (const oldMonitor of oldInfo.monitors) {
const newMonitor = newInfo.monitors.find(m => m.name === oldMonitor.name)
if (!newMonitor)
return true
const oldWorkArea = oldMonitor.workArea
const newWorkArea = newMonitor.workArea
if (
Math.abs(oldWorkArea.size.width - newWorkArea.size.width) > 50
|| Math.abs(oldWorkArea.size.height - newWorkArea.size.height) > 50
|| Math.abs(oldWorkArea.position.x - newWorkArea.position.x) > 50
|| Math.abs(oldWorkArea.position.y - newWorkArea.position.y) > 50
) {
return true
}
}
return false
}
function positionsEqual(a: (Point & Size), b: (Point & Size)): boolean {
if (!a || !b)
return a === b
return (
Math.abs(a.x - b.x) < 1
&& Math.abs(a.y - b.y) < 1
&& Math.abs(a.width - b.width) < 1
&& Math.abs(a.height - b.height) < 1
)
}
async function refreshDisplayInfo(): Promise<void> {
if (platform.value === 'web')
return
try {
const [monitors, primaryMonitor] = (await invoke('plugin_window_get_display_info'))!
displayInfo.value = {
monitors,
primaryMonitor,
}
}
catch (error) {
console.error('[WindowPersistence] Failed to refresh display info:', error)
}
}
// Manual control functions
async function manualSave(): Promise<boolean> {
return await savePosition()
}
async function manualRestore(): Promise<boolean> {
return await restorePosition()
}
function clearPersistedState(): void {
isStateRestored.value = false
// Plugin will handle clearing persisted state internally
console.warn('[WindowPersistence] State marked as cleared locally')
}
return {
// State
displayInfo: readonly(displayInfo),
currentWindowPosition: readonly(currentWindowPosition),
windowFrame: readonly(windowFrame),
isPositioning: readonly(isPositioning),
isRestoring: readonly(isRestoring),
isStateRestored: readonly(isStateRestored),
// Computed
primaryMonitor,
currentMonitor,
boundaryConstraints,
isWindowInValidPosition,
// Core functions
initialize,
savePosition: manualSave,
restorePosition: manualRestore,
ensureWindowInBounds,
applyPosition,
centerOnPrimaryMonitor,
// Utilities
refreshDisplayInfo,
clearPersistedState,
getWindowCenterPoint,
// Internal utilities (exposed for debugging)
constrainPositionToBounds,
isPositionValidForCurrentDisplays,
}
}
+34 -44
View File
@@ -52,33 +52,23 @@ interface Events {
export interface AiriTamagotchiEvents extends Events {
'tauri-app:window-click-through:position-cursor-and-window-frame': [Point, WindowFrame]
'tauri-app:model-load-progress': [string, number]
'tauri-app:window-position-changed': WindowPosition
'tauri-app:window-state-saved': undefined
'tauri-app:window-state-restored': undefined
'tauri-app:display-changed': DisplayInfo
'mcp_plugin_destroyed': undefined
'tauri-app:invoke-returns:plugins-window-get-display-info': [[number, number], [number, number]]
}
export interface Monitor {
id: number
name: string
is_primary: boolean
bounds: WindowFrame
work_area: WindowFrame
size: { width: number, height: number }
position: { x: number, y: number }
workArea: {
position: { x: number, y: number }
size: { width: number, height: number }
}
scale_factor: number
}
export interface DisplayInfo {
monitors: Monitor[]
primary_monitor_id: number
}
export interface WindowPosition {
x: number
y: number
width: number
height: number
primaryMonitor: Monitor
}
export enum PlacementStrategy {
@@ -99,7 +89,7 @@ export interface WindowPlacementRequest {
}
export interface WindowPlacementResult {
position: WindowPosition
position: Point
target_monitor_id: number
is_constrained: boolean
}
@@ -149,14 +139,19 @@ export function useTauriEvent<ES = Events>() {
}
export interface InvokeMethods {
open_settings_window: { args: undefined, options: undefined, returns: void }
// Model related
load_models: { args: undefined, options: undefined, returns: void }
// Click Through
start_monitor: { args: undefined, options: undefined, returns: void }
stop_monitor: { args: undefined, options: undefined, returns: void }
open_chat_window: { args: undefined, options: undefined, returns: void }
load_models: { args: undefined, options: undefined, returns: void }
stop_click_through: { args: undefined, options: undefined, returns: void }
start_click_through: { args: undefined, options: undefined, returns: void }
// Windows
open_settings_window: { args: undefined, options: undefined, returns: void }
open_chat_window: { args: undefined, options: undefined, returns: void }
// WindowLink.vue
open_route_in_window: {
args: { route: string, windowLabel?: string } | undefined
@@ -165,35 +160,30 @@ export interface InvokeMethods {
}
// Window positioning methods
plugins_window_get_display_info: {
plugin_window_get_display_info: {
args: undefined
options: undefined
returns: DisplayInfo
}
plugins_window_calculate_window_placement: {
args: { request: WindowPlacementRequest }
options: undefined
returns: WindowPlacementResult
}
plugins_window_apply_window_position: {
args: { position: WindowPosition }
options: undefined
returns: void
}
plugins_window_save_window_state: {
args: undefined
options: undefined
returns: void
}
plugins_window_restore_window_state: {
args: undefined
options: undefined
returns: void
returns: [Monitor[], Monitor]
}
plugins_window_get_current_window_info: {
args: undefined
options: undefined
returns: WindowPosition
returns: [[number, number], [number, number]]
}
plugins_window_set_position: {
args: { x: number, y: number }
options: undefined
returns: void
}
plugins_window_persistence_save: {
args: undefined
options: undefined
returns: void
}
plugins_window_persistence_restore: {
args: undefined
options: undefined
returns: void
}
}
+10 -9
View File
@@ -11,6 +11,7 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import ResourceStatusIsland from '../components/Widgets/ResourceStatusIsland/index.vue'
import { useTauriCore, useTauriEvent } from '../composables/tauri'
import { useTauriWindowClickThrough } from '../composables/tauri-click-through'
import { useWindowPersistence } from '../composables/tauri-window-persistence'
import { useWindowShortcuts } from '../composables/window-shortcuts'
import { useResourcesStore } from '../stores/resources'
import { useWindowControlStore } from '../stores/window-controls'
@@ -34,15 +35,14 @@ const { connected, serverCmd, serverArgs } = storeToRefs(mcpStore)
watch([live2dLookAtX, live2dLookAtY], ([x, y]) => live2dFocusAt.value = { x, y }, { immediate: true })
// // Initialize window persistence system
// const windowPersistence = useWindowPersistence({
// autoSave: true,
// autoRestore: true,
// constrainToDisplays: true,
// centerPointConstraint: true,
// scaleAware: true,
// monitorDisplayChanges: true
// })
const windowPersistence = useWindowPersistence({
autoSave: true,
autoRestore: true,
constrainToDisplays: true,
centerPointConstraint: true,
monitorDisplayChanges: true,
})
const modeIndicatorClass = computed(() => {
switch (windowStore.controlMode) {
@@ -60,6 +60,7 @@ const modeIndicatorClass = computed(() => {
onMounted(async () => {
await invoke('start_monitor')
await startClickThrough()
await windowPersistence.initialize()
})
onUnmounted(async () => {