MVP tauri frontend - Windows #1

Merged
manbo merged 8 commits from dev-tauri into master 2026-02-14 20:15:55 +08:00
6 changed files with 288 additions and 8 deletions
Showing only changes of commit 907974e61f - Show all commits

View File

@@ -16,7 +16,7 @@ sprimo-platform = { path = "../sprimo-platform" }
sprimo-sprite = { path = "../sprimo-sprite" } sprimo-sprite = { path = "../sprimo-sprite" }
sprimo-runtime-core = { path = "../sprimo-runtime-core" } sprimo-runtime-core = { path = "../sprimo-runtime-core" }
sprimo-protocol = { path = "../sprimo-protocol" } sprimo-protocol = { path = "../sprimo-protocol" }
tauri = { version = "2.0.0", features = [] } tauri = { version = "2.0.0", features = ["tray-icon"] }
tauri-plugin-global-shortcut = "2.0.0" tauri-plugin-global-shortcut = "2.0.0"
tauri-plugin-log = "2.0.0" tauri-plugin-log = "2.0.0"
thiserror.workspace = true thiserror.workspace = true

View File

@@ -2,18 +2,29 @@
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use base64::Engine; use base64::Engine;
use sprimo_protocol::v1::FrontendCommand;
use sprimo_platform::{create_adapter, PlatformAdapter}; use sprimo_platform::{create_adapter, PlatformAdapter};
use sprimo_protocol::v1::{FrontendState, FrontendStateSnapshot}; use sprimo_protocol::v1::{FrontendState, FrontendStateSnapshot};
use sprimo_runtime_core::{RuntimeCore, RuntimeCoreError}; use sprimo_runtime_core::{RuntimeCore, RuntimeCoreError};
use sprimo_sprite::{load_manifest, resolve_pack_path, AnimationDefinition}; use sprimo_sprite::{load_manifest, resolve_pack_path, AnimationDefinition};
use std::sync::Arc; use std::sync::Arc;
use tauri::{Emitter, Manager}; use tauri::menu::{CheckMenuItem, Menu, MenuItem};
use tauri::tray::TrayIconBuilder;
use tauri::{AppHandle, Emitter, Manager, Wry};
use thiserror::Error; use thiserror::Error;
use tokio::runtime::Runtime; use tokio::runtime::Runtime;
use tracing::warn; use tracing::warn;
const APP_NAME: &str = "sprimo"; const APP_NAME: &str = "sprimo";
const DEFAULT_PACK: &str = "default"; const DEFAULT_PACK: &str = "default";
const MAIN_WINDOW_LABEL: &str = "main";
const TRAY_ID: &str = "main";
const MENU_ID_TOGGLE_VISIBILITY: &str = "toggle_visibility";
const MENU_ID_TOGGLE_ALWAYS_ON_TOP: &str = "toggle_always_on_top";
const MENU_ID_TOGGLE_DEBUG_OVERLAY: &str = "toggle_debug_overlay";
const MENU_ID_QUIT: &str = "quit";
const EVENT_RUNTIME_SNAPSHOT: &str = "runtime:snapshot";
const EVENT_RUNTIME_DEBUG_OVERLAY_VISIBLE: &str = "runtime:debug-overlay-visible";
#[derive(Debug, Clone, serde::Serialize)] #[derive(Debug, Clone, serde::Serialize)]
struct UiAnimationClip { struct UiAnimationClip {
@@ -65,6 +76,13 @@ struct AppState {
runtime: Arc<Runtime>, runtime: Arc<Runtime>,
} }
#[derive(Clone)]
struct TrayMenuState {
toggle_visibility: MenuItem<Wry>,
toggle_always_on_top: CheckMenuItem<Wry>,
toggle_debug_overlay: CheckMenuItem<Wry>,
}
#[tauri::command] #[tauri::command]
fn current_state(state: tauri::State<'_, AppState>) -> Result<UiSnapshot, String> { fn current_state(state: tauri::State<'_, AppState>) -> Result<UiSnapshot, String> {
let snapshot = state let snapshot = state
@@ -131,6 +149,7 @@ fn debug_overlay_visible(state: tauri::State<'_, AppState>) -> Result<bool, Stri
#[tauri::command] #[tauri::command]
fn set_debug_overlay_visible( fn set_debug_overlay_visible(
app_handle: tauri::AppHandle,
state: tauri::State<'_, AppState>, state: tauri::State<'_, AppState>,
visible: bool, visible: bool,
) -> Result<bool, String> { ) -> Result<bool, String> {
@@ -138,6 +157,7 @@ fn set_debug_overlay_visible(
.runtime_core .runtime_core
.set_frontend_debug_overlay_visible(visible) .set_frontend_debug_overlay_visible(visible)
.map_err(|err| err.to_string())?; .map_err(|err| err.to_string())?;
let _ = emit_debug_overlay_visibility(state.runtime_core.as_ref(), &app_handle);
Ok(visible) Ok(visible)
} }
@@ -173,13 +193,17 @@ fn main() -> Result<(), AppError> {
let runtime = Arc::clone(&app_state.runtime); let runtime = Arc::clone(&app_state.runtime);
let app_handle = app.handle().clone(); let app_handle = app.handle().clone();
let tray_state = setup_tray(&app_handle, &runtime_core)?;
if let Ok(snapshot) = runtime_core.snapshot().read() { if let Ok(snapshot) = runtime_core.snapshot().read() {
let _ = app_handle.emit("runtime:snapshot", to_ui_snapshot(&snapshot)); let _ = app_handle.emit(EVENT_RUNTIME_SNAPSHOT, to_ui_snapshot(&snapshot));
} }
let _ = emit_debug_overlay_visibility(runtime_core.as_ref(), &app_handle);
let _ = refresh_tray_menu_state(runtime_core.as_ref(), &tray_state);
let command_rx = runtime_core.command_receiver(); let command_rx = runtime_core.command_receiver();
let runtime_core_for_commands = Arc::clone(&runtime_core); let runtime_core_for_commands = Arc::clone(&runtime_core);
let app_handle_for_commands = app_handle.clone(); let app_handle_for_commands = app_handle.clone();
let tray_state_for_commands = tray_state.clone();
runtime.spawn(async move { runtime.spawn(async move {
loop { loop {
let next = { let next = {
@@ -203,7 +227,11 @@ fn main() -> Result<(), AppError> {
} }
}; };
if let Some(value) = payload { if let Some(value) = payload {
let _ = app_handle_for_commands.emit("runtime:snapshot", value); let _ = app_handle_for_commands.emit(EVENT_RUNTIME_SNAPSHOT, value);
let _ = refresh_tray_menu_state(
runtime_core_for_commands.as_ref(),
&tray_state_for_commands,
);
} }
} }
}); });
@@ -211,6 +239,7 @@ fn main() -> Result<(), AppError> {
if let Some(window) = app.get_webview_window("main") { if let Some(window) = app.get_webview_window("main") {
let runtime_core = Arc::clone(&runtime_core); let runtime_core = Arc::clone(&runtime_core);
let app_handle = app_handle.clone(); let app_handle = app_handle.clone();
let tray_state_for_window = tray_state.clone();
window.on_window_event(move |event| { window.on_window_event(move |event| {
if let tauri::WindowEvent::Moved(position) = event { if let tauri::WindowEvent::Moved(position) = event {
let command = sprimo_protocol::v1::FrontendCommand::SetTransform { let command = sprimo_protocol::v1::FrontendCommand::SetTransform {
@@ -222,13 +251,34 @@ fn main() -> Result<(), AppError> {
}; };
if runtime_core.apply_command(&command).is_ok() { if runtime_core.apply_command(&command).is_ok() {
if let Ok(snapshot) = runtime_core.snapshot().read() { if let Ok(snapshot) = runtime_core.snapshot().read() {
let _ = app_handle.emit("runtime:snapshot", to_ui_snapshot(&snapshot)); let _ = app_handle.emit(
EVENT_RUNTIME_SNAPSHOT,
to_ui_snapshot(&snapshot),
);
} }
let _ = refresh_tray_menu_state(
runtime_core.as_ref(),
&tray_state_for_window,
);
} }
} }
}); });
} }
let runtime_core_for_menu = Arc::clone(&runtime_core);
let app_handle_for_menu = app_handle.clone();
let tray_state_for_menu = tray_state.clone();
app.on_menu_event(move |_app, event| {
if let Err(err) = handle_menu_event(
runtime_core_for_menu.as_ref(),
&app_handle_for_menu,
&tray_state_for_menu,
event.id().as_ref(),
) {
warn!(%err, "tray/menu action failed");
}
});
let _ = app; let _ = app;
Ok(()) Ok(())
}) })
@@ -248,6 +298,211 @@ fn to_ui_snapshot(snapshot: &FrontendStateSnapshot) -> UiSnapshot {
} }
} }
fn setup_tray(
app_handle: &AppHandle<Wry>,
runtime_core: &RuntimeCore,
) -> Result<TrayMenuState, tauri::Error> {
let snapshot = runtime_core
.snapshot()
.read()
.map_err(|_| tauri::Error::AssetNotFound("snapshot lock poisoned".to_string()))?
.clone();
let debug_overlay_visible = runtime_core
.frontend_debug_overlay_visible()
.map_err(|err| tauri::Error::AssetNotFound(err.to_string()))?;
let toggle_visibility = MenuItem::with_id(
app_handle,
MENU_ID_TOGGLE_VISIBILITY,
visibility_menu_title(snapshot.flags.visible),
true,
None::<&str>,
)?;
let toggle_always_on_top = CheckMenuItem::with_id(
app_handle,
MENU_ID_TOGGLE_ALWAYS_ON_TOP,
"Always on top",
true,
snapshot.flags.always_on_top,
None::<&str>,
)?;
let toggle_debug_overlay = CheckMenuItem::with_id(
app_handle,
MENU_ID_TOGGLE_DEBUG_OVERLAY,
"Debug overlay",
true,
debug_overlay_visible,
None::<&str>,
)?;
let quit = MenuItem::with_id(app_handle, MENU_ID_QUIT, "Quit", true, None::<&str>)?;
let menu = Menu::with_items(
app_handle,
&[
&toggle_visibility,
&toggle_always_on_top,
&toggle_debug_overlay,
&quit,
],
)?;
let mut builder = TrayIconBuilder::with_id(TRAY_ID).menu(&menu);
if let Some(icon) = app_handle.default_window_icon().cloned() {
builder = builder.icon(icon);
}
builder
.tooltip("sprimo-tauri")
.show_menu_on_left_click(true)
.build(app_handle)?;
Ok(TrayMenuState {
toggle_visibility,
toggle_always_on_top,
toggle_debug_overlay,
})
}
fn handle_menu_event(
runtime_core: &RuntimeCore,
app_handle: &AppHandle<Wry>,
tray_state: &TrayMenuState,
menu_id: &str,
) -> Result<(), String> {
match menu_id {
MENU_ID_TOGGLE_VISIBILITY => {
let current = runtime_core
.snapshot()
.read()
.map_err(|_| "snapshot lock poisoned".to_string())?
.flags
.visible;
let next = !current;
runtime_core
.apply_command(&FrontendCommand::SetFlags {
click_through: None,
always_on_top: None,
visible: Some(next),
})
.map_err(|err| err.to_string())?;
if let Some(window) = app_handle.get_webview_window(MAIN_WINDOW_LABEL) {
if next {
window.show().map_err(|err| err.to_string())?;
window.set_focus().map_err(|err| err.to_string())?;
} else {
window.hide().map_err(|err| err.to_string())?;
}
}
}
MENU_ID_TOGGLE_ALWAYS_ON_TOP => {
let current = runtime_core
.snapshot()
.read()
.map_err(|_| "snapshot lock poisoned".to_string())?
.flags
.always_on_top;
let next = !current;
runtime_core
.apply_command(&FrontendCommand::SetFlags {
click_through: None,
always_on_top: Some(next),
visible: None,
})
.map_err(|err| err.to_string())?;
if let Some(window) = app_handle.get_webview_window(MAIN_WINDOW_LABEL) {
window.set_always_on_top(next).map_err(|err| err.to_string())?;
}
}
MENU_ID_TOGGLE_DEBUG_OVERLAY => {
let current = runtime_core
.frontend_debug_overlay_visible()
.map_err(|err| err.to_string())?;
let next = !current;
runtime_core
.set_frontend_debug_overlay_visible(next)
.map_err(|err| err.to_string())?;
emit_debug_overlay_visibility(runtime_core, app_handle).map_err(|err| err.to_string())?;
}
MENU_ID_QUIT => {
persist_current_ui_flags(runtime_core)?;
app_handle.exit(0);
}
_ => return Ok(()),
}
if let Ok(snapshot) = runtime_core.snapshot().read() {
let _ = app_handle.emit(EVENT_RUNTIME_SNAPSHOT, to_ui_snapshot(&snapshot));
}
let _ = emit_debug_overlay_visibility(runtime_core, app_handle);
let _ = refresh_tray_menu_state(runtime_core, tray_state);
Ok(())
}
fn persist_current_ui_flags(runtime_core: &RuntimeCore) -> Result<(), String> {
let snapshot = runtime_core
.snapshot()
.read()
.map_err(|_| "snapshot lock poisoned".to_string())?
.clone();
runtime_core
.apply_command(&FrontendCommand::SetFlags {
click_through: None,
always_on_top: Some(snapshot.flags.always_on_top),
visible: Some(snapshot.flags.visible),
})
.map_err(|err| err.to_string())?;
let debug_overlay = runtime_core
.frontend_debug_overlay_visible()
.map_err(|err| err.to_string())?;
runtime_core
.set_frontend_debug_overlay_visible(debug_overlay)
.map_err(|err| err.to_string())
}
fn emit_debug_overlay_visibility(
runtime_core: &RuntimeCore,
app_handle: &AppHandle<Wry>,
) -> Result<(), tauri::Error> {
let value = runtime_core
.frontend_debug_overlay_visible()
.map_err(|err| tauri::Error::AssetNotFound(err.to_string()))?;
app_handle.emit(EVENT_RUNTIME_DEBUG_OVERLAY_VISIBLE, value)
}
fn refresh_tray_menu_state(
runtime_core: &RuntimeCore,
tray_state: &TrayMenuState,
) -> Result<(), tauri::Error> {
let snapshot = runtime_core
.snapshot()
.read()
.map_err(|_| tauri::Error::AssetNotFound("snapshot lock poisoned".to_string()))?
.clone();
let debug_overlay_visible = runtime_core
.frontend_debug_overlay_visible()
.map_err(|err| tauri::Error::AssetNotFound(err.to_string()))?;
tray_state
.toggle_visibility
.set_text(visibility_menu_title(snapshot.flags.visible))
.map_err(|err| tauri::Error::AssetNotFound(err.to_string()))?;
tray_state
.toggle_always_on_top
.set_checked(snapshot.flags.always_on_top)
.map_err(|err| tauri::Error::AssetNotFound(err.to_string()))?;
tray_state
.toggle_debug_overlay
.set_checked(debug_overlay_visible)
.map_err(|err| tauri::Error::AssetNotFound(err.to_string()))?;
Ok(())
}
fn visibility_menu_title(visible: bool) -> &'static str {
if visible {
"Hide"
} else {
"Show"
}
}
fn state_name(value: FrontendState) -> &'static str { fn state_name(value: FrontendState) -> &'static str {
match value { match value {
FrontendState::Idle => "idle", FrontendState::Idle => "idle",

View File

@@ -19,7 +19,7 @@ Date: 2026-02-12
| Random backend API tester | Implemented | `scripts/random_backend_tester.py` with `just random-backend-test` and strict variant | | Random backend API tester | Implemented | `scripts/random_backend_tester.py` with `just random-backend-test` and strict variant |
| QA/documentation workflow | Implemented | `docs/QA_WORKFLOW.md`, issue/evidence templates, and `scripts/qa_validate.py` with `just qa-validate` | | QA/documentation workflow | Implemented | `docs/QA_WORKFLOW.md`, issue/evidence templates, and `scripts/qa_validate.py` with `just qa-validate` |
| Shared runtime core | Implemented | `sprimo-runtime-core` now backs both Tauri and Bevy startup, snapshot/config ownership, and API wiring | | Shared runtime core | Implemented | `sprimo-runtime-core` now backs both Tauri and Bevy startup, snapshot/config ownership, and API wiring |
| Tauri alternative frontend | In progress | `sprimo-tauri` now runs runtime-core/API + PixiJS sprite rendering shell; scale auto-fit and persisted debug-overlay toggle are implemented | | Tauri alternative frontend | In progress | `sprimo-tauri` now runs runtime-core/API + PixiJS sprite rendering shell; scale auto-fit, persisted debug-overlay toggle, and Windows-first tray/menu MVP are implemented |
| Tauri runtime testing workflow | Implemented | `docs/TAURI_RUNTIME_TESTING.md` defines strict workspace testing; packaged mode pending packaging support | | Tauri runtime testing workflow | Implemented | `docs/TAURI_RUNTIME_TESTING.md` defines strict workspace testing; packaged mode pending packaging support |
## Next Major Gaps ## Next Major Gaps
@@ -28,4 +28,4 @@ Date: 2026-02-12
2. Linux/macOS overlay behavior remains best-effort with no-op platform adapter. 2. Linux/macOS overlay behavior remains best-effort with no-op platform adapter.
3. `/v1/state` diagnostics are minimal; error history is not persisted beyond latest runtime error. 3. `/v1/state` diagnostics are minimal; error history is not persisted beyond latest runtime error.
4. Issue 1 runtime after-fix screenshot evidence is still pending before closure. 4. Issue 1 runtime after-fix screenshot evidence is still pending before closure.
5. `sprimo-tauri` still lacks tray/menu/window-behavior parity and full acceptance parity tests. 5. `sprimo-tauri` still lacks cross-platform tray/menu parity and full acceptance parity tests.

View File

@@ -54,10 +54,15 @@ Frontend:
- Tauri window drag is implemented for undecorated mode: - Tauri window drag is implemented for undecorated mode:
- left-mouse drag starts native window dragging - left-mouse drag starts native window dragging
- moved position is synced into runtime-core snapshot/config state. - moved position is synced into runtime-core snapshot/config state.
- Windows-first tray/menu MVP is implemented:
- `Show/Hide`
- `Always on top` toggle
- `Debug overlay` toggle
- `Quit`
- Bevy frontend remains intact. - Bevy frontend remains intact.
- Runtime QA workflow is defined in `docs/TAURI_RUNTIME_TESTING.md`. - Runtime QA workflow is defined in `docs/TAURI_RUNTIME_TESTING.md`.
## Remaining Work ## Remaining Work
1. Add tray/menu parity and window behavior parity with Bevy path. 1. Extend tray/menu implementation beyond Windows-first MVP and close platform parity gaps.
2. Add frontend parity acceptance tests (Bevy vs Tauri state transitions). 2. Add frontend parity acceptance tests (Bevy vs Tauri state transitions).

View File

@@ -78,6 +78,12 @@ An issue touching Tauri runtime behaviors must satisfy all requirements before `
- default startup behavior follows `frontend.debug_overlay_visible` config - default startup behavior follows `frontend.debug_overlay_visible` config
- `debug_overlay_visible`/`set_debug_overlay_visible` invoke commands toggle panel at runtime - `debug_overlay_visible`/`set_debug_overlay_visible` invoke commands toggle panel at runtime
- toggle state persists after restart - toggle state persists after restart
10. Verify Windows tray/menu controls:
- tray left-click opens menu without directly toggling visibility
- `Show/Hide` toggles window visibility and persists state
- `Always on top` toggles top-most behavior and persists state
- `Debug overlay` toggles panel visibility and persists state
- `Quit` exits cleanly and preserves current persisted visibility/top-most/debug settings
## API + Runtime Contract Checklist ## API + Runtime Contract Checklist

View File

@@ -102,6 +102,20 @@ function App(): JSX.Element {
} }
}); });
}); });
const unlistenDebug = await listen<boolean>(
"runtime:debug-overlay-visible",
(event) => {
if (!mounted) {
return;
}
setDebugOverlayVisible(Boolean(event.payload));
}
);
const previousUnlisten = unlisten;
unlisten = () => {
previousUnlisten();
unlistenDebug();
};
}) })
.catch((err) => { .catch((err) => {
if (mounted) { if (mounted) {